Skip to content
Draft
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
58 changes: 57 additions & 1 deletion src/main/java/io/percy/selenium/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,54 @@ private String buildSnapshotJS(Map<String, Object> options) {
return jsBuilder.toString();
}

/**
* Readiness gate (PER-7348): 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.
*
* Readiness config precedence: options["readiness"] > cliConfig.snapshot.readiness
* > empty (CLI applies balanced default). "disabled" preset skips the
* executeAsyncScript call entirely. Any exception is swallowed at debug level;
* serialize still runs.
*
* @return Readiness diagnostics to attach to the domSnapshot, or null.
*/
protected Object waitForReady(JavascriptExecutor jse, Map<String, Object> options) {
Object perSnapshot = options != null ? options.get("readiness") : null;
JSONObject readinessConfig;
if (perSnapshot instanceof Map) {
readinessConfig = new JSONObject((Map<?, ?>) perSnapshot);
} else if (perSnapshot instanceof JSONObject) {
readinessConfig = (JSONObject) perSnapshot;
} else if (cliConfig != null) {
JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot");
readinessConfig = snapshotConfig == null ? new JSONObject()
: snapshotConfig.optJSONObject("readiness");
if (readinessConfig == null) { readinessConfig = new JSONObject(); }
} else {
readinessConfig = new JSONObject();
}
if ("disabled".equals(readinessConfig.optString("preset", null))) {
return null;
}
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;
}
}

static class FatalIframeException extends RuntimeException {
FatalIframeException(String message, Throwable cause) {
super(message, cause);
Expand Down Expand Up @@ -673,10 +721,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 (PER-7348). 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 (PER-7348) -----------------------------------------

@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