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
52 changes: 52 additions & 0 deletions src/main/java/io/percy/playwright/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,50 @@ private String buildSnapshotJS(Map<String, Object> options) {
return jsBuilder.toString();
}

/**
* Readiness gate: runs PercyDOM.waitForReady BEFORE serialize (PER-7348).
*
* Uses page.evaluate — Playwright auto-awaits Promises. 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). If preset is "disabled", skip the
* evaluate call entirely. Any exception is swallowed at debug level — the
* serialize call still runs.
*
* @return Readiness diagnostics to attach to the domSnapshot, or null.
*/
protected Object waitForReady(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 {
JSONObject snapshotConfig = cliConfig.optJSONObject("snapshot");
readinessConfig = snapshotConfig == null ? new JSONObject()
: snapshotConfig.optJSONObject("readiness");
if (readinessConfig == null) { readinessConfig = new JSONObject(); }
}
if ("disabled".equals(readinessConfig.optString("preset", null))) {
return null;
}
try {
String js =
"(cfg) => {"
+ " if (typeof PercyDOM !== 'undefined' && typeof PercyDOM.waitForReady === 'function') {"
+ " return PercyDOM.waitForReady(cfg);"
+ " }"
+ "}";
return page.evaluate(js, readinessConfig.toMap());
} catch (Exception e) {
log("waitForReady failed, proceeding to serialize: " + e.getMessage(), "debug");
return null;
}
}

/**
* Attempts to load dom.js from the local Percy server. Use cached value in `domJs`,
* if it exists.
Expand Down Expand Up @@ -837,13 +881,21 @@ Map<String, Object> getSerializedDOM(
String percyDomScript,
Map<String, Object> options) {

// Readiness gate before serialize (PER-7348). Graceful on old CLI.
Object readinessDiagnostics = waitForReady(options);

Map<String, Object> domSnapshot =
(Map<String, Object>) page.evaluate(buildSnapshotJS(options));
if (domSnapshot == null) {
throw new RuntimeException("DOM serialization returned null — PercyDOM.serialize() may not be loaded or returned undefined");
}
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);

// Attach readiness diagnostics so the CLI can log timing and pass/fail
if (readinessDiagnostics != null) {
mutableSnapshot.put("readiness_diagnostics", readinessDiagnostics);
}

// Process cross-origin iframes
try {
URI pageUri = new URI(page.url());
Expand Down
91 changes: 91 additions & 0 deletions src/test/java/io/percy/playwright/SDKTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -489,4 +489,95 @@ public void sameOriginFramesAreNotProcessedAsCorsIframes() throws Exception {
assertNull(result.get("corsIframes"), "Same-origin frames must not be added to corsIframes");
}

// -------------------------------------------------------------------------
// Readiness gate (PER-7348)
// -------------------------------------------------------------------------

@Test
@Order(90)
@SuppressWarnings("unchecked")
public void readinessRunsBeforeSerializeAndAttachesDiagnostics() throws Exception {
Page mockPage = Mockito.mock(Page.class);

Map<String, Object> domMap = new HashMap<>();
domMap.put("html", "<html></html>");

// Readiness path: page.evaluate(js, config) — any 2-arg call with a Map
Map<String, Object> diagnostics = new HashMap<>();
diagnostics.put("ok", true);
diagnostics.put("timed_out", false);
when(mockPage.evaluate(anyString(), any(Map.class))).thenReturn(diagnostics);

// Serialize path: single-arg evaluate (buildSnapshotJS)
when(mockPage.evaluate(anyString())).thenReturn(domMap);
when(mockPage.url()).thenReturn("http://example.com");
when(mockPage.frames()).thenReturn(new ArrayList<>());

Percy percyInstance = new Percy(mockPage);

Map<String, Object> result = percyInstance.getSerializedDOM(
new ArrayList<>(), "// percy dom script", new HashMap<>());

assertNotNull(result);
// waitForReady script was sent via the 2-arg evaluate overload
verify(mockPage, atLeastOnce()).evaluate(contains("waitForReady"), any(Map.class));
// Diagnostics propagated onto the domSnapshot
assertEquals(diagnostics, result.get("readiness_diagnostics"));
}

@Test
@Order(91)
@SuppressWarnings("unchecked")
public void readinessSkippedWhenPresetDisabled() throws Exception {
Page mockPage = Mockito.mock(Page.class);

Map<String, Object> domMap = new HashMap<>();
domMap.put("html", "<html></html>");
when(mockPage.evaluate(anyString())).thenReturn(domMap);
when(mockPage.url()).thenReturn("http://example.com");
when(mockPage.frames()).thenReturn(new ArrayList<>());

Percy percyInstance = new Percy(mockPage);

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

Map<String, Object> result = percyInstance.getSerializedDOM(
new ArrayList<>(), "// percy dom script", options);

assertNotNull(result);
// Readiness evaluate(js, config) must NOT have been called
verify(mockPage, never()).evaluate(contains("waitForReady"), any(Map.class));
// Serialize still ran — and no diagnostics attached
assertNull(result.get("readiness_diagnostics"));
}

@Test
@Order(92)
@SuppressWarnings("unchecked")
public void snapshotSurvivesReadinessThrow() throws Exception {
Page mockPage = Mockito.mock(Page.class);

Map<String, Object> domMap = new HashMap<>();
domMap.put("html", "<html></html>");

// 2-arg evaluate (readiness) blows up; 1-arg evaluate (serialize) still works
when(mockPage.evaluate(anyString(), any(Map.class))).thenThrow(new RuntimeException("readiness boom"));
when(mockPage.evaluate(anyString())).thenReturn(domMap);
when(mockPage.url()).thenReturn("http://example.com");
when(mockPage.frames()).thenReturn(new ArrayList<>());

Percy percyInstance = new Percy(mockPage);

Map<String, Object> result = percyInstance.getSerializedDOM(
new ArrayList<>(), "// percy dom script", new HashMap<>());

assertNotNull(result);
// domSnapshot was still built; no diagnostics attached
assertNull(result.get("readiness_diagnostics"));
assertEquals("<html></html>", result.get("html"));
}

}
Loading