From 47c2f77c4b335323f7b95e5cf52fca903e74d16e Mon Sep 17 00:00:00 2001 From: yashmahamulkar-bs Date: Tue, 10 Mar 2026 10:28:34 +0530 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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); + } }