From 31484603688890889ffbc30c51646d347068b656 Mon Sep 17 00:00:00 2001 From: agadda Date: Wed, 22 Jul 2026 19:57:04 +0530 Subject: [PATCH] feat: add hasScreenshot() visual comparison assertions for Page and Locator Implements the Java equivalent of upstream's toHaveScreenshot() (issue #1040), which is otherwise only available in the JS @playwright/test runner. - Adds PageAssertions.hasScreenshot()/LocatorAssertions.hasScreenshot() with full option parity (animations, caret, clip, fullPage, mask, maskColor, omitBackground, scale, maxDiffPixels, maxDiffPixelRatio, threshold, style, timeout), matching the JS API naming convention used elsewhere in the Java bindings (hasTitle/hasURL -> hasScreenshot). - Wires the existing driver-side Page.expectScreenshot protocol method (already bundled in the Node.js driver) via new Protocol.java DTOs and PageImpl.expectScreenshot(), so the actual pixel comparison (pixelmatch/ SSIM) is done by the driver exactly as in JS - no image-diff algorithm is reimplemented in Java. - Adds ScreenshotAssertionsHelper, a Java-specific adaptation of the @playwright/test SnapshotHelper snapshot lifecycle (baseline creation, comparison, .not() handling, actual/diff debug artifacts), since the Java bindings have no test-runner-managed snapshot directory: * Baselines stored under src/test/resources/__screenshots__// by default, overridable via -Dplaywright.snapshotDir * -Dplaywright.updateSnapshots=true regenerates baselines - Adds TestScreenshotAssertions covering baseline creation, matching, mismatch failure, .not() with a missing baseline, and locator screenshots. All existing assertion tests (TestPageAssertions, TestLocatorAssertions, TestLocatorAssertions2 - 147 tests) continue to pass unmodified. --- .../assertions/LocatorAssertions.java | 240 +++++++++++++++ .../playwright/assertions/PageAssertions.java | 273 ++++++++++++++++++ .../impl/LocatorAssertionsImpl.java | 20 ++ .../playwright/impl/PageAssertionsImpl.java | 20 ++ .../microsoft/playwright/impl/PageImpl.java | 39 +++ .../microsoft/playwright/impl/Protocol.java | 36 +++ .../impl/ScreenshotAssertionsHelper.java | 250 ++++++++++++++++ .../impl/ScreenshotAssertionsOptions.java | 43 +++ .../playwright/TestScreenshotAssertions.java | 93 ++++++ 9 files changed, 1014 insertions(+) create mode 100644 playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsHelper.java create mode 100644 playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsOptions.java create mode 100644 playwright/src/test/java/com/microsoft/playwright/TestScreenshotAssertions.java diff --git a/playwright/src/main/java/com/microsoft/playwright/assertions/LocatorAssertions.java b/playwright/src/main/java/com/microsoft/playwright/assertions/LocatorAssertions.java index 780b87751..4f4f2a72d 100644 --- a/playwright/src/main/java/com/microsoft/playwright/assertions/LocatorAssertions.java +++ b/playwright/src/main/java/com/microsoft/playwright/assertions/LocatorAssertions.java @@ -18,8 +18,13 @@ import java.util.*; import java.util.regex.Pattern; +import com.microsoft.playwright.Locator; import com.microsoft.playwright.options.AriaRole; +import com.microsoft.playwright.options.Clip; import com.microsoft.playwright.options.PseudoElement; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; /** * The {@code LocatorAssertions} class provides assertion methods that can be used to make assertions about the {@code @@ -59,6 +64,168 @@ public IsAttachedOptions setTimeout(double timeout) { return this; } } + class HasScreenshotOptions { + /** + * When set to {@code "disabled"}, stops CSS animations, CSS transitions and Web Animations. Animations get different + * treatment depending on their duration: + *
    + *
  • finite animations are fast-forwarded to completion, so they'll fire {@code transitionend} event.
  • + *
  • infinite animations are canceled to initial state, and then played over after the screenshot.
  • + *
+ * + *

Defaults to {@code "disabled"}. + */ + public ScreenshotAnimations animations; + /** + * When set to {@code "hide"}, screenshot will hide text caret. When set to {@code "initial"}, text caret behavior will not + * be changed. Defaults to {@code "hide"}. + */ + public ScreenshotCaret caret; + /** + * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink box + * {@code #FF00FF} (customized by {@code maskColor}) that completely covers its bounding box. + */ + public List mask; + /** + * Specify the color of the overlay box for masked elements, in CSS color format. Default color is pink {@code + * #FF00FF}. + */ + public String maskColor; + /** + * An acceptable amount of pixels that could be different. Unset by default. + */ + public Integer maxDiffPixels; + /** + * An acceptable ratio of pixels that are different to the total amount of pixels, between {@code 0} and {@code 1}. Unset + * by default. + */ + public Double maxDiffPixelRatio; + /** + * Hides default white background and allows capturing screenshots with transparency. Not applicable to {@code jpeg} + * images. Defaults to {@code false}. + */ + public Boolean omitBackground; + /** + * When set to {@code "css"}, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, + * this will keep screenshots small. Using {@code "device"} option will produce a single pixel per each device pixel, so + * screenshots of high-dpi devices will be twice as large or even larger. + * + *

Defaults to {@code "css"}. + */ + public ScreenshotScale scale; + /** + * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements + * invisible or change their properties to help you creating repeatable screenshots. + */ + public String style; + /** + * An acceptable perceived color difference between the same pixel in compared images, between zero (strict) and one + * (lax), default is {@code 0.2}. + */ + public Double threshold; + /** + * Time to retry the assertion for in milliseconds. Defaults to {@code 5000}. + */ + public Double timeout; + + /** + * When set to {@code "disabled"}, stops CSS animations, CSS transitions and Web Animations. Animations get different + * treatment depending on their duration: + *

    + *
  • finite animations are fast-forwarded to completion, so they'll fire {@code transitionend} event.
  • + *
  • infinite animations are canceled to initial state, and then played over after the screenshot.
  • + *
+ * + *

Defaults to {@code "disabled"}. + */ + public HasScreenshotOptions setAnimations(ScreenshotAnimations animations) { + this.animations = animations; + return this; + } + /** + * When set to {@code "hide"}, screenshot will hide text caret. When set to {@code "initial"}, text caret behavior will not + * be changed. Defaults to {@code "hide"}. + */ + public HasScreenshotOptions setCaret(ScreenshotCaret caret) { + this.caret = caret; + return this; + } + /** + * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink box + * {@code #FF00FF} (customized by {@code maskColor}) that completely covers its bounding box. + */ + public HasScreenshotOptions setMask(List mask) { + this.mask = mask; + return this; + } + /** + * Specify the color of the overlay box for masked elements, in CSS color format. Default color is pink {@code + * #FF00FF}. + */ + public HasScreenshotOptions setMaskColor(String maskColor) { + this.maskColor = maskColor; + return this; + } + /** + * An acceptable amount of pixels that could be different. Unset by default. + */ + public HasScreenshotOptions setMaxDiffPixels(int maxDiffPixels) { + this.maxDiffPixels = maxDiffPixels; + return this; + } + /** + * An acceptable ratio of pixels that are different to the total amount of pixels, between {@code 0} and {@code 1}. Unset + * by default. + */ + public HasScreenshotOptions setMaxDiffPixelRatio(double maxDiffPixelRatio) { + this.maxDiffPixelRatio = maxDiffPixelRatio; + return this; + } + /** + * Hides default white background and allows capturing screenshots with transparency. Not applicable to {@code jpeg} + * images. Defaults to {@code false}. + */ + public HasScreenshotOptions setOmitBackground(boolean omitBackground) { + this.omitBackground = omitBackground; + return this; + } + /** + * When set to {@code "css"}, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, + * this will keep screenshots small. Using {@code "device"} option will produce a single pixel per each device pixel, so + * screenshots of high-dpi devices will be twice as large or even larger. + * + *

Defaults to {@code "css"}. + */ + public HasScreenshotOptions setScale(ScreenshotScale scale) { + this.scale = scale; + return this; + } + /** + * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements + * invisible or change their properties to help you creating repeatable screenshots. + */ + public HasScreenshotOptions setStyle(String style) { + this.style = style; + return this; + } + /** + * An acceptable perceived color difference between the same pixel in compared images, between zero (strict) and one + * (lax), default is {@code 0.2}. + */ + public HasScreenshotOptions setThreshold(double threshold) { + this.threshold = threshold; + return this; + } + /** + * Time to retry the assertion for in milliseconds. Defaults to {@code 5000}. + */ + public HasScreenshotOptions setTimeout(double timeout) { + this.timeout = timeout; + return this; + } + } class IsCheckedOptions { /** * Provides state to assert for. Asserts for input to be checked by default. This option can't be used when {@code @@ -2459,5 +2626,78 @@ default void matchesAriaSnapshot(String expected) { * @since v1.49 */ void matchesAriaSnapshot(String expected, MatchesAriaSnapshotOptions options); + /** + * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + *

Usage + *

{@code
+   * Locator locator = page.getByRole(AriaRole.BUTTON);
+   * assertThat(locator).hasScreenshot("image.png");
+   * }
+ * + *

Note that screenshot assertions only work with the Playwright driver's screenshot comparison support; there is no + * concept of a test-runner-managed snapshot directory as in {@code @playwright/test}. By default, baseline images are + * stored under {@code src/test/resources/__screenshots__//}, overridable via the {@code + * playwright.snapshotDir} system property. Pass {@code -Dplaywright.updateSnapshots=true} to (re-)generate baselines. + * + * @param name Snapshot name. Must have a {@code .png} or {@code .webp} extension, the screenshot is captured in the corresponding format. Both formats are lossless. + * @since v1.23 + */ + default void hasScreenshot(String name) { + hasScreenshot(name, null); + } + /** + * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + * @param name Snapshot name. Must have a {@code .png} or {@code .webp} extension, the screenshot is captured in the corresponding format. Both formats are lossless. + * @since v1.23 + */ + void hasScreenshot(String name, HasScreenshotOptions options); + /** + * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + * @param nameSegments Snapshot name segments that will be joined to form the file name. Must have a {@code .png} or {@code .webp} extension on the last segment. + * @since v1.23 + */ + default void hasScreenshot(String[] nameSegments) { + hasScreenshot(nameSegments, null); + } + /** + * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + * @param nameSegments Snapshot name segments that will be joined to form the file name. Must have a {@code .png} or {@code .webp} extension on the last segment. + * @since v1.23 + */ + void hasScreenshot(String[] nameSegments, HasScreenshotOptions options); + /** + * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + *

The snapshot is stored in the PNG format. To store it in the WebP format instead, pass a snapshot name with the + * {@code .webp} extension via {@link com.microsoft.playwright.assertions.LocatorAssertions#hasScreenshot + * LocatorAssertions.hasScreenshot()}. + * + *

Usage + *

{@code
+   * Locator locator = page.getByRole(AriaRole.BUTTON);
+   * assertThat(locator).hasScreenshot();
+   * }
+ * + * @since v1.23 + */ + default void hasScreenshot() { + hasScreenshot((HasScreenshotOptions) null); + } + /** + * This function will wait until two consecutive locator screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + * @since v1.23 + */ + void hasScreenshot(HasScreenshotOptions options); } diff --git a/playwright/src/main/java/com/microsoft/playwright/assertions/PageAssertions.java b/playwright/src/main/java/com/microsoft/playwright/assertions/PageAssertions.java index 88e529e38..9198749c0 100644 --- a/playwright/src/main/java/com/microsoft/playwright/assertions/PageAssertions.java +++ b/playwright/src/main/java/com/microsoft/playwright/assertions/PageAssertions.java @@ -16,7 +16,13 @@ package com.microsoft.playwright.assertions; +import java.util.List; import java.util.regex.Pattern; +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.options.Clip; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; /** * The {@code PageAssertions} class provides assertion methods that can be used to make assertions about the {@code Page} @@ -51,6 +57,192 @@ public MatchesAriaSnapshotOptions setTimeout(double timeout) { return this; } } + class HasScreenshotOptions { + /** + * When set to {@code "disabled"}, stops CSS animations, CSS transitions and Web Animations. Animations get different + * treatment depending on their duration: + *
    + *
  • finite animations are fast-forwarded to completion, so they'll fire {@code transitionend} event.
  • + *
  • infinite animations are canceled to initial state, and then played over after the screenshot.
  • + *
+ * + *

Defaults to {@code "disabled"}. + */ + public ScreenshotAnimations animations; + /** + * When set to {@code "hide"}, screenshot will hide text caret. When set to {@code "initial"}, text caret behavior will not + * be changed. Defaults to {@code "hide"}. + */ + public ScreenshotCaret caret; + /** + * An object which specifies clipping of the resulting image. + */ + public Clip clip; + /** + * When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to + * {@code false}. + */ + public Boolean fullPage; + /** + * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink box + * {@code #FF00FF} (customized by {@code maskColor}) that completely covers its bounding box. + */ + public List mask; + /** + * Specify the color of the overlay box for masked elements, in CSS color format. Default color is pink {@code + * #FF00FF}. + */ + public String maskColor; + /** + * An acceptable amount of pixels that could be different. Unset by default. + */ + public Integer maxDiffPixels; + /** + * An acceptable ratio of pixels that are different to the total amount of pixels, between {@code 0} and {@code 1}. Unset + * by default. + */ + public Double maxDiffPixelRatio; + /** + * Hides default white background and allows capturing screenshots with transparency. Not applicable to {@code jpeg} + * images. Defaults to {@code false}. + */ + public Boolean omitBackground; + /** + * When set to {@code "css"}, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, + * this will keep screenshots small. Using {@code "device"} option will produce a single pixel per each device pixel, so + * screenshots of high-dpi devices will be twice as large or even larger. + * + *

Defaults to {@code "css"}. + */ + public ScreenshotScale scale; + /** + * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements + * invisible or change their properties to help you creating repeatable screenshots. + */ + public String style; + /** + * An acceptable perceived color difference between the same pixel in compared images, between zero (strict) and one + * (lax), default is {@code 0.2}. + */ + public Double threshold; + /** + * Time to retry the assertion for in milliseconds. Defaults to {@code 5000}. + */ + public Double timeout; + + /** + * When set to {@code "disabled"}, stops CSS animations, CSS transitions and Web Animations. Animations get different + * treatment depending on their duration: + *

    + *
  • finite animations are fast-forwarded to completion, so they'll fire {@code transitionend} event.
  • + *
  • infinite animations are canceled to initial state, and then played over after the screenshot.
  • + *
+ * + *

Defaults to {@code "disabled"}. + */ + public HasScreenshotOptions setAnimations(ScreenshotAnimations animations) { + this.animations = animations; + return this; + } + /** + * When set to {@code "hide"}, screenshot will hide text caret. When set to {@code "initial"}, text caret behavior will not + * be changed. Defaults to {@code "hide"}. + */ + public HasScreenshotOptions setCaret(ScreenshotCaret caret) { + this.caret = caret; + return this; + } + /** + * An object which specifies clipping of the resulting image. + */ + public HasScreenshotOptions setClip(Clip clip) { + this.clip = clip; + return this; + } + /** + * When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to + * {@code false}. + */ + public HasScreenshotOptions setFullPage(boolean fullPage) { + this.fullPage = fullPage; + return this; + } + /** + * Specify locators that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink box + * {@code #FF00FF} (customized by {@code maskColor}) that completely covers its bounding box. + */ + public HasScreenshotOptions setMask(List mask) { + this.mask = mask; + return this; + } + /** + * Specify the color of the overlay box for masked elements, in CSS color format. Default color is pink {@code + * #FF00FF}. + */ + public HasScreenshotOptions setMaskColor(String maskColor) { + this.maskColor = maskColor; + return this; + } + /** + * An acceptable amount of pixels that could be different. Unset by default. + */ + public HasScreenshotOptions setMaxDiffPixels(int maxDiffPixels) { + this.maxDiffPixels = maxDiffPixels; + return this; + } + /** + * An acceptable ratio of pixels that are different to the total amount of pixels, between {@code 0} and {@code 1}. Unset + * by default. + */ + public HasScreenshotOptions setMaxDiffPixelRatio(double maxDiffPixelRatio) { + this.maxDiffPixelRatio = maxDiffPixelRatio; + return this; + } + /** + * Hides default white background and allows capturing screenshots with transparency. Not applicable to {@code jpeg} + * images. Defaults to {@code false}. + */ + public HasScreenshotOptions setOmitBackground(boolean omitBackground) { + this.omitBackground = omitBackground; + return this; + } + /** + * When set to {@code "css"}, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, + * this will keep screenshots small. Using {@code "device"} option will produce a single pixel per each device pixel, so + * screenshots of high-dpi devices will be twice as large or even larger. + * + *

Defaults to {@code "css"}. + */ + public HasScreenshotOptions setScale(ScreenshotScale scale) { + this.scale = scale; + return this; + } + /** + * Text of the stylesheet to apply while making the screenshot. This is where you can hide dynamic elements, make elements + * invisible or change their properties to help you creating repeatable screenshots. + */ + public HasScreenshotOptions setStyle(String style) { + this.style = style; + return this; + } + /** + * An acceptable perceived color difference between the same pixel in compared images, between zero (strict) and one + * (lax), default is {@code 0.2}. + */ + public HasScreenshotOptions setThreshold(double threshold) { + this.threshold = threshold; + return this; + } + /** + * Time to retry the assertion for in milliseconds. Defaults to {@code 5000}. + */ + public HasScreenshotOptions setTimeout(double timeout) { + this.timeout = timeout; + return this; + } + } class HasTitleOptions { /** * Time to retry the assertion for in milliseconds. Defaults to {@code 5000}. @@ -139,6 +331,87 @@ default void matchesAriaSnapshot(String expected) { * @since v1.60 */ void matchesAriaSnapshot(String expected, MatchesAriaSnapshotOptions options); + /** + * This function will wait until two consecutive page screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + *

Usage + *

{@code
+   * assertThat(page).hasScreenshot("image.png");
+   * }
+ * + *

Note that screenshot assertions only work with the Playwright driver's screenshot comparison support; there is no + * concept of a test-runner-managed snapshot directory as in {@code @playwright/test}. By default, baseline images are + * stored under {@code src/test/resources/__screenshots__//}, overridable via the {@code + * playwright.snapshotDir} system property. Pass {@code -Dplaywright.updateSnapshots=true} to (re-)generate baselines. + * + * @param name Snapshot name. Must have a {@code .png} or {@code .webp} extension, the screenshot is captured in the corresponding format. Both formats are lossless. + * @since v1.23 + */ + default void hasScreenshot(String name) { + hasScreenshot(name, null); + } + /** + * This function will wait until two consecutive page screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + *

Usage + *

{@code
+   * assertThat(page).hasScreenshot("image.png");
+   * }
+ * + * @param name Snapshot name. Must have a {@code .png} or {@code .webp} extension, the screenshot is captured in the corresponding format. Both formats are lossless. + * @since v1.23 + */ + void hasScreenshot(String name, HasScreenshotOptions options); + /** + * This function will wait until two consecutive page screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + *

Usage + *

{@code
+   * assertThat(page).hasScreenshot(new String[] {"folder", "image.png"});
+   * }
+ * + * @param nameSegments Snapshot name segments that will be joined to form the file name. Must have a {@code .png} or {@code .webp} extension on the last segment. + * @since v1.23 + */ + default void hasScreenshot(String[] nameSegments) { + hasScreenshot(nameSegments, null); + } + /** + * This function will wait until two consecutive page screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + * @param nameSegments Snapshot name segments that will be joined to form the file name. Must have a {@code .png} or {@code .webp} extension on the last segment. + * @since v1.23 + */ + void hasScreenshot(String[] nameSegments, HasScreenshotOptions options); + /** + * This function will wait until two consecutive page screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + *

The snapshot is stored in the PNG format. To store it in the WebP format instead, pass a snapshot name with the + * {@code .webp} extension via {@link com.microsoft.playwright.assertions.PageAssertions#hasScreenshot + * PageAssertions.hasScreenshot()}. + * + *

Usage + *

{@code
+   * assertThat(page).hasScreenshot();
+   * }
+ * + * @since v1.23 + */ + default void hasScreenshot() { + hasScreenshot((HasScreenshotOptions) null); + } + /** + * This function will wait until two consecutive page screenshots yield the same result, and then compare the last + * screenshot with the expectation. + * + * @since v1.23 + */ + void hasScreenshot(HasScreenshotOptions options); /** * Ensures the page has the given title. * diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/LocatorAssertionsImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/LocatorAssertionsImpl.java index 088910c98..b7dc9d083 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/LocatorAssertionsImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/LocatorAssertionsImpl.java @@ -385,6 +385,26 @@ public void matchesAriaSnapshot(String expected, MatchesAriaSnapshotOptions snap expectImpl("to.match.aria", options, expected,"Locator expected to match Aria snapshot", "Assert \"matchesAriaSnapshot\""); } + @Override + public void hasScreenshot(String name, HasScreenshotOptions options) { + hasScreenshotImpl(name, options); + } + + @Override + public void hasScreenshot(String[] nameSegments, HasScreenshotOptions options) { + hasScreenshotImpl(nameSegments, options); + } + + @Override + public void hasScreenshot(HasScreenshotOptions options) { + hasScreenshotImpl(null, options); + } + + private void hasScreenshotImpl(Object nameOrNames, HasScreenshotOptions options) { + ScreenshotAssertionsOptions screenshotOptions = convertType(options, ScreenshotAssertionsOptions.class); + new ScreenshotAssertionsHelper(actualLocator.frame.page, actualLocator, isNot).assertScreenshot(nameOrNames, screenshotOptions, "Assert \"hasScreenshot\""); + } + @Override public void isChecked(IsCheckedOptions options) { if (options == null) { diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/PageAssertionsImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/PageAssertionsImpl.java index 2cdd63d17..4f130a4e4 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/PageAssertionsImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/PageAssertionsImpl.java @@ -83,6 +83,26 @@ public void matchesAriaSnapshot(String expected, MatchesAriaSnapshotOptions snap expectImpl("to.match.aria", options, expected, "Page expected to match Aria snapshot", "Assert \"matchesAriaSnapshot\""); } + @Override + public void hasScreenshot(String name, HasScreenshotOptions options) { + hasScreenshotImpl(name, options); + } + + @Override + public void hasScreenshot(String[] nameSegments, HasScreenshotOptions options) { + hasScreenshotImpl(nameSegments, options); + } + + @Override + public void hasScreenshot(HasScreenshotOptions options) { + hasScreenshotImpl(null, options); + } + + private void hasScreenshotImpl(Object nameOrNames, HasScreenshotOptions options) { + ScreenshotAssertionsOptions screenshotOptions = convertType(options, ScreenshotAssertionsOptions.class); + new ScreenshotAssertionsHelper(actualPage, null, isNot).assertScreenshot(nameOrNames, screenshotOptions, "Assert \"hasScreenshot\""); + } + @Override public PageAssertions not() { return new PageAssertionsImpl(actualPage, !isNot); diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java index 4956ea623..fe2e2a7d8 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java @@ -1246,6 +1246,45 @@ public List selectOption(String selector, SelectOption value, SelectOpti return selectOption(selector, values, options); } + static class ExpectScreenshotResult { + byte[] actual; + byte[] previous; + byte[] diff; + String errorMessage; + List log; + boolean timedOut; + } + + ExpectScreenshotResult expectScreenshot(PageExpectScreenshotOptions options, String title) { + return withTitle(title, () -> expectScreenshot(options)); + } + + ExpectScreenshotResult expectScreenshot(PageExpectScreenshotOptions options) { + JsonObject params = gson().toJsonTree(options).getAsJsonObject(); + ExpectScreenshotResult result = new ExpectScreenshotResult(); + try { + JsonObject json = sendMessage("expectScreenshot", params, options.timeout).getAsJsonObject(); + if (json.has("actual")) { + result.actual = Base64.getDecoder().decode(json.get("actual").getAsString()); + } + } catch (ServerErrorWithDetails e) { + PageExpectScreenshotErrorDetails details = gson().fromJson(e.errorDetails(), PageExpectScreenshotErrorDetails.class); + if (details.actual != null) { + result.actual = Base64.getDecoder().decode(details.actual); + } + if (details.previous != null) { + result.previous = Base64.getDecoder().decode(details.previous); + } + if (details.diff != null) { + result.diff = Base64.getDecoder().decode(details.diff); + } + result.errorMessage = details.customErrorMessage; + result.log = details.log; + result.timedOut = Boolean.TRUE.equals(details.timedOut); + } + return result; + } + private byte[] screenshotImpl(ScreenshotOptions options) { if (options == null) { options = new ScreenshotOptions(); diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/Protocol.java b/playwright/src/main/java/com/microsoft/playwright/impl/Protocol.java index b7e02eed2..ebc27f884 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/Protocol.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/Protocol.java @@ -128,4 +128,40 @@ class FrameExpectErrorDetails { String customErrorMessage; } +class PageExpectScreenshotOptions { + String expected; + boolean isNot; + LocatorImpl locator; + String comparator; + Double maxDiffPixels; + Double maxDiffPixelRatio; + Double threshold; + Boolean fullPage; + com.microsoft.playwright.options.Clip clip; + // Only "png" or "webp" are valid here, computed internally based on the snapshot + // file extension (there is no public "webp" value in ScreenshotType). + String type; + Boolean omitBackground; + com.microsoft.playwright.options.ScreenshotCaret caret; + com.microsoft.playwright.options.ScreenshotAnimations animations; + com.microsoft.playwright.options.ScreenshotScale scale; + List mask; + String maskColor; + String style; + Double timeout; +} + +class PageExpectScreenshotResult { + String actual; +} + +class PageExpectScreenshotErrorDetails { + String diff; + String customErrorMessage; + String actual; + String previous; + Boolean timedOut; + List log; +} + diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsHelper.java b/playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsHelper.java new file mode 100644 index 000000000..8560ca3be --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsHelper.java @@ -0,0 +1,250 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed 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 + * + * http://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 com.microsoft.playwright.impl; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.PlaywrightException; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; +import org.opentest4j.AssertionFailedError; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +// Implements the Java counterpart of `toHaveScreenshot()`, ported from the +// `SnapshotHelper` logic in `packages/playwright/src/matchers/toMatchSnapshot.ts` +// of the upstream Playwright repository. Since the Java bindings do not have a +// dedicated test-runner (unlike @playwright/test), the snapshot storage/update +// conventions below are a Java-specific adaptation: +// - Snapshots are stored under "src/test/resources/__screenshots__//" +// by default. Override the root with the "playwright.snapshotDir" system property. +// - Pass "-Dplaywright.updateSnapshots=true" to (re-)generate/update baseline snapshots, +// analogous to "--update-snapshots" for @playwright/test. +// The actual pixel-level image comparison (pixelmatch/SSIM) is performed by the +// Playwright driver (Node.js) via the "Page.expectScreenshot" protocol method - +// this class only handles the Java-side snapshot lifecycle (resolving the file, +// reading/writing baselines) and error formatting. +class ScreenshotAssertionsHelper { + private static final String SNAPSHOT_DIR_PROPERTY = "playwright.snapshotDir"; + private static final String DEFAULT_SNAPSHOT_DIR = "src/test/resources/__screenshots__"; + private static final String UPDATE_SNAPSHOTS_PROPERTY = "playwright.updateSnapshots"; + private static final ConcurrentHashMap anonymousNameCounters = new ConcurrentHashMap<>(); + + private final PageImpl page; + private final LocatorImpl locator; + private final boolean isNot; + + ScreenshotAssertionsHelper(PageImpl page, LocatorImpl locator, boolean isNot) { + this.page = page; + this.locator = locator; + this.isNot = isNot; + } + + void assertScreenshot(Object nameOrNames, ScreenshotAssertionsOptions options, String title) { + if (options == null) { + options = new ScreenshotAssertionsOptions(); + } + Path expectedPath = resolveSnapshotPath(nameOrNames); + String extension = expectedPath.getFileName().toString().toLowerCase().endsWith(".webp") ? "webp" : "png"; + + PageExpectScreenshotOptions protocolOptions = toProtocolOptions(options, extension); + protocolOptions.timeout = options.timeout == null ? AssertionsTimeout.defaultTimeout : options.timeout; + protocolOptions.isNot = isNot; + + boolean hasSnapshot = Files.exists(expectedPath); + + if (isNot) { + if (!hasSnapshot) { + // Nothing to compare against - matchers using ".not()" won't write baselines automatically. + return; + } + protocolOptions.expected = encode(readFile(expectedPath)); + PageImpl.ExpectScreenshotResult result = page.expectScreenshot(protocolOptions, title); + if (result.errorMessage == null) { + // Screenshots differ, exactly as ".not()" expects. + return; + } + throw new AssertionFailedError(title + "\nScreenshot comparison failed:\n Expected result should be different from the actual one." + callLog(result.log)); + } + + boolean updateAll = isUpdateSnapshotsAll(); + + if (!hasSnapshot) { + protocolOptions.expected = null; + PageImpl.ExpectScreenshotResult result = page.expectScreenshot(protocolOptions, title); + if (result.errorMessage != null) { + writeDebugArtifacts(expectedPath, result); + throw new AssertionFailedError(title + "\n" + result.errorMessage + callLog(result.log)); + } + Utils.writeToFile(result.actual, expectedPath); + return; + } + + byte[] expectedBytes = readFile(expectedPath); + if (updateAll) { + protocolOptions.expected = null; + PageImpl.ExpectScreenshotResult result = page.expectScreenshot(protocolOptions, title); + if (result.errorMessage != null) { + writeDebugArtifacts(expectedPath, result); + throw new AssertionFailedError(title + "\n Failed to re-generate expected.\n" + result.errorMessage + callLog(result.log)); + } + if (!Arrays.equals(result.actual, expectedBytes)) { + Utils.writeToFile(result.actual, expectedPath); + } + return; + } + + protocolOptions.expected = encode(expectedBytes); + PageImpl.ExpectScreenshotResult result = page.expectScreenshot(protocolOptions, title); + if (result.errorMessage == null) { + return; + } + writeDebugArtifacts(expectedPath, result); + throw new AssertionFailedError(title + "\nScreenshot comparison failed:\n " + result.errorMessage + + callLog(result.log) + "\n\n Expected: " + expectedPath + + "\n Actual: " + actualDebugPath(expectedPath) + + (result.diff != null ? "\n Diff: " + diffDebugPath(expectedPath) : "")); + } + + private PageExpectScreenshotOptions toProtocolOptions(ScreenshotAssertionsOptions options, String extension) { + PageExpectScreenshotOptions result = new PageExpectScreenshotOptions(); + result.locator = locator; + result.animations = options.animations == null ? ScreenshotAnimations.DISABLED : options.animations; + result.caret = options.caret == null ? ScreenshotCaret.HIDE : options.caret; + result.clip = options.clip; + result.fullPage = options.fullPage; + result.omitBackground = options.omitBackground; + result.scale = options.scale == null ? ScreenshotScale.CSS : options.scale; + result.maxDiffPixels = options.maxDiffPixels == null ? null : options.maxDiffPixels.doubleValue(); + result.maxDiffPixelRatio = options.maxDiffPixelRatio; + result.threshold = options.threshold; + result.maskColor = options.maskColor; + result.style = options.style; + result.type = extension; + if (options.mask != null) { + List mask = new ArrayList<>(); + for (Locator l : options.mask) { + mask.add((LocatorImpl) l); + } + result.mask = mask; + } + return result; + } + + private static boolean isUpdateSnapshotsAll() { + return Boolean.parseBoolean(System.getProperty(UPDATE_SNAPSHOTS_PROPERTY, "false")); + } + + private static Path resolveSnapshotPath(Object nameOrNames) { + String name; + if (nameOrNames instanceof String[]) { + String[] segments = (String[]) nameOrNames; + name = String.join("-", segments); + } else if (nameOrNames instanceof String) { + name = (String) nameOrNames; + } else { + name = null; + } + StackTraceElement caller = callerFrame(); + if (name == null || name.isEmpty()) { + String key = caller.getClassName() + "#" + caller.getMethodName(); + int index = anonymousNameCounters.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet(); + name = caller.getMethodName() + "-" + index + ".png"; + } + if (!name.toLowerCase().endsWith(".png") && !name.toLowerCase().endsWith(".webp")) { + throw new PlaywrightException("Screenshot name \"" + name + "\" must have a '.png' or '.webp' extension"); + } + String baseDir = System.getProperty(SNAPSHOT_DIR_PROPERTY, DEFAULT_SNAPSHOT_DIR); + String simpleClassName = simpleClassName(caller.getClassName()); + return Paths.get(baseDir, simpleClassName, name); + } + + private static String simpleClassName(String className) { + // Strip package name and any enclosing-class '$' qualifiers (e.g. anonymous/nested classes). + int dot = className.lastIndexOf('.'); + String simple = dot == -1 ? className : className.substring(dot + 1); + int dollar = simple.indexOf('$'); + return dollar == -1 ? simple : simple.substring(0, dollar); + } + + private static StackTraceElement callerFrame() { + for (StackTraceElement frame : Thread.currentThread().getStackTrace()) { + String className = frame.getClassName(); + if (className.startsWith("java.") || className.startsWith("jdk.")) { + continue; + } + if (className.startsWith("com.microsoft.playwright.impl.") || className.startsWith("com.microsoft.playwright.assertions.")) { + continue; + } + return frame; + } + throw new PlaywrightException("Could not determine the caller of the screenshot assertion to infer the snapshot name"); + } + + private static byte[] readFile(Path path) { + try { + return Files.readAllBytes(path); + } catch (IOException e) { + throw new PlaywrightException("Failed to read snapshot file: " + path, e); + } + } + + private static String encode(byte[] bytes) { + return Base64.getEncoder().encodeToString(bytes); + } + + private static Path actualDebugPath(Path expectedPath) { + return withSuffix(expectedPath, "-actual"); + } + + private static Path diffDebugPath(Path expectedPath) { + return withSuffix(expectedPath, "-diff"); + } + + private static Path withSuffix(Path expectedPath, String suffix) { + String fileName = expectedPath.getFileName().toString(); + int dot = fileName.lastIndexOf('.'); + String newFileName = dot == -1 ? fileName + suffix : fileName.substring(0, dot) + suffix + fileName.substring(dot); + Path parent = expectedPath.getParent(); + return parent == null ? Paths.get(newFileName) : parent.resolve(newFileName); + } + + private static void writeDebugArtifacts(Path expectedPath, PageImpl.ExpectScreenshotResult result) { + if (result.actual != null) { + Utils.writeToFile(result.actual, actualDebugPath(expectedPath)); + } + if (result.diff != null) { + Utils.writeToFile(result.diff, diffDebugPath(expectedPath)); + } + } + + private static String callLog(List log) { + if (log == null || log.isEmpty()) { + return ""; + } + return "\nCall log:\n" + String.join("\n", log); + } +} diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsOptions.java b/playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsOptions.java new file mode 100644 index 000000000..9b90f876f --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/ScreenshotAssertionsOptions.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed 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 + * + * http://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 com.microsoft.playwright.impl; + +import com.microsoft.playwright.Locator; +import com.microsoft.playwright.options.Clip; +import com.microsoft.playwright.options.ScreenshotAnimations; +import com.microsoft.playwright.options.ScreenshotCaret; +import com.microsoft.playwright.options.ScreenshotScale; + +import java.util.List; + +// Common shape shared by PageAssertions.HasScreenshotOptions and LocatorAssertions.HasScreenshotOptions, +// used as an intermediate type for Utils#convertType(). +class ScreenshotAssertionsOptions { + Double timeout; + ScreenshotAnimations animations; + ScreenshotCaret caret; + Clip clip; + Boolean fullPage; + List mask; + String maskColor; + Boolean omitBackground; + ScreenshotScale scale; + Integer maxDiffPixels; + Double maxDiffPixelRatio; + Double threshold; + String style; +} diff --git a/playwright/src/test/java/com/microsoft/playwright/TestScreenshotAssertions.java b/playwright/src/test/java/com/microsoft/playwright/TestScreenshotAssertions.java new file mode 100644 index 000000000..53ccba8a5 --- /dev/null +++ b/playwright/src/test/java/com/microsoft/playwright/TestScreenshotAssertions.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed 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 + * + * http://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 com.microsoft.playwright; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class TestScreenshotAssertions extends TestBase { + private static final Path SNAPSHOT_ROOT = Paths.get("src/test/resources/__screenshots__/TestScreenshotAssertions"); + + @BeforeEach + @AfterEach + void cleanupSnapshots() throws IOException { + if (Files.exists(SNAPSHOT_ROOT)) { + Files.walk(SNAPSHOT_ROOT) + .sorted((a, b) -> b.compareTo(a)) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + // ignore + } + }); + } + } + + @Test + void shouldGenerateAndMatchPageScreenshot() { + page.setContent("
"); + // First run: baseline does not exist yet, it should be created and the assertion should pass. + assertThat(page).hasScreenshot("page-baseline.png"); + Path expected = SNAPSHOT_ROOT.resolve("page-baseline.png"); + assertTrue(Files.exists(expected), "Baseline screenshot should have been created at " + expected); + + // Second run: baseline exists and matches, assertion should pass without changes. + assertThat(page).hasScreenshot("page-baseline.png"); + } + + @Test + void shouldFailWhenScreenshotDiffers() throws IOException { + Path expected = SNAPSHOT_ROOT.resolve("page-mismatch.png"); + page.setContent("
"); + assertThat(page).hasScreenshot("page-mismatch.png"); + assertTrue(Files.exists(expected)); + + page.setContent("
"); + AssertionFailedError e = assertThrows(AssertionFailedError.class, () -> + assertThat(page).hasScreenshot("page-mismatch.png", + new com.microsoft.playwright.assertions.PageAssertions.HasScreenshotOptions().setTimeout(2_000))); + assertTrue(e.getMessage().contains("Screenshot comparison failed"), e.getMessage()); + } + + @Test + void shouldSupportNotWhenBaselineMissing() { + page.setContent("
Hello
"); + // No baseline exists - `.not()` should pass without writing a baseline. + assertThat(page).not().hasScreenshot("page-not-missing.png"); + assertTrue(!Files.exists(SNAPSHOT_ROOT.resolve("page-not-missing.png"))); + } + + @Test + void shouldSupportLocatorScreenshot() { + page.setContent("
"); + Locator locator = page.locator("#box"); + assertThat(locator).hasScreenshot("locator-baseline.png"); + assertTrue(Files.exists(SNAPSHOT_ROOT.resolve("locator-baseline.png"))); + } +}