Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"test": "npx percy exec --testing -- mvn test"
},
"devDependencies": {
"@percy/cli": "1.30.9"
"@percy/cli": "1.31.10-alpha.0"
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
}
}
269 changes: 216 additions & 53 deletions src/main/java/io/percy/selenium/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +50,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"));
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
// for logging
private static String LABEL = "[\u001b[35m" + (PERCY_DEBUG ? "percy:java" : "percy") + "\u001b[39m]";

Expand Down Expand Up @@ -247,6 +251,61 @@ public JSONObject snapshot(String name, @Nullable List<Integer> widths, Integer
return snapshot(name, options);
}

private List<Map<String, Object>> getResponsiveWidths(List<Integer> 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
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
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<Map<String, Object>> result = new ArrayList<>();
for (int i = 0; i < widthsArray.length(); i++) {
JSONObject entry = widthsArray.getJSONObject(i);
Map<String, Object> 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);
}
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
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);
}
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
}
private boolean isCaptureResponsiveDOM(Map<String, Object> options) {
if (cliConfig.has("percy") && !cliConfig.isNull("percy")) {
JSONObject percyProperty = cliConfig.getJSONObject("percy");
Expand Down Expand Up @@ -515,11 +574,122 @@ private String buildSnapshotJS(Map<String, Object> 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 "";
}
}
Comment thread
yashmahamulkar-bs marked this conversation as resolved.

private Map<String, Object> processFrame(WebElement frameElement, Map<String, Object> 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");
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
if (percyElementId == null || percyElementId.isEmpty()) {
log("Skipping frame " + finalFrameUrl + ": no matching percyElementId found", "debug");
return null;
}

Map<String, Object> 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<String, Object> iframeOptions = new HashMap<>(options);
iframeOptions.put("enableJavaScript", true);
JSONObject optionsJson = new JSONObject(iframeOptions);
iframeSnapshot = (Map<String, Object>) 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."
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
);
}
}

Map<String, Object> iframeData = new HashMap<>();
iframeData.put("percyElementId", percyElementId);

Map<String, Object> result = new HashMap<>();
result.put("iframeData", iframeData);
result.put("iframeSnapshot", iframeSnapshot);
result.put("frameUrl", finalFrameUrl);
return result;
}

private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
// 1. Serialize the main page first (this adds the data-percy-element-ids)
Map<String, Object> domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
mutableSnapshot.put("cookies", cookies);


// 2. Process CORS IFrames
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
try {
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
String pageOrigin = getOrigin(driver.getCurrentUrl());
List<WebElement> iframes = driver.findElements(By.tagName("iframe"));
if (!iframes.isEmpty() && !domJs.trim().isEmpty()) {
List<Map<String, Object>> 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<String, Object> result = processFrame(frame, options);
if (result != null) {
processedFrames.add(result);
}
} catch (Exception e) {
log("Skipping frame \"" + frameSrc + "\" due to error: " + e.getMessage(), "debug");
}
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
}
if (!processedFrames.isEmpty()) {
mutableSnapshot.put("corsIframes", processedFrames);
}
}
} catch (Exception e) {
log("Failed to process cross-origin iframes: " + e.getMessage(), "debug");
}
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
return mutableSnapshot;
}

Expand All @@ -532,39 +702,6 @@ private List<String> getElementIdFromElement(List<RemoteWebElement> elements) {
return ignoredElementsArray;
}

// Get widths for multi DOM
private List<Integer> getWidthsForMultiDom(Map<String, Object> options) {
List<Integer> widths;
if (options.containsKey("widths") && options.get("widths") instanceof List<?>) {
widths = (List<Integer>) options.get("widths");
} else {
widths = new ArrayList<>();
}
// Create a Set to avoid duplicates
Set<Integer> 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 {
Expand Down Expand Up @@ -595,56 +732,82 @@ 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
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
try {
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(1));
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
wait.until((ExpectedCondition<Boolean>) d ->
(long) ((JavascriptExecutor) d).executeScript("return window.resizeCount") == resizeCount);
wait.until((ExpectedCondition<Boolean>) d -> {
Object resizeCountObj = ((JavascriptExecutor) d).executeScript("return window.resizeCount");
if (resizeCountObj == null) {
return false;
}
return (long) resizeCountObj == resizeCount;
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
});
} catch (WebDriverException e) {
log("Timed out waiting for window resize event for width " + width, "debug");
}
}

// Capture responsive DOM for different widths
public List<Map<String, Object>> captureResponsiveDom(WebDriver driver, Set<Cookie> cookies, Map<String, Object> options) {
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
List<Integer> widths = getWidthsForMultiDom(options);

List<Map<String, Object>> widths = getResponsiveWidths((List<Integer>) options.get("widths"));
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
List<Map<String, Object>> 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<String, Object> widthMap : widths) {
int width = (int) widthMap.get("width");
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
if (lastWindowWidth != width) {
resizeCount++;
changeWindowDimensionAndWait(driver, width, currentHeight, resizeCount);
changeWindowDimensionAndWait(driver, width, targetHeight, resizeCount);
lastWindowWidth = width;
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
}

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<String, Object> 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");
}
Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
protected static void log(String message) {
log(message, "info");
}

Comment thread
yashmahamulkar-bs marked this conversation as resolved.
Outdated
protected static void log(String message, String level) {
Expand Down
Loading