From 47c2f77c4b335323f7b95e5cf52fca903e74d16e Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Tue, 10 Mar 2026 10:28:34 +0530 Subject: [PATCH 01/19] Added PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE --- src/main/java/io/percy/selenium/Percy.java | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 0cf20f0..3cad74b 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -49,6 +49,9 @@ public class Percy { private static String RESONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESONSIVE_CAPTURE_SLEEP_TIME", ""); + private static String PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", "false").toLowerCase(); + + private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", "false")); // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; @@ -595,10 +598,16 @@ private static void changeWindowDimensionAndWait(WebDriver driver, int width, in } // Wait for window resize event using WebDriverWait + // Made changes to handle handles the temporary null state of resizeCountObj during page reload try { WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(1)); - wait.until((ExpectedCondition) d -> - (long) ((JavascriptExecutor) d).executeScript("return window.resizeCount") == resizeCount); + wait.until((ExpectedCondition) d -> { + Object resizeCountObj = ((JavascriptExecutor) d).executeScript("return window.resizeCount"); + if (resizeCountObj == null) { + return false; + } + return (long) resizeCountObj == resizeCount; + }); } catch (WebDriverException e) { log("Timed out waiting for window resize event for width " + width, "debug"); } @@ -627,6 +636,13 @@ public List> captureResponsiveDom(WebDriver driver, Set Date: Tue, 17 Mar 2026 00:24:36 +0530 Subject: [PATCH 02/19] adding responsive capture feature --- src/main/java/io/percy/selenium/Percy.java | 124 ++++++++++++++++++--- 1 file changed, 106 insertions(+), 18 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 3cad74b..5dc7ae5 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -250,6 +250,61 @@ public JSONObject snapshot(String name, @Nullable List widths, Integer return snapshot(name, options); } + 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); + } + } private boolean isCaptureResponsiveDOM(Map options) { if (cliConfig.has("percy") && !cliConfig.isNull("percy")) { JSONObject percyProperty = cliConfig.getJSONObject("percy"); @@ -523,6 +578,26 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); + // If PercyDOM serialized any processed cross-origin iframe frames, expose + // them on the snapshot as `corsIframes` so @percy/core can stitch them. + try { + Object processedFrames = null; + if (domSnapshot.containsKey("processedFrames")) { + processedFrames = domSnapshot.get("processedFrames"); + } else if (domSnapshot.containsKey("frames")) { + processedFrames = domSnapshot.get("frames"); + } + + if (processedFrames instanceof List) { + List pfList = (List) processedFrames; + if (!pfList.isEmpty()) { + mutableSnapshot.put("corsIframes", pfList); + } + } + } catch (Exception e) { + log("Failed to attach corsIframes to domSnapshot: " + e.getMessage(), "debug"); + } + return mutableSnapshot; } @@ -615,52 +690,65 @@ private static void changeWindowDimensionAndWait(WebDriver driver, int width, in // Capture responsive DOM for different widths public List> captureResponsiveDom(WebDriver driver, Set cookies, Map options) { - List widths = getWidthsForMultiDom(options); - + List> widths = getResponsiveWidths((List) options.get("widths")); List> domSnapshots = new ArrayList<>(); - Dimension windowSize = driver.manage().window().getSize(); int currentWidth = windowSize.getWidth(); int currentHeight = windowSize.getHeight(); + log("Initial window size: " + currentWidth + "x" + currentHeight, "debug"); int lastWindowWidth = currentWidth; int resizeCount = 0; JavascriptExecutor jse = (JavascriptExecutor) driver; - - // Inject JS to count window resize events jse.executeScript("PercyDOM.waitForResize()"); - - for (int width : widths) { + int targetHeight = currentHeight; + + if (PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) { + Integer minHeight = (Integer) options.get("minHeight"); + if (minHeight == null && cliConfig != null && cliConfig.has("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + if (snapshotConfig.has("minHeight")) { + minHeight = snapshotConfig.getInt("minHeight"); + } + } + if (minHeight != null) { + Object result = jse.executeScript("return window.outerHeight - window.innerHeight + " + minHeight); + if (result instanceof Number) { + targetHeight = ((Number) result).intValue(); + log("Calculated target height: " + targetHeight, "debug"); + } + } + } + for (Map widthMap : widths) { + int width = (int) widthMap.get("width"); if (lastWindowWidth != width) { resizeCount++; - changeWindowDimensionAndWait(driver, width, currentHeight, resizeCount); + changeWindowDimensionAndWait(driver, width, targetHeight, resizeCount); lastWindowWidth = width; } - if ("true".equals(PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE)) { log("Reloading page for width: " + width, "debug"); driver.navigate().refresh(); jse.executeScript(fetchPercyDOM()); jse.executeScript("PercyDOM.waitForResize()"); + resizeCount = 0; } - try { - int sleepTime = Integer.parseInt(RESONSIVE_CAPTURE_SLEEP_TIME); - Thread.sleep(sleepTime * 1000); // Sleep if needed + if (RESONSIVE_CAPTURE_SLEEP_TIME != null && !RESONSIVE_CAPTURE_SLEEP_TIME.isEmpty()) { + int sleepTime = Integer.parseInt(RESONSIVE_CAPTURE_SLEEP_TIME); + Thread.sleep(sleepTime * 1000L); + } } catch (InterruptedException | NumberFormatException ignored) { } Map domSnapshot = getSerializedDOM(jse, cookies, options); domSnapshot.put("width", width); domSnapshots.add(domSnapshot); } - - // Revert to the original window size changeWindowDimensionAndWait(driver, currentWidth, currentHeight, resizeCount + 1); return domSnapshots; - } - - protected static void log(String message) { - log(message, "info"); + } + protected static void log(String message) { + log(message, "info"); } protected static void log(String message, String level) { From 955c7677be5d6fce5eb8ae419cba521729096101 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Tue, 17 Mar 2026 01:08:48 +0530 Subject: [PATCH 03/19] fixing code --- src/main/java/io/percy/selenium/Percy.java | 157 ++++++++++++++------- 1 file changed, 108 insertions(+), 49 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 5dc7ae5..8cb5e3a 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -14,6 +14,7 @@ import org.json.JSONObject; import org.json.JSONArray; +import java.net.URI; import java.time.Duration; import java.util.*; import java.util.concurrent.ConcurrentHashMap; @@ -573,31 +574,122 @@ private String buildSnapshotJS(Map options) { return jsBuilder.toString(); } + private boolean isUnsupportedIframeSrc(String src) { + return src == null || src.isEmpty() || + src.equals("about:blank") || + src.startsWith("javascript:") || + src.startsWith("data:") || + src.startsWith("vbscript:"); + } + + private String getOrigin(String url) { + try { + URI uri = new URI(url); + String scheme = uri.getScheme(); + String authority = uri.getAuthority(); + if (scheme == null || authority == null) return ""; + return scheme + "://" + authority; + } catch (Exception e) { + return ""; + } + } + + private Map processFrame(WebElement frameElement, Map options) { + // Read attributes while still in parent context — these calls will + // fail if made after switchTo().frame(). + String frameUrl = frameElement.getAttribute("src"); + if (frameUrl == null) frameUrl = "unknown-src"; + final String finalFrameUrl = frameUrl; + log("processFrame: checking iframe src=\"" + finalFrameUrl + "\"", "debug"); + + String percyElementId = frameElement.getAttribute("data-percy-element-id"); + log("processFrame: data-percy-element-id=\"" + percyElementId + "\" for src=\"" + finalFrameUrl + "\"", "debug"); + if (percyElementId == null || percyElementId.isEmpty()) { + log("Skipping frame " + finalFrameUrl + ": no matching percyElementId found", "debug"); + return null; + } + + Map iframeSnapshot = null; + try { + driver.switchTo().frame(frameElement); + JavascriptExecutor jse = (JavascriptExecutor) driver; + // Inject Percy DOM into the cross-origin frame context + jse.executeScript(domJs); + // Serialize inside the frame; enableJavaScript=true is required for CORS iframes + Map iframeOptions = new HashMap<>(options); + iframeOptions.put("enableJavaScript", true); + JSONObject optionsJson = new JSONObject(iframeOptions); + iframeSnapshot = (Map) jse.executeScript( + "return PercyDOM.serialize(" + optionsJson.toString() + ")" + ); + } catch (Exception e) { + log("Failed to process cross-origin frame " + finalFrameUrl + ": " + e.getMessage(), "error"); + throw new RuntimeException("Failed to process cross-origin frame " + finalFrameUrl, e); + } finally { + try { + driver.switchTo().defaultContent(); + } catch (Exception err) { + throw new RuntimeException( + "Fatal: could not exit iframe context after processing \"" + finalFrameUrl + "\". Driver may be unstable." + ); + } + } + + Map iframeData = new HashMap<>(); + iframeData.put("percyElementId", percyElementId); + + Map result = new HashMap<>(); + result.put("iframeData", iframeData); + result.put("iframeSnapshot", iframeSnapshot); + result.put("frameUrl", finalFrameUrl); + return result; + } + private Map getSerializedDOM(JavascriptExecutor jse, Set cookies, Map options) { + // 1. Serialize the main page first (this adds the data-percy-element-ids) Map domSnapshot = (Map) jse.executeScript(buildSnapshotJS(options)); Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); - - // If PercyDOM serialized any processed cross-origin iframe frames, expose - // them on the snapshot as `corsIframes` so @percy/core can stitch them. + + // 2. Process CORS IFrames try { - Object processedFrames = null; - if (domSnapshot.containsKey("processedFrames")) { - processedFrames = domSnapshot.get("processedFrames"); - } else if (domSnapshot.containsKey("frames")) { - processedFrames = domSnapshot.get("frames"); - } - - if (processedFrames instanceof List) { - List pfList = (List) processedFrames; - if (!pfList.isEmpty()) { - mutableSnapshot.put("corsIframes", pfList); + String pageOrigin = getOrigin(driver.getCurrentUrl()); + List iframes = driver.findElements(By.tagName("iframe")); + if (!iframes.isEmpty() && !domJs.trim().isEmpty()) { + List> processedFrames = new ArrayList<>(); + for (WebElement frame : iframes) { + String frameSrc = frame.getAttribute("src"); + if (isUnsupportedIframeSrc(frameSrc)) { + continue; + } + String frameOrigin; + try { + URI base = new URI(driver.getCurrentUrl()); + URI resolved = base.resolve(frameSrc); + frameOrigin = getOrigin(resolved.toString()); + } catch (Exception e) { + log("Skipping iframe \"" + frameSrc + "\": " + e.getMessage(), "debug"); + continue; + } + if (frameOrigin.equals(pageOrigin)) { + continue; + } + try { + Map result = processFrame(frame, options); + if (result != null) { + processedFrames.add(result); + } + } catch (Exception e) { + log("Skipping frame \"" + frameSrc + "\" due to error: " + e.getMessage(), "debug"); + } + } + if (!processedFrames.isEmpty()) { + mutableSnapshot.put("corsIframes", processedFrames); } } } catch (Exception e) { - log("Failed to attach corsIframes to domSnapshot: " + e.getMessage(), "debug"); + log("Failed to process cross-origin iframes: " + e.getMessage(), "debug"); } - return mutableSnapshot; } @@ -610,39 +702,6 @@ private List getElementIdFromElement(List elements) { return ignoredElementsArray; } - // Get widths for multi DOM - private List getWidthsForMultiDom(Map options) { - List widths; - if (options.containsKey("widths") && options.get("widths") instanceof List) { - widths = (List) options.get("widths"); - } else { - widths = new ArrayList<>(); - } - // Create a Set to avoid duplicates - Set allWidths = new HashSet<>(); - - JSONArray mobileWidths = eligibleWidths.getJSONArray("mobile"); - for (int i = 0; i < mobileWidths.length(); i++) { - allWidths.add(mobileWidths.getInt(i)); - } - - // Add input widths if provided - if (widths.size() != 0) { - for (int width : widths) { - allWidths.add(width); - } - } else { - // Add config widths if no input widths are provided - JSONArray configWidths = eligibleWidths.getJSONArray("config"); - for (int i = 0; i < configWidths.length(); i++) { - allWidths.add(configWidths.getInt(i)); - } - } - - // Convert Set back to List - return allWidths.stream().collect(Collectors.toList()); - } - // Method to check if ChromeDriver supports CDP by checking the existence of executeCdpCommand private static boolean isCdpSupported(ChromeDriver chromeDriver) { try { From 57ab5eb030cac1aa6770a1d61899b48e1e70463d Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Tue, 17 Mar 2026 01:10:22 +0530 Subject: [PATCH 04/19] cli version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4711c51..3d4fd46 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 1f6a7ddc32a311be767673d1d18ba639d05625a3 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Wed, 18 Mar 2026 01:16:25 +0530 Subject: [PATCH 05/19] added unit testcases and fix: --- src/main/java/io/percy/selenium/Percy.java | 47 +- src/test/java/io/percy/selenium/SdkTest.java | 467 ++++++++++++++++++- 2 files changed, 494 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 8cb5e3a..f334117 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -48,7 +48,7 @@ public class Percy { // Determine if we're debug logging private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug"); - private static String RESONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESONSIVE_CAPTURE_SLEEP_TIME", ""); + private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESPONSIVE_CAPTURE_SLEEP_TIME", ""); private static String PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", "false").toLowerCase(); @@ -740,7 +740,7 @@ private static void changeWindowDimensionAndWait(WebDriver driver, int width, in if (resizeCountObj == null) { return false; } - return (long) resizeCountObj == resizeCount; + return (resizeCountObj instanceof Number) && ((Number) resizeCountObj).longValue() == resizeCount; }); } catch (WebDriverException e) { log("Timed out waiting for window resize event for width " + width, "debug"); @@ -762,26 +762,43 @@ public List> captureResponsiveDom(WebDriver driver, Set widthMap : widths) { - int width = (int) widthMap.get("width"); + Object widthObj = widthMap.get("width"); + if (!(widthObj instanceof Number)) { + continue; + } + int width = ((Number) widthObj).intValue(); + Object heightObj = widthMap.get("height"); + log("Width entry: width=" + width + ", height from widths config=" + heightObj + ", targetHeight=" + targetHeight, "debug"); + int heightForWidth = (heightObj instanceof Number)? ((Number) heightObj).intValue(): targetHeight; if (lastWindowWidth != width) { resizeCount++; - changeWindowDimensionAndWait(driver, width, targetHeight, resizeCount); + log("Resizing window to width=" + width + ", height=" + heightForWidth, "debug"); + changeWindowDimensionAndWait(driver, width, heightForWidth, resizeCount); lastWindowWidth = width; } if ("true".equals(PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE)) { @@ -792,8 +809,8 @@ public List> captureResponsiveDom(WebDriver driver, Set()); + + JSONObject mockedResponse = new JSONObject(); + mockedResponse.put("snapshot-name", "test_sync_cli_snapshot"); + mockedResponse.put("status", "success"); + mockedResponse.put("screenshots", new JSONArray()); + doReturn(mockedResponse).when(mockedPercy).request(eq("/percy/snapshot"), any(JSONObject.class), eq("test_sync_cli_snapshot")); + Map options = new HashMap(); options.put("sync", true); - JSONObject data = percy.snapshot("test_sync_cli_snapshot", options); + JSONObject data = mockedPercy.snapshot("test_sync_cli_snapshot", options); assertEquals(data.getString("snapshot-name"), "test_sync_cli_snapshot"); assertEquals(data.getString("status"), "success"); assertEquals(data.get("screenshots").getClass().isAssignableFrom(JSONArray.class), true); @@ -159,6 +199,11 @@ public void takeScreenshot() { } catch (Exception e) { } Percy mockedPercy = spy(new Percy(mockedDriver)); + try { + setField(mockedPercy, "isPercyEnabled", true); + } catch (Exception e) { + fail("Failed to setup test state: " + e.getMessage()); + } mockedPercy.sessionType = "automate"; when(mockedDriver.getSessionId()).thenReturn(new SessionId("123")); when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor); @@ -178,6 +223,11 @@ public void takeScreenshotWithOptions() { } catch (Exception e) { } Percy mockedPercy = spy(new Percy(mockedDriver)); + try { + setField(mockedPercy, "isPercyEnabled", true); + } catch (Exception e) { + fail("Failed to setup test state: " + e.getMessage()); + } mockedPercy.sessionType = "automate"; when(mockedDriver.getSessionId()).thenReturn(new SessionId("123")); when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor); @@ -196,8 +246,15 @@ public void takeScreenshotWithOptions() { @Test public void takeSnapshotThrowErrorForPOA() { - percy.sessionType = "automate"; - Throwable exception = assertThrows(RuntimeException.class, () -> percy.snapshot("Test")); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + mockedPercy.sessionType = "automate"; + try { + setField(mockedPercy, "isPercyEnabled", true); + } catch (Exception e) { + fail("Failed to setup test state: " + e.getMessage()); + } + Throwable exception = assertThrows(RuntimeException.class, () -> mockedPercy.snapshot("Test")); assertEquals("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", exception.getMessage()); } @@ -205,10 +262,385 @@ public void takeSnapshotThrowErrorForPOA() { public void takeScreenshotThrowErrorForWeb() { RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); Percy mockedPercy = spy(new Percy(mockedDriver)); + try { + setField(mockedPercy, "isPercyEnabled", true); + } catch (Exception e) { + fail("Failed to setup test state: " + e.getMessage()); + } Throwable exception = assertThrows(RuntimeException.class, () -> mockedPercy.screenshot("Test")); assertEquals("Invalid function call - screenshot(). Please use snapshot() function for taking screenshot. screenshot() should be used only while using Percy with Automate. For more information on usage of snapshot(), refer doc for your language https://www.browserstack.com/docs/percy/integrate/overview", exception.getMessage()); } + @Test + public void responsiveSnapshotCaptureUsesSdkOptionWhenEligible() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "eligibleWidths", new JSONObject().put("default", 1280)); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject().put("responsiveSnapshotCapture", false))); + + Map options = new HashMap(); + options.put("responsiveSnapshotCapture", true); + + boolean result = (boolean) invokePrivate(mockedPercy, "isCaptureResponsiveDOM", new Class[]{Map.class}, options); + + assertTrue(result); + } + + @Test + public void responsiveSnapshotCaptureDisabledForDeferUploads() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "eligibleWidths", new JSONObject().put("default", 1280)); + setField( + mockedPercy, + "cliConfig", + new JSONObject() + .put("percy", new JSONObject().put("deferUploads", true)) + .put("snapshot", new JSONObject().put("responsiveSnapshotCapture", true)) + ); + + Map options = new HashMap(); + options.put("responsiveSnapshotCapture", true); + + boolean result = (boolean) invokePrivate(mockedPercy, "isCaptureResponsiveDOM", new Class[]{Map.class}, options); + + assertFalse(result); + } + + @Test + public void getResponsiveWidthsParsesQueryAndResponse() throws Exception { + AtomicReference queryRef = new AtomicReference(null); + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + queryRef.set(exchange.getRequestURI().getQuery()); + byte[] body = "{\"widths\":[{\"width\":375},{\"width\":1280,\"height\":900}]}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + @SuppressWarnings("unchecked") + List> widths = (List>) invokePrivate( + mockedPercy, + "getResponsiveWidths", + new Class[]{List.class}, + Arrays.asList(375, 1280) + ); + + assertEquals("widths=375,1280", queryRef.get()); + assertEquals(2, widths.size()); + assertEquals(375, widths.get(0).get("width")); + assertEquals(1280, widths.get(1).get("width")); + assertEquals(900, widths.get(1).get("height")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void capturesCrossOriginIframeDataInSerializedDom() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn("frame-123"); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + TargetLocator targetLocator = mock(TargetLocator.class); + when(mockedDriver.switchTo()).thenReturn(targetLocator); + when(targetLocator.frame(iframe)).thenReturn(mockedDriver); + when(targetLocator.defaultContent()).thenReturn(mockedDriver); + + Map mainSnapshot = new HashMap(); + mainSnapshot.put("dom", "main"); + Map iframeSnapshot = new HashMap(); + iframeSnapshot.put("dom", "iframe"); + + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.startsWith("return PercyDOM.serialize(")) { + if (script.contains("\"enableJavaScript\":true")) { + return iframeSnapshot; + } + return mainSnapshot; + } + return null; + }); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + mockedPercy, + "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, + new HashSet(), + new HashMap() + ); + + assertTrue(serialized.containsKey("cookies")); + assertTrue(serialized.containsKey("corsIframes")); + + @SuppressWarnings("unchecked") + List> corsIframes = (List>) serialized.get("corsIframes"); + assertEquals(1, corsIframes.size()); + + Map frameData = corsIframes.get(0); + assertEquals("https://cdn.other.com/frame", frameData.get("frameUrl")); + + @SuppressWarnings("unchecked") + Map iframeData = (Map) frameData.get("iframeData"); + assertEquals("frame-123", iframeData.get("percyElementId")); + assertEquals("iframe", ((Map) frameData.get("iframeSnapshot")).get("dom")); + } + + @Test + public void getResponsiveWidthsThrowsForNon200Response() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "{}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_INTERNAL_ERROR, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> invokePrivate(mockedPercy, "getResponsiveWidths", new Class[]{List.class}, Arrays.asList(375, 1280)) + ); + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("Failed to fetch widths-config (HTTP 500)")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void getResponsiveWidthsThrowsWhenWidthsKeyMissing() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "{}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + InvocationTargetException exception = assertThrows( + InvocationTargetException.class, + () -> invokePrivate(mockedPercy, "getResponsiveWidths", new Class[]{List.class}, Arrays.asList(375, 1280)) + ); + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof RuntimeException); + assertTrue(exception.getCause().getMessage().contains("Missing \"widths\" in widths-config response")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void responsiveSnapshotCaptureIsFalseWhenEligibleWidthsMissing() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "eligibleWidths", null); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject().put("responsiveSnapshotCapture", true))); + + Map options = new HashMap(); + options.put("responsiveSnapshotCapture", true); + + boolean result = (boolean) invokePrivate(mockedPercy, "isCaptureResponsiveDOM", new Class[]{Map.class}, options); + assertFalse(result); + } + + @Test + public void skipsUnsupportedIframeSrcInSerializedDom() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("about:blank"); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + Map mainSnapshot = new HashMap(); + mainSnapshot.put("dom", "main"); + + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(mainSnapshot); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + mockedPercy, + "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, + new HashSet(), + new HashMap() + ); + + assertFalse(serialized.containsKey("corsIframes")); + } + + @Test + public void skipsSameOriginIframeInSerializedDom() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + setField(mockedPercy, "domJs", "window.PercyDOM = window.PercyDOM || {};"); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://app.example.com/frame"); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://app.example.com/page"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.singletonList(iframe)); + + Map mainSnapshot = new HashMap(); + mainSnapshot.put("dom", "main"); + + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(mainSnapshot); + + @SuppressWarnings("unchecked") + Map serialized = (Map) invokePrivate( + mockedPercy, + "getSerializedDOM", + new Class[]{JavascriptExecutor.class, Set.class, Map.class}, + mockedDriver, + new HashSet(), + new HashMap() + ); + + assertFalse(serialized.containsKey("corsIframes")); + } + + @Test + public void processFrameReturnsNullWhenPercyElementIdMissing() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + WebElement iframe = mock(WebElement.class); + when(iframe.getAttribute("src")).thenReturn("https://cdn.other.com/frame"); + when(iframe.getAttribute("data-percy-element-id")).thenReturn(null); + + Object result = invokePrivate(mockedPercy, "processFrame", new Class[]{WebElement.class, Map.class}, iframe, new HashMap()); + assertNull(result); + verify(mockedDriver, never()).switchTo(); + } + + @Test + public void takeScreenshotWithCamelCaseAliasOptions() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + HttpCommandExecutor commandExecutor = mock(HttpCommandExecutor.class); + when(commandExecutor.getAddressOfRemoteServer()).thenReturn(new URL("https://hub-cloud.browserstack.com/wd/hub")); + + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "isPercyEnabled", true); + mockedPercy.sessionType = "automate"; + + when(mockedDriver.getSessionId()).thenReturn(new SessionId("123")); + when(mockedDriver.getCommandExecutor()).thenReturn(commandExecutor); + DesiredCapabilities capabilities = new DesiredCapabilities(); + capabilities.setCapability("browserName", "Chrome"); + when(mockedDriver.getCapabilities()).thenReturn(capabilities); + + RemoteWebElement mockedIgnoreElement = mock(RemoteWebElement.class); + RemoteWebElement mockedConsiderElement = mock(RemoteWebElement.class); + when(mockedIgnoreElement.getId()).thenReturn("ignore-123"); + when(mockedConsiderElement.getId()).thenReturn("consider-456"); + + Map options = new HashMap(); + options.put("ignoreRegionSeleniumElements", Arrays.asList(mockedIgnoreElement)); + options.put("considerRegionSeleniumElements", Arrays.asList(mockedConsiderElement)); + + mockedPercy.screenshot("Test", options); + + ArgumentCaptor requestBodyCaptor = ArgumentCaptor.forClass(JSONObject.class); + verify(mockedPercy).request(eq("/percy/automateScreenshot"), requestBodyCaptor.capture(), eq("Test")); + + JSONObject requestBody = requestBodyCaptor.getValue(); + JSONObject capturedOptions = requestBody.getJSONObject("options"); + JSONArray ignoreElements = capturedOptions.getJSONArray("ignore_region_elements"); + JSONArray considerElements = capturedOptions.getJSONArray("consider_region_elements"); + + assertEquals("ignore-123", ignoreElements.getString(0)); + assertEquals("consider-456", considerElements.getString(0)); + assertFalse(capturedOptions.has("ignoreRegionSeleniumElements")); + assertFalse(capturedOptions.has("considerRegionSeleniumElements")); + } + + @Test + public void createRegionWithIntelliignoreIncludesConfiguration() { + Map params = new HashMap(); + params.put("algorithm", "intelliignore"); + params.put("diffSensitivity", 0.3); + params.put("carouselsEnabled", true); + + Map region = percy.createRegion(params); + + assertEquals("intelliignore", region.get("algorithm")); + @SuppressWarnings("unchecked") + Map configuration = (Map) region.get("configuration"); + assertNotNull(configuration); + assertEquals(0.3, configuration.get("diffSensitivity")); + assertTrue((Boolean) configuration.get("carouselsEnabled")); + } + + @Test + public void createRegionWithIgnoreAlgorithmOmitsConfiguration() { + Map params = new HashMap(); + params.put("algorithm", "ignore"); + params.put("diffSensitivity", 0.3); + + Map region = percy.createRegion(params); + + assertEquals("ignore", region.get("algorithm")); + assertFalse(region.containsKey("configuration")); + } + @Test public void createRegionTest() { // Setup the parameters for the region @@ -254,4 +686,29 @@ public void createRegionTest() { assertNotNull(assertion); assertEquals(0.1, assertion.get("diffIgnoreThreshold")); } + + private static Object invokePrivate(Object target, String methodName, Class[] paramTypes, Object... args) + throws Exception { + Method method = Percy.class.getDeclaredMethod(methodName, paramTypes); + method.setAccessible(true); + return method.invoke(target, args); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = Percy.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static void setStaticField(Class clazz, String fieldName, Object value) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(null, value); + } + + private static String getStaticStringField(Class clazz, String fieldName) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return (String) field.get(null); + } } From 98c859090ffd175e9fc40195cb8d5984a5ff287b Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Wed, 18 Mar 2026 09:55:39 +0530 Subject: [PATCH 06/19] resolved copilot comments --- src/main/java/io/percy/selenium/Percy.java | 58 +++++++++++++++++----- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index f334117..5b1d43b 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -630,7 +630,7 @@ private Map processFrame(WebElement frameElement, Map getSerializedDOM(JavascriptExecutor jse, Set } } catch (Exception e) { log("Skipping frame \"" + frameSrc + "\" due to error: " + e.getMessage(), "debug"); + String message = e.getMessage(); + if (message != null && message.contains("Fatal")) { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new RuntimeException("Fatal error while processing iframe \"" + frameSrc + "\"", e); + } + } } } if (!processedFrames.isEmpty()) { @@ -689,6 +697,15 @@ private Map getSerializedDOM(JavascriptExecutor jse, Set } } catch (Exception e) { log("Failed to process cross-origin iframes: " + e.getMessage(), "debug"); + String message = e.getMessage(); + if (message != null && message.contains("Fatal")) { + // Propagate fatal iframe processing errors to avoid returning a corrupted DOM snapshot + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new RuntimeException("Fatal error while processing cross-origin iframes", e); + } + } } return mutableSnapshot; } @@ -730,9 +747,7 @@ private static void changeWindowDimensionAndWait(WebDriver driver, int width, in log("Resizing using CDP failed, falling back to driver for width " + width + ": " + e.getMessage(), "debug"); driver.manage().window().setSize(new Dimension(width, height)); } - // Wait for window resize event using WebDriverWait - // Made changes to handle handles the temporary null state of resizeCountObj during page reload try { WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(1)); wait.until((ExpectedCondition) d -> { @@ -747,14 +762,36 @@ private static void changeWindowDimensionAndWait(WebDriver driver, int width, in } } - // Capture responsive DOM for different widths + private List extractResponsiveWidths(Map options) { + if (options == null) { + return null; + } + Object widthsOption = options.get("widths"); + if (!(widthsOption instanceof List)) { + return null; + } + List rawWidths = (List) widthsOption; + List coercedWidths = new ArrayList<>(); + for (Object value : rawWidths) { + if (value instanceof Number) { + coercedWidths.add(((Number) value).intValue()); + } else if (value instanceof String) { + try { + coercedWidths.add(Integer.parseInt((String) value)); + } catch (NumberFormatException ignore) { + } + } + } + return coercedWidths.isEmpty() ? null : coercedWidths; + } + public List> captureResponsiveDom(WebDriver driver, Set cookies, Map options) { - List> widths = getResponsiveWidths((List) options.get("widths")); + List responsiveWidths = extractResponsiveWidths(options); + List> widths = getResponsiveWidths(responsiveWidths); List> domSnapshots = new ArrayList<>(); Dimension windowSize = driver.manage().window().getSize(); int currentWidth = windowSize.getWidth(); int currentHeight = windowSize.getHeight(); - log("Initial window size: " + currentWidth + "x" + currentHeight, "debug"); int lastWindowWidth = currentWidth; int resizeCount = 0; JavascriptExecutor jse = (JavascriptExecutor) driver; @@ -775,7 +812,6 @@ public List> captureResponsiveDom(WebDriver driver, Set> captureResponsiveDom(WebDriver driver, Set> captureResponsiveDom(WebDriver driver, Set Date: Wed, 18 Mar 2026 15:23:56 +0530 Subject: [PATCH 07/19] Refactoring code --- src/main/java/io/percy/selenium/Percy.java | 179 ++++++++----- src/test/java/io/percy/selenium/SdkTest.java | 257 +++++++++++++++++++ 2 files changed, 367 insertions(+), 69 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 5b1d43b..2520a56 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -53,6 +53,7 @@ public class Percy { private static String PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", "false").toLowerCase(); private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", "false")); + private static final int WIDTHS_CONFIG_TIMEOUT_MS = 30000; // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; @@ -252,51 +253,12 @@ public JSONObject snapshot(String name, @Nullable List widths, Integer } 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(); + String queryParam = buildWidthsQueryParam(widths); + RequestConfig requestConfig = buildRequestConfig(WIDTHS_CONFIG_TIMEOUT_MS); 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; + HttpResponse response = fetchWidthsConfigResponse(httpClient, queryParam); + return parseWidthsConfigResponse(response); } catch (RuntimeException re) { throw re; } catch (Exception ex) { @@ -306,6 +268,63 @@ private List> getResponsiveWidths(List widths) { "Failed to fetch widths-config: " + ex.getMessage(), ex); } } + + // Builds the optional `?widths=` query string from SDK-provided widths. + private String buildWidthsQueryParam(List widths) { + if (widths == null || widths.isEmpty()) { + return ""; + } + String joined = widths.stream().map(String::valueOf).collect(Collectors.joining(",")); + return "?widths=" + joined; + } + + // Creates HTTP request timeout configuration for the widths-config endpoint. + private RequestConfig buildRequestConfig(int timeoutMs) { + return RequestConfig.custom() + .setSocketTimeout(timeoutMs) + .setConnectTimeout(timeoutMs) + .build(); + } + + // Calls Percy CLI widths-config endpoint and validates that the HTTP status is successful. + private HttpResponse fetchWidthsConfigResponse(CloseableHttpClient httpClient, String queryParam) throws Exception { + 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 + ")"); + } + + return response; + } + + // Parses widths-config JSON and converts the payload to SDK width/height maps. + private List> parseWidthsConfigResponse(HttpResponse response) throws Exception { + 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; + } + private boolean isCaptureResponsiveDOM(Map options) { if (cliConfig.has("percy") && !cliConfig.isNull("percy")) { JSONObject percyProperty = cliConfig.getJSONObject("percy"); @@ -785,6 +804,53 @@ private List extractResponsiveWidths(Map options) { return coercedWidths.isEmpty() ? null : coercedWidths; } + // Resolves final viewport height for responsive capture using minHeight config when enabled. + private int resolveResponsiveTargetHeight(Map options, JavascriptExecutor jse, int currentHeight) { + if (!PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) { + log("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT is disabled, using current window height: " + currentHeight, "debug"); + return currentHeight; + } + + Integer minHeight = resolveConfiguredMinHeight(options); + if (minHeight == null) { + log("minHeight not found in options or cliConfig, using current window height: " + currentHeight, "debug"); + return currentHeight; + } + + return calculateTargetHeight(jse, minHeight, currentHeight); + } + + // Reads minHeight from snapshot options first, then falls back to CLI snapshot config. + private Integer resolveConfiguredMinHeight(Map options) { + Object minHeightObj = options.get("minHeight"); + if (minHeightObj == null && cliConfig != null && cliConfig.has("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + if (snapshotConfig.has("minHeight")) { + minHeightObj = snapshotConfig.getInt("minHeight"); + } + } + + if (minHeightObj == null) { + return null; + } + + try { + return Integer.parseInt(minHeightObj.toString()); + } catch (NumberFormatException e) { + log("Invalid minHeight value " + minHeightObj + "; expected integer, using current window height instead.", "debug"); + return null; + } + } + + // Converts content minHeight into browser outer height while preserving fallback behavior. + private int calculateTargetHeight(JavascriptExecutor jse, int minHeight, int fallbackHeight) { + Object result = jse.executeScript("return window.outerHeight - window.innerHeight + " + minHeight); + if (result instanceof Number) { + return ((Number) result).intValue(); + } + return fallbackHeight; + } + public List> captureResponsiveDom(WebDriver driver, Set cookies, Map options) { List responsiveWidths = extractResponsiveWidths(options); List> widths = getResponsiveWidths(responsiveWidths); @@ -796,32 +862,7 @@ public List> captureResponsiveDom(WebDriver driver, Set widthMap : widths) { Object widthObj = widthMap.get("width"); if (!(widthObj instanceof Number)) { diff --git a/src/test/java/io/percy/selenium/SdkTest.java b/src/test/java/io/percy/selenium/SdkTest.java index 628045f..4553785 100644 --- a/src/test/java/io/percy/selenium/SdkTest.java +++ b/src/test/java/io/percy/selenium/SdkTest.java @@ -23,6 +23,11 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; +import org.apache.http.HttpResponse; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; + import org.json.JSONArray; import org.json.JSONObject; import org.openqa.selenium.By; @@ -309,6 +314,252 @@ public void responsiveSnapshotCaptureDisabledForDeferUploads() throws Exception assertFalse(result); } + @Test + public void buildWidthsQueryParamReturnsJoinedValues() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + String result = (String) invokePrivate( + mockedPercy, + "buildWidthsQueryParam", + new Class[]{List.class}, + Arrays.asList(375, 1280) + ); + + assertEquals("?widths=375,1280", result); + } + + @Test + public void buildWidthsQueryParamReturnsEmptyForNullOrEmptyInput() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + String nullResult = (String) invokePrivate( + mockedPercy, + "buildWidthsQueryParam", + new Class[]{List.class}, + new Object[]{null} + ); + String emptyResult = (String) invokePrivate( + mockedPercy, + "buildWidthsQueryParam", + new Class[]{List.class}, + Collections.emptyList() + ); + + assertEquals("", nullResult); + assertEquals("", emptyResult); + } + + @Test + public void buildRequestConfigUsesProvidedTimeout() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + RequestConfig requestConfig = (RequestConfig) invokePrivate( + mockedPercy, + "buildRequestConfig", + new Class[]{int.class}, + 12345 + ); + + assertEquals(12345, requestConfig.getSocketTimeout()); + assertEquals(12345, requestConfig.getConnectTimeout()); + } + + @Test + public void fetchWidthsConfigResponseReturnsHttp200Response() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "{\"widths\":[{\"width\":375}]}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + HttpResponse response = (HttpResponse) invokePrivate( + mockedPercy, + "fetchWidthsConfigResponse", + new Class[]{CloseableHttpClient.class, String.class}, + httpClient, + "?widths=375" + ); + + assertEquals(HttpURLConnection.HTTP_OK, response.getStatusLine().getStatusCode()); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void parseWidthsConfigResponseParsesWidthAndHeightValues() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + byte[] body = "{\"widths\":[{\"width\":375},{\"width\":1280,\"height\":900}]}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + HttpResponse response = (HttpResponse) invokePrivate( + mockedPercy, + "fetchWidthsConfigResponse", + new Class[]{CloseableHttpClient.class, String.class}, + httpClient, + "" + ); + + @SuppressWarnings("unchecked") + List> parsed = (List>) invokePrivate( + mockedPercy, + "parseWidthsConfigResponse", + new Class[]{HttpResponse.class}, + response + ); + + assertEquals(2, parsed.size()); + assertEquals(375, parsed.get(0).get("width")); + assertEquals(1280, parsed.get(1).get("width")); + assertEquals(900, parsed.get(1).get("height")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void resolveConfiguredMinHeightUsesOptionsAndCliFallback() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + Map optionsWithValue = new HashMap(); + optionsWithValue.put("minHeight", "1200"); + Integer fromOptions = (Integer) invokePrivate( + mockedPercy, + "resolveConfiguredMinHeight", + new Class[]{Map.class}, + optionsWithValue + ); + assertEquals(1200, fromOptions); + + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject().put("minHeight", 900))); + Map optionsWithoutValue = new HashMap(); + Integer fromCliConfig = (Integer) invokePrivate( + mockedPercy, + "resolveConfiguredMinHeight", + new Class[]{Map.class}, + optionsWithoutValue + ); + assertEquals(900, fromCliConfig); + } + + @Test + public void resolveConfiguredMinHeightReturnsNullForInvalidValue() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + + Map options = new HashMap(); + options.put("minHeight", "invalid"); + + Integer result = (Integer) invokePrivate( + mockedPercy, + "resolveConfiguredMinHeight", + new Class[]{Map.class}, + options + ); + + assertNull(result); + } + + @Test + public void calculateTargetHeightReturnsComputedOrFallbackValue() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + JavascriptExecutor mockedJs = mock(JavascriptExecutor.class); + + when(mockedJs.executeScript(any(String.class))).thenReturn(1337); + int computed = (int) invokePrivate( + mockedPercy, + "calculateTargetHeight", + new Class[]{JavascriptExecutor.class, int.class, int.class}, + mockedJs, + 1200, + 900 + ); + assertEquals(1337, computed); + + when(mockedJs.executeScript(any(String.class))).thenReturn("not-a-number"); + int fallback = (int) invokePrivate( + mockedPercy, + "calculateTargetHeight", + new Class[]{JavascriptExecutor.class, int.class, int.class}, + mockedJs, + 1200, + 900 + ); + assertEquals(900, fallback); + } + + @Test + public void resolveResponsiveTargetHeightRespectsFeatureFlagAndMinHeight() throws Exception { + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + JavascriptExecutor mockedJs = mock(JavascriptExecutor.class); + when(mockedJs.executeScript(any(String.class))).thenReturn(1400); + + boolean originalFlag = getStaticBooleanField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT"); + try { + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", false); + int disabledResult = (int) invokePrivate( + mockedPercy, + "resolveResponsiveTargetHeight", + new Class[]{Map.class, JavascriptExecutor.class, int.class}, + new HashMap(), + mockedJs, + 800 + ); + assertEquals(800, disabledResult); + + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", true); + Map options = new HashMap(); + options.put("minHeight", 1200); + int enabledResult = (int) invokePrivate( + mockedPercy, + "resolveResponsiveTargetHeight", + new Class[]{Map.class, JavascriptExecutor.class, int.class}, + options, + mockedJs, + 800 + ); + assertEquals(1400, enabledResult); + } finally { + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", originalFlag); + } + } + @Test public void getResponsiveWidthsParsesQueryAndResponse() throws Exception { AtomicReference queryRef = new AtomicReference(null); @@ -711,4 +962,10 @@ private static String getStaticStringField(Class clazz, String fieldName) thr field.setAccessible(true); return (String) field.get(null); } + + private static boolean getStaticBooleanField(Class clazz, String fieldName) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + return (boolean) field.get(null); + } } From 6aacb8a29bda9c4fee47067d75466694e7ae2934 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Mon, 23 Mar 2026 14:47:34 +0530 Subject: [PATCH 08/19] resolving comments --- src/main/java/io/percy/selenium/Percy.java | 17 +- src/test/java/io/percy/selenium/SdkTest.java | 198 +++++++++++++++++++ 2 files changed, 205 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 2520a56..34daee9 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -50,9 +50,9 @@ public class Percy { private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESPONSIVE_CAPTURE_SLEEP_TIME", ""); - private static String PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", "false").toLowerCase(); + private static boolean PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", "false").toLowerCase()); - private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", "false")); + private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", "false").toLowerCase()); private static final int WIDTHS_CONFIG_TIMEOUT_MS = 30000; // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; @@ -619,8 +619,6 @@ private Map processFrame(WebElement frameElement, Map processFrame(WebElement frameElement, Map getSerializedDOM(JavascriptExecutor jse, Set cookies, Map options) { - // 1. Serialize the main page first (this adds the data-percy-element-ids) Map domSnapshot = (Map) jse.executeScript(buildSnapshotJS(options)); Map mutableSnapshot = new HashMap<>(domSnapshot); mutableSnapshot.put("cookies", cookies); - - // 2. Process CORS IFrames try { String pageOrigin = getOrigin(driver.getCurrentUrl()); List iframes = driver.findElements(By.tagName("iframe")); @@ -859,6 +854,7 @@ public List> captureResponsiveDom(WebDriver driver, Set> captureResponsiveDom(WebDriver driver, Set> captureResponsiveDom(WebDriver driver, Set { + byte[] body = "{\"widths\":[{\"width\":375,\"height\":812},{\"width\":1280}]}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "domJs", "/* percy dom */"); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + // Track actual setSize calls so window.resizeCount can echo the right value. + AtomicInteger dimensionChangeCount = new AtomicInteger(0); + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(mockedDriver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + doAnswer(inv -> { dimensionChangeCount.incrementAndGet(); return null; }) + .when(driverWindow).setSize(any(Dimension.class)); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + return (long) dimensionChangeCount.get(); + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap<>(); + snap.put("dom", "test"); + return snap; + } + return null; + }); + + Map options = new HashMap<>(); + options.put("widths", Arrays.asList(375, 1280)); + List> snapshots = + mockedPercy.captureResponsiveDom(mockedDriver, new HashSet<>(), options); + + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Dimension.class); + // 3 calls expected: resize to 375x812, resize to 1280x768, restore 1024x768. + verify(driverWindow, times(3)).setSize(sizeCaptor.capture()); + List sizes = sizeCaptor.getAllValues(); + + // First width uses the explicit height returned by the server. + assertEquals(375, sizes.get(0).getWidth()); + assertEquals(812, sizes.get(0).getHeight()); + + // Second width has no explicit height: falls back to currentHeight (768). + assertEquals(1280, sizes.get(1).getWidth()); + assertEquals(768, sizes.get(1).getHeight()); + + // Final call restores the original window size. + assertEquals(1024, sizes.get(2).getWidth()); + assertEquals(768, sizes.get(2).getHeight()); + + // Each snapshot must carry the width it was captured at. + assertEquals(2, snapshots.size()); + assertEquals(375, snapshots.get(0).get("width")); + assertEquals(1280, snapshots.get(1).get("width")); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void captureResponsiveDomSkipsResizeWhenDimensionsUnchanged() throws Exception { + // Return the exact same width as the initial window — no per-width resize should occur. + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", exchange -> { + byte[] body = "{\"widths\":[{\"width\":1024}]}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "domJs", "/* percy dom */"); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + AtomicInteger dimensionChangeCount = new AtomicInteger(0); + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + when(mockedDriver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + doAnswer(inv -> { dimensionChangeCount.incrementAndGet(); return null; }) + .when(driverWindow).setSize(any(Dimension.class)); + + when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + return (long) dimensionChangeCount.get(); + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap<>(); + snap.put("dom", "test"); + return snap; + } + return null; + }); + + Map options = new HashMap<>(); + options.put("widths", Arrays.asList(1024)); + mockedPercy.captureResponsiveDom(mockedDriver, new HashSet<>(), options); + + // Only the final restore call should fire; no per-width resize. + ArgumentCaptor sizeCaptor = ArgumentCaptor.forClass(Dimension.class); + verify(driverWindow, times(1)).setSize(sizeCaptor.capture()); + Dimension restoreSize = sizeCaptor.getValue(); + assertEquals(1024, restoreSize.getWidth()); + assertEquals(768, restoreSize.getHeight()); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + server.stop(0); + } + } + + @Test + public void captureResponsiveDomRefreshesDriverForEachWidthWhenReloadFlagSet() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/percy/widths-config", exchange -> { + byte[] body = "{\"widths\":[{\"width\":375},{\"width\":1280}]}".getBytes("UTF-8"); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, body.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(body); } + }); + server.start(); + + String originalAddress = getStaticStringField(Percy.class, "PERCY_SERVER_ADDRESS"); + boolean originalReloadFlag = getStaticBooleanField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE"); + try { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", "http://localhost:" + server.getAddress().getPort()); + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", true); + + RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class); + Percy mockedPercy = spy(new Percy(mockedDriver)); + setField(mockedPercy, "domJs", "/* percy dom */"); + setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject())); + + WebDriver.Options driverOptions = mock(WebDriver.Options.class); + WebDriver.Window driverWindow = mock(WebDriver.Window.class); + WebDriver.Navigation navigation = mock(WebDriver.Navigation.class); + when(mockedDriver.manage()).thenReturn(driverOptions); + when(driverOptions.window()).thenReturn(driverWindow); + when(driverWindow.getSize()).thenReturn(new Dimension(1024, 768)); + when(mockedDriver.navigate()).thenReturn(navigation); + when(mockedDriver.getCurrentUrl()).thenReturn("https://example.com"); + when(mockedDriver.findElements(By.tagName("iframe"))).thenReturn(Collections.emptyList()); + + // After each reload resizeCount resets to 0, so the next changeWindowDimensionAndWait + // call uses resizeCount=1. Return 1L so the wait resolves without timing out. + when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenAnswer(invocation -> { + String script = invocation.getArgument(0); + if (script.equals("return window.resizeCount")) { + return 1L; + } + if (script.startsWith("return PercyDOM.serialize(")) { + Map snap = new HashMap<>(); + snap.put("dom", "test"); + return snap; + } + return null; + }); + + Map options = new HashMap<>(); + options.put("widths", Arrays.asList(375, 1280)); + mockedPercy.captureResponsiveDom(mockedDriver, new HashSet<>(), options); + + // driver.navigate().refresh() must be called once per captured width. + verify(navigation, times(2)).refresh(); + } finally { + setStaticField(Percy.class, "PERCY_SERVER_ADDRESS", originalAddress); + setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", originalReloadFlag); + server.stop(0); + } + } + private static Object invokePrivate(Object target, String methodName, Class[] paramTypes, Object... args) throws Exception { Method method = Percy.class.getDeclaredMethod(methodName, paramTypes); From b35e8371a1b0326a02522d73752fafb9085a8115 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Tue, 24 Mar 2026 17:32:54 +0530 Subject: [PATCH 09/19] fixing min height --- src/main/java/io/percy/selenium/Percy.java | 22 +++++------ src/test/java/io/percy/selenium/SdkTest.java | 39 ++------------------ 2 files changed, 12 insertions(+), 49 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 34daee9..1834176 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -800,7 +800,7 @@ private List extractResponsiveWidths(Map options) { } // Resolves final viewport height for responsive capture using minHeight config when enabled. - private int resolveResponsiveTargetHeight(Map options, JavascriptExecutor jse, int currentHeight) { + private int resolveResponsiveTargetHeight(Map options, int currentHeight) { if (!PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) { log("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT is disabled, using current window height: " + currentHeight, "debug"); return currentHeight; @@ -812,7 +812,7 @@ private int resolveResponsiveTargetHeight(Map options, Javascrip return currentHeight; } - return calculateTargetHeight(jse, minHeight, currentHeight); + return minHeight; } // Reads minHeight from snapshot options first, then falls back to CLI snapshot config. @@ -837,14 +837,7 @@ private Integer resolveConfiguredMinHeight(Map options) { } } - // Converts content minHeight into browser outer height while preserving fallback behavior. - private int calculateTargetHeight(JavascriptExecutor jse, int minHeight, int fallbackHeight) { - Object result = jse.executeScript("return window.outerHeight - window.innerHeight + " + minHeight); - if (result instanceof Number) { - return ((Number) result).intValue(); - } - return fallbackHeight; - } + public List> captureResponsiveDom(WebDriver driver, Set cookies, Map options) { List responsiveWidths = extractResponsiveWidths(options); @@ -858,7 +851,7 @@ public List> captureResponsiveDom(WebDriver driver, Set widthMap : widths) { Object widthObj = widthMap.get("width"); if (!(widthObj instanceof Number)) { @@ -866,14 +859,17 @@ public List> captureResponsiveDom(WebDriver driver, Set(), - mockedJs, 800 ); assertEquals(800, disabledResult); @@ -551,12 +519,11 @@ public void resolveResponsiveTargetHeightRespectsFeatureFlagAndMinHeight() throw int enabledResult = (int) invokePrivate( mockedPercy, "resolveResponsiveTargetHeight", - new Class[]{Map.class, JavascriptExecutor.class, int.class}, + new Class[]{Map.class, int.class}, options, - mockedJs, 800 ); - assertEquals(1400, enabledResult); + assertEquals(1200, enabledResult); } finally { setStaticField(Percy.class, "PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT", originalFlag); } From 37af5b453bb59daf49b0eedf3290cc5001d98ea6 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Tue, 24 Mar 2026 19:02:35 +0530 Subject: [PATCH 10/19] fix --- package.json | 2 +- src/main/java/io/percy/selenium/Percy.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3d4fd46..72d9dfe 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "test": "npx percy exec --testing -- mvn test" }, "devDependencies": { - "@percy/cli": "1.31.10-alpha.0" + "@percy/cli": "1.31.10" } } diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 1834176..3e23552 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -869,7 +869,7 @@ public List> captureResponsiveDom(WebDriver driver, Set Date: Wed, 25 Mar 2026 17:55:01 +0530 Subject: [PATCH 11/19] fix: address code review feedback for snapshot error handling and responsive capture - Catch Exception (not just WebDriverException) in snapshot() to prevent caller crashes - Wrap captureResponsiveDom loop in try/finally to always restore window size - Replace System.out.println debug statements with log() calls - Introduce FatalIframeException to replace fragile string matching on "Fatal" - Fix log(String) indentation - Add fallback for renamed RESONSIVE_CAPTURE_SLEEP_TIME env var Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/java/io/percy/selenium/Percy.java | 107 +++++++++++---------- 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index 3e23552..f35952c 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -48,7 +48,10 @@ public class Percy { // Determine if we're debug logging private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug"); - private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESPONSIVE_CAPTURE_SLEEP_TIME", ""); + private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault( + "RESPONSIVE_CAPTURE_SLEEP_TIME", + System.getenv().getOrDefault("RESONSIVE_CAPTURE_SLEEP_TIME", "") + ); private static boolean PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = Boolean.parseBoolean(System.getenv().getOrDefault("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE", "false").toLowerCase()); @@ -367,6 +370,8 @@ public JSONObject snapshot(String name, Map options) { } catch (WebDriverException e) { // For some reason, the execution in the browser failed. log(e.getMessage(), "debug"); + } catch (Exception e) { + log("Snapshot failed: " + e.getMessage(), "debug"); } return postSnapshot(domSnapshot, name, driver.getCurrentUrl(), options); @@ -593,6 +598,12 @@ private String buildSnapshotJS(Map options) { return jsBuilder.toString(); } + static class FatalIframeException extends RuntimeException { + FatalIframeException(String message, Throwable cause) { + super(message, cause); + } + } + private boolean isUnsupportedIframeSrc(String src) { return src == null || src.isEmpty() || src.equals("about:blank") || @@ -646,8 +657,8 @@ private Map processFrame(WebElement frameElement, Map getSerializedDOM(JavascriptExecutor jse, Set if (result != null) { processedFrames.add(result); } + } catch (FatalIframeException e) { + throw e; } catch (Exception e) { log("Skipping frame \"" + frameSrc + "\" due to error: " + e.getMessage(), "debug"); - String message = e.getMessage(); - if (message != null && message.contains("Fatal")) { - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { - throw new RuntimeException("Fatal error while processing iframe \"" + frameSrc + "\"", e); - } - } } } if (!processedFrames.isEmpty()) { mutableSnapshot.put("corsIframes", processedFrames); } } + } catch (FatalIframeException e) { + throw e; } catch (Exception e) { log("Failed to process cross-origin iframes: " + e.getMessage(), "debug"); - String message = e.getMessage(); - if (message != null && message.contains("Fatal")) { - // Propagate fatal iframe processing errors to avoid returning a corrupted DOM snapshot - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { - throw new RuntimeException("Fatal error while processing cross-origin iframes", e); - } - } } return mutableSnapshot; } @@ -852,47 +850,50 @@ public List> captureResponsiveDom(WebDriver driver, Set widthMap : widths) { - Object widthObj = widthMap.get("width"); - if (!(widthObj instanceof Number)) { - continue; - } - int width = ((Number) widthObj).intValue(); - Object heightObj = widthMap.get("height"); - System.out.println("Processing responsive snapshot for width " + width + " with target height " + targetHeight+ "height obj: " + heightObj); - int heightForWidth = (heightObj instanceof Number)? ((Number) heightObj).intValue(): targetHeight; - System.out.println("final height" + heightForWidth); - if (lastWindowWidth != width || lastWindowHeight != heightForWidth) { - resizeCount++; - System.out.println("Resizing window to width " + width + " and height " + heightForWidth); - changeWindowDimensionAndWait(driver, width, heightForWidth, resizeCount); - lastWindowWidth = width; - lastWindowHeight = heightForWidth; - } - if (PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) { - driver.navigate().refresh(); - jse.executeScript(fetchPercyDOM()); - jse.executeScript("PercyDOM.waitForResize()"); - resizeCount = 0; - } - try { - if (RESPONSIVE_CAPTURE_SLEEP_TIME != null && !RESPONSIVE_CAPTURE_SLEEP_TIME.isEmpty()) { - int sleepTime = Integer.parseInt(RESPONSIVE_CAPTURE_SLEEP_TIME); - Thread.sleep(sleepTime * 1000L); + try { + for (Map widthMap : widths) { + Object widthObj = widthMap.get("width"); + if (!(widthObj instanceof Number)) { + continue; + } + int width = ((Number) widthObj).intValue(); + Object heightObj = widthMap.get("height"); + log("Processing responsive snapshot for width " + width + " with target height " + targetHeight + ", height obj: " + heightObj, "debug"); + int heightForWidth = (heightObj instanceof Number) ? ((Number) heightObj).intValue() : targetHeight; + log("Final height: " + heightForWidth, "debug"); + if (lastWindowWidth != width || lastWindowHeight != heightForWidth) { + resizeCount++; + log("Resizing window to width " + width + " and height " + heightForWidth, "debug"); + changeWindowDimensionAndWait(driver, width, heightForWidth, resizeCount); + lastWindowWidth = width; + lastWindowHeight = heightForWidth; } - } catch (InterruptedException | NumberFormatException ignored) { + if (PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) { + driver.navigate().refresh(); + jse.executeScript(fetchPercyDOM()); + jse.executeScript("PercyDOM.waitForResize()"); + resizeCount = 0; + } + try { + if (RESPONSIVE_CAPTURE_SLEEP_TIME != null && !RESPONSIVE_CAPTURE_SLEEP_TIME.isEmpty()) { + int sleepTime = Integer.parseInt(RESPONSIVE_CAPTURE_SLEEP_TIME); + Thread.sleep(sleepTime * 1000L); + } + } catch (InterruptedException | NumberFormatException ignored) { + } + Map domSnapshot = getSerializedDOM(jse, cookies, options); + domSnapshot.put("width", width); + domSnapshots.add(domSnapshot); } - Map domSnapshot = getSerializedDOM(jse, cookies, options); - domSnapshot.put("width", width); - domSnapshots.add(domSnapshot); + } finally { + changeWindowDimensionAndWait(driver, currentWidth, currentHeight, resizeCount + 1); } - changeWindowDimensionAndWait(driver, currentWidth, currentHeight, resizeCount + 1); return domSnapshots; } protected static void log(String message) { - log(message, "info"); + log(message, "info"); } protected static void log(String message, String level) { From 361b5c34aafdfdc3fa087f69115de19f5e2a8bab Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Wed, 25 Mar 2026 18:56:41 +0530 Subject: [PATCH 12/19] bump version to 2.1.2-alpha.0 for alpha release Co-Authored-By: Claude Opus 4.6 (1M context) --- pom.xml | 6 ++---- src/main/java/io/percy/selenium/Environment.java | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index 0590aa6..dbb5336 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.percy percy-java-selenium - 2.1.1 + 2.1.2-alpha.0 jar ${project.groupId}:${project.artifactId} @@ -32,7 +32,7 @@ scm:git:git://github.com/percy/percy-java-selenium.git scm:git:https://github.com/percy/percy-java-selenium.git https://github.com/percy/percy-java-selenium/tree/master - v2.0.0 + v2.1.2-alpha.0 @@ -46,7 +46,6 @@ org.seleniumhq.selenium selenium-java 4.5.3 - provided org.seleniumhq.selenium @@ -62,7 +61,6 @@ io.github.bonigarcia webdrivermanager 5.3.2 - test org.junit.jupiter diff --git a/src/main/java/io/percy/selenium/Environment.java b/src/main/java/io/percy/selenium/Environment.java index 0c38284..eb57940 100644 --- a/src/main/java/io/percy/selenium/Environment.java +++ b/src/main/java/io/percy/selenium/Environment.java @@ -10,7 +10,7 @@ */ class Environment { private WebDriver driver; - private final static String SDK_VERSION = "2.1.1"; + private final static String SDK_VERSION = "2.1.2-alpha.0"; private final static String SDK_NAME = "percy-java-selenium"; Environment(WebDriver driver) { From 3372ce4d4c9fdfce002fb7004cf2866dd00a982d Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Wed, 25 Mar 2026 19:00:22 +0530 Subject: [PATCH 13/19] restore provided scope for selenium-java and test scope for webdrivermanager Co-Authored-By: Claude Opus 4.6 (1M context) --- pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pom.xml b/pom.xml index dbb5336..29fc9bf 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,7 @@ org.seleniumhq.selenium selenium-java 4.5.3 + provided org.seleniumhq.selenium @@ -61,6 +62,7 @@ io.github.bonigarcia webdrivermanager 5.3.2 + test org.junit.jupiter From 31c54b77b6de60388f31e39607fd6c3daba07958 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Thu, 26 Mar 2026 14:03:31 +0530 Subject: [PATCH 14/19] removing version from pr --- pom.xml | 13 ++++++------- src/main/java/io/percy/selenium/Environment.java | 2 +- src/main/java/io/percy/selenium/Percy.java | 1 - 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pom.xml b/pom.xml index 29fc9bf..c259b6a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.percy percy-java-selenium - 2.1.2-alpha.0 + 4.28.0 jar ${project.groupId}:${project.artifactId} @@ -32,7 +32,7 @@ scm:git:git://github.com/percy/percy-java-selenium.git scm:git:https://github.com/percy/percy-java-selenium.git https://github.com/percy/percy-java-selenium/tree/master - v2.1.2-alpha.0 + v2.0.0 @@ -45,13 +45,13 @@ org.seleniumhq.selenium selenium-java - 4.5.3 - provided + 4.15.0 + org.seleniumhq.selenium selenium-firefox-driver - 4.5.3 + 4.15.0 org.json @@ -61,8 +61,7 @@ io.github.bonigarcia webdrivermanager - 5.3.2 - test + 5.6.2 org.junit.jupiter diff --git a/src/main/java/io/percy/selenium/Environment.java b/src/main/java/io/percy/selenium/Environment.java index eb57940..0c38284 100644 --- a/src/main/java/io/percy/selenium/Environment.java +++ b/src/main/java/io/percy/selenium/Environment.java @@ -10,7 +10,7 @@ */ class Environment { private WebDriver driver; - private final static String SDK_VERSION = "2.1.2-alpha.0"; + private final static String SDK_VERSION = "2.1.1"; private final static String SDK_NAME = "percy-java-selenium"; Environment(WebDriver driver) { diff --git a/src/main/java/io/percy/selenium/Percy.java b/src/main/java/io/percy/selenium/Percy.java index f35952c..453c4bf 100644 --- a/src/main/java/io/percy/selenium/Percy.java +++ b/src/main/java/io/percy/selenium/Percy.java @@ -888,7 +888,6 @@ public List> captureResponsiveDom(WebDriver driver, Set Date: Thu, 26 Mar 2026 15:30:14 +0530 Subject: [PATCH 15/19] removing version --- pom.xml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pom.xml b/pom.xml index c259b6a..0590aa6 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.percy percy-java-selenium - 4.28.0 + 2.1.1 jar ${project.groupId}:${project.artifactId} @@ -45,13 +45,13 @@ org.seleniumhq.selenium selenium-java - 4.15.0 - + 4.5.3 + provided org.seleniumhq.selenium selenium-firefox-driver - 4.15.0 + 4.5.3 org.json @@ -61,7 +61,8 @@ io.github.bonigarcia webdrivermanager - 5.6.2 + 5.3.2 + test org.junit.jupiter From 06f341f6fe4bf5c4a8f4bad650ba150d0f2d9629 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Thu, 26 Mar 2026 18:59:33 +0530 Subject: [PATCH 16/19] Release 2.1.2-beta.0 --- pom.xml | 4 ++-- src/main/java/io/percy/selenium/Environment.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 0590aa6..bacd8fc 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ io.percy percy-java-selenium - 2.1.1 + 2.1.2-beta.0 jar ${project.groupId}:${project.artifactId} @@ -32,7 +32,7 @@ scm:git:git://github.com/percy/percy-java-selenium.git scm:git:https://github.com/percy/percy-java-selenium.git https://github.com/percy/percy-java-selenium/tree/master - v2.0.0 + v2.1.2-beta.0 diff --git a/src/main/java/io/percy/selenium/Environment.java b/src/main/java/io/percy/selenium/Environment.java index 0c38284..c2b4e9d 100644 --- a/src/main/java/io/percy/selenium/Environment.java +++ b/src/main/java/io/percy/selenium/Environment.java @@ -10,7 +10,7 @@ */ class Environment { private WebDriver driver; - private final static String SDK_VERSION = "2.1.1"; + private final static String SDK_VERSION = "2.1.2-beta.0"; private final static String SDK_NAME = "percy-java-selenium"; Environment(WebDriver driver) { From 6b57f1fcad9aadadecc30a72a237f1f1711d1818 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Thu, 26 Mar 2026 19:26:05 +0530 Subject: [PATCH 17/19] adding test for crossiframe --- src/test/java/io/percy/selenium/SdkTest.java | 6 ++++++ src/test/resources/testapp/cors-iframe.html | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 src/test/resources/testapp/cors-iframe.html diff --git a/src/test/java/io/percy/selenium/SdkTest.java b/src/test/java/io/percy/selenium/SdkTest.java index 645a642..7a92677 100644 --- a/src/test/java/io/percy/selenium/SdkTest.java +++ b/src/test/java/io/percy/selenium/SdkTest.java @@ -113,6 +113,12 @@ public void takesMultipleSnapshotsInOneTestCase() { percy.snapshot("Multiple snapshots in one test case -- #2", Arrays.asList(768, 992, 1200)); } + @Test + public void takesSnapshotWithCrossOriginIframe() { + driver.get(TEST_URL + "/cors-iframe.html"); + percy.snapshot("Snapshot with cross-origin iframe"); + } + @Test public void snapshotALiveHTTPSite() { driver.get("http://example.com"); 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 e71ea094cf626564b645aa15c5c2c2697ee4d695 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Thu, 26 Mar 2026 21:40:34 +0530 Subject: [PATCH 18/19] fixing release pipeline --- .github/workflows/release.yml | 2 +- pom.xml | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26c20d1..125c438 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: with: java-version: 8 distribution: 'adopt' - server-id: ossrh # Value of the distributionManagement/repository/id field of the pom.xml + server-id: central # Value of the distributionManagement/repository/id field of the pom.xml server-username: MAVEN_USERNAME # env variable for username in deploy server-password: MAVEN_PASSWORD # env variable for token in deploy gpg-private-key: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} # Value of the GPG private key to import diff --git a/pom.xml b/pom.xml index bacd8fc..f72d9be 100644 --- a/pom.xml +++ b/pom.xml @@ -92,12 +92,12 @@ - ossrh - https://oss.sonatype.org/content/repositories/snapshots + central + https://central.sonatype.com/repository/maven-snapshots - ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ + central + https://central.sonatype.com/ @@ -184,15 +184,12 @@ 3.0.0 - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.13 + org.sonatype.central + central-publishing-maven-plugin + 0.9.0 true - ossrh - https://oss.sonatype.org/ - - true + central From aa33493b0b609a42760d9f18f62d4636d7e82ed8 Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Fri, 27 Mar 2026 12:51:07 +0530 Subject: [PATCH 19/19] add autoPublish to central publishing plugin config Co-Authored-By: Claude Opus 4.6 (1M context) --- pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/pom.xml b/pom.xml index f72d9be..6a37984 100644 --- a/pom.xml +++ b/pom.xml @@ -190,6 +190,7 @@ true central + true