Skip to content

Commit 4b7835d

Browse files
committed
Merge remote-tracking branch 'origin/master' into feat/cors-iframes-and-shadow-dom
# Conflicts: # package.json # src/main/java/io/percy/selenium/Percy.java
2 parents 0c33f24 + 4530bb1 commit 4b7835d

5 files changed

Lines changed: 181 additions & 5 deletions

File tree

package.json

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

pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
<groupId>io.percy</groupId>
77
<artifactId>percy-java-selenium</artifactId>
8-
<version>2.2.0</version>
8+
<version>2.2.1-beta.0</version>
99
<packaging>jar</packaging>
1010

1111
<name>${project.groupId}:${project.artifactId}</name>
@@ -32,7 +32,7 @@
3232
<connection>scm:git:git://github.com/percy/percy-java-selenium.git</connection>
3333
<developerConnection>scm:git:https://github.com/percy/percy-java-selenium.git</developerConnection>
3434
<url>https://github.com/percy/percy-java-selenium/tree/master</url>
35-
<tag>v2.1.2</tag>
35+
<tag>v2.2.1-beta.0</tag>
3636
</scm>
3737

3838
<properties>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111
class Environment {
1212
private WebDriver driver;
13-
private final static String SDK_VERSION = "2.1.2";
13+
private final static String SDK_VERSION = "2.2.1-beta.0";
1414
private final static String SDK_NAME = "percy-java-selenium";
1515

1616
private String clientInfoOverride;

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

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

568568
// Build a JSON object to POST back to the agent node process
569569
JSONObject json = new JSONObject(options);
570+
// `readiness` is SDK-local — the CLI already has it via healthcheck.
571+
// Strip before posting to avoid round-tripping and to stay forward-
572+
// compatible with future CLI-side validators rejecting unknown fields.
573+
json.remove("readiness");
570574
json.put("url", url);
571575
json.put("name", name);
572576
json.put("domSnapshot", domSnapshot);
@@ -617,11 +621,99 @@ protected JSONObject request(String url, JSONObject json, String name) {
617621
private String buildSnapshotJS(Map<String, Object> options) {
618622
StringBuilder jsBuilder = new StringBuilder();
619623
JSONObject json = new JSONObject(options);
624+
// `readiness` is consumed by waitForReady upstream — not a serialize arg.
625+
json.remove("readiness");
620626
jsBuilder.append(String.format("return PercyDOM.serialize(%s)\n", json.toString()));
621627

622628
return jsBuilder.toString();
623629
}
624630

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

966-
private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
1058+
Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie> cookies, Map<String, Object> options) {
1059+
// Readiness gate before serialize. Graceful on old CLI.
1060+
Object readinessDiagnostics = waitForReady(jse, options);
1061+
9671062
Object raw = jse.executeScript(buildSnapshotJS(options));
9681063
if (!(raw instanceof Map)) {
9691064
throw new RuntimeException("PercyDOM.serialize returned null or non-object; "
@@ -974,6 +1069,11 @@ private Map<String, Object> getSerializedDOM(JavascriptExecutor jse, Set<Cookie>
9741069
Map<String, Object> mutableSnapshot = new HashMap<>(domSnapshot);
9751070
mutableSnapshot.put("cookies", cookies);
9761071

1072+
// Attach readiness diagnostics so the CLI can log timing and pass/fail
1073+
if (readinessDiagnostics != null) {
1074+
mutableSnapshot.put("readiness_diagnostics", readinessDiagnostics);
1075+
}
1076+
9771077
// Expose closed shadow roots via CDP (Chromium only) so PercyDOM.serialize
9781078
// can pierce them through the WeakMap it reads. Non-fatal — skip on errors.
9791079
try { exposeClosedShadowRoots(driver); } catch (Exception ignore) {}

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,82 @@ public void captureResponsiveDomRefreshesDriverForEachWidthWhenReloadFlagSet() t
11281128
}
11291129
}
11301130

1131+
// --- Readiness gate -----------------------------------------
1132+
1133+
@Test
1134+
public void readinessRunsBeforeSerializeAndAttachesDiagnostics() throws Exception {
1135+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1136+
Percy mockedPercy = new Percy(mockedDriver);
1137+
setField(mockedPercy, "isPercyEnabled", true);
1138+
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));
1139+
1140+
Map<String, Object> diagnostics = new HashMap<>();
1141+
diagnostics.put("ok", true);
1142+
diagnostics.put("timed_out", false);
1143+
// executeAsyncScript (readiness)
1144+
when(((JavascriptExecutor) mockedDriver).executeAsyncScript(any(String.class))).thenReturn(diagnostics);
1145+
// executeScript (serialize + any other sync scripts)
1146+
Map<String, Object> domSnap = new HashMap<>();
1147+
domSnap.put("html", "<html></html>");
1148+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);
1149+
1150+
Map<String, Object> result = mockedPercy.getSerializedDOM(
1151+
(JavascriptExecutor) mockedDriver, new HashSet<>(), new HashMap<>());
1152+
1153+
// Readiness script was sent via executeAsyncScript
1154+
ArgumentCaptor<String> scriptCap = ArgumentCaptor.forClass(String.class);
1155+
verify((JavascriptExecutor) mockedDriver, atLeastOnce()).executeAsyncScript(scriptCap.capture());
1156+
assertTrue(scriptCap.getValue().contains("waitForReady"),
1157+
"readiness script should mention waitForReady");
1158+
// Diagnostics propagated to the snapshot
1159+
assertEquals(diagnostics, result.get("readiness_diagnostics"));
1160+
}
1161+
1162+
@Test
1163+
public void readinessSkippedWhenPresetDisabled() throws Exception {
1164+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1165+
Percy mockedPercy = new Percy(mockedDriver);
1166+
setField(mockedPercy, "isPercyEnabled", true);
1167+
1168+
Map<String, Object> domSnap = new HashMap<>();
1169+
domSnap.put("html", "<html></html>");
1170+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);
1171+
1172+
Map<String, Object> disabled = new HashMap<>();
1173+
disabled.put("preset", "disabled");
1174+
Map<String, Object> options = new HashMap<>();
1175+
options.put("readiness", disabled);
1176+
1177+
Map<String, Object> result = mockedPercy.getSerializedDOM(
1178+
(JavascriptExecutor) mockedDriver, new HashSet<>(), options);
1179+
1180+
// executeAsyncScript must NOT have been called
1181+
verify((JavascriptExecutor) mockedDriver, never()).executeAsyncScript(any(String.class));
1182+
// serialize still ran; no diagnostics attached
1183+
assertNull(result.get("readiness_diagnostics"));
1184+
}
1185+
1186+
@Test
1187+
public void snapshotSurvivesReadinessThrow() throws Exception {
1188+
RemoteWebDriver mockedDriver = mock(RemoteWebDriver.class);
1189+
Percy mockedPercy = new Percy(mockedDriver);
1190+
setField(mockedPercy, "isPercyEnabled", true);
1191+
setField(mockedPercy, "cliConfig", new JSONObject().put("snapshot", new JSONObject()));
1192+
1193+
when(((JavascriptExecutor) mockedDriver).executeAsyncScript(any(String.class)))
1194+
.thenThrow(new RuntimeException("readiness boom"));
1195+
Map<String, Object> domSnap = new HashMap<>();
1196+
domSnap.put("html", "<html></html>");
1197+
when(((JavascriptExecutor) mockedDriver).executeScript(any(String.class))).thenReturn(domSnap);
1198+
1199+
Map<String, Object> result = mockedPercy.getSerializedDOM(
1200+
(JavascriptExecutor) mockedDriver, new HashSet<>(), new HashMap<>());
1201+
1202+
// Serialize still ran; no diagnostics attached
1203+
assertNull(result.get("readiness_diagnostics"));
1204+
assertEquals("<html></html>", result.get("html"));
1205+
}
1206+
11311207
private static Object invokePrivate(Object target, String methodName, Class<?>[] paramTypes, Object... args)
11321208
throws Exception {
11331209
Method method = Percy.class.getDeclaredMethod(methodName, paramTypes);

0 commit comments

Comments
 (0)