From 3137347f830193069a2a12712f0c984fd934b51f Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Thu, 20 Aug 2026 12:05:26 -0500 Subject: [PATCH 1/3] Add BrowserWebDriverContainer#restartVncRecording() for per-test recordings Restarting a browser container's VNC recorder mid-lifecycle has no public API today, forcing anyone who reuses a single BrowserWebDriverContainer across multiple tests (e.g. to avoid paying for a fresh browser session per test) to reach into the private vncRecordingContainer field via reflection to get a separate recording per test. This has been an open ask since #3998, with maintainers and users converging on exactly this shape of fix in that issue's discussion. restartVncRecording() stops the current recording container and starts a fresh one, clearing the field before the replacement starts so a failed start() can't leave a stale reference to an already-stopped container in place for afterTest() to save from - and stops the replacement's container explicitly on a failed start so it isn't orphaned. As a companion safety net, retainRecordingIfNeeded() now guards against a null vncRecordingContainer instead of throwing a NullPointerException that would otherwise propagate out of afterTest(). Verified against a real Docker daemon: two calls to afterTest() separated by a restartVncRecording() call now produce two distinct recording files instead of one continuous recording. Closes #3998 --- docs/modules/webdriver_containers.md | 8 +++ .../selenium/BrowserWebDriverContainer.java | 52 ++++++++++++++++ ...ChromeRecordingWebDriverContainerTest.java | 61 +++++++++++++++---- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/docs/modules/webdriver_containers.md b/docs/modules/webdriver_containers.md index 8101f489e3a..a7771d24eab 100644 --- a/docs/modules/webdriver_containers.md +++ b/docs/modules/webdriver_containers.md @@ -80,6 +80,14 @@ If you would like to customise the file name of the recording, or provide a diff Note the factory must implement `org.testcontainers.containers.RecordingFileFactory`. +If you reuse a single `BrowserWebDriverContainer` across multiple tests (e.g. to avoid the cost of starting a new +browser container per test), call `restartVncRecording()` before each test so that `afterTest()` saves a separate +recording per test instead of one continuous recording for the whole container's lifetime: + + +[Restart recording between tests](../../modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java) inside_block:restart + + ## More examples A few different examples are shown in [ChromeWebDriverContainerTest.java](https://github.com/testcontainers/testcontainers-java/blob/main/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeWebDriverContainerTest.java). diff --git a/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java b/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java index 97ac23f5d55..5ca00c6c05f 100644 --- a/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java +++ b/modules/selenium/src/main/java/org/testcontainers/selenium/BrowserWebDriverContainer.java @@ -201,6 +201,52 @@ public void afterTest(TestDescription description, Optional throwable retainRecordingIfNeeded(description.getFilesystemFriendlyName(), !throwable.isPresent()); } + /** + * Restarts VNC recording, so that a separate recording is captured for each test method when a single + * {@link BrowserWebDriverContainer} instance is reused across multiple tests (e.g. to avoid the cost of + * starting a new browser container per test). Call this before each test starts; {@link #afterTest} will then + * save the recording captured since the last restart. + *

+ * Does nothing if recording is not enabled ({@link VncRecordingMode#SKIP}) or the container has not started yet. + * + * @throws ContainerLaunchException if the replacement recording container fails to start. The previous + * recording container is stopped regardless, so recording is disabled (as if {@link VncRecordingMode#SKIP} had + * been used) rather than left in a stale or partially-started state. + */ + public void restartVncRecording() { + if (recordingMode == VncRecordingMode.SKIP || vncRecordingContainer == null) { + return; + } + + VncRecordingContainer previousRecordingContainer = vncRecordingContainer; + // Clear the field before starting the replacement below: if start() throws, a stale reference to this + // now-stopped container must not be left in place for afterTest() to save from. + vncRecordingContainer = null; + try { + previousRecordingContainer.stop(); + } catch (Exception e) { + LOGGER.debug("Failed to stop vncRecordingContainer", e); + } + + VncRecordingContainer nextRecordingContainer = new VncRecordingContainer(this) + .withVncPassword(DEFAULT_PASSWORD) + .withVncPort(VNC_PORT) + .withVideoFormat(recordingFormat); + try { + nextRecordingContainer.start(); + } catch (Exception e) { + // start() may have already created the underlying container (e.g. its wait strategy timed out) - + // stop it explicitly so it isn't left running until Ryuk reaps it. + try { + nextRecordingContainer.stop(); + } catch (Exception stopException) { + e.addSuppressed(stopException); + } + throw new ContainerLaunchException("Failed to restart VNC recording container", e); + } + vncRecordingContainer = nextRecordingContainer; + } + @Override public void stop() { if (vncRecordingContainer != null) { @@ -230,6 +276,12 @@ private void retainRecordingIfNeeded(String prefix, boolean succeeded) { } if (shouldRecord) { + if (vncRecordingContainer == null) { + // Can happen if restartVncRecording() failed to start a replacement recording container. + LOGGER.warn("No VNC recording container available for test {} - recording will not be saved", prefix); + return; + } + File recordingFile = recordingFileFactory.recordingFileForTest( vncRecordingDirectory, prefix, diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java index dbb39b86599..c03376dcbe8 100644 --- a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java @@ -63,23 +63,62 @@ private File[] runSimpleExploreInContainer(BrowserWebDriverContainer container, TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); doSimpleExplore(container, new ChromeOptions()); container.afterTest( - new TestDescription() { - @Override - public String getTestId() { - return getFilesystemFriendlyName(); - } - - @Override - public String getFilesystemFriendlyName() { - return "ChromeThatRecordsAllTests-recordingTestThatShouldBeRecordedAndRetained"; - } - }, + testDescription("ChromeThatRecordsAllTests-recordingTestThatShouldBeRecordedAndRetained"), Optional.empty() ); return vncRecordingDirectory.toFile().listFiles(new PatternFilenameFilter(fileNamePattern)); } + private TestDescription testDescription(String filesystemFriendlyName) { + return new TestDescription() { + @Override + public String getTestId() { + return getFilesystemFriendlyName(); + } + + @Override + public String getFilesystemFriendlyName() { + return filesystemFriendlyName; + } + }; + } + + @Test + void restartVncRecordingProducesASeparateFileForEachTest() throws InterruptedException { + File target = vncRecordingDirectory.toFile(); + try ( + // restart { + BrowserWebDriverContainer chrome = new BrowserWebDriverContainer("selenium/standalone-chrome:4.13.0") + .withRecordingMode(VncRecordingMode.RECORD_ALL, target) + .withRecordingFileFactory(new DefaultRecordingFileFactory()) + .withNetwork(NETWORK) + ) { + chrome.start(); + + TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); + doSimpleExplore(chrome, new ChromeOptions()); + chrome.afterTest( + testDescription("restartVncRecordingProducesASeparateFileForEachTest-first"), + Optional.empty() + ); + + // Call this before each subsequent test so its recording doesn't get appended to the previous one + chrome.restartVncRecording(); + // } + + TimeUnit.MILLISECONDS.sleep(MINIMUM_VIDEO_DURATION_MILLISECONDS); + doSimpleExplore(chrome, new ChromeOptions()); + chrome.afterTest( + testDescription("restartVncRecordingProducesASeparateFileForEachTest-second"), + Optional.empty() + ); + + File[] files = vncRecordingDirectory.toFile().listFiles(new PatternFilenameFilter("PASSED-.*\\.flv")); + assertThat(files).as("a separate recording file exists per test").hasSize(2); + } + } + @Test void recordingTestShouldHaveFlvExtension() throws InterruptedException { File target = vncRecordingDirectory.toFile(); From 14ffe4b02ff39c2d1f1a6653d43450fbdd81093d Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Sun, 23 Aug 2026 11:44:32 -0500 Subject: [PATCH 2/3] Verify restartVncRecording() actually splits the recording boundary CodeRabbit's review on #11974 noted that asserting two output files exist doesn't prove the recording was restarted - the same result occurs even if restartVncRecording() is a no-op, since afterTest() always writes differently-named files from a single continuous recorder. Extract each recording's actual duration via ffmpeg and assert the second is not roughly double the first, which is what a no-op restart would produce (one continuous stream covering both explores) versus a genuine restart (each recording covering only its own explore). --- ...ChromeRecordingWebDriverContainerTest.java | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java index c03376dcbe8..7a22e2cd807 100644 --- a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java @@ -20,8 +20,11 @@ import java.nio.file.Path; import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; @@ -33,6 +36,10 @@ class ChromeRecordingWebDriverContainerTest extends BaseWebDriverContainerTest { */ private static final int MINIMUM_VIDEO_DURATION_MILLISECONDS = 200; + private static final Pattern FFMPEG_DURATION_PATTERN = Pattern.compile( + "Duration: (\\d{2}):(\\d{2}):(\\d{2})\\.(\\d{2})" + ); + @Nested class ChromeThatRecordsAllTests { @@ -85,7 +92,7 @@ public String getFilesystemFriendlyName() { } @Test - void restartVncRecordingProducesASeparateFileForEachTest() throws InterruptedException { + void restartVncRecordingProducesASeparateFileForEachTest() throws InterruptedException, IOException { File target = vncRecordingDirectory.toFile(); try ( // restart { @@ -116,6 +123,56 @@ void restartVncRecordingProducesASeparateFileForEachTest() throws InterruptedExc File[] files = vncRecordingDirectory.toFile().listFiles(new PatternFilenameFilter("PASSED-.*\\.flv")); assertThat(files).as("a separate recording file exists per test").hasSize(2); + + Duration firstRecordingDuration = extractRecordedDuration(fileEndingWith(files, "-first")); + Duration secondRecordingDuration = extractRecordedDuration(fileEndingWith(files, "-second")); + + // Both recordings cover one explore each, so their durations should be in the same ballpark + // regardless of how long a single explore happens to take on this machine. If + // restartVncRecording() were a no-op, the second recording would be one continuous stream + // covering both explores - roughly double the first recording's duration - rather than just + // the interval captured after the restart. + assertThat(secondRecordingDuration) + .as("the second recording excludes the first test's interval") + .isLessThan(firstRecordingDuration.multipliedBy(3).dividedBy(2)); + } + } + + private File fileEndingWith(File[] files, String suffix) { + return Arrays + .stream(files) + .filter(file -> file.getName().contains(suffix + "-")) + .findFirst() + .orElseThrow(() -> new AssertionError("No recording file found matching " + suffix)); + } + + private Duration extractRecordedDuration(File recordingFile) throws IOException { + MountableFile mountableFile = MountableFile.forHostPath(recordingFile.getCanonicalPath()); + try ( + GenericContainer container = new GenericContainer<>( + DockerImageName.parse("testcontainers/vnc-recorder:1.3.0") + ) + ) { + String recordFileContainerPath = "/tmp/recording.flv"; + container + .withCopyFileToContainer(mountableFile, recordFileContainerPath) + .withCreateContainerCmdModifier(createContainerCmd -> createContainerCmd.withEntrypoint("ffmpeg")) + .withCommand("-i", recordFileContainerPath, "-f", "null", "-") + .waitingFor( + new LogMessageWaitStrategy() + .withRegEx(".*Duration.*") + .withStartupTimeout(Duration.of(60, ChronoUnit.SECONDS)) + ) + .start(); + + Matcher matcher = FFMPEG_DURATION_PATTERN.matcher(container.getLogs()); + assertThat(matcher.find()).as("ffmpeg output contains a Duration line").isTrue(); + + return Duration + .ofHours(Long.parseLong(matcher.group(1))) + .plusMinutes(Long.parseLong(matcher.group(2))) + .plusSeconds(Long.parseLong(matcher.group(3))) + .plusMillis(Long.parseLong(matcher.group(4)) * 10); } } From 4543e488519e9cdb23358d86894ea17a9445839d Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Sun, 23 Aug 2026 18:34:34 -0500 Subject: [PATCH 3/3] Assert second VNC recording has a positive duration CodeRabbit noted that the existing assertion only checked an upper bound, so a zero-duration or near-empty second recording would still pass. --- .../selenium/ChromeRecordingWebDriverContainerTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java index 7a22e2cd807..1846a6f0e5b 100644 --- a/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java +++ b/modules/selenium/src/test/java/org/testcontainers/selenium/ChromeRecordingWebDriverContainerTest.java @@ -134,6 +134,7 @@ void restartVncRecordingProducesASeparateFileForEachTest() throws InterruptedExc // the interval captured after the restart. assertThat(secondRecordingDuration) .as("the second recording excludes the first test's interval") + .isGreaterThan(Duration.ZERO) .isLessThan(firstRecordingDuration.multipliedBy(3).dividedBy(2)); } }