Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.31.10"
"@percy/cli": "^1.31.15-beta.0"
}
}
102 changes: 101 additions & 1 deletion src/main/java/io/percy/selenium/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,10 @@ private JSONObject postSnapshot(

// Build a JSON object to POST back to the agent node process
JSONObject json = new JSONObject(options);
// `readiness` is SDK-local — the CLI already has it via healthcheck.
// Strip before posting to avoid round-tripping and to stay forward-
// compatible with future CLI-side validators rejecting unknown fields.
json.remove("readiness");
json.put("url", url);
json.put("name", name);
json.put("domSnapshot", domSnapshot);
Expand Down Expand Up @@ -593,11 +597,99 @@ protected JSONObject request(String url, JSONObject json, String name) {
private String buildSnapshotJS(Map<String, Object> options) {
StringBuilder jsBuilder = new StringBuilder();
JSONObject json = new JSONObject(options);
// `readiness` is consumed by waitForReady upstream — not a serialize arg.
json.remove("readiness");
jsBuilder.append(String.format("return PercyDOM.serialize(%s)\n", json.toString()));

return jsBuilder.toString();
}

/**
* Readiness gate: runs PercyDOM.waitForReady BEFORE serialize.
*
* Uses executeAsyncScript with a callback signal. The embedded JS checks
* typeof PercyDOM.waitForReady === 'function' so older CLI versions that
* lack the method are a graceful no-op.
*
* Config precedence: per-snapshot options["readiness"] is shallow-merged
* over cliConfig.snapshot.readiness so a partial per-snapshot override
* inherits global keys (notably preset: disabled) instead of dropping
* them. The merged "disabled" preset skips the executeAsyncScript entirely.
*
* @return Readiness diagnostics to attach to the domSnapshot, or null.
*/
protected Object waitForReady(JavascriptExecutor jse, Map<String, Object> options) {
JSONObject readinessConfig = resolveReadinessConfig(options);
if ("disabled".equals(readinessConfig.optString("preset", null))) {
return null;
}
// Match the driver's async-script timeout to readiness.timeoutMs so
// a higher user-configured timeout isn't silently capped by WebDriver
// firing ScriptTimeoutException before the in-page Promise resolves.
long timeoutMs = readinessConfig.optLong("timeoutMs", 0L);
Duration previousTimeout = null;
if (timeoutMs > 0) {
try {
previousTimeout = jse instanceof org.openqa.selenium.WebDriver
? ((org.openqa.selenium.WebDriver) jse).manage().timeouts().getScriptTimeout()
: null;
if (jse instanceof org.openqa.selenium.WebDriver) {
((org.openqa.selenium.WebDriver) jse).manage().timeouts()
.scriptTimeout(Duration.ofMillis(timeoutMs + 2000L));
}
} catch (Exception e) {
previousTimeout = null; // best-effort; older Selenium / unsupported
}
}
try {
String script =
"var cfg = " + readinessConfig.toString() + ";"
+ "var done = arguments[arguments.length - 1];"
+ "try {"
+ " if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {"
+ " PercyDOM.waitForReady(cfg).then(function(r){ done(r); }).catch(function(){ done(); });"
+ " } else { done(); }"
+ "} catch (e) { done(); }";
return jse.executeAsyncScript(script);
} catch (Exception e) {
log("waitForReady failed, proceeding to serialize: " + e.getMessage(), "debug");
return null;
} finally {
if (previousTimeout != null && jse instanceof org.openqa.selenium.WebDriver) {
try {
((org.openqa.selenium.WebDriver) jse).manage().timeouts()
.scriptTimeout(previousTimeout);
} catch (Exception ignored) { /* best effort */ }
}
}
}

/**
* Shallow-merge of global (cliConfig.snapshot.readiness) and per-snapshot
* (options["readiness"]) readiness config. Per-snapshot keys win, global
* keys are inherited. Defensive against null / wrong-type values.
*/
@SuppressWarnings("unchecked")
private JSONObject resolveReadinessConfig(Map<String, Object> options) {
JSONObject merged = new JSONObject();
if (cliConfig != null) {
JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot");
JSONObject global = snapshotConfig == null ? null : snapshotConfig.optJSONObject("readiness");
if (global != null) {
for (String key : global.keySet()) merged.put(key, global.get(key));
}
}
Object perSnapshot = options != null ? options.get("readiness") : null;
if (perSnapshot instanceof Map) {
JSONObject perJson = new JSONObject((Map<String, Object>) perSnapshot);
for (String key : perJson.keySet()) merged.put(key, perJson.get(key));
} else if (perSnapshot instanceof JSONObject) {
JSONObject perJson = (JSONObject) perSnapshot;
for (String key : perJson.keySet()) merged.put(key, perJson.get(key));
}
return merged;
}

static class FatalIframeException extends RuntimeException {
FatalIframeException(String message, Throwable cause) {
super(message, cause);
Expand Down Expand Up @@ -673,10 +765,18 @@ private Map<String, Object> processFrame(WebElement frameElement, Map<String, Ob
return result;
}

private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
// Readiness gate before serialize. Graceful on old CLI.
Object readinessDiagnostics = waitForReady(jse, options);

Map<String, Object> domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
mutableSnapshot.put("cookies", cookies);

// Attach readiness diagnostics so the CLI can log timing and pass/fail
if (readinessDiagnostics != null) {
mutableSnapshot.put("readiness_diagnostics", readinessDiagnostics);
}
try {
String pageOrigin = getOrigin(driver.getCurrentUrl());
List<WebElement> iframes = driver.findElements(By.tagName("iframe"));
Expand Down
76 changes: 76 additions & 0 deletions src/test/java/io/percy/selenium/SdkTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,82 @@ public void captureResponsiveDomRefreshesDriverForEachWidthWhenReloadFlagSet() t
}
}

// --- Readiness gate -----------------------------------------

@Test
public void readinessRunsBeforeSerializeAndAttachesDiagnostics() throws Exception {
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
Percy mockedPercy = new Percy(mockedDriver);
setField(mockedPercy, "isPercyEnabled", true);
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));

Map<String, Object> diagnostics = new HashMap<>();
diagnostics.put("ok", true);
diagnostics.put("timed_out", false);
// executeAsyncScript (readiness)
when(((JavascriptExecutor) mockedDriver).executeAsyncScript(any(String.class))).thenReturn(diagnostics);
// executeScript (serialize + any other sync scripts)
Map<String, Object> domSnap = new HashMap<>();
domSnap.put("html", "<html></html>");
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);

Map<String, Object> result = mockedPercy.getSerializedDOM(
(JavascriptExecutor) mockedDriver, new HashSet<>(), new HashMap<>());

// Readiness script was sent via executeAsyncScript
ArgumentCaptor<String> scriptCap = ArgumentCaptor.forClass(String.class);
verify((JavascriptExecutor) mockedDriver, atLeastOnce()).executeAsyncScript(scriptCap.capture());
assertTrue(scriptCap.getValue().contains("waitForReady"),
"readiness script should mention waitForReady");
// Diagnostics propagated to the snapshot
assertEquals(diagnostics, result.get("readiness_diagnostics"));
}

@Test
public void readinessSkippedWhenPresetDisabled() throws Exception {
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
Percy mockedPercy = new Percy(mockedDriver);
setField(mockedPercy, "isPercyEnabled", true);

Map<String, Object> domSnap = new HashMap<>();
domSnap.put("html", "<html></html>");
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);

Map<String, Object> disabled = new HashMap<>();
disabled.put("preset", "disabled");
Map<String, Object> options = new HashMap<>();
options.put("readiness", disabled);

Map<String, Object> result = mockedPercy.getSerializedDOM(
(JavascriptExecutor) mockedDriver, new HashSet<>(), options);

// executeAsyncScript must NOT have been called
verify((JavascriptExecutor) mockedDriver, never()).executeAsyncScript(any(String.class));
// serialize still ran; no diagnostics attached
assertNull(result.get("readiness_diagnostics"));
}

@Test
public void snapshotSurvivesReadinessThrow() throws Exception {
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
Percy mockedPercy = new Percy(mockedDriver);
setField(mockedPercy, "isPercyEnabled", true);
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));

when(((JavascriptExecutor) mockedDriver).executeAsyncScript(any(String.class)))
.thenThrow(new RuntimeException("readiness boom"));
Map<String, Object> domSnap = new HashMap<>();
domSnap.put("html", "<html></html>");
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);

Map<String, Object> result = mockedPercy.getSerializedDOM(
(JavascriptExecutor) mockedDriver, new HashSet<>(), new HashMap<>());

// Serialize still ran; no diagnostics attached
assertNull(result.get("readiness_diagnostics"));
assertEquals("<html></html>", result.get("html"));
}

private static Object invokePrivate(Object target, String methodName, Class<?>[] paramTypes, Object... args)
throws Exception {
Method method = Percy.class.getDeclaredMethod(methodName, paramTypes);
Expand Down
Loading