@@ -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 ) {}
0 commit comments