jdaugherty commented on code in PR #16031:
URL: https://github.com/apache/grails-core/pull/16031#discussion_r3821956670
##########
grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy:
##########
@@ -440,13 +447,18 @@ class WebDriverContainerHolder {
if (vncContainer) {
// Stop the current VNC recording container
vncContainer.stop()
- // Create and start a new VNC recording container for the next
test
+ // Create and start a new VNC recording container for the next
test.
+ // start() must succeed BEFORE the field is updated: if it
throws (e.g. the
+ // "Connected" wait strategy times out), the exception below
is deliberately
+ // swallowed to avoid breaking test execution - so if the
field were already
+ // pointing at newVncContainer by then, every subsequent
saveRecordingToFile()
+ // would silently target a container that never actually
started.
def newVncContainer = new VncRecordingContainer(container)
.withVncPassword('secret')
.withVncPort(5900)
.withVideoFormat(settings.recordingFormat)
- field.set(container, newVncContainer)
newVncContainer.start()
+ field.set(container, newVncContainer)
Review Comment:
If `newVncContainer.start()` throws, the swallow below leaves this field
still pointing at the **old** container — which `vncContainer.stop()` above has
already stopped *and removed* (testcontainers' `GenericContainer.stop()`
removes the container). The next `afterIteration` → `saveRecordingToFile()`
then hits a docker `NotFoundException` whose message names the missing
container id, not `/newScreen.mp4`, so `GebRecordingTestListener`'s guard
rethrows it and a passing test gets reported as an error — the exact outcome
the swallow is meant to prevent.
Consider clearing the field (`field.set(container, null)`) in the failure
path so the save is skipped cleanly instead of throwing.
##########
grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy:
##########
@@ -440,13 +447,18 @@ class WebDriverContainerHolder {
if (vncContainer) {
// Stop the current VNC recording container
vncContainer.stop()
- // Create and start a new VNC recording container for the next
test
+ // Create and start a new VNC recording container for the next
test.
+ // start() must succeed BEFORE the field is updated: if it
throws (e.g. the
+ // "Connected" wait strategy times out), the exception below
is deliberately
+ // swallowed to avoid breaking test execution - so if the
field were already
+ // pointing at newVncContainer by then, every subsequent
saveRecordingToFile()
+ // would silently target a container that never actually
started.
def newVncContainer = new VncRecordingContainer(container)
.withVncPassword('secret')
.withVncPort(5900)
.withVideoFormat(settings.recordingFormat)
- field.set(container, newVncContainer)
newVncContainer.start()
Review Comment:
Related: when `start()` throws after the docker container has already been
created (e.g. the `Connected` wait strategy times out), nothing references or
stops `newVncContainer` anymore — a live ffmpeg recorder stays attached to the
browser container's VNC port until Ryuk reaps it at JVM exit. The previous
ordering self-healed: the field held the failed container, so the next
restart's `vncContainer.stop()` cleaned it up.
A `try { newVncContainer.start(); field.set(container, newVncContainer) }
catch (Exception e) { newVncContainer.stop(); throw e }` around just the start
would cover both this and the stale-field issue above.
##########
grails-test-examples/geb/src/integration-test/groovy/org/demo/spock/PerTestRecordingSpec.groovy:
##########
@@ -85,12 +85,35 @@ class PerTestRecordingSpec extends ContainerGebSpec {
names.contains('setup_running_a_test_to_create_a_recording')
names.contains('setup_running_a_second_test_to_create_another')
- and: 'the recording files should have different content'
+ and: 'each recording captured meaningful content, not just a
near-blank connection handshake'
+ // A VNC recording container that was only just restarted (see
+ // WebDriverContainerHolder#restartVncRecordingContainer) is
guaranteed to have
+ // connected, but not to have captured more than a frame or two by the
time a fast
+ // iteration finishes. Two such near-blank captures can encode to
identical,
+ // non-zero, stable-sized bytes via ffmpeg - passing a raw
byte-difference check
+ // without actually being distinct, meaningful recordings. Requiring a
sensible
+ // minimum size asserts the real framework contract - a real,
played-out recording -
+ // rather than raw byte inequality of whatever ffmpeg happened to
produce.
def firstRecording = recordingFiles.find {
it.name.contains('setup_running_a_test_to_create_a_recording') }
def secondRecording = recordingFiles.find {
it.name.contains('setup_running_a_second_test_to_create_another') }
+ firstRecording.length() > MIN_MEANINGFUL_RECORDING_BYTES
Review Comment:
Two concerns with the size check and its justification:
1. The comment misstates the old check's semantics: two identical near-blank
captures make `Files.mismatch` return `-1`, which *failed* the pre-existing `!=
-1` assertion — they couldn't "pass a raw byte-difference check".
2. In the exact near-blank scenario the comment describes (recorder just
restarted, fast iteration finishes before more than a frame or two is
captured), these size assertions also fail — so that flake is relabeled rather
than eliminated. And the 5,000-byte floor is calibrated from only two local
runs; a short, low-motion encode on a slow-to-restart CI iteration could
legitimately land below it.
If the real contract is "a played-out recording exists per test", something
like waiting for the file to stop growing / reach the floor (with a timeout)
before asserting would be less flaky than a static minimum on whatever ffmpeg
had flushed at iteration end.
##########
grails-geb/src/testFixtures/groovy/grails/plugin/geb/WebDriverContainerHolder.groovy:
##########
@@ -88,11 +88,18 @@ class WebDriverContainerHolder {
}
void stop() {
- container?.stop()
- container = null
- browser = null
- testManager = null
- containerConf = null
+ try {
+ container?.stop()
+ } finally {
+ // Reset state even if stop() throws - otherwise isInitialized()
keeps reporting
+ // true for a container that's actually broken, and a later
reinitialize() call
+ // would see matchesCurrentContainerConfiguration() as a false
positive without
+ // ever attempting to recover.
+ container = null
+ browser = null
Review Comment:
While touching `stop()`: the comment in `reinitialize()` says "The driver is
explicitly quit by us in stop() method", but `stop()` never calls
`browser?.driver?.quit()` — it only nulls the reference. Since
`cacheDriver=false` and `quitDriverOnBrowserReset=false` were set precisely on
that promise (disabling Geb's own cleanup), every container recycle abandons a
`RemoteWebDriver` and its HTTP client/connection pool. Worth either quitting
the driver here (inside the `try`, before `container?.stop()`) or correcting
the comment.
##########
grails-geb/src/test/groovy/grails/plugin/geb/WebDriverContainerHolderSpec.groovy:
##########
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.plugin.geb
+
+import java.time.LocalDateTime
+
+import org.testcontainers.containers.BrowserWebDriverContainer
+import org.testcontainers.containers.VncRecordingContainer
+
+import geb.Browser
+import geb.test.GebTestManager
+import spock.lang.Specification
+
+import static
org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode
+
+class WebDriverContainerHolderSpec extends Specification {
+
+ WebDriverContainerHolder holder = new WebDriverContainerHolder(new
GrailsGebSettings(LocalDateTime.now()))
Review Comment:
`GrailsGebSettings` reads live JVM system properties, and
`gradle/test-config.gradle` forwards every `grails.geb.*` project property into
test JVMs — so an externally supplied value (e.g. a malformed
`grails.geb.recording.mode` or `.format` from a developer's gradle properties)
makes this field initializer throw in `VncRecordingMode.valueOf` and errors all
five features before any assertion runs. Constructing the settings hermetically
here (explicit values, or saving/clearing the relevant system properties around
the spec) would isolate the tests from the environment.
##########
grails-geb/src/test/groovy/grails/plugin/geb/WebDriverContainerHolderSpec.groovy:
##########
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package grails.plugin.geb
+
+import java.time.LocalDateTime
+
+import org.testcontainers.containers.BrowserWebDriverContainer
+import org.testcontainers.containers.VncRecordingContainer
+
+import geb.Browser
+import geb.test.GebTestManager
+import spock.lang.Specification
+
+import static
org.testcontainers.containers.BrowserWebDriverContainer.VncRecordingMode
+
+class WebDriverContainerHolderSpec extends Specification {
+
+ WebDriverContainerHolder holder = new WebDriverContainerHolder(new
GrailsGebSettings(LocalDateTime.now()))
+
+ void 'stop() resets container, browser and testManager on the happy
path'() {
+ given: 'a holder with an initialized container'
+ def container = Mock(BrowserWebDriverContainer)
+ holder.container = container
+ holder.browser = Mock(Browser)
+ holder.testManager = Mock(GebTestManager)
+
+ when: 'the holder is stopped'
+ holder.stop()
+
+ then: 'the underlying container is stopped'
+ 1 * container.stop()
+
+ and: 'all held state is cleared'
+ holder.container == null
+ holder.browser == null
+ holder.testManager == null
+ !holder.initialized
+ }
+
+ void 'stop() still resets all held state when container.stop() throws'() {
+ given: 'a holder whose container fails to stop cleanly'
+ def container = Mock(BrowserWebDriverContainer)
+ container.stop() >> { throw new IllegalStateException('boom') }
+ holder.container = container
+ holder.browser = Mock(Browser)
+ holder.testManager = Mock(GebTestManager)
+
+ when: 'the holder is stopped'
+ holder.stop()
+
+ then: 'the exception from stop() propagates'
+ thrown(IllegalStateException)
+
+ and: 'held state is still cleared, so a broken container is never
reported as initialized'
+ holder.container == null
+ holder.browser == null
+ holder.testManager == null
+ !holder.initialized
+ }
+
+ void 'restartVncRecordingContainer() does nothing when recording is
disabled'() {
+ given:
+ holder.settings.recordingMode = VncRecordingMode.SKIP
+ holder.settings.restartRecordingContainerPerTest = true
+ def container = Mock(BrowserWebDriverContainer)
+ holder.container = container
+
+ when:
+ holder.restartVncRecordingContainer()
+
+ then:
+ 0 * container._
+ }
+
+ void 'restartVncRecordingContainer() does nothing when per-test restart is
disabled'() {
+ given:
+ holder.settings.recordingMode = VncRecordingMode.RECORD_ALL
+ holder.settings.restartRecordingContainerPerTest = false
+ def container = Mock(BrowserWebDriverContainer)
+ holder.container = container
+
+ when:
+ holder.restartVncRecordingContainer()
+
+ then:
+ 0 * container._
+ }
+
+ void 'restartVncRecordingContainer() does nothing when no container has
been initialized'() {
+ given:
+ holder.settings.recordingMode = VncRecordingMode.RECORD_ALL
+ holder.settings.restartRecordingContainerPerTest = true
+ holder.container = null
+
+ expect: 'no exception is thrown even though there is nothing to
restart'
+ holder.restartVncRecordingContainer()
+ }
+
+ void 'restartVncRecordingContainer() swallows a failure from the current
recording container instead of propagating it'() {
+ given: 'a container whose active VNC recording container fails to stop'
+ holder.settings.recordingMode = VncRecordingMode.RECORD_ALL
+ holder.settings.restartRecordingContainerPerTest = true
+ def container = Mock(BrowserWebDriverContainer)
+ holder.container = container
+
+ def vncContainer = Mock(VncRecordingContainer)
+ vncContainer.stop() >> { throw new IllegalStateException('vnc
container refused to stop') }
+ def vncField =
BrowserWebDriverContainer.getDeclaredField('vncRecordingContainer')
Review Comment:
This test injects and asserts state through testcontainers' private
`vncRecordingContainer` field via reflection, which runs against the repo test
conventions (CLAUDE.md rule 9: test via public APIs, never bypass the public
surface). It also pins a third-party private implementation detail — a field
rename in a testcontainers upgrade breaks this test with no public-API contract
explaining why. The production code admittedly uses the same reflection, but
the assertion here could target observable behavior instead (e.g. that a
subsequent recording save/restart still behaves after a failed stop) rather
than re-reading the private field.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]