diff --git a/pom.xml b/pom.xml index 9391e05c0..1c3edee95 100644 --- a/pom.xml +++ b/pom.xml @@ -111,7 +111,7 @@ - 8 + 16 false diff --git a/src/main/java/com/xceptance/neodymium/common/ScreenshotWriter.java b/src/main/java/com/xceptance/neodymium/common/ScreenshotWriter.java index 8ccd5dca9..e80f20db4 100644 --- a/src/main/java/com/xceptance/neodymium/common/ScreenshotWriter.java +++ b/src/main/java/com/xceptance/neodymium/common/ScreenshotWriter.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.text.SimpleDateFormat; +import java.util.Map; import java.util.Optional; import javax.imageio.ImageIO; @@ -19,9 +20,16 @@ import org.openqa.selenium.Dimension; import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.OutputType; import org.openqa.selenium.Point; import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chromium.HasCdp; +import org.openqa.selenium.devtools.DevTools; +import org.openqa.selenium.devtools.HasDevTools; +import org.openqa.selenium.devtools.v137.page.Page; +import org.openqa.selenium.devtools.v137.page.model.Viewport; import org.openqa.selenium.firefox.FirefoxDriver; +import org.openqa.selenium.firefox.HasFullPageScreenshot; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,6 +38,7 @@ import com.assertthat.selenium_shutterbug.core.Shutterbug; import com.assertthat.selenium_shutterbug.utils.image.ImageProcessor; import com.assertthat.selenium_shutterbug.utils.web.Coordinates; +import com.google.common.collect.ImmutableMap; import com.xceptance.neodymium.common.testdata.DataSet; import com.xceptance.neodymium.util.AllureAddons; import com.xceptance.neodymium.util.Neodymium; @@ -76,73 +85,156 @@ public static boolean doScreenshot(String filename, String pathname) throws IOEx WebDriver driver = Neodymium.getDriver(); Capture captureMode = getCaptureMode(); - - PageSnapshot snapshot = Shutterbug.shootPage(driver, captureMode); - BufferedImage image = snapshot.getImage(); - Files.createDirectories(Paths.get(pathname)); - String imagePath = pathname + File.separator + filename + ".png"; - File outputfile = new File(imagePath); - - if (highlightViewPort() || blurFullPageScreenshot()) + WebDriver webDriver = Neodymium.getDriver(); + Optional imageOptional = Optional.empty(); + if (captureMode.equals(Capture.FULL)) { - double devicePixelRatio = Double.parseDouble(((JavascriptExecutor) driver).executeScript("return window.devicePixelRatio") + ""); - int offsetY = (int) (Double.parseDouble(((JavascriptExecutor) driver) - .executeScript("return Math.round(Math.max(document.documentElement.scrollTop, document.body.scrollTop))") - .toString())); - int offsetX = (int) (Double.parseDouble(((JavascriptExecutor) driver) - .executeScript("return Math.round(Math.max(document.documentElement.scrollLeft, document.body.scrollLeft))") - .toString())); - - Dimension size = Neodymium.getViewportSize(); - if (driver instanceof FirefoxDriver) + Optional imageFile = Optional.empty(); + if (webDriver instanceof HasFullPageScreenshot firefoxDriver) { - size = new Dimension(size.width - (int) (15 * devicePixelRatio), size.height - (int) (15 * devicePixelRatio)); + imageFile = Optional.of(firefoxDriver.getFullPageScreenshotAs(OutputType.FILE)); } - Point currentLocation = new Point(offsetX, offsetY); - Coordinates coords = new Coordinates(currentLocation, currentLocation, size, new Dimension(0, 0), devicePixelRatio); - - if (highlightViewPort()) + else if (webDriver instanceof HasCdp) { - image = highlightScreenShot(image, coords, Color.decode(Neodymium.configuration().fullScreenHighlightColor())); + imageFile = takeScreenshotWithCDP((WebDriver & HasCdp & JavascriptExecutor) webDriver, OutputType.FILE); } - if (blurFullPageScreenshot()) + else if (webDriver instanceof HasDevTools) { - image = ImageProcessor.blurExceptArea(image, coords); + imageFile = takeScreenshot((WebDriver & HasDevTools & JavascriptExecutor) webDriver, OutputType.FILE); } + if (imageFile.isPresent()) + { + imageOptional = Optional.of(ImageIO.read(imageFile.get())); + } + } + else + { + PageSnapshot snapshot = Shutterbug.shootPage(driver, captureMode); + imageOptional = Optional.of(snapshot.getImage()); } - if (Neodymium.configuration().enableHighlightLastElement() && Neodymium.hasLastUsedElement()) + if (imageOptional.isPresent()) { - try + BufferedImage image = imageOptional.get(); + Files.createDirectories(Paths.get(pathname)); + String imagePath = pathname + File.separator + filename + ".png"; + File outputfile = new File(imagePath); + + if (highlightViewPort() || blurFullPageScreenshot()) { - double devicePixelRatio = Double.parseDouble("" + ((JavascriptExecutor) driver).executeScript("return window.devicePixelRatio")); - image = highlightScreenShot(image, new Coordinates(Neodymium.getLastUsedElement(), devicePixelRatio), - Color.decode(Neodymium.configuration().screenshotElementHighlightColor())); + double devicePixelRatio = Double.parseDouble(((JavascriptExecutor) driver).executeScript("return window.devicePixelRatio") + ""); + int offsetY = (int) (Double.parseDouble(((JavascriptExecutor) driver) + .executeScript("return Math.round(Math.max(document.documentElement.scrollTop, document.body.scrollTop))") + .toString())); + int offsetX = (int) (Double.parseDouble(((JavascriptExecutor) driver) + .executeScript("return Math.round(Math.max(document.documentElement.scrollLeft, document.body.scrollLeft))") + .toString())); + + Dimension size = Neodymium.getViewportSize(); + if (driver instanceof FirefoxDriver) + { + size = new Dimension(size.width - (int) (15 * devicePixelRatio), size.height - (int) (15 * devicePixelRatio)); + } + Point currentLocation = new Point(offsetX, offsetY); + Coordinates coords = new Coordinates(currentLocation, currentLocation, size, new Dimension(0, 0), devicePixelRatio); + + if (highlightViewPort()) + { + image = highlightScreenShot(image, coords, Color.decode(Neodymium.configuration().fullScreenHighlightColor())); + } + if (blurFullPageScreenshot()) + { + image = ImageProcessor.blurExceptArea(image, coords); + } } - catch (NoSuchElementException e) + if (Neodymium.configuration().enableHighlightLastElement() && Neodymium.hasLastUsedElement()) { - // If the test is breaking because we can't find an element, we also can't highlight this element... so - // a NoSuchElementException is expected and can be ignored. + try + { + double devicePixelRatio = Double.parseDouble("" + ((JavascriptExecutor) driver).executeScript("return window.devicePixelRatio")); + image = highlightScreenShot(image, new Coordinates(Neodymium.getLastUsedElement(), devicePixelRatio), + Color.decode(Neodymium.configuration().screenshotElementHighlightColor())); + } + catch (NoSuchElementException e) + { + // If the test is breaking because we can't find an element, we also can't highlight this element... + // so + // a NoSuchElementException is expected and can be ignored. + } } - } - log.info("captured Screenshot to: " + imagePath); + log.info("captured Screenshot to: " + imagePath); - boolean result = ImageIO.write(image, "png", outputfile); - if (result) - { - // The idea is to put the screenshot to the best place in the report, - // but for before methods, this is not possible due to allure limitations - // so we just add it normally when the allure lifecycle does not allow to be altered - boolean screenshotAdded; - Allure.getLifecycle().addAttachment("Screenshot", "image/png", ".png", new FileInputStream(imagePath)); - screenshotAdded = true; - - // to spare disk space, remove the file if we already used it inside the report - if (screenshotAdded) + boolean result = ImageIO.write(image, "png", outputfile); + if (result) { - outputfile.delete(); + // The idea is to put the screenshot to the best place in the report, + // but for before methods, this is not possible due to allure limitations + // so we just add it normally when the allure lifecycle does not allow to be altered + boolean screenshotAdded; + Allure.getLifecycle().addAttachment("Screenshot", "image/png", ".png", new FileInputStream(imagePath)); + screenshotAdded = true; + + // to spare disk space, remove the file if we already used it inside the report + if (screenshotAdded) + { + outputfile.delete(); + } } + return result; } - return result; + return false; + } + + public static Optional takeScreenshot( + WD devtoolsDriver, + OutputType outputType) + { + DevTools devTools = devtoolsDriver.getDevTools(); + devTools.createSessionIfThereIsNotOne(devtoolsDriver.getWindowHandle()); + + long fullWidth = (long) devtoolsDriver.executeScript("return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.body.clientWidth, document.documentElement.clientWidth)"); + long fullHeight = (long) devtoolsDriver.executeScript("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight)"); + + long viewWidth = (long) devtoolsDriver.executeScript("return window.innerWidth"); + long viewHeight = (long) devtoolsDriver.executeScript("return window.innerHeight"); + boolean exceedViewport = fullWidth > viewWidth || fullHeight > viewHeight; + Viewport viewport = new Viewport(0, 0, fullWidth, fullHeight, 1); + + String base64 = devTools.send(Page.captureScreenshot( + Optional.empty(), + Optional.empty(), + Optional.of(viewport), + Optional.empty(), + Optional.of(exceedViewport), + Optional.of(true))); + + ResultType screenshot = outputType.convertFromBase64Png(base64); + return Optional.of(screenshot); + } + + public static Optional takeScreenshotWithCDP( + WD cdpDriver, + OutputType outputType) + { + long fullWidth = (long) cdpDriver.executeScript("return Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.body.clientWidth, document.documentElement.clientWidth)"); + long fullHeight = (long) cdpDriver.executeScript("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight)"); + + long viewWidth = (long) cdpDriver.executeScript("return window.innerWidth"); + long viewHeight = (long) cdpDriver.executeScript("return window.innerHeight"); + boolean exceedViewport = fullWidth > viewWidth || fullHeight > viewHeight; + Map captureScreenshotOptions = ImmutableMap.of( + "clip", ImmutableMap.of( + "x", 0, + "y", 0, + "width", fullWidth, + "height", fullHeight, + "scale", 1), + "captureBeyondViewport", exceedViewport); + + Map result = cdpDriver.executeCdpCommand("Page.captureScreenshot", captureScreenshotOptions); + + String base64 = (String) result.get("data"); + ResultType screenshot = outputType.convertFromBase64Png(base64); + return Optional.of(screenshot); } public static BufferedImage highlightScreenShot(BufferedImage sourceImage, Coordinates coords, Color color) diff --git a/src/main/java/com/xceptance/neodymium/common/recording/TakeScreenshotsThread.java b/src/main/java/com/xceptance/neodymium/common/recording/TakeScreenshotsThread.java index 5f72d5e7b..ca36671f5 100644 --- a/src/main/java/com/xceptance/neodymium/common/recording/TakeScreenshotsThread.java +++ b/src/main/java/com/xceptance/neodymium/common/recording/TakeScreenshotsThread.java @@ -1,9 +1,13 @@ package com.xceptance.neodymium.common.recording; +import java.awt.image.BufferedImage; import com.xceptance.neodymium.common.recording.config.RecordingConfigurations; import com.xceptance.neodymium.common.recording.writers.Writer; import com.xceptance.neodymium.util.AllureAddons; import io.qameta.allure.Allure; +import javax.imageio.ImageIO; + +import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.OutputType; import org.openqa.selenium.TakesScreenshot; import org.openqa.selenium.WebDriver; @@ -22,8 +26,9 @@ /** * Background thread to take screenshots and write them to the files using {@link Writer}. *

- * This class constructs the required writer on its own, it's only needed to pass the {@link Writer} class it should use. It also requires configuration object - * of type {@link RecordingConfigurations} and the name of the result file (will also be created by the class itself) + * This class constructs the required writer on its own, it's only needed to pass the {@link Writer} class it should + * use. It also requires configuration object of type {@link RecordingConfigurations} and the name of the result file + * (will also be created by the class itself) * * @author olha */ @@ -44,13 +49,13 @@ public class TakeScreenshotsThread extends Thread private Writer writer; public TakeScreenshotsThread(WebDriver driver, Class writerClass, RecordingConfigurations recordingConfigurations, - String testName) + String testName) throws IOException, NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException { this.recordingConfigurations = recordingConfigurations; fileName = recordingConfigurations.tempFolderToStoreRecording() - + testName.replaceAll("\\s", "-").replaceAll(":", "-").replaceAll("/", "_") + "." + recordingConfigurations.format(); + + testName.replaceAll("\\s", "-").replaceAll(":", "-").replaceAll("/", "_") + "." + recordingConfigurations.format(); this.writer = Writer.instantiate(writerClass, recordingConfigurations, fileName); File directory = new File(recordingConfigurations.tempFolderToStoreRecording()); if (!directory.exists()) @@ -82,6 +87,16 @@ public synchronized void run() LOGGER.error("Could not create temp file for last screenshot, alert handling for test recording will be disabled.", e); } + try + { + // Create a temp file to hold the last frame + lastFrameTempFile = File.createTempFile("last_frame_", ".png"); + } + catch (IOException e) + { + LOGGER.error("Could not create temp file for last screenshot, alert handling for test recording will be disabled.", e); + } + try { // try to start writer @@ -101,7 +116,8 @@ public synchronized void run() { long delay = Math.max(recordingConfigurations.oneImagePerMilliseconds(), duration); - // taking a screenshot while an alert is open will throw an exception and closes the alert, so it is checked here + // taking a screenshot while an alert is open will throw an exception and closes the alert, so + // it is checked here if (alertIsPresent().apply(driver) != null) { // write the last successful frame again, if it exists @@ -113,6 +129,14 @@ public synchronized void run() else { File file = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE); + int cropWidth = ((Long) ((JavascriptExecutor) driver).executeScript("return window.innerWidth;")).intValue(); + int cropHeight = ((Long) ((JavascriptExecutor) driver).executeScript("return window.innerHeight;")).intValue(); + BufferedImage fullImage = ImageIO.read(file); + if (cropWidth > 0 && cropHeight > 0) + { + BufferedImage croppedImage = fullImage.getSubimage(0, 0, cropWidth, cropHeight); + ImageIO.write(croppedImage, "png", file); + } writer.compressImageIfNeeded(file, recordingConfigurations.imageScaleFactor(), recordingConfigurations.imageQuality()); writer.write(file, delay); @@ -148,7 +172,7 @@ public synchronized void run() if (recordingConfigurations.logInformationAboutRecording()) { AllureAddons.addToReport("average " + (isGif ? "gif" : "video") + " sequence recording creation duration = " + millis + " / " + turns + "=" - + millis / turns, ""); + + millis / turns, ""); } writer.stop(); try @@ -189,7 +213,8 @@ public synchronized void run() * Stops screenshot loop. Please, don't forget to call {@link Thread#join()} method after this to kill the thread * * @param testFailed - * {@link Boolean} if the filmed test failed (needed to decide whether the record should be attached to the allure report) + * {@link Boolean} if the filmed test failed (needed to decide whether the record should be attached to + * the allure report) */ public void stopRun(boolean testFailed) { diff --git a/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationTest.java b/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationTest.java index e7163f5d6..14660b86a 100644 --- a/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationTest.java +++ b/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationTest.java @@ -10,6 +10,7 @@ import org.aeonbits.owner.ConfigFactory; import org.apache.commons.lang3.StringUtils; import org.junit.Assert; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import com.xceptance.neodymium.common.recording.FilmTestExecution; @@ -19,7 +20,9 @@ public class RecordingDurationTest extends AbstractNeodymiumTest { - public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOException + private File recordingFile; + + public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOException, InterruptedException { CustomRecordingTest.isGif = isGif; String format = isGif ? "gif" : "video"; @@ -36,8 +39,11 @@ public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOEx tempFiles.add(tempConfigFile1); run(CustomRecordingTest.class); RecordingConfigurations config = isGif ? FilmTestExecution.getContextGif() : FilmTestExecution.getContextVideo(); - File recordingFile = new File(config.tempFolderToStoreRecording() + CustomRecordingTest.uuid + "." + config.format()); - recordingFile.deleteOnExit(); + recordingFile = new File(config.tempFolderToStoreRecording() + CustomRecordingTest.uuid + "." + config.format()); + for (int i = 0; i < 3 && !recordingFile.exists(); i++) + { + Thread.sleep(1000); + } Assert.assertTrue("the recording file doesn't exist", recordingFile.exists()); ProcessBuilder pb = new ProcessBuilder("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recordingFile.getAbsolutePath()); pb.redirectErrorStream(true); @@ -53,7 +59,7 @@ public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOEx } @Test - public void testVideoRecording() throws IOException + public void testVideoRecording() throws IOException, InterruptedException { double run100 = runTest(false, "100"); double run1000 = runTest(false, "1000"); @@ -63,7 +69,7 @@ public void testVideoRecording() throws IOException } @Test - public void testGifRecording() throws IOException + public void testGifRecording() throws IOException, InterruptedException { double run100 = runTest(true, "100"); double run1500 = runTest(true, "1000"); @@ -72,11 +78,20 @@ public void testGifRecording() throws IOException } @Test - public void testMixedRecording() throws IOException + public void testMixedRecording() throws IOException, InterruptedException { double runVideo1000 = runTest(false, "1000"); double runGif1000 = runTest(true, "1000"); Assert.assertEquals("Gifs with different oneImagePerMilliseconds value should have approximaty the same length (video = " + runVideo1000 + ", gif = " + runGif1000 + ")", runVideo1000, runGif1000, 5.0); } + + @AfterEach + public void cleanup() + { + if (recordingFile != null) + { + recordingFile.delete(); + } + } } diff --git a/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationWithAlertTest.java b/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationWithAlertTest.java index 1746a887e..32b47051d 100644 --- a/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationWithAlertTest.java +++ b/src/test/java/com/xceptance/neodymium/junit5/tests/recording/automatic/RecordingDurationWithAlertTest.java @@ -7,6 +7,7 @@ import org.aeonbits.owner.ConfigFactory; import org.apache.commons.lang3.StringUtils; import org.junit.Assert; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import java.io.BufferedReader; @@ -18,7 +19,9 @@ public class RecordingDurationWithAlertTest extends AbstractNeodymiumTest { - public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOException + private File recordingFile; + + public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOException, InterruptedException { CustomRecordingWithAlertTest.isGif = isGif; String format = isGif ? "gif" : "video"; @@ -40,12 +43,15 @@ public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOEx run(CustomRecordingWithAlertTest.class); RecordingConfigurations config = isGif ? FilmTestExecution.getContextGif() : FilmTestExecution.getContextVideo(); - File recordingFile = new File(config.tempFolderToStoreRecording() + CustomRecordingWithAlertTest.uuid + "." + config.format()); - recordingFile.deleteOnExit(); + recordingFile = new File(config.tempFolderToStoreRecording() + CustomRecordingWithAlertTest.uuid + "." + config.format()); + + for (int i = 0; i < 3 && !recordingFile.exists(); i++) + { + Thread.sleep(1000); + } Assert.assertTrue("the recording file doesn't exist", recordingFile.exists()); - ProcessBuilder pb = new ProcessBuilder("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", - recordingFile.getAbsolutePath()); + ProcessBuilder pb = new ProcessBuilder("ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", recordingFile.getAbsolutePath()); pb.redirectErrorStream(true); Process p = pb.start(); @@ -61,30 +67,39 @@ public double runTest(boolean isGif, String oneImagePerMilliseconds) throws IOEx } @Test - public void testVideoRecording() throws IOException + public void testVideoRecording() throws IOException, InterruptedException { double run100 = runTest(false, "100"); double run1000 = runTest(false, "1000"); Assert.assertEquals("Videos with different oneImagePerMilliseconds value should have approximaty the same length (1/100 = " + run1000 - + ", 1/1000 = " + run100 + ")", run1000, run1000, 5.0); + + ", 1/1000 = " + run100 + ")", run1000, run1000, 5.0); } @Test - public void testGifRecording() throws IOException + public void testGifRecording() throws IOException, InterruptedException { double run100 = runTest(true, "100"); double run1000 = runTest(true, "1000"); Assert.assertEquals("Gifs with different oneImagePerMilliseconds value should have approximaty the same length (1/100 = " + run100 + ", 1/1000 = " - + run1000 + ")", run100, run1000, 5.0); + + run1000 + ")", run100, run1000, 5.0); } @Test - public void testMixedRecording() throws IOException + public void testMixedRecording() throws IOException, InterruptedException { double runVideo1000 = runTest(false, "1000"); double runGif1000 = runTest(true, "1000"); Assert.assertEquals("Gifs with different oneImagePerMilliseconds value should have approximaty the same length (video = " + runVideo1000 + ", gif = " - + runGif1000 + ")", runVideo1000, runGif1000, 5.0); + + runGif1000 + ")", runVideo1000, runGif1000, 5.0); + } + + @AfterEach + public void cleanup() + { + if (recordingFile != null) + { + recordingFile.delete(); + } } }