From b6226ee5c3f50e8c1feb473573196d6c4d33d773 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Mon, 9 Mar 2026 15:17:09 +0530 Subject: [PATCH 01/19] feat: add responsive snapshot capture, cross-origin iframe, and cookie support - Add responsive snapshot capture with CLI widths-config integration - Add cross-origin iframe processing via processFrame() and getSerializedDOM() - Add cookie capture using page.context().cookies() - Parse cliConfig from healthcheck for responsive capture detection - Add HTTP status checks with clear error messages for failed requests - Update logging to always show responsive capture errors - Add 600s timeout for snapshot requests, 30s for widths-config - Support PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE and RESPONSIVE_CAPTURE_SLEEP_TIME env vars --- src/main/java/io/percy/playwright/Percy.java | 432 ++++++++++++++++++- 1 file changed, 419 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 038689a..db0dd15 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -5,18 +5,22 @@ import org.apache.http.util.EntityUtils; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; +import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; import org.json.JSONObject; +import java.net.URI; import java.util.*; - -import javax.swing.text.html.CSS; -import javax.xml.xpath.XPath; +import java.util.stream.Collectors; import com.microsoft.playwright.*; +import com.microsoft.playwright.options.Cookie; +import com.microsoft.playwright.options.ViewportSize; /** @@ -35,12 +39,22 @@ public class Percy { // Determine if we're debug logging private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug"); + // Optional sleep between responsive captures (milliseconds) + private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESPONSIVE_CAPTURE_SLEEP_TIME", ""); + + // Whether to reload the page between responsive captures + private static boolean PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = + "true".equalsIgnoreCase(System.getenv("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE")); + // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; // Type of session automate/web protected String sessionType = null; + // CLI config returned by healthcheck + private JSONObject cliConfig = new JSONObject(); + // Is the Percy server running or not private boolean isPercyEnabled = healthcheck(); @@ -280,12 +294,26 @@ public JSONObject snapshot(String name, Map options) { if (!isPercyEnabled) { return null; } if ("automate".equals(sessionType)) { throw new RuntimeException("Invalid function call - snapshot(). Please use screenshot() function while using Percy with Automate. For more information on usage of PercyScreenshot, refer https://www.browserstack.com/docs/percy/integrate/functional-and-visual"); } - Map domSnapshot = null; + Object domSnapshot = null; + log("Taking snapshot: " + name); try { - page.evaluate(fetchPercyDOM()); - domSnapshot = (Map) page.evaluate(buildSnapshotJS(options)); + String percyDomScript = fetchPercyDOM(); + page.evaluate(percyDomScript); + + List cookies = new ArrayList<>(); + try { + cookies = page.context().cookies(); + } catch (Exception e) { + log("Cookie collection failed: " + e.getMessage(), "debug"); + } + + if (isCaptureResponsiveDOM(options)) { + domSnapshot = captureResponsiveDom(cookies, percyDomScript, options); + } else { + domSnapshot = getSerializedDOM(cookies, percyDomScript, options); + } } catch (Exception e) { - if (PERCY_DEBUG) { log(e.getMessage()); } + log(e.getMessage(), "debug"); } return postSnapshot(domSnapshot, name, page.url(), options); @@ -326,13 +354,15 @@ public JSONObject screenshot(String name, Map options) throws Ex /** * POST the DOM taken from the test browser to the Percy Agent node process. * - * @param domSnapshot Stringified & serialized version of the site/applications DOM + * @param domSnapshot Stringified & serialized version of the site/applications DOM. + * May be a {@code Map} for a single capture or a + * {@code List>} for a responsive (multi-width) capture. * @param name The human-readable name of the snapshot. Should be unique. * @param url The url of current website * @param options Map of various options support in percySnapshot Command. */ private JSONObject postSnapshot( - Map domSnapshot, + Object domSnapshot, String name, String url, Map options @@ -359,7 +389,13 @@ private JSONObject postSnapshot( protected JSONObject request(String url, JSONObject json, String name) { StringEntity entity = new StringEntity(json.toString(), ContentType.APPLICATION_JSON); - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + int timeout = 600000; // 600 seconds + RequestConfig requestConfig = RequestConfig.custom() + .setSocketTimeout(timeout) + .setConnectTimeout(timeout) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { HttpPost request = new HttpPost(PERCY_SERVER_ADDRESS + url); request.setEntity(entity); HttpResponse response = httpClient.execute(request); @@ -369,7 +405,7 @@ protected JSONObject request(String url, JSONObject json, String name) { return jsonResponse.getJSONObject("data"); } } catch (Exception ex) { - if (PERCY_DEBUG) { log(ex.toString()); } + log(ex.toString(), "debug"); log("Could not post snapshot " + name); } return null; @@ -455,11 +491,15 @@ private boolean healthcheck() { String responseString = EntityUtils.toString(entity, "UTF-8"); JSONObject responseObject = new JSONObject(responseString); sessionType = (String) responseObject.optString("type", null); + JSONObject parsedConfig = responseObject.optJSONObject("config"); + if (parsedConfig != null) { + cliConfig = parsedConfig; + } return true; } catch (Exception ex) { log("Percy is not running, disabling snapshots"); - if (PERCY_DEBUG) { log(ex.toString()); } + log(ex.toString(), "debug"); return false; } @@ -472,7 +512,373 @@ protected void setPageMetadata() throws Exception{ this.pageMetadata = new PageMetadata(this.page); } + // ------------------------------------------------------------------------- + // Responsive snapshot capture helpers + // ------------------------------------------------------------------------- + + /** + * Determines whether responsive DOM capture should be performed for this snapshot. + * Defers to the CLI config first, then falls back to the per-snapshot option. + */ + private boolean isCaptureResponsiveDOM(Map options) { + // Respect deferUploads: if enabled, responsive capture is not supported + if (cliConfig.has("percy") && !cliConfig.isNull("percy")) { + JSONObject percyProperty = cliConfig.getJSONObject("percy"); + if (percyProperty.has("deferUploads") && !percyProperty.isNull("deferUploads") + && percyProperty.getBoolean("deferUploads")) { + return false; + } + } + + boolean responsiveSnapshotCaptureCLI = false; + if (cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + if (snapshotConfig.has("responsiveSnapshotCapture")) { + responsiveSnapshotCaptureCLI = snapshotConfig.getBoolean("responsiveSnapshotCapture"); + } + } + + Object responsiveSnapshotCaptureSDK = options.get("responsiveSnapshotCapture"); + return (responsiveSnapshotCaptureSDK instanceof Boolean && (Boolean) responsiveSnapshotCaptureSDK) + || responsiveSnapshotCaptureCLI; + } + + /** + * Fetches responsive width/height pairs from the Percy CLI {@code /percy/widths-config} + * endpoint. The optional {@code widths} list is forwarded as a query parameter so that + * the CLI can merge user-supplied widths with its own configuration. + * + * @param widths Optional list of user-supplied widths (may be null or empty). + * @return A list of {@code {"width": N, "height": N}} maps as returned by the CLI. + * @throws RuntimeException when the CLI is unavailable or returns an unexpected payload. + */ + @SuppressWarnings("unchecked") + private List> getResponsiveWidths(List widths) { + String queryParam = ""; + if (widths != null && !widths.isEmpty()) { + String joined = widths.stream().map(String::valueOf).collect(Collectors.joining(",")); + queryParam = "?widths=" + joined; + } + + int timeout = 30000; // 30 seconds + RequestConfig requestConfig = RequestConfig.custom() + .setSocketTimeout(timeout) + .setConnectTimeout(timeout) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { + HttpGet httpget = new HttpGet(PERCY_SERVER_ADDRESS + "/percy/widths-config" + queryParam); + HttpResponse response = httpClient.execute(httpget); + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode != 200) { + EntityUtils.consume(response.getEntity()); + log("Update Percy CLI to the latest version to use responsiveSnapshotCapture"); + throw new RuntimeException( + "Failed to fetch widths-config (HTTP " + statusCode + ")"); + } + + String responseString = EntityUtils.toString(response.getEntity(), "UTF-8"); + JSONObject json = new JSONObject(responseString); + + if (!json.has("widths") || json.isNull("widths")) { + log("Update Percy CLI to the latest version to use responsiveSnapshotCapture"); + throw new RuntimeException( + "Missing \"widths\" in widths-config response"); + } + + JSONArray widthsArray = json.getJSONArray("widths"); + List> result = new ArrayList<>(); + for (int i = 0; i < widthsArray.length(); i++) { + JSONObject entry = widthsArray.getJSONObject(i); + Map item = new HashMap<>(); + item.put("width", entry.getInt("width")); + if (entry.has("height") && !entry.isNull("height")) { + item.put("height", entry.getInt("height")); + } + result.add(item); + } + return result; + } catch (RuntimeException re) { + throw re; + } catch (Exception ex) { + log("Update Percy CLI to the latest version to use responsiveSnapshotCapture"); + log("Failed to fetch widths-config: " + ex.getMessage(), "debug"); + throw new RuntimeException( + "Failed to fetch widths-config: " + ex.getMessage(), ex); + } + } + + /** + * Resizes the page viewport to the requested dimensions and waits for the page to + * acknowledge the resize via the {@code window.resizeCount} counter injected by + * {@code PercyDOM.waitForResize()}. + * + * @param width Target viewport width in pixels. + * @param height Target viewport height in pixels. + * @param resizeCount The expected value of {@code window.resizeCount} after resize. + */ + private void changeViewportAndWait(int width, int height, int resizeCount) { + try { + page.setViewportSize(width, height); + } catch (Exception e) { + log("Resizing viewport failed for width " + width + ": " + e.getMessage(), "debug"); + } + + try { + page.waitForFunction( + "window.resizeCount === " + resizeCount, + null, + new Page.WaitForFunctionOptions().setTimeout(1000) + ); + } catch (Exception e) { + log("Timed out waiting for window resize event for width " + width, "debug"); + } + } + + /** + * Captures serialized DOM snapshots for each responsive width/height pair returned + * by the Percy CLI. The viewport is restored to its original size after capture. + * + * @param cookies Page cookies to embed in each snapshot. + * @param percyDomScript The cached percy DOM serialization script. + * @param options Snapshot options (passed through to the DOM serializer). + * @return A list of DOM snapshot maps, each annotated with its capture {@code width}. + */ + public List> captureResponsiveDom( + List cookies, + String percyDomScript, + Map options) { + + @SuppressWarnings("unchecked") + List userWidths = (options.get("widths") instanceof List) + ? (List) options.get("widths") + : new ArrayList<>(); + + List> widthHeights = getResponsiveWidths(userWidths); + + List> domSnapshots = new ArrayList<>(); + + ViewportSize originalViewport = page.viewportSize(); + int currentWidth = (originalViewport != null) ? originalViewport.width : 1280; + int currentHeight = (originalViewport != null) ? originalViewport.height : 720; + int lastWindowWidth = currentWidth; + int resizeCount = 0; + + // Inject the resize counter before iterating widths + page.evaluate("PercyDOM.waitForResize()"); + + for (Map widthHeight : widthHeights) { + int width = (int) widthHeight.get("width"); + int height = widthHeight.containsKey("height") + ? (int) widthHeight.get("height") + : currentHeight; + + if (lastWindowWidth != width) { + resizeCount++; + changeViewportAndWait(width, height, resizeCount); + lastWindowWidth = width; + } + + if (PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) { + page.reload(); + page.evaluate(percyDomScript); + page.evaluate("PercyDOM.waitForResize()"); + resizeCount = 0; + } + + if (!RESPONSIVE_CAPTURE_SLEEP_TIME.isEmpty()) { + try { + int sleepMs = Integer.parseInt(RESPONSIVE_CAPTURE_SLEEP_TIME) * 1000; + if (sleepMs > 0) { Thread.sleep(sleepMs); } + } catch (InterruptedException | NumberFormatException ignored) { } + } + + Map domSnapshot = getSerializedDOM(cookies, percyDomScript, options); + domSnapshot.put("width", width); + domSnapshots.add(domSnapshot); + } + + // Restore original viewport + changeViewportAndWait(currentWidth, currentHeight, resizeCount + 1); + + return domSnapshots; + } + + // ------------------------------------------------------------------------- + // Cross-origin iframe helpers + // ------------------------------------------------------------------------- + + /** + * Processes a single cross-origin {@link Frame}: injects the Percy DOM script, + * serializes the frame, and retrieves the matching {@code data-percy-element-id} + * from the main page so the CLI can stitch the iframe content into the snapshot. + * + * @param frame The cross-origin frame to process. + * @param percyDomScript The cached percy DOM serialization script. + * @param options Snapshot options forwarded to the frame serializer. + * @return A map containing {@code iframeData}, {@code iframeSnapshot}, and + * {@code frameUrl}, or {@code null} if the frame cannot be processed. + */ + @SuppressWarnings("unchecked") + private Map processFrame( + Frame frame, + String percyDomScript, + Map options) { + + String frameUrl = frame.url(); + try { + // Inject Percy DOM into the cross-origin frame + frame.evaluate(percyDomScript); + + // enableJavaScript=true prevents standard iframe serialization so we can + // handle cross-origin frames manually + Map frameOptions = new HashMap<>(options); + frameOptions.put("enableJavaScript", true); + JSONObject frameOptionsJson = new JSONObject(frameOptions); + + Map iframeSnapshot = + (Map) frame.evaluate( + String.format("PercyDOM.serialize(%s)", frameOptionsJson)); + + // Retrieve the matching iframe element's percy ID from the main page + String js = + "(fUrl) => {" + + " const iframes = Array.from(document.querySelectorAll('iframe'));" + + " const match = iframes.find(f => f.src.startsWith(fUrl));" + + " if (match) {" + + " return { percyElementId: match.getAttribute('data-percy-element-id') };" + + " }" + + "}"; + + Map iframeData = + (Map) page.evaluate(js, frameUrl); + + if (iframeData == null || iframeData.get("percyElementId") == null) { + log("Skipping cross-origin frame " + frameUrl + + ": no matching iframe with percyElementId found", "debug"); + return null; + } + + Map result = new HashMap<>(); + result.put("iframeData", iframeData); + result.put("iframeSnapshot", iframeSnapshot); + result.put("frameUrl", frameUrl); + return result; + + } catch (Exception e) { + log("Failed to process cross-origin frame " + frameUrl + ": " + e.getMessage(), "debug"); + return null; + } + } + + // ------------------------------------------------------------------------- + // DOM serialization + // ------------------------------------------------------------------------- + + /** + * Serializes the main page DOM, captures cross-origin iframes, and attaches cookies. + * + * @param cookies Page cookies to embed in the snapshot payload. + * @param percyDomScript The cached percy DOM serialization script. + * @param options Snapshot options forwarded to the DOM serializer. + * @return A mutable snapshot map ready for posting to the Percy CLI. + */ + @SuppressWarnings("unchecked") + private Map getSerializedDOM( + List cookies, + String percyDomScript, + Map options) { + + Map domSnapshot = + (Map) page.evaluate(buildSnapshotJS(options)); + Map mutableSnapshot = new HashMap<>(domSnapshot); + + // Process cross-origin iframes + try { + URI pageUri = new URI(page.url()); + String pageHost = pageUri.getHost(); + + List crossOriginFrames = page.frames().stream() + .filter(f -> { + String fUrl = f.url(); + if ("about:blank".equals(fUrl) || fUrl.isEmpty()) { return false; } + try { + return !new URI(fUrl).getHost().equals(pageHost); + } catch (Exception e) { + return false; + } + }) + .collect(Collectors.toList()); + + if (!crossOriginFrames.isEmpty()) { + List> processedFrames = new ArrayList<>(); + for (Frame frame : crossOriginFrames) { + Map frameResult = processFrame(frame, percyDomScript, options); + if (frameResult != null) { + processedFrames.add(frameResult); + } + } + if (!processedFrames.isEmpty()) { + mutableSnapshot.put("corsIframes", processedFrames); + } + } + } catch (Exception e) { + log("Failed to process cross-origin iframes: " + e.getMessage(), "debug"); + } + + // Serialize cookies as a list of plain maps + List> cookiesList = new ArrayList<>(); + for (Cookie c : cookies) { + Map cookieMap = new HashMap<>(); + cookieMap.put("name", c.name); + cookieMap.put("value", c.value); + cookieMap.put("domain", c.domain); + cookieMap.put("path", c.path); + cookieMap.put("expires", c.expires); + cookieMap.put("httpOnly", c.httpOnly); + cookieMap.put("secure", c.secure); + if (c.sameSite != null) { + cookieMap.put("sameSite", c.sameSite.toString()); + } + cookiesList.add(cookieMap); + } + mutableSnapshot.put("cookies", cookiesList); + + return mutableSnapshot; + } + + // ------------------------------------------------------------------------- + // Logging + // ------------------------------------------------------------------------- + protected static void log(String message) { - System.out.println(LABEL + " " + message); + log(message, "info"); + } + + protected static void log(String message, String level) { + message = LABEL + " " + message; + String logJsonString = "{\"message\": \"" + message + "\", \"level\": \"" + level + "\"}"; + StringEntity entity = new StringEntity(logJsonString, ContentType.APPLICATION_JSON); + + int timeout = 1000; // 1 second + RequestConfig requestConfig = RequestConfig.custom() + .setSocketTimeout(timeout) + .setConnectTimeout(timeout) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { + HttpPost logRequest = new HttpPost( + System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338") + "/percy/log"); + logRequest.setEntity(entity); + httpClient.execute(logRequest); + } catch (Exception ex) { + if (PERCY_DEBUG) { System.out.println("Sending log to CLI Failed " + ex.toString()); } + } finally { + // Print to stdout unless it is a debug message and debug mode is off + if (!"debug".equals(level) || PERCY_DEBUG) { + System.out.println(message); + } + } } } From 8d5d45343981b1297bb65911f083ddb584945322 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Mon, 9 Mar 2026 17:31:02 +0530 Subject: [PATCH 02/19] adding remaining features --- src/main/java/io/percy/playwright/Percy.java | 39 +++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index db0dd15..d5a1243 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -46,6 +46,10 @@ public class Percy { private static boolean PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = "true".equalsIgnoreCase(System.getenv("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE")); + // Whether to adjust the default height to account for browser chrome during responsive capture + private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = + "true".equalsIgnoreCase(System.getenv("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT")); + // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; @@ -313,6 +317,10 @@ public JSONObject snapshot(String name, Map options) { domSnapshot = getSerializedDOM(cookies, percyDomScript, options); } } catch (Exception e) { + if(domSnapshot == null) { + log("Snapshot capture failed: " + e.getMessage()); + return null; + } log(e.getMessage(), "debug"); } @@ -543,6 +551,34 @@ private boolean isCaptureResponsiveDOM(Map options) { || responsiveSnapshotCaptureCLI; } + /** + * Calculates the default height for responsive capture. + * When {@code PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT} is enabled, the height is adjusted + * to account for browser chrome: {@code window.outerHeight - window.innerHeight + minH}. + * + * @param currentHeight The current viewport height to use as a fallback. + * @param options Snapshot options; may contain a {@code minHeight} override. + * @return The computed default height in pixels. + */ + private int calculateDefaultHeight(int currentHeight, Map options) { + if (!PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) { + return currentHeight; + } + try { + Object minHeightOption = options.get("minHeight"); + int minHeight = (minHeightOption instanceof Number) + ? ((Number) minHeightOption).intValue() + : currentHeight; + Object result = page.evaluate("(minH) => window.outerHeight - window.innerHeight + minH", minHeight); + if (result instanceof Number) { + return ((Number) result).intValue(); + } + } catch (Exception e) { + log("Failed to calculate default height: " + e.getMessage(), "debug"); + } + return currentHeight; + } + /** * Fetches responsive width/height pairs from the Percy CLI {@code /percy/widths-config} * endpoint. The optional {@code widths} list is forwarded as a query parameter so that @@ -662,6 +698,7 @@ public List> captureResponsiveDom( ViewportSize originalViewport = page.viewportSize(); int currentWidth = (originalViewport != null) ? originalViewport.width : 1280; int currentHeight = (originalViewport != null) ? originalViewport.height : 720; + int defaultHeight = calculateDefaultHeight(currentHeight, options); int lastWindowWidth = currentWidth; int resizeCount = 0; @@ -672,7 +709,7 @@ public List> captureResponsiveDom( int width = (int) widthHeight.get("width"); int height = widthHeight.containsKey("height") ? (int) widthHeight.get("height") - : currentHeight; + : defaultHeight; if (lastWindowWidth != width) { resizeCount++; From 155843dd179985b488d653149e0df418d9f12523 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Mon, 9 Mar 2026 17:33:11 +0530 Subject: [PATCH 03/19] updating cli version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4711c51..9ff98ce 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "test": "npx percy exec --testing -- mvn test" }, "devDependencies": { - "@percy/cli": "1.30.9" + "@percy/cli": "^1.31.10-alpha.0" } } From 2f180ada7d12884611c1f45ecb979e656bee27f5 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Mon, 9 Mar 2026 22:55:20 +0530 Subject: [PATCH 04/19] Added Tests --- src/main/java/io/percy/playwright/Percy.java | 3 + .../java/io/percy/playwright/SDKTest.java | 240 ++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index d5a1243..40d6298 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -829,6 +829,9 @@ private Map getSerializedDOM( Map domSnapshot = (Map) page.evaluate(buildSnapshotJS(options)); + if (domSnapshot == null) { + throw new RuntimeException("DOM serialization returned null — PercyDOM.serialize() may not be loaded or returned undefined"); + } Map mutableSnapshot = new HashMap<>(domSnapshot); // Process cross-origin iframes diff --git a/src/test/java/io/percy/playwright/SDKTest.java b/src/test/java/io/percy/playwright/SDKTest.java index 8512ad3..61c48cf 100644 --- a/src/test/java/io/percy/playwright/SDKTest.java +++ b/src/test/java/io/percy/playwright/SDKTest.java @@ -1,13 +1,16 @@ package io.percy.playwright; import com.microsoft.playwright.*; +import com.microsoft.playwright.options.Cookie; import org.json.JSONObject; import org.junit.jupiter.api.*; import org.mockito.Mockito; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -61,6 +64,16 @@ public void snapshotWithOptions() { percy.snapshot("Site with options", options); } + @Test + @Order(2) + public void snapshotWithResponsiveSnapshotCapture() { + page.navigate("https://howbigismybrowser.com"); + Map options = new HashMap<>(); + options.put("widths", Arrays.asList(768, 992, 1200)); + options.put("responsiveSnapshotCapture", true); + percy.snapshot("Site with responsive snapshot capture", options); + } + @Test @Order(3) public void takeSnapshotWithSyncCLI() { @@ -220,6 +233,7 @@ public void takeScreenshotWithOptions() throws Exception { assertEquals(json.getString("framework"), capturedJson.getString("framework")); assertTrue(json.getJSONObject("options").similar(capturedJson.getJSONObject("options"))); } + @Test @Order(9) public void createRegionTest() { @@ -267,4 +281,230 @@ public void createRegionTest() { assertEquals(0.1, assertion.get("diffIgnoreThreshold")); } + // ------------------------------------------------------------------------- + // Responsive snapshot capture tests + // ------------------------------------------------------------------------- + + @Test + @Order(11) + public void isCaptureResponsiveDOMReturnsTrueForSDKOption() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Percy percyInstance = new Percy(mockPage); + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("isCaptureResponsiveDOM", Map.class); + method.setAccessible(true); + + Map options = new HashMap<>(); + options.put("responsiveSnapshotCapture", true); + + boolean result = (boolean) method.invoke(percyInstance, options); + assertTrue(result); + } + + @Test + @Order(12) + public void isCaptureResponsiveDOMReturnsFalseWhenDeferUploadsEnabled() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Percy percyInstance = new Percy(mockPage); + + JSONObject percyConfig = new JSONObject(); + percyConfig.put("deferUploads", true); + JSONObject config = new JSONObject(); + config.put("percy", percyConfig); + + java.lang.reflect.Field cliConfigField = Percy.class.getDeclaredField("cliConfig"); + cliConfigField.setAccessible(true); + cliConfigField.set(percyInstance, config); + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("isCaptureResponsiveDOM", Map.class); + method.setAccessible(true); + + Map options = new HashMap<>(); + options.put("responsiveSnapshotCapture", true); + + boolean result = (boolean) method.invoke(percyInstance, options); + assertFalse(result, "deferUploads should take priority and disable responsive capture"); + } + + @Test + @Order(13) + public void isCaptureResponsiveDOMReturnsTrueFromCLIConfig() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Percy percyInstance = new Percy(mockPage); + + JSONObject snapshotConfig = new JSONObject(); + snapshotConfig.put("responsiveSnapshotCapture", true); + JSONObject config = new JSONObject(); + config.put("snapshot", snapshotConfig); + + java.lang.reflect.Field cliConfigField = Percy.class.getDeclaredField("cliConfig"); + cliConfigField.setAccessible(true); + cliConfigField.set(percyInstance, config); + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("isCaptureResponsiveDOM", Map.class); + method.setAccessible(true); + + // No SDK-level flag — should still return true because CLI config enables it + Map options = new HashMap<>(); + boolean result = (boolean) method.invoke(percyInstance, options); + assertTrue(result, "CLI config responsiveSnapshotCapture should enable responsive capture"); + } + + // ------------------------------------------------------------------------- + // Cookie capture tests + // ------------------------------------------------------------------------- + + @Test + @Order(14) + @SuppressWarnings("unchecked") + public void cookiesAreCapturedInSerializedDOM() throws Exception { + Page mockPage = Mockito.mock(Page.class); + + Map domMap = new HashMap<>(); + domMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(domMap); + when(mockPage.url()).thenReturn("http://example.com"); + when(mockPage.frames()).thenReturn(new ArrayList<>()); + + Percy percyInstance = new Percy(mockPage); + + Cookie cookie = new Cookie("session", "abc123"); + cookie.domain = "example.com"; + cookie.path = "/"; + cookie.expires = -1.0; + cookie.httpOnly = false; + cookie.secure = false; + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); + method.setAccessible(true); + + Map result = (Map) method.invoke( + percyInstance, Arrays.asList(cookie), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNotNull(result.get("cookies")); + + List> cookies = (List>) result.get("cookies"); + assertEquals(1, cookies.size()); + assertEquals("session", cookies.get(0).get("name")); + assertEquals("abc123", cookies.get(0).get("value")); + assertEquals("example.com", cookies.get(0).get("domain")); + assertEquals("/", cookies.get(0).get("path")); + } + + @Test + @Order(15) + @SuppressWarnings("unchecked") + public void emptyCookieListIsAttachedWhenNoCookiesPresent() throws Exception { + Page mockPage = Mockito.mock(Page.class); + + Map domMap = new HashMap<>(); + domMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(domMap); + when(mockPage.url()).thenReturn("http://example.com"); + when(mockPage.frames()).thenReturn(new ArrayList<>()); + + Percy percyInstance = new Percy(mockPage); + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); + method.setAccessible(true); + + Map result = (Map) method.invoke( + percyInstance, new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + List> cookies = (List>) result.get("cookies"); + assertNotNull(cookies); + assertEquals(0, cookies.size()); + } + + // ------------------------------------------------------------------------- + // Cross-origin iframe capture tests + // ------------------------------------------------------------------------- + + @Test + @Order(16) + @SuppressWarnings("unchecked") + public void corsIframesAreProcessedAndAttachedInSnapshot() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Frame mockFrame = Mockito.mock(Frame.class); + + // Main page DOM + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + + // page.evaluate(js, frameUrl) returns percyElementId for the iframe match + Map iframeDataMap = new HashMap<>(); + iframeDataMap.put("percyElementId", "percy-elem-1"); + when(mockPage.evaluate(anyString(), any())).thenReturn(iframeDataMap); + + when(mockPage.url()).thenReturn("http://example.com/page"); + when(mockFrame.url()).thenReturn("http://other.com/"); + + // frame.evaluate: first call injects script (return ignored), + // second call serializes the frame DOM + Map iframeSnapshot = new HashMap<>(); + iframeSnapshot.put("html", "iframe content"); + when(mockFrame.evaluate(anyString())) + .thenReturn(null) // inject percyDomScript + .thenReturn(iframeSnapshot); // PercyDOM.serialize(...) + + when(mockPage.frames()).thenReturn(Arrays.asList(mockFrame)); + + Percy percyInstance = new Percy(mockPage); + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); + method.setAccessible(true); + + Map result = (Map) method.invoke( + percyInstance, new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNotNull(result.get("corsIframes"), "corsIframes key should be present"); + + List> corsIframes = (List>) result.get("corsIframes"); + assertEquals(1, corsIframes.size()); + + Map capturedFrame = corsIframes.get(0); + assertEquals("http://other.com/", capturedFrame.get("frameUrl")); + assertNotNull(capturedFrame.get("iframeSnapshot")); + assertEquals("percy-elem-1", + ((Map) capturedFrame.get("iframeData")).get("percyElementId")); + } + + @Test + @Order(17) + @SuppressWarnings("unchecked") + public void sameOriginFramesAreNotProcessedAsCorsIframes() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Frame mockSameOriginFrame = Mockito.mock(Frame.class); + + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + when(mockPage.url()).thenReturn("http://example.com/page"); + // Same origin as the page — should not be treated as cross-origin + when(mockSameOriginFrame.url()).thenReturn("http://example.com/iframe"); + when(mockPage.frames()).thenReturn(Arrays.asList(mockSameOriginFrame)); + + Percy percyInstance = new Percy(mockPage); + + java.lang.reflect.Method method = + Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); + method.setAccessible(true); + + Map result = (Map) method.invoke( + percyInstance, new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNull(result.get("corsIframes"), "Same-origin frames must not be added to corsIframes"); + } + } From 19a9d5f74eb402fcb18893e6ab4512b8706a3661 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 00:54:58 +0530 Subject: [PATCH 05/19] adding test for cors iframe --- src/main/java/io/percy/playwright/Percy.java | 1 - src/test/java/io/percy/playwright/SDKTest.java | 14 ++++++++++++++ src/test/resources/testapp/cors-iframe.html | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/test/resources/testapp/cors-iframe.html diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 40d6298..365cd12 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -299,7 +299,6 @@ public JSONObject snapshot(String name, Map options) { if ("automate".equals(sessionType)) { throw new RuntimeException("Invalid function call - snapshot(). Please use screenshot() function while using Percy with Automate. For more information on usage of PercyScreenshot, refer https://www.browserstack.com/docs/percy/integrate/functional-and-visual"); } Object domSnapshot = null; - log("Taking snapshot: " + name); try { String percyDomScript = fetchPercyDOM(); page.evaluate(percyDomScript); diff --git a/src/test/java/io/percy/playwright/SDKTest.java b/src/test/java/io/percy/playwright/SDKTest.java index 61c48cf..e8e653d 100644 --- a/src/test/java/io/percy/playwright/SDKTest.java +++ b/src/test/java/io/percy/playwright/SDKTest.java @@ -74,6 +74,20 @@ public void snapshotWithResponsiveSnapshotCapture() { percy.snapshot("Site with responsive snapshot capture", options); } + @Test + @Order(3) + public void snapshotWithCorsIframe() { + // cors-iframe.html embeds https://todomvc.com/examples/react/dist/ inside an iframe, making it + // a genuine cross-origin frame for Percy to detect and capture. + List allowedHostnames = Arrays.asList("*"); + Map discoveryOptions = new HashMap<>(); + discoveryOptions.put("allowedHostnames", allowedHostnames); + Map percyOptions = new HashMap<>(); + percyOptions.put("discovery", discoveryOptions); + page.navigate(TEST_URL + "/cors-iframe.html"); + percy.snapshot("Page with cross-origin iframe", percyOptions); + } + @Test @Order(3) public void takeSnapshotWithSyncCLI() { diff --git a/src/test/resources/testapp/cors-iframe.html b/src/test/resources/testapp/cors-iframe.html new file mode 100644 index 0000000..f42e06b --- /dev/null +++ b/src/test/resources/testapp/cors-iframe.html @@ -0,0 +1,16 @@ + + + + + CORS Iframe Test + + + +

Page with cross-origin iframe

+

The iframe below loads an external origin and is used to test Percy CORS iframe capture.

+ + + From c1dd40f31f39ca5878368387ca038099d3c35981 Mon Sep 17 00:00:00 2001 From: Moin Bhokare Date: Tue, 10 Mar 2026 11:45:17 +0530 Subject: [PATCH 06/19] Update src/main/java/io/percy/playwright/Percy.java Refactoring Log Request Body construction Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/io/percy/playwright/Percy.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 365cd12..5e25eef 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -897,8 +897,10 @@ protected static void log(String message) { protected static void log(String message, String level) { message = LABEL + " " + message; - String logJsonString = "{\"message\": \"" + message + "\", \"level\": \"" + level + "\"}"; - StringEntity entity = new StringEntity(logJsonString, ContentType.APPLICATION_JSON); + JSONObject logJson = new JSONObject(); + logJson.put("message", message); + logJson.put("level", level); + StringEntity entity = new StringEntity(logJson.toString(), ContentType.APPLICATION_JSON); int timeout = 1000; // 1 second RequestConfig requestConfig = RequestConfig.custom() From ff356ca801e638822311418c1575108ba07d5919 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 13:16:31 +0530 Subject: [PATCH 07/19] resolving co-pilot comments --- src/main/java/io/percy/playwright/Percy.java | 2 +- .../java/io/percy/playwright/SDKTest.java | 68 ++++---- .../resources/testapp/responsive-capture.html | 155 ++++++++++++++++++ 3 files changed, 190 insertions(+), 35 deletions(-) create mode 100644 src/test/resources/testapp/responsive-capture.html diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 5e25eef..1edaccf 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -39,7 +39,7 @@ public class Percy { // Determine if we're debug logging private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug"); - // Optional sleep between responsive captures (milliseconds) + // Optional sleep between responsive captures (seconds) private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESPONSIVE_CAPTURE_SLEEP_TIME", ""); // Whether to reload the page between responsive captures diff --git a/src/test/java/io/percy/playwright/SDKTest.java b/src/test/java/io/percy/playwright/SDKTest.java index e8e653d..7876d4d 100644 --- a/src/test/java/io/percy/playwright/SDKTest.java +++ b/src/test/java/io/percy/playwright/SDKTest.java @@ -64,30 +64,6 @@ public void snapshotWithOptions() { percy.snapshot("Site with options", options); } - @Test - @Order(2) - public void snapshotWithResponsiveSnapshotCapture() { - page.navigate("https://howbigismybrowser.com"); - Map options = new HashMap<>(); - options.put("widths", Arrays.asList(768, 992, 1200)); - options.put("responsiveSnapshotCapture", true); - percy.snapshot("Site with responsive snapshot capture", options); - } - - @Test - @Order(3) - public void snapshotWithCorsIframe() { - // cors-iframe.html embeds https://todomvc.com/examples/react/dist/ inside an iframe, making it - // a genuine cross-origin frame for Percy to detect and capture. - List allowedHostnames = Arrays.asList("*"); - Map discoveryOptions = new HashMap<>(); - discoveryOptions.put("allowedHostnames", allowedHostnames); - Map percyOptions = new HashMap<>(); - percyOptions.put("discovery", discoveryOptions); - page.navigate(TEST_URL + "/cors-iframe.html"); - percy.snapshot("Page with cross-origin iframe", percyOptions); - } - @Test @Order(3) public void takeSnapshotWithSyncCLI() { @@ -125,6 +101,30 @@ public void takeSnapshotThrowErrorForPOA() { @Test @Order(6) + public void snapshotWithResponsiveSnapshotCapture() { + page.navigate(TEST_URL + "/responsive-capture.html"); + Map options = new HashMap<>(); + options.put("widths", Arrays.asList(480, 680, 992, 1200)); + options.put("responsiveSnapshotCapture", true); + percy.snapshot("Site with responsive snapshot capture", options); + } + + @Test + @Order(7) + public void snapshotWithCorsIframe() { + // cors-iframe.html embeds https://todomvc.com/examples/react/dist/ inside an iframe, making it + // a genuine cross-origin frame for Percy to detect and capture. + List allowedHostnames = Arrays.asList("*"); + Map discoveryOptions = new HashMap<>(); + discoveryOptions.put("allowedHostnames", allowedHostnames); + Map percyOptions = new HashMap<>(); + percyOptions.put("discovery", discoveryOptions); + page.navigate(TEST_URL + "/cors-iframe.html"); + percy.snapshot("Page with cross-origin iframe", percyOptions); + } + + @Test + @Order(8) public void takeScreenshotThrowErrorForWeb() throws Exception { Playwright playwright = Playwright.create(); Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); @@ -141,7 +141,7 @@ public void takeScreenshotThrowErrorForWeb() throws Exception { } @Test - @Order(7) + @Order(9) public void takeScreenshot() throws Exception { // Mock Page and dependencies Page mockPage = Mockito.mock(Page.class); @@ -194,7 +194,7 @@ public void takeScreenshot() throws Exception { } @Test - @Order(8) + @Order(10) public void takeScreenshotWithOptions() throws Exception { // Mock Page and dependencies Page mockPage = Mockito.mock(Page.class); @@ -249,7 +249,7 @@ public void takeScreenshotWithOptions() throws Exception { } @Test - @Order(9) + @Order(11) public void createRegionTest() { // Setup the parameters for the region Map params = new HashMap<>(); @@ -300,7 +300,7 @@ public void createRegionTest() { // ------------------------------------------------------------------------- @Test - @Order(11) + @Order(12) public void isCaptureResponsiveDOMReturnsTrueForSDKOption() throws Exception { Page mockPage = Mockito.mock(Page.class); Percy percyInstance = new Percy(mockPage); @@ -317,7 +317,7 @@ public void isCaptureResponsiveDOMReturnsTrueForSDKOption() throws Exception { } @Test - @Order(12) + @Order(13) public void isCaptureResponsiveDOMReturnsFalseWhenDeferUploadsEnabled() throws Exception { Page mockPage = Mockito.mock(Page.class); Percy percyInstance = new Percy(mockPage); @@ -343,7 +343,7 @@ public void isCaptureResponsiveDOMReturnsFalseWhenDeferUploadsEnabled() throws E } @Test - @Order(13) + @Order(14) public void isCaptureResponsiveDOMReturnsTrueFromCLIConfig() throws Exception { Page mockPage = Mockito.mock(Page.class); Percy percyInstance = new Percy(mockPage); @@ -372,7 +372,7 @@ public void isCaptureResponsiveDOMReturnsTrueFromCLIConfig() throws Exception { // ------------------------------------------------------------------------- @Test - @Order(14) + @Order(15) @SuppressWarnings("unchecked") public void cookiesAreCapturedInSerializedDOM() throws Exception { Page mockPage = Mockito.mock(Page.class); @@ -411,7 +411,7 @@ public void cookiesAreCapturedInSerializedDOM() throws Exception { } @Test - @Order(15) + @Order(16) @SuppressWarnings("unchecked") public void emptyCookieListIsAttachedWhenNoCookiesPresent() throws Exception { Page mockPage = Mockito.mock(Page.class); @@ -442,7 +442,7 @@ public void emptyCookieListIsAttachedWhenNoCookiesPresent() throws Exception { // ------------------------------------------------------------------------- @Test - @Order(16) + @Order(17) @SuppressWarnings("unchecked") public void corsIframesAreProcessedAndAttachedInSnapshot() throws Exception { Page mockPage = Mockito.mock(Page.class); @@ -494,7 +494,7 @@ public void corsIframesAreProcessedAndAttachedInSnapshot() throws Exception { } @Test - @Order(17) + @Order(18) @SuppressWarnings("unchecked") public void sameOriginFramesAreNotProcessedAsCorsIframes() throws Exception { Page mockPage = Mockito.mock(Page.class); diff --git a/src/test/resources/testapp/responsive-capture.html b/src/test/resources/testapp/responsive-capture.html new file mode 100644 index 0000000..d3d2e63 --- /dev/null +++ b/src/test/resources/testapp/responsive-capture.html @@ -0,0 +1,155 @@ + + + + + + Responsive Snapshot Capture + + + + + + + + +
+ + +
+
+ + + + Percy Responsive Capture +
+

Responsive Snapshot Testing

+

+ This page captures live viewport dimensions at each configured breakpoint for visual regression testing. +

+
+ + +
+
+ Live Viewport + +
+ +
+ +
+
Width
+
+
+ +
+ +
+
Height
+
+
+
+ + +
+ + + + Device Pixel Ratio: +
+
+ + +
+

Breakpoint Reference

+
+
+
XS
+
< 640px
+
+
+
SM
+
640 – 767px
+
+
+
MD
+
768 – 1023px
+
+
+
LG+
+
≥ 1024px
+
+
+
+ +
+ + + + From 5be12d6f48eafba4b2a65cd0c0b3bc8a0297de30 Mon Sep 17 00:00:00 2001 From: Moin Bhokare Date: Tue, 10 Mar 2026 13:17:31 +0530 Subject: [PATCH 08/19] Update src/main/java/io/percy/playwright/Percy.java error handling for cors iframe url check Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/io/percy/playwright/Percy.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 1edaccf..e1f8168 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -842,8 +842,12 @@ private Map getSerializedDOM( .filter(f -> { String fUrl = f.url(); if ("about:blank".equals(fUrl) || fUrl.isEmpty()) { return false; } + // If the page has no host (e.g., file:, data:), skip CORS detection + if (pageHost == null) { return false; } try { - return !new URI(fUrl).getHost().equals(pageHost); + String frameHost = new URI(fUrl).getHost(); + // Treat frames with no host as non-cross-origin + return frameHost != null && !Objects.equals(frameHost, pageHost); } catch (Exception e) { return false; } From cf34decc07eb009a07a8179ede3e484ddbe2d996 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 14:09:29 +0530 Subject: [PATCH 09/19] resolving co-pilot comments --- src/main/java/io/percy/playwright/Percy.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index e1f8168..84dbdad 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -699,6 +699,7 @@ public List> captureResponsiveDom( int currentHeight = (originalViewport != null) ? originalViewport.height : 720; int defaultHeight = calculateDefaultHeight(currentHeight, options); int lastWindowWidth = currentWidth; + int lastWindowHeight = currentHeight; int resizeCount = 0; // Inject the resize counter before iterating widths @@ -710,10 +711,11 @@ public List> captureResponsiveDom( ? (int) widthHeight.get("height") : defaultHeight; - if (lastWindowWidth != width) { + if (lastWindowWidth != width || lastWindowHeight != height) { resizeCount++; changeViewportAndWait(width, height, resizeCount); lastWindowWidth = width; + lastWindowHeight = height; } if (PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) { @@ -735,8 +737,10 @@ public List> captureResponsiveDom( domSnapshots.add(domSnapshot); } - // Restore original viewport - changeViewportAndWait(currentWidth, currentHeight, resizeCount + 1); + // Restore original viewport only if it was changed + if (lastWindowWidth != currentWidth || lastWindowHeight != currentHeight) { + changeViewportAndWait(currentWidth, currentHeight, resizeCount + 1); + } return domSnapshots; } From 0e269c6b1b92cf2cb39952a37df9d80eb2e53ad8 Mon Sep 17 00:00:00 2001 From: Moin Bhokare Date: Tue, 10 Mar 2026 14:32:41 +0530 Subject: [PATCH 10/19] Update src/main/java/io/percy/playwright/Percy.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/io/percy/playwright/Percy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 84dbdad..51c61db 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -680,7 +680,7 @@ private void changeViewportAndWait(int width, int height, int resizeCount) { * @param options Snapshot options (passed through to the DOM serializer). * @return A list of DOM snapshot maps, each annotated with its capture {@code width}. */ - public List> captureResponsiveDom( + private List> captureResponsiveDom( List cookies, String percyDomScript, Map options) { From abf3a7f2c20b93a9b129438d1f7bfdae44b1f93d Mon Sep 17 00:00:00 2001 From: Moin Bhokare Date: Tue, 10 Mar 2026 14:37:08 +0530 Subject: [PATCH 11/19] Update src/main/java/io/percy/playwright/Percy.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/io/percy/playwright/Percy.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 51c61db..fb86f65 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -729,7 +729,10 @@ private List> captureResponsiveDom( try { int sleepMs = Integer.parseInt(RESPONSIVE_CAPTURE_SLEEP_TIME) * 1000; if (sleepMs > 0) { Thread.sleep(sleepMs); } - } catch (InterruptedException | NumberFormatException ignored) { } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (NumberFormatException ignored) { } } Map domSnapshot = getSerializedDOM(cookies, percyDomScript, options); From 7e125cd71889ff2121e32bf1a774371bf718b897 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 14:41:34 +0530 Subject: [PATCH 12/19] updating code comment --- src/main/java/io/percy/playwright/Percy.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index fb86f65..75c527c 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -525,7 +525,10 @@ protected void setPageMetadata() throws Exception{ /** * Determines whether responsive DOM capture should be performed for this snapshot. - * Defers to the CLI config first, then falls back to the per-snapshot option. + * Returns {@code true} if either the per-snapshot {@code responsiveSnapshotCapture} option + * or the CLI config's {@code snapshot.responsiveSnapshotCapture} flag is {@code true}. + * Always returns {@code false} when {@code percy.deferUploads} is enabled in the CLI config, + * since responsive capture is not supported in that mode. */ private boolean isCaptureResponsiveDOM(Map options) { // Respect deferUploads: if enabled, responsive capture is not supported From 259346e579c5f49d889a348a670555a838b8a212 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 15:23:40 +0530 Subject: [PATCH 13/19] resolving comment --- src/test/java/io/percy/playwright/SDKTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/io/percy/playwright/SDKTest.java b/src/test/java/io/percy/playwright/SDKTest.java index 7876d4d..96baa3e 100644 --- a/src/test/java/io/percy/playwright/SDKTest.java +++ b/src/test/java/io/percy/playwright/SDKTest.java @@ -114,7 +114,7 @@ public void snapshotWithResponsiveSnapshotCapture() { public void snapshotWithCorsIframe() { // cors-iframe.html embeds https://todomvc.com/examples/react/dist/ inside an iframe, making it // a genuine cross-origin frame for Percy to detect and capture. - List allowedHostnames = Arrays.asList("*"); + List allowedHostnames = Arrays.asList("todomvc.com"); Map discoveryOptions = new HashMap<>(); discoveryOptions.put("allowedHostnames", allowedHostnames); Map percyOptions = new HashMap<>(); From 002a28be3b7ee5f52fd31919ff3f4e56e06ce8cc Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 15:26:07 +0530 Subject: [PATCH 14/19] resolving comments --- src/main/java/io/percy/playwright/Percy.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 75c527c..2f5bf15 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -928,7 +928,7 @@ protected static void log(String message, String level) { logRequest.setEntity(entity); httpClient.execute(logRequest); } catch (Exception ex) { - if (PERCY_DEBUG) { System.out.println("Sending log to CLI Failed " + ex.toString()); } + if (PERCY_DEBUG) { System.out.println("Sending log to CLI failed: " + ex.toString()); } } finally { // Print to stdout unless it is a debug message and debug mode is off if (!"debug".equals(level) || PERCY_DEBUG) { From dd495ba47b84b53843dff340552f58b8abde7062 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Tue, 10 Mar 2026 23:56:41 +0530 Subject: [PATCH 15/19] resolving comments --- src/main/java/io/percy/playwright/Percy.java | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 2f5bf15..f4bea56 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -34,7 +34,7 @@ public class Percy { private String domJs = ""; // Maybe get the CLI server address - private String PERCY_SERVER_ADDRESS = System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338"); + private static String PERCY_SERVER_ADDRESS = System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338"); // Determine if we're debug logging private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug"); @@ -567,14 +567,17 @@ private int calculateDefaultHeight(int currentHeight, Map option return currentHeight; } try { + int minHeight = currentHeight; Object minHeightOption = options.get("minHeight"); - int minHeight = (minHeightOption instanceof Number) - ? ((Number) minHeightOption).intValue() - : currentHeight; - Object result = page.evaluate("(minH) => window.outerHeight - window.innerHeight + minH", minHeight); - if (result instanceof Number) { - return ((Number) result).intValue(); + if (minHeightOption instanceof Number) { + minHeight = ((Number) minHeightOption).intValue(); + } else if (cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + if (snapshotConfig.has("minHeight") && !snapshotConfig.isNull("minHeight")) { + minHeight = snapshotConfig.getInt("minHeight"); + } } + return minHeight; } catch (Exception e) { log("Failed to calculate default height: " + e.getMessage(), "debug"); } @@ -923,8 +926,7 @@ protected static void log(String message, String level) { .build(); try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { - HttpPost logRequest = new HttpPost( - System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338") + "/percy/log"); + HttpPost logRequest = new HttpPost(PERCY_SERVER_ADDRESS + "/percy/log"); logRequest.setEntity(entity); httpClient.execute(logRequest); } catch (Exception ex) { From 5ca3a016524e80afab6d115d011ac7efe2dcc716 Mon Sep 17 00:00:00 2001 From: Moin Bhokare Date: Wed, 11 Mar 2026 00:10:19 +0530 Subject: [PATCH 16/19] Update src/main/java/io/percy/playwright/Percy.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/main/java/io/percy/playwright/Percy.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index f4bea56..2faff0f 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -316,11 +316,9 @@ public JSONObject snapshot(String name, Map options) { domSnapshot = getSerializedDOM(cookies, percyDomScript, options); } } catch (Exception e) { - if(domSnapshot == null) { - log("Snapshot capture failed: " + e.getMessage()); - return null; - } + log("Snapshot capture failed: " + e.getMessage()); log(e.getMessage(), "debug"); + return null; } return postSnapshot(domSnapshot, name, page.url(), options); From dd83e2e83008504f33bd80607bb3fab02918c9b5 Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Wed, 11 Mar 2026 15:51:45 +0530 Subject: [PATCH 17/19] Resolving comments --- src/main/java/io/percy/playwright/Percy.java | 6 +- .../java/io/percy/playwright/SDKTest.java | 58 +++++-------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 2faff0f..2f2224e 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -57,7 +57,7 @@ public class Percy { protected String sessionType = null; // CLI config returned by healthcheck - private JSONObject cliConfig = new JSONObject(); + JSONObject cliConfig = new JSONObject(); // Is the Percy server running or not private boolean isPercyEnabled = healthcheck(); @@ -528,7 +528,7 @@ protected void setPageMetadata() throws Exception{ * Always returns {@code false} when {@code percy.deferUploads} is enabled in the CLI config, * since responsive capture is not supported in that mode. */ - private boolean isCaptureResponsiveDOM(Map options) { + boolean isCaptureResponsiveDOM(Map options) { // Respect deferUploads: if enabled, responsive capture is not supported if (cliConfig.has("percy") && !cliConfig.isNull("percy")) { JSONObject percyProperty = cliConfig.getJSONObject("percy"); @@ -832,7 +832,7 @@ private Map processFrame( * @return A mutable snapshot map ready for posting to the Percy CLI. */ @SuppressWarnings("unchecked") - private Map getSerializedDOM( + Map getSerializedDOM( List cookies, String percyDomScript, Map options) { diff --git a/src/test/java/io/percy/playwright/SDKTest.java b/src/test/java/io/percy/playwright/SDKTest.java index 96baa3e..2206af5 100644 --- a/src/test/java/io/percy/playwright/SDKTest.java +++ b/src/test/java/io/percy/playwright/SDKTest.java @@ -305,14 +305,10 @@ public void isCaptureResponsiveDOMReturnsTrueForSDKOption() throws Exception { Page mockPage = Mockito.mock(Page.class); Percy percyInstance = new Percy(mockPage); - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("isCaptureResponsiveDOM", Map.class); - method.setAccessible(true); - Map options = new HashMap<>(); options.put("responsiveSnapshotCapture", true); - boolean result = (boolean) method.invoke(percyInstance, options); + boolean result = percyInstance.isCaptureResponsiveDOM(options); assertTrue(result); } @@ -327,18 +323,12 @@ public void isCaptureResponsiveDOMReturnsFalseWhenDeferUploadsEnabled() throws E JSONObject config = new JSONObject(); config.put("percy", percyConfig); - java.lang.reflect.Field cliConfigField = Percy.class.getDeclaredField("cliConfig"); - cliConfigField.setAccessible(true); - cliConfigField.set(percyInstance, config); - - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("isCaptureResponsiveDOM", Map.class); - method.setAccessible(true); + percyInstance.cliConfig = config; Map options = new HashMap<>(); options.put("responsiveSnapshotCapture", true); - boolean result = (boolean) method.invoke(percyInstance, options); + boolean result = percyInstance.isCaptureResponsiveDOM(options); assertFalse(result, "deferUploads should take priority and disable responsive capture"); } @@ -353,17 +343,11 @@ public void isCaptureResponsiveDOMReturnsTrueFromCLIConfig() throws Exception { JSONObject config = new JSONObject(); config.put("snapshot", snapshotConfig); - java.lang.reflect.Field cliConfigField = Percy.class.getDeclaredField("cliConfig"); - cliConfigField.setAccessible(true); - cliConfigField.set(percyInstance, config); - - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("isCaptureResponsiveDOM", Map.class); - method.setAccessible(true); + percyInstance.cliConfig = config; // No SDK-level flag — should still return true because CLI config enables it Map options = new HashMap<>(); - boolean result = (boolean) method.invoke(percyInstance, options); + boolean result = percyInstance.isCaptureResponsiveDOM(options); assertTrue(result, "CLI config responsiveSnapshotCapture should enable responsive capture"); } @@ -392,12 +376,8 @@ public void cookiesAreCapturedInSerializedDOM() throws Exception { cookie.httpOnly = false; cookie.secure = false; - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); - method.setAccessible(true); - - Map result = (Map) method.invoke( - percyInstance, Arrays.asList(cookie), "// percy dom script", new HashMap<>()); + Map result = percyInstance.getSerializedDOM( + Arrays.asList(cookie), "// percy dom script", new HashMap<>()); assertNotNull(result); assertNotNull(result.get("cookies")); @@ -424,12 +404,8 @@ public void emptyCookieListIsAttachedWhenNoCookiesPresent() throws Exception { Percy percyInstance = new Percy(mockPage); - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); - method.setAccessible(true); - - Map result = (Map) method.invoke( - percyInstance, new ArrayList<>(), "// percy dom script", new HashMap<>()); + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); assertNotNull(result); List> cookies = (List>) result.get("cookies"); @@ -473,12 +449,8 @@ public void corsIframesAreProcessedAndAttachedInSnapshot() throws Exception { Percy percyInstance = new Percy(mockPage); - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); - method.setAccessible(true); - - Map result = (Map) method.invoke( - percyInstance, new ArrayList<>(), "// percy dom script", new HashMap<>()); + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); assertNotNull(result); assertNotNull(result.get("corsIframes"), "corsIframes key should be present"); @@ -510,12 +482,8 @@ public void sameOriginFramesAreNotProcessedAsCorsIframes() throws Exception { Percy percyInstance = new Percy(mockPage); - java.lang.reflect.Method method = - Percy.class.getDeclaredMethod("getSerializedDOM", List.class, String.class, Map.class); - method.setAccessible(true); - - Map result = (Map) method.invoke( - percyInstance, new ArrayList<>(), "// percy dom script", new HashMap<>()); + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); assertNotNull(result); assertNull(result.get("corsIframes"), "Same-origin frames must not be added to corsIframes"); From 94ee039efc7c66a9be957c546bb2dc9f2cb6a3ed Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Thu, 12 Mar 2026 19:47:16 +0530 Subject: [PATCH 18/19] Minor Readme change - to run the semgrep workflow --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a2b8014..496a7e5 100644 --- a/README.md +++ b/README.md @@ -228,4 +228,3 @@ $ percy exec -- [java test command] ``` Refer to docs here: [Percy on Automate](https://www.browserstack.com/docs/percy/integrate/functional-and-visual) - From 7628cb6715ebd60714d7805da8bd78a3b186850b Mon Sep 17 00:00:00 2001 From: bhokaremoin Date: Thu, 12 Mar 2026 19:58:18 +0530 Subject: [PATCH 19/19] reverting readme changes --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 496a7e5..a2b8014 100644 --- a/README.md +++ b/README.md @@ -228,3 +228,4 @@ $ percy exec -- [java test command] ``` Refer to docs here: [Percy on Automate](https://www.browserstack.com/docs/percy/integrate/functional-and-visual) +