diff --git a/package.json b/package.json index 4711c51..9ff98ce 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "test": "npx percy exec --testing -- mvn test" }, "devDependencies": { - "@percy/cli": "1.30.9" + "@percy/cli": "^1.31.10-alpha.0" } } diff --git a/src/main/java/io/percy/playwright/Percy.java b/src/main/java/io/percy/playwright/Percy.java index 038689a..2f2224e 100644 --- a/src/main/java/io/percy/playwright/Percy.java +++ b/src/main/java/io/percy/playwright/Percy.java @@ -5,18 +5,22 @@ import org.apache.http.util.EntityUtils; import org.apache.http.entity.ContentType; import org.apache.http.entity.StringEntity; +import org.apache.http.client.config.RequestConfig; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; import org.jetbrains.annotations.Nullable; +import org.json.JSONArray; import org.json.JSONObject; +import java.net.URI; import java.util.*; - -import javax.swing.text.html.CSS; -import javax.xml.xpath.XPath; +import java.util.stream.Collectors; import com.microsoft.playwright.*; +import com.microsoft.playwright.options.Cookie; +import com.microsoft.playwright.options.ViewportSize; /** @@ -30,17 +34,31 @@ public class Percy { private String domJs = ""; // Maybe get the CLI server address - private String PERCY_SERVER_ADDRESS = System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338"); + private static String PERCY_SERVER_ADDRESS = System.getenv().getOrDefault("PERCY_SERVER_ADDRESS", "http://localhost:5338"); // Determine if we're debug logging private static boolean PERCY_DEBUG = System.getenv().getOrDefault("PERCY_LOGLEVEL", "info").equals("debug"); + // Optional sleep between responsive captures (seconds) + private static String RESPONSIVE_CAPTURE_SLEEP_TIME = System.getenv().getOrDefault("RESPONSIVE_CAPTURE_SLEEP_TIME", ""); + + // Whether to reload the page between responsive captures + private static boolean PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE = + "true".equalsIgnoreCase(System.getenv("PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE")); + + // Whether to adjust the default height to account for browser chrome during responsive capture + private static boolean PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT = + "true".equalsIgnoreCase(System.getenv("PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT")); + // for logging private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]"; // Type of session automate/web protected String sessionType = null; + // CLI config returned by healthcheck + JSONObject cliConfig = new JSONObject(); + // Is the Percy server running or not private boolean isPercyEnabled = healthcheck(); @@ -280,12 +298,27 @@ public JSONObject snapshot(String name, Map options) { if (!isPercyEnabled) { return null; } if ("automate".equals(sessionType)) { throw new RuntimeException("Invalid function call - snapshot(). Please use screenshot() function while using Percy with Automate. For more information on usage of PercyScreenshot, refer https://www.browserstack.com/docs/percy/integrate/functional-and-visual"); } - Map domSnapshot = null; + Object domSnapshot = null; try { - page.evaluate(fetchPercyDOM()); - domSnapshot = (Map) page.evaluate(buildSnapshotJS(options)); + String percyDomScript = fetchPercyDOM(); + page.evaluate(percyDomScript); + + List cookies = new ArrayList<>(); + try { + cookies = page.context().cookies(); + } catch (Exception e) { + log("Cookie collection failed: " + e.getMessage(), "debug"); + } + + if (isCaptureResponsiveDOM(options)) { + domSnapshot = captureResponsiveDom(cookies, percyDomScript, options); + } else { + domSnapshot = getSerializedDOM(cookies, percyDomScript, options); + } } catch (Exception e) { - if (PERCY_DEBUG) { log(e.getMessage()); } + log("Snapshot capture failed: " + e.getMessage()); + log(e.getMessage(), "debug"); + return null; } return postSnapshot(domSnapshot, name, page.url(), options); @@ -326,13 +359,15 @@ public JSONObject screenshot(String name, Map options) throws Ex /** * POST the DOM taken from the test browser to the Percy Agent node process. * - * @param domSnapshot Stringified & serialized version of the site/applications DOM + * @param domSnapshot Stringified & serialized version of the site/applications DOM. + * May be a {@code Map} for a single capture or a + * {@code List>} for a responsive (multi-width) capture. * @param name The human-readable name of the snapshot. Should be unique. * @param url The url of current website * @param options Map of various options support in percySnapshot Command. */ private JSONObject postSnapshot( - Map domSnapshot, + Object domSnapshot, String name, String url, Map options @@ -359,7 +394,13 @@ private JSONObject postSnapshot( protected JSONObject request(String url, JSONObject json, String name) { StringEntity entity = new StringEntity(json.toString(), ContentType.APPLICATION_JSON); - try (CloseableHttpClient httpClient = HttpClientBuilder.create().build()) { + int timeout = 600000; // 600 seconds + RequestConfig requestConfig = RequestConfig.custom() + .setSocketTimeout(timeout) + .setConnectTimeout(timeout) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { HttpPost request = new HttpPost(PERCY_SERVER_ADDRESS + url); request.setEntity(entity); HttpResponse response = httpClient.execute(request); @@ -369,7 +410,7 @@ protected JSONObject request(String url, JSONObject json, String name) { return jsonResponse.getJSONObject("data"); } } catch (Exception ex) { - if (PERCY_DEBUG) { log(ex.toString()); } + log(ex.toString(), "debug"); log("Could not post snapshot " + name); } return null; @@ -455,11 +496,15 @@ private boolean healthcheck() { String responseString = EntityUtils.toString(entity, "UTF-8"); JSONObject responseObject = new JSONObject(responseString); sessionType = (String) responseObject.optString("type", null); + JSONObject parsedConfig = responseObject.optJSONObject("config"); + if (parsedConfig != null) { + cliConfig = parsedConfig; + } return true; } catch (Exception ex) { log("Percy is not running, disabling snapshots"); - if (PERCY_DEBUG) { log(ex.toString()); } + log(ex.toString(), "debug"); return false; } @@ -472,7 +517,423 @@ protected void setPageMetadata() throws Exception{ this.pageMetadata = new PageMetadata(this.page); } + // ------------------------------------------------------------------------- + // Responsive snapshot capture helpers + // ------------------------------------------------------------------------- + + /** + * Determines whether responsive DOM capture should be performed for this snapshot. + * Returns {@code true} if either the per-snapshot {@code responsiveSnapshotCapture} option + * or the CLI config's {@code snapshot.responsiveSnapshotCapture} flag is {@code true}. + * Always returns {@code false} when {@code percy.deferUploads} is enabled in the CLI config, + * since responsive capture is not supported in that mode. + */ + boolean isCaptureResponsiveDOM(Map options) { + // Respect deferUploads: if enabled, responsive capture is not supported + if (cliConfig.has("percy") && !cliConfig.isNull("percy")) { + JSONObject percyProperty = cliConfig.getJSONObject("percy"); + if (percyProperty.has("deferUploads") && !percyProperty.isNull("deferUploads") + && percyProperty.getBoolean("deferUploads")) { + return false; + } + } + + boolean responsiveSnapshotCaptureCLI = false; + if (cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + if (snapshotConfig.has("responsiveSnapshotCapture")) { + responsiveSnapshotCaptureCLI = snapshotConfig.getBoolean("responsiveSnapshotCapture"); + } + } + + Object responsiveSnapshotCaptureSDK = options.get("responsiveSnapshotCapture"); + return (responsiveSnapshotCaptureSDK instanceof Boolean && (Boolean) responsiveSnapshotCaptureSDK) + || responsiveSnapshotCaptureCLI; + } + + /** + * Calculates the default height for responsive capture. + * When {@code PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT} is enabled, the height is adjusted + * to account for browser chrome: {@code window.outerHeight - window.innerHeight + minH}. + * + * @param currentHeight The current viewport height to use as a fallback. + * @param options Snapshot options; may contain a {@code minHeight} override. + * @return The computed default height in pixels. + */ + private int calculateDefaultHeight(int currentHeight, Map options) { + if (!PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT) { + return currentHeight; + } + try { + int minHeight = currentHeight; + Object minHeightOption = options.get("minHeight"); + if (minHeightOption instanceof Number) { + minHeight = ((Number) minHeightOption).intValue(); + } else if (cliConfig.has("snapshot") && !cliConfig.isNull("snapshot")) { + JSONObject snapshotConfig = cliConfig.getJSONObject("snapshot"); + if (snapshotConfig.has("minHeight") && !snapshotConfig.isNull("minHeight")) { + minHeight = snapshotConfig.getInt("minHeight"); + } + } + return minHeight; + } catch (Exception e) { + log("Failed to calculate default height: " + e.getMessage(), "debug"); + } + return currentHeight; + } + + /** + * Fetches responsive width/height pairs from the Percy CLI {@code /percy/widths-config} + * endpoint. The optional {@code widths} list is forwarded as a query parameter so that + * the CLI can merge user-supplied widths with its own configuration. + * + * @param widths Optional list of user-supplied widths (may be null or empty). + * @return A list of {@code {"width": N, "height": N}} maps as returned by the CLI. + * @throws RuntimeException when the CLI is unavailable or returns an unexpected payload. + */ + @SuppressWarnings("unchecked") + private List> getResponsiveWidths(List widths) { + String queryParam = ""; + if (widths != null && !widths.isEmpty()) { + String joined = widths.stream().map(String::valueOf).collect(Collectors.joining(",")); + queryParam = "?widths=" + joined; + } + + int timeout = 30000; // 30 seconds + RequestConfig requestConfig = RequestConfig.custom() + .setSocketTimeout(timeout) + .setConnectTimeout(timeout) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { + HttpGet httpget = new HttpGet(PERCY_SERVER_ADDRESS + "/percy/widths-config" + queryParam); + HttpResponse response = httpClient.execute(httpget); + int statusCode = response.getStatusLine().getStatusCode(); + + if (statusCode != 200) { + EntityUtils.consume(response.getEntity()); + log("Update Percy CLI to the latest version to use responsiveSnapshotCapture"); + throw new RuntimeException( + "Failed to fetch widths-config (HTTP " + statusCode + ")"); + } + + String responseString = EntityUtils.toString(response.getEntity(), "UTF-8"); + JSONObject json = new JSONObject(responseString); + + if (!json.has("widths") || json.isNull("widths")) { + log("Update Percy CLI to the latest version to use responsiveSnapshotCapture"); + throw new RuntimeException( + "Missing \"widths\" in widths-config response"); + } + + JSONArray widthsArray = json.getJSONArray("widths"); + List> result = new ArrayList<>(); + for (int i = 0; i < widthsArray.length(); i++) { + JSONObject entry = widthsArray.getJSONObject(i); + Map item = new HashMap<>(); + item.put("width", entry.getInt("width")); + if (entry.has("height") && !entry.isNull("height")) { + item.put("height", entry.getInt("height")); + } + result.add(item); + } + return result; + } catch (RuntimeException re) { + throw re; + } catch (Exception ex) { + log("Update Percy CLI to the latest version to use responsiveSnapshotCapture"); + log("Failed to fetch widths-config: " + ex.getMessage(), "debug"); + throw new RuntimeException( + "Failed to fetch widths-config: " + ex.getMessage(), ex); + } + } + + /** + * Resizes the page viewport to the requested dimensions and waits for the page to + * acknowledge the resize via the {@code window.resizeCount} counter injected by + * {@code PercyDOM.waitForResize()}. + * + * @param width Target viewport width in pixels. + * @param height Target viewport height in pixels. + * @param resizeCount The expected value of {@code window.resizeCount} after resize. + */ + private void changeViewportAndWait(int width, int height, int resizeCount) { + try { + page.setViewportSize(width, height); + } catch (Exception e) { + log("Resizing viewport failed for width " + width + ": " + e.getMessage(), "debug"); + } + + try { + page.waitForFunction( + "window.resizeCount === " + resizeCount, + null, + new Page.WaitForFunctionOptions().setTimeout(1000) + ); + } catch (Exception e) { + log("Timed out waiting for window resize event for width " + width, "debug"); + } + } + + /** + * Captures serialized DOM snapshots for each responsive width/height pair returned + * by the Percy CLI. The viewport is restored to its original size after capture. + * + * @param cookies Page cookies to embed in each snapshot. + * @param percyDomScript The cached percy DOM serialization script. + * @param options Snapshot options (passed through to the DOM serializer). + * @return A list of DOM snapshot maps, each annotated with its capture {@code width}. + */ + private List> captureResponsiveDom( + List cookies, + String percyDomScript, + Map options) { + + @SuppressWarnings("unchecked") + List userWidths = (options.get("widths") instanceof List) + ? (List) options.get("widths") + : new ArrayList<>(); + + List> widthHeights = getResponsiveWidths(userWidths); + + List> domSnapshots = new ArrayList<>(); + + ViewportSize originalViewport = page.viewportSize(); + int currentWidth = (originalViewport != null) ? originalViewport.width : 1280; + int currentHeight = (originalViewport != null) ? originalViewport.height : 720; + int defaultHeight = calculateDefaultHeight(currentHeight, options); + int lastWindowWidth = currentWidth; + int lastWindowHeight = currentHeight; + int resizeCount = 0; + + // Inject the resize counter before iterating widths + page.evaluate("PercyDOM.waitForResize()"); + + for (Map widthHeight : widthHeights) { + int width = (int) widthHeight.get("width"); + int height = widthHeight.containsKey("height") + ? (int) widthHeight.get("height") + : defaultHeight; + + if (lastWindowWidth != width || lastWindowHeight != height) { + resizeCount++; + changeViewportAndWait(width, height, resizeCount); + lastWindowWidth = width; + lastWindowHeight = height; + } + + if (PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) { + page.reload(); + page.evaluate(percyDomScript); + page.evaluate("PercyDOM.waitForResize()"); + resizeCount = 0; + } + + if (!RESPONSIVE_CAPTURE_SLEEP_TIME.isEmpty()) { + try { + int sleepMs = Integer.parseInt(RESPONSIVE_CAPTURE_SLEEP_TIME) * 1000; + if (sleepMs > 0) { Thread.sleep(sleepMs); } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (NumberFormatException ignored) { } + } + + Map domSnapshot = getSerializedDOM(cookies, percyDomScript, options); + domSnapshot.put("width", width); + domSnapshots.add(domSnapshot); + } + + // Restore original viewport only if it was changed + if (lastWindowWidth != currentWidth || lastWindowHeight != currentHeight) { + changeViewportAndWait(currentWidth, currentHeight, resizeCount + 1); + } + + return domSnapshots; + } + + // ------------------------------------------------------------------------- + // Cross-origin iframe helpers + // ------------------------------------------------------------------------- + + /** + * Processes a single cross-origin {@link Frame}: injects the Percy DOM script, + * serializes the frame, and retrieves the matching {@code data-percy-element-id} + * from the main page so the CLI can stitch the iframe content into the snapshot. + * + * @param frame The cross-origin frame to process. + * @param percyDomScript The cached percy DOM serialization script. + * @param options Snapshot options forwarded to the frame serializer. + * @return A map containing {@code iframeData}, {@code iframeSnapshot}, and + * {@code frameUrl}, or {@code null} if the frame cannot be processed. + */ + @SuppressWarnings("unchecked") + private Map processFrame( + Frame frame, + String percyDomScript, + Map options) { + + String frameUrl = frame.url(); + try { + // Inject Percy DOM into the cross-origin frame + frame.evaluate(percyDomScript); + + // enableJavaScript=true prevents standard iframe serialization so we can + // handle cross-origin frames manually + Map frameOptions = new HashMap<>(options); + frameOptions.put("enableJavaScript", true); + JSONObject frameOptionsJson = new JSONObject(frameOptions); + + Map iframeSnapshot = + (Map) frame.evaluate( + String.format("PercyDOM.serialize(%s)", frameOptionsJson)); + + // Retrieve the matching iframe element's percy ID from the main page + String js = + "(fUrl) => {" + + " const iframes = Array.from(document.querySelectorAll('iframe'));" + + " const match = iframes.find(f => f.src.startsWith(fUrl));" + + " if (match) {" + + " return { percyElementId: match.getAttribute('data-percy-element-id') };" + + " }" + + "}"; + + Map iframeData = + (Map) page.evaluate(js, frameUrl); + + if (iframeData == null || iframeData.get("percyElementId") == null) { + log("Skipping cross-origin frame " + frameUrl + + ": no matching iframe with percyElementId found", "debug"); + return null; + } + + Map result = new HashMap<>(); + result.put("iframeData", iframeData); + result.put("iframeSnapshot", iframeSnapshot); + result.put("frameUrl", frameUrl); + return result; + + } catch (Exception e) { + log("Failed to process cross-origin frame " + frameUrl + ": " + e.getMessage(), "debug"); + return null; + } + } + + // ------------------------------------------------------------------------- + // DOM serialization + // ------------------------------------------------------------------------- + + /** + * Serializes the main page DOM, captures cross-origin iframes, and attaches cookies. + * + * @param cookies Page cookies to embed in the snapshot payload. + * @param percyDomScript The cached percy DOM serialization script. + * @param options Snapshot options forwarded to the DOM serializer. + * @return A mutable snapshot map ready for posting to the Percy CLI. + */ + @SuppressWarnings("unchecked") + Map getSerializedDOM( + List cookies, + String percyDomScript, + Map options) { + + Map domSnapshot = + (Map) page.evaluate(buildSnapshotJS(options)); + if (domSnapshot == null) { + throw new RuntimeException("DOM serialization returned null — PercyDOM.serialize() may not be loaded or returned undefined"); + } + Map mutableSnapshot = new HashMap<>(domSnapshot); + + // Process cross-origin iframes + try { + URI pageUri = new URI(page.url()); + String pageHost = pageUri.getHost(); + + List crossOriginFrames = page.frames().stream() + .filter(f -> { + String fUrl = f.url(); + if ("about:blank".equals(fUrl) || fUrl.isEmpty()) { return false; } + // If the page has no host (e.g., file:, data:), skip CORS detection + if (pageHost == null) { return false; } + try { + String frameHost = new URI(fUrl).getHost(); + // Treat frames with no host as non-cross-origin + return frameHost != null && !Objects.equals(frameHost, pageHost); + } catch (Exception e) { + return false; + } + }) + .collect(Collectors.toList()); + + if (!crossOriginFrames.isEmpty()) { + List> processedFrames = new ArrayList<>(); + for (Frame frame : crossOriginFrames) { + Map frameResult = processFrame(frame, percyDomScript, options); + if (frameResult != null) { + processedFrames.add(frameResult); + } + } + if (!processedFrames.isEmpty()) { + mutableSnapshot.put("corsIframes", processedFrames); + } + } + } catch (Exception e) { + log("Failed to process cross-origin iframes: " + e.getMessage(), "debug"); + } + + // Serialize cookies as a list of plain maps + List> cookiesList = new ArrayList<>(); + for (Cookie c : cookies) { + Map cookieMap = new HashMap<>(); + cookieMap.put("name", c.name); + cookieMap.put("value", c.value); + cookieMap.put("domain", c.domain); + cookieMap.put("path", c.path); + cookieMap.put("expires", c.expires); + cookieMap.put("httpOnly", c.httpOnly); + cookieMap.put("secure", c.secure); + if (c.sameSite != null) { + cookieMap.put("sameSite", c.sameSite.toString()); + } + cookiesList.add(cookieMap); + } + mutableSnapshot.put("cookies", cookiesList); + + return mutableSnapshot; + } + + // ------------------------------------------------------------------------- + // Logging + // ------------------------------------------------------------------------- + protected static void log(String message) { - System.out.println(LABEL + " " + message); + log(message, "info"); + } + + protected static void log(String message, String level) { + message = LABEL + " " + message; + JSONObject logJson = new JSONObject(); + logJson.put("message", message); + logJson.put("level", level); + StringEntity entity = new StringEntity(logJson.toString(), ContentType.APPLICATION_JSON); + + int timeout = 1000; // 1 second + RequestConfig requestConfig = RequestConfig.custom() + .setSocketTimeout(timeout) + .setConnectTimeout(timeout) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.custom().setDefaultRequestConfig(requestConfig).build()) { + HttpPost logRequest = new HttpPost(PERCY_SERVER_ADDRESS + "/percy/log"); + logRequest.setEntity(entity); + httpClient.execute(logRequest); + } catch (Exception ex) { + if (PERCY_DEBUG) { System.out.println("Sending log to CLI failed: " + ex.toString()); } + } finally { + // Print to stdout unless it is a debug message and debug mode is off + if (!"debug".equals(level) || PERCY_DEBUG) { + System.out.println(message); + } + } } } diff --git a/src/test/java/io/percy/playwright/SDKTest.java b/src/test/java/io/percy/playwright/SDKTest.java index 8512ad3..2206af5 100644 --- a/src/test/java/io/percy/playwright/SDKTest.java +++ b/src/test/java/io/percy/playwright/SDKTest.java @@ -1,13 +1,16 @@ package io.percy.playwright; import com.microsoft.playwright.*; +import com.microsoft.playwright.options.Cookie; import org.json.JSONObject; import org.junit.jupiter.api.*; import org.mockito.Mockito; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -98,6 +101,30 @@ public void takeSnapshotThrowErrorForPOA() { @Test @Order(6) + public void snapshotWithResponsiveSnapshotCapture() { + page.navigate(TEST_URL + "/responsive-capture.html"); + Map options = new HashMap<>(); + options.put("widths", Arrays.asList(480, 680, 992, 1200)); + options.put("responsiveSnapshotCapture", true); + percy.snapshot("Site with responsive snapshot capture", options); + } + + @Test + @Order(7) + public void snapshotWithCorsIframe() { + // cors-iframe.html embeds https://todomvc.com/examples/react/dist/ inside an iframe, making it + // a genuine cross-origin frame for Percy to detect and capture. + List allowedHostnames = Arrays.asList("todomvc.com"); + Map discoveryOptions = new HashMap<>(); + discoveryOptions.put("allowedHostnames", allowedHostnames); + Map percyOptions = new HashMap<>(); + percyOptions.put("discovery", discoveryOptions); + page.navigate(TEST_URL + "/cors-iframe.html"); + percy.snapshot("Page with cross-origin iframe", percyOptions); + } + + @Test + @Order(8) public void takeScreenshotThrowErrorForWeb() throws Exception { Playwright playwright = Playwright.create(); Browser browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); @@ -114,7 +141,7 @@ public void takeScreenshotThrowErrorForWeb() throws Exception { } @Test - @Order(7) + @Order(9) public void takeScreenshot() throws Exception { // Mock Page and dependencies Page mockPage = Mockito.mock(Page.class); @@ -167,7 +194,7 @@ public void takeScreenshot() throws Exception { } @Test - @Order(8) + @Order(10) public void takeScreenshotWithOptions() throws Exception { // Mock Page and dependencies Page mockPage = Mockito.mock(Page.class); @@ -220,8 +247,9 @@ public void takeScreenshotWithOptions() throws Exception { assertEquals(json.getString("framework"), capturedJson.getString("framework")); assertTrue(json.getJSONObject("options").similar(capturedJson.getJSONObject("options"))); } + @Test - @Order(9) + @Order(11) public void createRegionTest() { // Setup the parameters for the region Map params = new HashMap<>(); @@ -267,4 +295,198 @@ public void createRegionTest() { assertEquals(0.1, assertion.get("diffIgnoreThreshold")); } + // ------------------------------------------------------------------------- + // Responsive snapshot capture tests + // ------------------------------------------------------------------------- + + @Test + @Order(12) + public void isCaptureResponsiveDOMReturnsTrueForSDKOption() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Percy percyInstance = new Percy(mockPage); + + Map options = new HashMap<>(); + options.put("responsiveSnapshotCapture", true); + + boolean result = percyInstance.isCaptureResponsiveDOM(options); + assertTrue(result); + } + + @Test + @Order(13) + public void isCaptureResponsiveDOMReturnsFalseWhenDeferUploadsEnabled() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Percy percyInstance = new Percy(mockPage); + + JSONObject percyConfig = new JSONObject(); + percyConfig.put("deferUploads", true); + JSONObject config = new JSONObject(); + config.put("percy", percyConfig); + + percyInstance.cliConfig = config; + + Map options = new HashMap<>(); + options.put("responsiveSnapshotCapture", true); + + boolean result = percyInstance.isCaptureResponsiveDOM(options); + assertFalse(result, "deferUploads should take priority and disable responsive capture"); + } + + @Test + @Order(14) + public void isCaptureResponsiveDOMReturnsTrueFromCLIConfig() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Percy percyInstance = new Percy(mockPage); + + JSONObject snapshotConfig = new JSONObject(); + snapshotConfig.put("responsiveSnapshotCapture", true); + JSONObject config = new JSONObject(); + config.put("snapshot", snapshotConfig); + + percyInstance.cliConfig = config; + + // No SDK-level flag — should still return true because CLI config enables it + Map options = new HashMap<>(); + boolean result = percyInstance.isCaptureResponsiveDOM(options); + assertTrue(result, "CLI config responsiveSnapshotCapture should enable responsive capture"); + } + + // ------------------------------------------------------------------------- + // Cookie capture tests + // ------------------------------------------------------------------------- + + @Test + @Order(15) + @SuppressWarnings("unchecked") + public void cookiesAreCapturedInSerializedDOM() throws Exception { + Page mockPage = Mockito.mock(Page.class); + + Map domMap = new HashMap<>(); + domMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(domMap); + when(mockPage.url()).thenReturn("http://example.com"); + when(mockPage.frames()).thenReturn(new ArrayList<>()); + + Percy percyInstance = new Percy(mockPage); + + Cookie cookie = new Cookie("session", "abc123"); + cookie.domain = "example.com"; + cookie.path = "/"; + cookie.expires = -1.0; + cookie.httpOnly = false; + cookie.secure = false; + + Map result = percyInstance.getSerializedDOM( + Arrays.asList(cookie), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNotNull(result.get("cookies")); + + List> cookies = (List>) result.get("cookies"); + assertEquals(1, cookies.size()); + assertEquals("session", cookies.get(0).get("name")); + assertEquals("abc123", cookies.get(0).get("value")); + assertEquals("example.com", cookies.get(0).get("domain")); + assertEquals("/", cookies.get(0).get("path")); + } + + @Test + @Order(16) + @SuppressWarnings("unchecked") + public void emptyCookieListIsAttachedWhenNoCookiesPresent() throws Exception { + Page mockPage = Mockito.mock(Page.class); + + Map domMap = new HashMap<>(); + domMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(domMap); + when(mockPage.url()).thenReturn("http://example.com"); + when(mockPage.frames()).thenReturn(new ArrayList<>()); + + Percy percyInstance = new Percy(mockPage); + + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + List> cookies = (List>) result.get("cookies"); + assertNotNull(cookies); + assertEquals(0, cookies.size()); + } + + // ------------------------------------------------------------------------- + // Cross-origin iframe capture tests + // ------------------------------------------------------------------------- + + @Test + @Order(17) + @SuppressWarnings("unchecked") + public void corsIframesAreProcessedAndAttachedInSnapshot() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Frame mockFrame = Mockito.mock(Frame.class); + + // Main page DOM + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + + // page.evaluate(js, frameUrl) returns percyElementId for the iframe match + Map iframeDataMap = new HashMap<>(); + iframeDataMap.put("percyElementId", "percy-elem-1"); + when(mockPage.evaluate(anyString(), any())).thenReturn(iframeDataMap); + + when(mockPage.url()).thenReturn("http://example.com/page"); + when(mockFrame.url()).thenReturn("http://other.com/"); + + // frame.evaluate: first call injects script (return ignored), + // second call serializes the frame DOM + Map iframeSnapshot = new HashMap<>(); + iframeSnapshot.put("html", "iframe content"); + when(mockFrame.evaluate(anyString())) + .thenReturn(null) // inject percyDomScript + .thenReturn(iframeSnapshot); // PercyDOM.serialize(...) + + when(mockPage.frames()).thenReturn(Arrays.asList(mockFrame)); + + Percy percyInstance = new Percy(mockPage); + + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNotNull(result.get("corsIframes"), "corsIframes key should be present"); + + List> corsIframes = (List>) result.get("corsIframes"); + assertEquals(1, corsIframes.size()); + + Map capturedFrame = corsIframes.get(0); + assertEquals("http://other.com/", capturedFrame.get("frameUrl")); + assertNotNull(capturedFrame.get("iframeSnapshot")); + assertEquals("percy-elem-1", + ((Map) capturedFrame.get("iframeData")).get("percyElementId")); + } + + @Test + @Order(18) + @SuppressWarnings("unchecked") + public void sameOriginFramesAreNotProcessedAsCorsIframes() throws Exception { + Page mockPage = Mockito.mock(Page.class); + Frame mockSameOriginFrame = Mockito.mock(Frame.class); + + Map mainDomMap = new HashMap<>(); + mainDomMap.put("html", ""); + when(mockPage.evaluate(anyString())).thenReturn(mainDomMap); + when(mockPage.url()).thenReturn("http://example.com/page"); + // Same origin as the page — should not be treated as cross-origin + when(mockSameOriginFrame.url()).thenReturn("http://example.com/iframe"); + when(mockPage.frames()).thenReturn(Arrays.asList(mockSameOriginFrame)); + + Percy percyInstance = new Percy(mockPage); + + Map result = percyInstance.getSerializedDOM( + new ArrayList<>(), "// percy dom script", new HashMap<>()); + + assertNotNull(result); + assertNull(result.get("corsIframes"), "Same-origin frames must not be added to corsIframes"); + } + } 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.

+ + + diff --git a/src/test/resources/testapp/responsive-capture.html b/src/test/resources/testapp/responsive-capture.html new file mode 100644 index 0000000..d3d2e63 --- /dev/null +++ b/src/test/resources/testapp/responsive-capture.html @@ -0,0 +1,155 @@ + + + + + + Responsive Snapshot Capture + + + + + + + + +
+ + +
+
+ + + + Percy Responsive Capture +
+

Responsive Snapshot Testing

+

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

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

Breakpoint Reference

+
+
+
XS
+
< 640px
+
+
+
SM
+
640 – 767px
+
+
+
MD
+
768 – 1023px
+
+
+
LG+
+
≥ 1024px
+
+
+
+ +
+ + + +