Skip to content

Commit 39e6deb

Browse files
Shivanshu-07claude
andauthored
feat: PER-7348 add waitForReady() call before serialize() (#308)
* feat: PER-7348 add waitForReady() call before serialize() Adds the readiness gate from percy/cli#2184. New waitForReady() helper runs PercyDOM.waitForReady via executeAsyncScript (callback signal) before the existing PercyDOM.serialize executeScript inside getSerializedDOM. Diagnostics are attached to the mutable snapshot as readiness_diagnostics. serialize is unchanged. Config precedence: options['readiness'] > cliConfig.snapshot.readiness > empty. Backward compat via in-browser typeof guard. Disabled preset short-circuits. Graceful on exception. Visibility: getSerializedDOM is now package-private so tests can call it directly; it was previously private. Tests (Mockito): diagnostics attached + readiness script contains waitForReady, disabled preset skips executeAsyncScript, readiness throw leaves serialize intact. Local: mvn test → 3 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address ce:review findings on readiness gate (PER-7348) - Replaced exclusive precedence (`else if`) with shallow-merge in a new `resolveReadinessConfig()` helper. Per-snapshot keys win, global keys inherited — a partial per-snapshot override no longer silently drops a global `preset: disabled` kill switch. - Set driver script timeout to match `readiness.timeoutMs` (+ 2s buffer) around the executeAsyncScript call, with finally-restore. WebDriver's default ~30s script timeout was silently capping higher user-configured readiness timeouts via ScriptTimeoutException. - Strip `readiness` from `buildSnapshotJS` (so it doesn't leak into PercyDOM.serialize args) and from `postSnapshot` JSON (so it doesn't round-trip to the CLI which already has it via healthcheck). `mvn compile` passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: retrigger CI after upstream maven central resolution issue (PER-7348) * chore: bump @percy/cli to ^1.31.15-beta.0 in tests (PER-7348) CLI 1.31.15-beta.0 ships PercyDOM.waitForReady (the readiness gate). The SDK changes in this PR call waitForReady end-to-end in tests; old CLI pins (1.30.9, 1.31.10) don't have it, causing the typeof guard's done() callback path to never quite settle in geckodriver's async-script handling. Bump so tests run against a CLI that actually has the feature. * comments: remove JIRA ticket reference from code comments --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d7f79c commit 39e6deb

3 files changed

Lines changed: 178 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
"test": "npx percy exec --testing -- mvn test"
55
},
66
"devDependencies": {
7-
"@percy/cli": "1.31.10"
7+
"@percy/cli": "^1.31.15-beta.0"
88
}
99
}

src/main/java/io/percy/selenium/Percy.java

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,10 @@ private JSONObject postSnapshot(
564564

565565
// Build a JSON object to POST back to the agent node process
566566
JSONObject json = new JSONObject(options);
567+
// `readiness` is SDK-local — the CLI already has it via healthcheck.
568+
// Strip before posting to avoid round-tripping and to stay forward-
569+
// compatible with future CLI-side validators rejecting unknown fields.
570+
json.remove("readiness");
567571
json.put("url", url);
568572
json.put("name", name);
569573
json.put("domSnapshot", domSnapshot);
@@ -614,11 +618,99 @@ protected JSONObject request(String url, JSONObject json, String name) {
614618
private String buildSnapshotJS(Map<String, Object> options) {
615619
StringBuilder jsBuilder = new StringBuilder();
616620
JSONObject json = new JSONObject(options);
621+
// `readiness` is consumed by waitForReady upstream — not a serialize arg.
622+
json.remove("readiness");
617623
jsBuilder.append(String.format("return PercyDOM.serialize(%s)\n", json.toString()));
618624

619625
return jsBuilder.toString();
620626
}
621627

628+
/**
629+
* Readiness gate: runs PercyDOM.waitForReady BEFORE serialize.
630+
*
631+
* Uses executeAsyncScript with a callback signal. The embedded JS checks
632+
* typeof PercyDOM.waitForReady === 'function' so older CLI versions that
633+
* lack the method are a graceful no-op.
634+
*
635+
* Config precedence: per-snapshot options["readiness"] is shallow-merged
636+
* over cliConfig.snapshot.readiness so a partial per-snapshot override
637+
* inherits global keys (notably preset: disabled) instead of dropping
638+
* them. The merged "disabled" preset skips the executeAsyncScript entirely.
639+
*
640+
* @return Readiness diagnostics to attach to the domSnapshot, or null.
641+
*/
642+
protected Object waitForReady(JavascriptExecutor jse, Map<String, Object> options) {
643+
JSONObject readinessConfig = resolveReadinessConfig(options);
644+
if ("disabled".equals(readinessConfig.optString("preset", null))) {
645+
return null;
646+
}
647+
// Match the driver's async-script timeout to readiness.timeoutMs so
648+
// a higher user-configured timeout isn't silently capped by WebDriver
649+
// firing ScriptTimeoutException before the in-page Promise resolves.
650+
long timeoutMs = readinessConfig.optLong("timeoutMs", 0L);
651+
Duration previousTimeout = null;
652+
if (timeoutMs > 0) {
653+
try {
654+
previousTimeout = jse instanceof org.openqa.selenium.WebDriver
655+
? ((org.openqa.selenium.WebDriver) jse).manage().timeouts().getScriptTimeout()
656+
: null;
657+
if (jse instanceof org.openqa.selenium.WebDriver) {
658+
((org.openqa.selenium.WebDriver) jse).manage().timeouts()
659+
.scriptTimeout(Duration.ofMillis(timeoutMs + 2000L));
660+
}
661+
} catch (Exception e) {
662+
previousTimeout = null; // best-effort; older Selenium / unsupported
663+
}
664+
}
665+
try {
666+
String script =
667+
"var cfg = " + readinessConfig.toString() + ";"
668+
+ "var done = arguments[arguments.length - 1];"
669+
+ "try {"
670+
+ " if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {"
671+
+ " PercyDOM.waitForReady(cfg).then(function(r){ done(r); }).catch(function(){ done(); });"
672+
+ " } else { done(); }"
673+
+ "} catch (e) { done(); }";
674+
return jse.executeAsyncScript(script);
675+
} catch (Exception e) {
676+
log("waitForReady failed, proceeding to serialize: " + e.getMessage(), "debug");
677+
return null;
678+
} finally {
679+
if (previousTimeout != null && jse instanceof org.openqa.selenium.WebDriver) {
680+
try {
681+
((org.openqa.selenium.WebDriver) jse).manage().timeouts()
682+
.scriptTimeout(previousTimeout);
683+
} catch (Exception ignored) { /* best effort */ }
684+
}
685+
}
686+
}
687+
688+
/**
689+
* Shallow-merge of global (cliConfig.snapshot.readiness) and per-snapshot
690+
* (options["readiness"]) readiness config. Per-snapshot keys win, global
691+
* keys are inherited. Defensive against null / wrong-type values.
692+
*/
693+
@SuppressWarnings("unchecked")
694+
private JSONObject resolveReadinessConfig(Map<String, Object> options) {
695+
JSONObject merged = new JSONObject();
696+
if (cliConfig != null) {
697+
JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot");
698+
JSONObject global = snapshotConfig == null ? null : snapshotConfig.optJSONObject("readiness");
699+
if (global != null) {
700+
for (String key : global.keySet()) merged.put(key, global.get(key));
701+
}
702+
}
703+
Object perSnapshot = options != null ? options.get("readiness") : null;
704+
if (perSnapshot instanceof Map) {
705+
JSONObject perJson = new JSONObject((Map<String, Object>) perSnapshot);
706+
for (String key : perJson.keySet()) merged.put(key, perJson.get(key));
707+
} else if (perSnapshot instanceof JSONObject) {
708+
JSONObject perJson = (JSONObject) perSnapshot;
709+
for (String key : perJson.keySet()) merged.put(key, perJson.get(key));
710+
}
711+
return merged;
712+
}
713+
622714
static class FatalIframeException extends RuntimeException {
623715
FatalIframeException(String message, Throwable cause) {
624716
super(message, cause);
@@ -694,10 +786,18 @@ private Map<String, Object> processFrame(WebElement frameElement, Map<String, Ob
694786
return result;
695787
}
696788

697-
private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
789+
Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
790+
// Readiness gate before serialize. Graceful on old CLI.
791+
Object readinessDiagnostics = waitForReady(jse, options);
792+
698793
Map<String, Object> domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
699794
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
700795
mutableSnapshot.put("cookies", cookies);
796+
797+
// Attach readiness diagnostics so the CLI can log timing and pass/fail
798+
if (readinessDiagnostics != null) {
799+
mutableSnapshot.put("readiness_diagnostics", readinessDiagnostics);
800+
}
701801
try {
702802
String pageOrigin = getOrigin(driver.getCurrentUrl());
703803
List<WebElement> iframes = driver.findElements(By.tagName("iframe"));

src/test/java/io/percy/selenium/SdkTest.java

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,82 @@ public void captureResponsiveDomRefreshesDriverForEachWidthWhenReloadFlagSet() t
11091109
}
11101110
}
11111111

1112+
// --- Readiness gate -----------------------------------------
1113+
1114+
@Test
1115+
public void readinessRunsBeforeSerializeAndAttachesDiagnostics() throws Exception {
1116+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1117+
Percy mockedPercy = new Percy(mockedDriver);
1118+
setField(mockedPercy, "isPercyEnabled", true);
1119+
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));
1120+
1121+
Map<String, Object> diagnostics = new HashMap<>();
1122+
diagnostics.put("ok", true);
1123+
diagnostics.put("timed_out", false);
1124+
// executeAsyncScript (readiness)
1125+
when(((JavascriptExecutor) mockedDriver).executeAsyncScript(any(String.class))).thenReturn(diagnostics);
1126+
// executeScript (serialize + any other sync scripts)
1127+
Map<String, Object> domSnap = new HashMap<>();
1128+
domSnap.put("html", "<html></html>");
1129+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);
1130+
1131+
Map<String, Object> result = mockedPercy.getSerializedDOM(
1132+
(JavascriptExecutor) mockedDriver, new HashSet<>(), new HashMap<>());
1133+
1134+
// Readiness script was sent via executeAsyncScript
1135+
ArgumentCaptor<String> scriptCap = ArgumentCaptor.forClass(String.class);
1136+
verify((JavascriptExecutor) mockedDriver, atLeastOnce()).executeAsyncScript(scriptCap.capture());
1137+
assertTrue(scriptCap.getValue().contains("waitForReady"),
1138+
"readiness script should mention waitForReady");
1139+
// Diagnostics propagated to the snapshot
1140+
assertEquals(diagnostics, result.get("readiness_diagnostics"));
1141+
}
1142+
1143+
@Test
1144+
public void readinessSkippedWhenPresetDisabled() throws Exception {
1145+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1146+
Percy mockedPercy = new Percy(mockedDriver);
1147+
setField(mockedPercy, "isPercyEnabled", true);
1148+
1149+
Map<String, Object> domSnap = new HashMap<>();
1150+
domSnap.put("html", "<html></html>");
1151+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);
1152+
1153+
Map<String, Object> disabled = new HashMap<>();
1154+
disabled.put("preset", "disabled");
1155+
Map<String, Object> options = new HashMap<>();
1156+
options.put("readiness", disabled);
1157+
1158+
Map<String, Object> result = mockedPercy.getSerializedDOM(
1159+
(JavascriptExecutor) mockedDriver, new HashSet<>(), options);
1160+
1161+
// executeAsyncScript must NOT have been called
1162+
verify((JavascriptExecutor) mockedDriver, never()).executeAsyncScript(any(String.class));
1163+
// serialize still ran; no diagnostics attached
1164+
assertNull(result.get("readiness_diagnostics"));
1165+
}
1166+
1167+
@Test
1168+
public void snapshotSurvivesReadinessThrow() throws Exception {
1169+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1170+
Percy mockedPercy = new Percy(mockedDriver);
1171+
setField(mockedPercy, "isPercyEnabled", true);
1172+
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));
1173+
1174+
when(((JavascriptExecutor) mockedDriver).executeAsyncScript(any(String.class)))
1175+
.thenThrow(new RuntimeException("readiness boom"));
1176+
Map<String, Object> domSnap = new HashMap<>();
1177+
domSnap.put("html", "<html></html>");
1178+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);
1179+
1180+
Map<String, Object> result = mockedPercy.getSerializedDOM(
1181+
(JavascriptExecutor) mockedDriver, new HashSet<>(), new HashMap<>());
1182+
1183+
// Serialize still ran; no diagnostics attached
1184+
assertNull(result.get("readiness_diagnostics"));
1185+
assertEquals("<html></html>", result.get("html"));
1186+
}
1187+
11121188
private static Object invokePrivate(Object target, String methodName, Class<?>[] paramTypes, Object... args)
11131189
throws Exception {
11141190
Method method = Percy.class.getDeclaredMethod(methodName, paramTypes);

0 commit comments

Comments
 (0)