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"
}
}
67 changes: 67 additions & 0 deletions src/main/java/io/percy/playwright/Percy.java
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,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-trip and stay forward-compatible
// with future CLI-side validators.
json.remove("readiness");
json.put("url", url);
json.put("name", name);
json.put("domSnapshot", domSnapshot);
Expand Down Expand Up @@ -423,11 +427,66 @@ 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("PercyDOM.serialize(%s)\n", json.toString()));

return jsBuilder.toString();
}

/**
* Readiness gate: runs PercyDOM.waitForReady BEFORE serialize.
*
* 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. If the merged preset is "disabled", skip the evaluate call.
*
* @return Readiness diagnostics to attach to the domSnapshot, or null.
*/
protected Object waitForReady(Map<String, Object> options) {
JSONObject readinessConfig = resolveReadinessConfig(options);
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;
}
}

/**
* Shallow-merge of global (cliConfig.snapshot.readiness) and per-snapshot
* (options["readiness"]) readiness config. Per-snapshot keys win; global
* keys (notably preset: disabled) inherited.
*/
@SuppressWarnings("unchecked")
private JSONObject resolveReadinessConfig(Map<String, Object> options) {
JSONObject merged = new JSONObject();
JSONObject snapshotConfig = cliConfig == null ? null : 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;
}

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

// Readiness gate before serialize. 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
// -------------------------------------------------------------------------

@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