Skip to content

Commit 5eb6aa7

Browse files
Shivanshu-07claude
andcommitted
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>
1 parent 4bda7d4 commit 5eb6aa7

2 files changed

Lines changed: 133 additions & 1 deletion

File tree

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

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,54 @@ private String buildSnapshotJS(Map<String, Object> options) {
598598
return jsBuilder.toString();
599599
}
600600

601+
/**
602+
* Readiness gate (PER-7348): runs PercyDOM.waitForReady BEFORE serialize.
603+
*
604+
* Uses executeAsyncScript with a callback signal. The embedded JS checks
605+
* typeof PercyDOM.waitForReady === 'function' so older CLI versions that
606+
* lack the method are a graceful no-op.
607+
*
608+
* Readiness config precedence: options["readiness"] > cliConfig.snapshot.readiness
609+
* > empty (CLI applies balanced default). "disabled" preset skips the
610+
* executeAsyncScript call entirely. Any exception is swallowed at debug level;
611+
* serialize still runs.
612+
*
613+
* @return Readiness diagnostics to attach to the domSnapshot, or null.
614+
*/
615+
protected Object waitForReady(JavascriptExecutor jse, Map<String, Object> options) {
616+
Object perSnapshot = options != null ? options.get("readiness") : null;
617+
JSONObject readinessConfig;
618+
if (perSnapshot instanceof Map) {
619+
readinessConfig = new JSONObject((Map<?, ?>) perSnapshot);
620+
} else if (perSnapshot instanceof JSONObject) {
621+
readinessConfig = (JSONObject) perSnapshot;
622+
} else if (cliConfig != null) {
623+
JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot");
624+
readinessConfig = snapshotConfig == null ? new JSONObject()
625+
: snapshotConfig.optJSONObject("readiness");
626+
if (readinessConfig == null) { readinessConfig = new JSONObject(); }
627+
} else {
628+
readinessConfig = new JSONObject();
629+
}
630+
if ("disabled".equals(readinessConfig.optString("preset", null))) {
631+
return null;
632+
}
633+
try {
634+
String script =
635+
"var cfg = " + readinessConfig.toString() + ";"
636+
+ "var done = arguments[arguments.length - 1];"
637+
+ "try {"
638+
+ " if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {"
639+
+ " PercyDOM.waitForReady(cfg).then(function(r){ done(r); }).catch(function(){ done(); });"
640+
+ " } else { done(); }"
641+
+ "} catch (e) { done(); }";
642+
return jse.executeAsyncScript(script);
643+
} catch (Exception e) {
644+
log("waitForReady failed, proceeding to serialize: " + e.getMessage(), "debug");
645+
return null;
646+
}
647+
}
648+
601649
static class FatalIframeException extends RuntimeException {
602650
FatalIframeException(String message, Throwable cause) {
603651
super(message, cause);
@@ -673,10 +721,18 @@ private Map<String, Object> processFrame(WebElement frameElement, Map<String, Ob
673721
return result;
674722
}
675723

676-
private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
724+
Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
725+
// Readiness gate before serialize (PER-7348). Graceful on old CLI.
726+
Object readinessDiagnostics = waitForReady(jse, options);
727+
677728
Map<String, Object> domSnapshot = (Map<String, Object>) jse.executeScript(buildSnapshotJS(options));
678729
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
679730
mutableSnapshot.put("cookies", cookies);
731+
732+
// Attach readiness diagnostics so the CLI can log timing and pass/fail
733+
if (readinessDiagnostics != null) {
734+
mutableSnapshot.put("readiness_diagnostics", readinessDiagnostics);
735+
}
680736
try {
681737
String pageOrigin = getOrigin(driver.getCurrentUrl());
682738
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 (PER-7348) -----------------------------------------
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)