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..1846a6f0e5b 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 { @@ -63,23 +70,113 @@ 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, IOException { + 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); + + 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") + .isGreaterThan(Duration.ZERO) + .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); + } + } + @Test void recordingTestShouldHaveFlvExtension() throws InterruptedException { File target = vncRecordingDirectory.toFile();