@codeyam-editor/codeyam-editor 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/npm/cloud.js +361 -18
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +355 -21
  5. package/npm/fleet-branch.js +124 -1
  6. package/npm/fleet-build-data.js +12 -1
  7. package/npm/fleet-cleanup.js +84 -3
  8. package/npm/fleet-datastore-health.js +107 -0
  9. package/npm/fleet-revision.js +108 -0
  10. package/npm/fleet-run-setup.js +66 -0
  11. package/npm/fleet-session-activity.js +162 -0
  12. package/npm/fleet-vm-readiness.js +57 -16
  13. package/npm/launch-config.js +126 -2
  14. package/npm/scenario-check.js +697 -34
  15. package/npm/scenario-interactivity.js +477 -23
  16. package/npm/scenario-issues.js +61 -0
  17. package/npm/scenario-playwright.js +67 -14
  18. package/npm/utils.js +262 -22
  19. package/package.json +5 -5
  20. package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-C7I9wctz.js} +1 -1
  21. package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
  22. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  23. package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
  24. package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
  25. package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
  26. package/ui/dist/assets/index-BRoAenc1.css +1 -0
  27. package/ui/dist/assets/{index-BPLKgwon.js → index-iMR5siiG.js} +9 -9
  28. package/ui/dist/index.html +2 -2
  29. package/ui/dist/preview-subdir-demo/index.html +62 -0
  30. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  31. package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
  32. package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
  33. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
  34. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
  35. package/ui/dist/assets/index-DKpgJLo3.css +0 -1
@@ -35,6 +35,15 @@ const CAPTURE_LAUNCH_SIGSEGV_RETRIES = 2;
35
35
  // clear; small enough not to eat the per-slug recapture budget.
36
36
  const CAPTURE_LAUNCH_SIGSEGV_BACKOFF_MS = 250;
37
37
 
38
+ // The sentinel the reverse proxy stamps on its dev-server-down placeholder —
39
+ // the other half of this contract is
40
+ // `crates/proxy-http/src/reverse_proxy.rs::DEV_SERVER_DOWN_HEADER`, and the two
41
+ // spellings are deliberately greppable from each other. Keyed on the header
42
+ // rather than the placeholder's copy for two reasons: the response is a 200, so
43
+ // no status check can see it, and the copy is per-framework prose that drifts
44
+ // the moment `render_placeholder` is reworded.
45
+ const DEV_SERVER_DOWN_HEADER = "x-codeyam-dev-server-down";
46
+
38
47
  // Pin the headless capture browser's `localhost` resolution to the IPv4
39
48
  // loopback the editor's listeners bind. The browser-facing preview origin is
40
49
  // now `localhost` (secure-context apps refuse a bare IP — see
@@ -166,6 +175,7 @@ const {
166
175
 
167
176
  const {
168
177
  assertAppPortReachable,
178
+ defaultReadServerState,
169
179
  loadScenarioInIframe,
170
180
  loadScenarioTopLevel,
171
181
  resolveHarnessOrigin,
@@ -214,6 +224,21 @@ function originHost(origin) {
214
224
  }
215
225
  }
216
226
 
227
+ // Read the on-box `.codeyam/session-token` (0600), or `null` when there is
228
+ // none. Every control-API call the capture makes is token-gated on a
229
+ // non-loopback bind, and its three callers were each re-deriving this path;
230
+ // one reader means a capture cannot authenticate one route and silently fail
231
+ // to authenticate another. Never throws — an unreadable file reads as absent,
232
+ // which degrades to the un-credentialed behavior a loopback bind is happy with.
233
+ function readSessionToken() {
234
+ try {
235
+ const p = path.join(process.cwd(), ".codeyam", "session-token");
236
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() || null : null;
237
+ } catch (_) {
238
+ return null;
239
+ }
240
+ }
241
+
217
242
  // Build the Playwright cookie array that authenticates the capture context to
218
243
  // the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
219
244
  // (0600) and emits a `cy_session` cookie for every host the capture might address
@@ -226,10 +251,7 @@ function originHost(origin) {
226
251
  // laptop capture is completely unaffected. `deps` is injectable so the builder is
227
252
  // unit-testable without disk.
228
253
  function buildSessionTokenCookies({
229
- readTokenFile = () => {
230
- const p = path.join(process.cwd(), ".codeyam", "session-token");
231
- return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : null;
232
- },
254
+ readTokenFile = readSessionToken,
233
255
  harnessOrigin = resolveHarnessOrigin(),
234
256
  } = {}) {
235
257
  try {
@@ -262,12 +284,7 @@ function buildSessionTokenCookies({
262
284
  // heading into a guaranteed 401). Never throws — an unreadable file reports
263
285
  // absent, degrading to today's behavior.
264
286
  function onBoxSessionTokenExists() {
265
- try {
266
- const p = path.join(process.cwd(), ".codeyam", "session-token");
267
- return fs.existsSync(p) && fs.readFileSync(p, "utf8").trim().length > 0;
268
- } catch (_) {
269
- return false;
270
- }
287
+ return readSessionToken() !== null;
271
288
  }
272
289
 
273
290
  // Pure predicate behind the capability-skew guard: true when the capture WILL
@@ -290,9 +307,134 @@ const {
290
307
  } = require("./scenario-handlers");
291
308
 
292
309
  const {
310
+ buildCounterpartProbe,
311
+ subpathHydrationEscalation,
293
312
  waitForHydration,
294
313
  } = require("./scenario-interactivity");
295
314
 
315
+ // The content frame's own URL, or `null` when it cannot answer.
316
+ //
317
+ // `frame.url()` is the only correct source for the URL a hydration verdict is
318
+ // ABOUT. `page.url()` is the top-level document, which under the iframe harness
319
+ // is the wrapper — carrying the real route only percent-encoded inside its
320
+ // `?src=` parameter. `loadScenarioTopLevel` / `loadScenarioInIframe` both
321
+ // document the returned frame as uniformly usable, and its URL is right in every
322
+ // load shape: a top-level navigation (redirects followed, which is why
323
+ // `page.url()` was preferred originally), the harness iframe, the degraded
324
+ // `setContent` harness whose ancestor is `about:blank`, and a frame re-pointed
325
+ // by a `navigate` step — where a decoded `?src=` would be stale.
326
+ //
327
+ // A frame detached by a navigation mid-capture throws or returns `""`, and a
328
+ // reporting detail must never cost the capture, so every caller falls back to
329
+ // exactly the behavior that shipped before this existed.
330
+ function safeFrameUrl(frame) {
331
+ try {
332
+ const value = frame && frame.url();
333
+ return typeof value === "string" && value ? value : null;
334
+ } catch (_) {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ // Resolve where the subpath-hydration verdict is POSTed and what headers carry
340
+ // it, or `null` when there is no reachable control port to report to. Pure.
341
+ //
342
+ // Split out of `reportSubpathHydrationFailure` because the AUTH half is where
343
+ // the real defect lives and the network half is what makes it untestable. Every
344
+ // control-API route is session-token-gated; this call is made from Node rather
345
+ // than from the browser context, so it carries no `cy_session` cookie and the
346
+ // token must ride as a Bearer header. Omit it and the POST is refused, the
347
+ // escalation silently never happens, and the capture still reports success —
348
+ // exactly the invisible failure this whole feature exists to end. As a pure
349
+ // function that contract is asserted with no server running.
350
+ //
351
+ // A loopback bind that never persisted a token yields no header, which the
352
+ // editor accepts; that is why the token is optional rather than required.
353
+ function subpathHydrationReportTarget({ controlPort, token }) {
354
+ if (!(controlPort > 0)) return null;
355
+ const headers = { "Content-Type": "application/json" };
356
+ if (token) headers.Authorization = `Bearer ${token}`;
357
+ return {
358
+ url: `http://127.0.0.1:${controlPort}/api/editor-preview-subpath-hydration`,
359
+ headers,
360
+ };
361
+ }
362
+
363
+ // Tell the editor that this route proved dead under the `/__codeyam_preview`
364
+ // mount and alive at the app's own origin, so `/api/config` can escalate the
365
+ // preview off the subpath for the rest of the session.
366
+ //
367
+ // The verdict is worthless unless it reaches the decision-maker, and the
368
+ // capture process is the only thing that ever runs the two-origin experiment.
369
+ // It travels over the editor's control plane rather than a file so it is read
370
+ // on the next `/api/config` resolution — a launch-time channel could not vary
371
+ // per route.
372
+ //
373
+ // FAIL-SOFT ON EVERY LEG. A missing state file, a control port that is not
374
+ // listening, a non-2xx, a timeout: none of them may change the capture's
375
+ // outcome. The capture's job is to report what it saw, and it has already done
376
+ // that by the time this runs; a reporting failure costs the escalation, never
377
+ // the finding.
378
+ async function reportSubpathHydrationFailure(
379
+ escalation,
380
+ { readServerState = defaultReadServerState, readToken = readSessionToken } = {},
381
+ ) {
382
+ try {
383
+ const state = readServerState();
384
+ const target = subpathHydrationReportTarget({
385
+ controlPort: state && state.controlPort,
386
+ token: readToken(),
387
+ });
388
+ if (!target) return false;
389
+ const response = await fetch(target.url, {
390
+ method: "POST",
391
+ headers: target.headers,
392
+ body: JSON.stringify({
393
+ failedUrl: escalation.failedUrl,
394
+ counterpartUrl: escalation.counterpartUrl,
395
+ }),
396
+ signal: AbortSignal.timeout(5000),
397
+ });
398
+ return response.ok;
399
+ } catch (_) {
400
+ return false;
401
+ }
402
+ }
403
+
404
+ // Decide-and-report the subpath-hydration escalation for one hydration verdict.
405
+ //
406
+ // Two call sites now run this exact sequence — the top-level wait in
407
+ // `runScenarioCheck` and the per-`navigate` wait in `runFlowSteps` — and both
408
+ // must read the verdict the same way, since a difference between them would be
409
+ // a route that escalates at the top level and silently does not one navigation
410
+ // deep. Hoisted once the second call site made the duplication real.
411
+ //
412
+ // Returns the escalation payload (or `null`) rather than a boolean, so the
413
+ // decision is assertable without a control port or a browser; `reportSubpath
414
+ // HydrationFailure` is already fail-soft on every leg, so a reporting failure
415
+ // costs the escalation and never the capture.
416
+ //
417
+ // Deliberately does NOT touch the caller's issue list: whether the finding is
418
+ // reported as an issue is the CALLER's concern and the two sites differ on it.
419
+ //
420
+ // `report` is injectable for the same reason `reportSubpathHydrationFailure`
421
+ // takes its own readers: the real one POSTs to the live control port, which a
422
+ // unit test must never do.
423
+ async function escalateSubpathHydration(
424
+ hydration,
425
+ { report = reportSubpathHydrationFailure } = {},
426
+ ) {
427
+ const escalation = subpathHydrationEscalation({
428
+ crossOrigin: hydration.crossOrigin,
429
+ url: hydration.issue && hydration.issue.url,
430
+ counterpartUrl: hydration.counterpartUrl,
431
+ });
432
+ if (escalation) {
433
+ await report(escalation);
434
+ }
435
+ return escalation;
436
+ }
437
+
296
438
  // Read project-specific loading markers from `.codeyam/stack.json`
297
439
  // (`capture.loadingMarkers`). The capture script runs with cwd = project dir
298
440
  // (scenario_check.rs sets `.current_dir(project_dir)`), so this relative path
@@ -443,6 +585,22 @@ function isCaptureFatalRequestFailure(url, appOrigin) {
443
585
  return !isCrossOriginRequest(url, appOrigin);
444
586
  }
445
587
 
588
+ // True when this response is the reverse proxy's dev-server-down placeholder
589
+ // rather than the app. The proxy stamps `DEV_SERVER_DOWN_HEADER` on exactly
590
+ // that response and four Rust consumers already key on it; this is the fifth,
591
+ // and the first on the capture side. Header names are compared
592
+ // case-insensitively — Playwright lowercases them, but the contract is the
593
+ // header, not Playwright's normalization of it. A null response is not a
594
+ // placeholder (the null-response case has its own branch). Pure, so the
595
+ // decision is unit-testable without a live browser or a downed dev server.
596
+ function isDevServerPlaceholderResponse(response) {
597
+ if (!response || typeof response.headers !== "function") return false;
598
+ const headers = response.headers() || {};
599
+ return Object.keys(headers).some(
600
+ (name) => name.toLowerCase() === DEV_SERVER_DOWN_HEADER,
601
+ );
602
+ }
603
+
446
604
  // Return a copy of `headers` with every name in `markerNames` removed.
447
605
  // Names are matched case-insensitively against the (lowercased) header keys
448
606
  // Playwright reports. Pure — never mutates its input — so unrelated headers
@@ -610,15 +768,49 @@ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config
610
768
  );
611
769
  }
612
770
 
771
+ // How many visible text nodes `dumpPageState` samples. Named because two
772
+ // callers must agree on it: the sampler itself, and `verifyProbeSeedLanded`,
773
+ // which can only treat "probe not found" as proof of absence when the sample
774
+ // did NOT hit this cap.
775
+ const PAGE_STATE_TEXT_NODE_CAP = 40;
776
+
777
+ // Fallback for how long `verifyProbeSeedLanded` re-reads a frame that is still
778
+ // showing committed content. Mirrors `SEED_LANDING_BUDGET` in
779
+ // `seed_landing.rs`, and is used ONLY when the editor did not send
780
+ // `config.seedLandingBudgetMs` — a capture request from a binary that predates
781
+ // the field. Degrading to today's constant is deliberate: degrading to zero
782
+ // would fail on the first read, which is exactly the bug being fixed.
783
+ const SEED_LANDING_BUDGET_MS = 10000;
784
+
785
+ // Gap between frame re-reads. Mirrors `SEED_LANDING_POLL_INTERVAL` in
786
+ // `seed_landing.rs` for the same reason the budget does — the two halves of one
787
+ // gate should not sample at visibly different rates when a report compares
788
+ // their logs.
789
+ const SEED_LANDING_RETRY_INTERVAL_MS = 250;
790
+
791
+ // The re-read budget this capture applies, in milliseconds.
792
+ //
793
+ // A non-finite, negative, or absent value falls back to the constant above; a
794
+ // zero is treated as absent for the same reason the Rust resolver does it, so a
795
+ // config typo cannot turn the gate into "fail on the first read" across a whole
796
+ // corpus.
797
+ function readSeedLandingBudgetMs(config) {
798
+ const raw = config && config.seedLandingBudgetMs;
799
+ return typeof raw === "number" && Number.isFinite(raw) && raw > 0
800
+ ? raw
801
+ : SEED_LANDING_BUDGET_MS;
802
+ }
803
+
613
804
  // Read-only page-state snapshot for `capture-state`: the full localStorage
614
- // map, a bounded sample of visible text nodes (document order), and — when a
805
+ // map, a bounded sample of visible text nodes (document order), the page's
806
+ // head metadata (`<title>` plus every named `<meta>`'s content), and — when a
615
807
  // selector is given — that element's text. Evaluated in-page against the
616
808
  // settled frame so it reflects exactly what a real capture saw (the proxy
617
809
  // already injected the scenario's seed into the served HTML). Every read is
618
810
  // individually guarded so a sandboxed/cross-origin localStorage never throws
619
811
  // the whole capture; the worst case is an empty section, not a failure.
620
812
  async function dumpPageState(frame, selector) {
621
- return frame.evaluate((sel) => {
813
+ return frame.evaluate(([sel, textNodeCap]) => {
622
814
  const localStorage = {};
623
815
  try {
624
816
  for (let i = 0; i < window.localStorage.length; i++) {
@@ -658,7 +850,7 @@ async function dumpPageState(frame, selector) {
658
850
  },
659
851
  );
660
852
  let node;
661
- while ((node = walker.nextNode()) && visibleText.length < 40) {
853
+ while ((node = walker.nextNode()) && visibleText.length < textNodeCap) {
662
854
  const text = (node.textContent || "").replace(/\s+/g, " ").trim();
663
855
  if (text) visibleText.push(text);
664
856
  }
@@ -666,6 +858,38 @@ async function dumpPageState(frame, selector) {
666
858
  /* no body / detached document */
667
859
  }
668
860
 
861
+ // Head metadata, deliberately its OWN field rather than more `visibleText`.
862
+ // `capture-state` consumers read `visibleText` as "text a reader sees on
863
+ // the page", and a `<title>` is not that. But a page that renders its
864
+ // seeded values only into `<title>` / `<meta content>` — a title-only page
865
+ // is exactly the reported case — still received the seed, and the Rust
866
+ // poll already counts it: matching the raw response body, head markup is
867
+ // in its haystack. Collecting the same surface here is what lets the two
868
+ // halves of one gate agree about what counts as the render.
869
+ const headMetadata = [];
870
+ try {
871
+ const title = (document.title || "").replace(/\s+/g, " ").trim();
872
+ if (title) headMetadata.push(title);
873
+ } catch (_) {
874
+ /* detached document */
875
+ }
876
+ try {
877
+ // `name` or `property` only: a bare `<meta charset>` or an
878
+ // `http-equiv` carries no authored content, and requiring one of the two
879
+ // naming attributes keeps the haystack to metadata someone declared.
880
+ const metas = document.querySelectorAll(
881
+ "meta[name][content], meta[property][content]",
882
+ );
883
+ for (let i = 0; i < metas.length && headMetadata.length < textNodeCap; i++) {
884
+ const content = (metas[i].getAttribute("content") || "")
885
+ .replace(/\s+/g, " ")
886
+ .trim();
887
+ if (content) headMetadata.push(content);
888
+ }
889
+ } catch (_) {
890
+ /* no head / detached document */
891
+ }
892
+
669
893
  let selectorText = null;
670
894
  if (sel) {
671
895
  try {
@@ -676,22 +900,42 @@ async function dumpPageState(frame, selector) {
676
900
  }
677
901
  }
678
902
 
679
- return { localStorage, visibleText, selectorText };
680
- }, selector || null);
903
+ return { localStorage, visibleText, headMetadata, selectorText };
904
+ }, [selector || null, PAGE_STATE_TEXT_NODE_CAP]);
905
+ }
906
+
907
+ // Landed-state verification, across every transport a seed can travel: prove
908
+ // the seed reached the frame before the capture is blessed. The core promise of
909
+ // a seeded scenario is remote control of the app — a seed that silently doesn't
910
+ // land produces a screenshot that looks like a successful capture of a
911
+ // different state entirely, which is exactly the failure this guards.
912
+ //
913
+ // Two halves, because a seed reaches the app two structurally different ways:
914
+ // - storage — the localStorage the capture INJECTED
915
+ // (`config.browserState.localStorage`, already carrying the seed-session
916
+ // overlay the editor merged in) is read back from the settled page.
917
+ // - probes — `config.seedProbes` carries strings the editor derived by
918
+ // diffing the seeded sandbox against the production tree it was reset from
919
+ // (see `seed_landing.rs`), so at least one must appear in the rendered
920
+ // text. This is the half a filesystem-seeded (content-collection) stack
921
+ // needs: it injects nothing into storage, so the storage half alone
922
+ // declared every such capture clean no matter what actually rendered.
923
+ //
924
+ // Returns a loud `seed-not-landed` issue (which fails the capture, since `ok`
925
+ // requires zero issues) when a declared seed is missing from the frame, or
926
+ // `null` when NEITHER half had anything to verify, the seed landed, or the
927
+ // read-back itself was unavailable — never fail a capture over the verifier.
928
+ async function verifySeedLanded(frame, config) {
929
+ const storageIssue = await verifyStorageSeedLanded(frame, config);
930
+ if (storageIssue) return storageIssue;
931
+ return await verifyProbeSeedLanded(frame, config);
681
932
  }
682
933
 
683
- // Landed-state verification: assert the localStorage the capture INJECTED
684
- // (`config.browserState.localStorage`, which already carries the seed-session
685
- // overlay the editor merged in) actually reached the capture browser after the
686
- // page settled. The core promise of a seeded scenario is remote control of the
687
- // app — a seed that silently doesn't land produces an empty screenshot that
688
- // looks like a successful capture of an empty app, which is exactly the
689
- // failure this guards. Returns a loud `seed-not-landed` issue (which fails the
690
- // capture, since `ok` requires zero issues) when a non-empty injected seed is
691
- // missing/empty on read-back, or `null` when there was nothing to verify, the
692
- // seed landed, or storage is unavailable (sandboxed/opaque origin — never fail
693
- // the capture over the verifier itself).
694
- async function verifySeededStorageLanded(frame, config) {
934
+ // The storage half of `verifySeedLanded`, unchanged in behavior: assert every
935
+ // non-empty injected localStorage key is present on read-back. `null` when no
936
+ // storage was seeded — which no longer means "clean", only "this transport had
937
+ // nothing to verify"; the probe half below answers for the filesystem one.
938
+ async function verifyStorageSeedLanded(frame, config) {
695
939
  const expected =
696
940
  (config && config.browserState && config.browserState.localStorage) || {};
697
941
  const expectedKeys = Object.keys(expected);
@@ -740,6 +984,293 @@ async function verifySeededStorageLanded(frame, config) {
740
984
  );
741
985
  }
742
986
 
987
+ // The comparison form both sides of a probe match are reduced to: whitespace
988
+ // collapsed, then lowercased. Mirrors `normalize_for_match` in
989
+ // `seed_landing.rs` — the poll and this frame assertion must agree, or one bug
990
+ // becomes a differently-worded one.
991
+ //
992
+ // Rendering already reflows whitespace. Case-folding is the same argument one
993
+ // step further: an app that lowercases a value before putting it on the page
994
+ // has still put the value on the page.
995
+ function normalizeForMatch(text) {
996
+ return String(text == null ? "" : text)
997
+ .replace(/\s+/g, " ")
998
+ .trim()
999
+ .toLowerCase();
1000
+ }
1001
+
1002
+ // Does an already-normalized surface carry `value`?
1003
+ //
1004
+ // One line, and named anyway, because the probe half asks it of TWO surfaces
1005
+ // — the reader-visible body text and the page's head metadata — for both a
1006
+ // probe's seeded value and its committed counterpart. Four inline copies of
1007
+ // "normalize the needle, then substring-test the haystack" are four chances
1008
+ // for one of them to drift, and `normalizeForMatch` exists precisely because
1009
+ // this comparison rule drifting is the bug class that keeps recurring here.
1010
+ //
1011
+ // The haystack is normalized by the caller, once per surface, rather than per
1012
+ // probe: it is the long side, and re-normalizing it inside a `.some()` would
1013
+ // redo that work for every probe.
1014
+ function surfaceCarries(haystack, value) {
1015
+ return haystack.includes(normalizeForMatch(value));
1016
+ }
1017
+
1018
+ // One entry of `config.seedProbes`, normalized to
1019
+ // `{ value, committed, removed }`.
1020
+ //
1021
+ // A bare string is tolerated as `{ value }` so a version-skewed capture script
1022
+ // paired with an older editor degrades to the two-way behavior rather than
1023
+ // throwing — an unpaired probe simply can never witness the committed case.
1024
+ //
1025
+ // `removed` mirrors `ProbeSense` in `seed_landing.rs`: false (the default, and
1026
+ // the shape of every probe the editor sent before the removing kind existed)
1027
+ // means the seed INTRODUCED `value` and it must appear; true means the seed
1028
+ // DELETED it and it must be gone. An unrecognized `sense` string reads as
1029
+ // introduced rather than throwing — the same degrade-don't-fail rule the bare
1030
+ // string above follows, and never failing a capture over the verifier.
1031
+ function readSeedProbe(entry) {
1032
+ if (typeof entry === "string")
1033
+ return { value: entry, committed: null, removed: false };
1034
+ if (!entry || typeof entry.value !== "string") return null;
1035
+ return {
1036
+ value: entry.value,
1037
+ committed: typeof entry.committed === "string" ? entry.committed : null,
1038
+ removed: entry.sense === "removed",
1039
+ };
1040
+ }
1041
+
1042
+ // Describe an unreadable probe entry precisely enough to name the contract that
1043
+ // broke. `typeof` alone answers "object" for the shape that actually matters, so
1044
+ // the keys carry the diagnosis: a payload of `{ text, prior }` is a renamed
1045
+ // field, `{}` is an empty row, and a nested array is a serialization bug.
1046
+ function describeProbeShape(entry) {
1047
+ if (entry === null) return "null";
1048
+ const kind = typeof entry;
1049
+ if (kind !== "object") return kind;
1050
+ if (Array.isArray(entry)) return `array(length ${entry.length})`;
1051
+ const keys = Object.keys(entry);
1052
+ return keys.length === 0 ? "object with no keys" : `object with keys [${keys.join(", ")}]`;
1053
+ }
1054
+
1055
+ // The probe half of `verifySeedLanded`, classifying the frame three ways rather
1056
+ // than two. Each probe carries a discriminating string the editor derived from
1057
+ // the seeded sandbox, plus the committed value it replaced at the same file and
1058
+ // field:
1059
+ //
1060
+ // - a seeded value appears → the seed reached the render. Clean.
1061
+ // - only a committed counterpart → this route renders the field and is
1062
+ // appears showing UNSEEDED content. The real defect,
1063
+ // and a stronger claim than the old one.
1064
+ // - neither appears → the route does not render these fields at
1065
+ // all, so their absence proves nothing. Not
1066
+ // an issue; the Rust half already recorded
1067
+ // the unverifiable advisory.
1068
+ //
1069
+ // The two-way reading answered the third case with the second case's loud
1070
+ // failure, which blocked correct captures whose route simply had nothing to say
1071
+ // about the seeded field — a collection-picker page asserted against a blog
1072
+ // post body, or a preview page whose whole purpose is withholding the content.
1073
+ //
1074
+ // Existence, not coverage — a seed writes many strings and rendering may show
1075
+ // only some, so requiring all of them would fail correct captures.
1076
+ //
1077
+ // "Appears" spans two surfaces, checked in that order: the reader-visible body
1078
+ // text, then the page's head metadata. A title-only page renders its seeded
1079
+ // title into `<h1>` and its seeded description into `<meta>` and nothing else,
1080
+ // so a body-only haystack calls that seed missing while the Rust poll — which
1081
+ // matches the raw response body — calls it landed. One gate cannot hold two
1082
+ // definitions of the render, and the poll's is the shipped, more permissive
1083
+ // one, so this side matches it rather than the other way round.
1084
+ //
1085
+ // The probes are scoped by the editor before they reach here: only a capture
1086
+ // whose OWN merged seed wrote to the sandbox carries any, and a component
1087
+ // scenario never does. So the empty short-circuit below is the whole gate this
1088
+ // side needs — an unseeded or isolated-component capture simply sends none.
1089
+ async function verifyProbeSeedLanded(frame, config) {
1090
+ const rawProbes = (config && config.seedProbes) || [];
1091
+ const probes = rawProbes.map(readSeedProbe).filter(Boolean);
1092
+
1093
+ // An EMPTY `seedProbes` is a legitimate no-op — an unseeded or
1094
+ // isolated-component capture sends none, and there is nothing to verify. A
1095
+ // NON-EMPTY set whose every entry is unreadable is a different animal: it
1096
+ // means this script and the binary that invoked it disagree about the probe
1097
+ // contract, and silently filtering those entries turns "I cannot check the
1098
+ // seed" into "the seed is fine" — a capture that asserts nothing while
1099
+ // reporting success. Say so instead, and name the shape received so the
1100
+ // diagnosis lands on the script/binary contract rather than on the app.
1101
+ if (rawProbes.length > 0 && probes.length === 0) {
1102
+ const shapes = rawProbes.map(describeProbeShape).join("; ");
1103
+ throw new Error(
1104
+ `capture.js does not understand this scenario's seedProbes: received ${rawProbes.length} ` +
1105
+ `entry/entries, none readable as a string or { value: string } — [${shapes}]. This is a ` +
1106
+ `capture-script/binary contract break, not a scenario failure: the seed was never ` +
1107
+ `verified either way. Run \`codeyam-editor editor sync-capture-scripts\` to bring ` +
1108
+ `.codeyam/capture.js up to date with the running binary.`,
1109
+ );
1110
+ }
1111
+
1112
+ if (probes.length === 0) return null; // nothing derived — nothing to verify.
1113
+
1114
+ // The committed value showing is a TRANSIENT state, not a verdict: on a
1115
+ // content-collection stack the seed adapter writes the sandbox and the
1116
+ // content layer re-globs asynchronously, so the first frame read can
1117
+ // legitimately still be rendering what the seed replaced. Reading once and
1118
+ // failing turned that race into false `seed-not-landed` failures on captures
1119
+ // whose seeded string was visibly present seconds later — one scenario failed
1120
+ // while its seeded "Protected preview" appeared twice in the rendered text.
1121
+ //
1122
+ // So only THIS arm gains patience. Every other arm still returns on the first
1123
+ // read, because each is already a settled answer: a seeded value found is a
1124
+ // landing, a capped text sample is inconclusive, and neither-value-present
1125
+ // means the route does not render these fields at all. Re-reading those would
1126
+ // buy nothing and cost the budget.
1127
+ const budgetMs = readSeedLandingBudgetMs(config);
1128
+ const startedAt = Date.now();
1129
+ for (;;) {
1130
+ const verdict = await readSeedVerdict(frame, probes);
1131
+ if (verdict.kind !== "committed-showing") return null;
1132
+ const waitedMs = Date.now() - startedAt;
1133
+ if (waitedMs >= budgetMs) {
1134
+ return seedNotLandedIssue(frame, config, verdict.showing, waitedMs);
1135
+ }
1136
+ await new Promise((r) => setTimeout(r, SEED_LANDING_RETRY_INTERVAL_MS));
1137
+ }
1138
+ }
1139
+
1140
+ // One read of the frame, classified. The READ half only: it owns the page
1141
+ // access and the never-fail-over-the-verifier rule, and delegates every verdict
1142
+ // to the pure classifier below.
1143
+ //
1144
+ // An unreadable frame is `inconclusive`, never a failure — the verifier must
1145
+ // not be what fails a capture.
1146
+ async function readSeedVerdict(frame, probes) {
1147
+ let state;
1148
+ try {
1149
+ state = await dumpPageState(frame, null);
1150
+ } catch (_) {
1151
+ return { kind: "inconclusive" }; // read-back failed.
1152
+ }
1153
+ if (!state) return { kind: "inconclusive" };
1154
+ return classifySeedFrameState(state, probes);
1155
+ }
1156
+
1157
+ // Classify one already-read page state against a probe set, the same three ways
1158
+ // `classify_served_body` classifies one served response — so the poll and this
1159
+ // assertion cannot drift into two definitions of "landed", which is the bug
1160
+ // class that keeps recurring here.
1161
+ //
1162
+ // Returns `{ kind: "landed" | "inconclusive" | "committed-showing", showing }`.
1163
+ // Only `committed-showing` is worth re-reading for; the other two are settled.
1164
+ //
1165
+ // Pure and synchronous, and split from the read above for exactly the reason
1166
+ // `classify_served_body` was lifted out of the Rust polling loop: every verdict
1167
+ // this gate can reach becomes reachable in a test from a plain state object,
1168
+ // with no fake frame and no waiting out a retry budget. The decision used to be
1169
+ // expressible only through an async page read.
1170
+ function classifySeedFrameState(state, probes) {
1171
+ // First surface: the text a reader actually sees in the capture.
1172
+ const visibleText = state.visibleText || [];
1173
+ const haystack = normalizeForMatch(
1174
+ visibleText.concat(state.selectorText || []).join(" "),
1175
+ );
1176
+
1177
+ // Second surface: a page whose seeded value renders only into `<title>` or a
1178
+ // `<meta content>` still received the seed. The Rust poll already reads it
1179
+ // that way — it matches the raw response body, head markup included — so a
1180
+ // refusal here would leave the two halves of one gate disagreeing about what
1181
+ // counts as the render, which is the reported bug: the poll reports landed
1182
+ // and the frame assertion reports the seed missing, for one served page.
1183
+ //
1184
+ // Deliberately NOT folded into `haystack` above: the two are separate
1185
+ // questions (did the reader see it / did the render receive it), and the cap
1186
+ // rule below applies to the body sample only.
1187
+ const headHaystack = normalizeForMatch((state.headMetadata || []).join(" "));
1188
+ const carries = (value) =>
1189
+ surfaceCarries(haystack, value) || surfaceCarries(headHaystack, value);
1190
+
1191
+ const introduced = probes.filter((probe) => !probe.removed);
1192
+ const removed = probes.filter((probe) => probe.removed);
1193
+
1194
+ if (introduced.some((probe) => carries(probe.value))) {
1195
+ return { kind: "landed" }; // a seeded value reached the render.
1196
+ }
1197
+
1198
+ // A removing probe still on the page is the defect, and a stronger
1199
+ // observation than the committed sweep below: the value is one the seed
1200
+ // explicitly deleted, so the route rendering it cannot be explained away by
1201
+ // the route simply not showing the field. Mirrors the same-ordered arm in
1202
+ // `classify_served_body`.
1203
+ const stillRemoved = removed.find((probe) => carries(probe.value));
1204
+ if (stillRemoved) {
1205
+ return {
1206
+ kind: "committed-showing",
1207
+ showing: { value: stillRemoved.value, committed: stillRemoved.value },
1208
+ };
1209
+ }
1210
+
1211
+ // `dumpPageState` samples a bounded number of text nodes. When it came back
1212
+ // at the cap, "not found" cannot be distinguished from "past the sample", so
1213
+ // the honest answer is inconclusive rather than a failure — same rule the
1214
+ // storage half follows for unavailable storage. It bounds the BODY sample
1215
+ // only; the metadata read above is complete, so a metadata match is
1216
+ // conclusive regardless of where the text walk stopped.
1217
+ //
1218
+ // Below the two match arms above deliberately: a match is conclusive however
1219
+ // the walk ended, and only a MISS needs the sample to have been complete.
1220
+ if (visibleText.length >= PAGE_STATE_TEXT_NODE_CAP) {
1221
+ return { kind: "inconclusive" };
1222
+ }
1223
+
1224
+ // Both surfaces again, and for the same reason: a route emitting the
1225
+ // COMMITTED value into its `<meta description>` demonstrably renders that
1226
+ // field and demonstrably is not rendering the seed. Widening where a seed
1227
+ // counts as landed without widening where its committed counterpart counts
1228
+ // as showing would turn the real defect into a silent pass.
1229
+ const showing = probes.find(
1230
+ (probe) => probe.committed && carries(probe.committed),
1231
+ );
1232
+ if (showing) return { kind: "committed-showing", showing };
1233
+
1234
+ // Every removing probe is gone, which is exactly what the seed set out to
1235
+ // achieve — and the only landing a purely-deleting seed can demonstrate.
1236
+ // Last, because absence is the weakest of the three claims: it is also what
1237
+ // an unrelated route looks like, so it must not pre-empt a positive landing
1238
+ // or a still-showing observation.
1239
+ if (removed.length > 0) return { kind: "landed" };
1240
+
1241
+ // Neither the seed nor what it replaced is on this page: the route does not
1242
+ // render these fields, and demanding one is asserting a fact about a page
1243
+ // that has nothing to say.
1244
+ return { kind: "inconclusive" };
1245
+ }
1246
+
1247
+ // The loud `seed-not-landed` issue, built once the re-read budget has elapsed
1248
+ // with the render still showing what the seed replaced.
1249
+ //
1250
+ // `waitedMs` is named in the message because a reader who sees "the seed did
1251
+ // not land" needs to know the gate was patient before concluding it — and,
1252
+ // when a project has raised `seedLandingBudgetMs`, that the raised budget is
1253
+ // the one that actually elapsed.
1254
+ function seedNotLandedIssue(frame, config, showing, waitedMs) {
1255
+ return createIssue(
1256
+ "seed-not-landed",
1257
+ `The captured frame still showed "${showing.committed}" where this scenario's own seed ` +
1258
+ `wrote "${showing.value}" after ${Math.round(waitedMs / 1000)}s of re-reading — the ` +
1259
+ `served app is rendering COMMITTED content, not the seed. This route demonstrably ` +
1260
+ `renders that field, so its committed value appearing is direct evidence the seed did ` +
1261
+ `not reach the frame — fix the seed; do not delete the scenario. Check, in order: ` +
1262
+ `(1) the seed's tables/files target the collection this route actually renders, ` +
1263
+ `(2) the app cached a content index built before the seed landed, (3) the seed ` +
1264
+ `adapter wrote somewhere the served app does not read, or (4) the dev server was not ` +
1265
+ `launched by the editor, so it resolves committed source instead of ` +
1266
+ `CODEYAM_CONTENT_ROOT/CODEYAM_DATA_ROOT (those roots travel through the spawned ` +
1267
+ `process's environment — there are no .codeyam/tmp/*-root sidecar files to look for, ` +
1268
+ `and their absence is not the fault). If this content layer is simply slow to ` +
1269
+ `rebuild, raise "seedLandingBudgetMs" in .codeyam/editor.json.`,
1270
+ { url: (frame && frame.url && frame.url()) || (config && config.url) },
1271
+ );
1272
+ }
1273
+
743
1274
  // Drive an ordered list of flow steps against ONE already-loaded browser
744
1275
  // session so a scripted multi-step demo (`editor preview-flow`) is captured as
745
1276
  // the real round-trip — click state and client transients persist across
@@ -767,6 +1298,7 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
767
1298
  harnessOrigin,
768
1299
  hydrationTimeoutMs = 10000,
769
1300
  settleMs,
1301
+ probeCounterpart,
770
1302
  } = ctx;
771
1303
  // Honor a caller-supplied `settleMs` for the in-flow stability windows,
772
1304
  // falling back to today's hardcoded defaults (5s for interaction steps, 10s
@@ -799,10 +1331,24 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
799
1331
  // fill/click, or the interaction lands on inert SSR markup (the same
800
1332
  // bug the top-level wait fixes, one route deeper). Bounded and
801
1333
  // stack-gated exactly like the top-level wait.
802
- await waitForHydration(frame, {
803
- url: target,
1334
+ const navHydration = await waitForHydration(frame, {
1335
+ // `target` is only where we aimed; the frame's own URL is where we
1336
+ // landed, so a redirect during the step is followed. Falls back to
1337
+ // `target` when a detached frame cannot answer.
1338
+ url: safeFrameUrl(frame) || target,
804
1339
  timeoutMs: hydrationTimeoutMs,
1340
+ probeCounterpart,
805
1341
  });
1342
+ // The mount that breaks a top-level load breaks it here too, and
1343
+ // before this a route that died one navigation deep got no
1344
+ // attribution at all. Report it so the editor can escalate the
1345
+ // preview off the subpath for the rest of the session.
1346
+ //
1347
+ // The returned issue is deliberately NOT pushed: this wait exists to
1348
+ // gate the next interaction, and adding an issue here would give a
1349
+ // flow that passes today a new way to fail. Computing a verdict and
1350
+ // not raising it reads as a bug otherwise — it is the point.
1351
+ await escalateSubpathHydration(navHydration);
806
1352
  break;
807
1353
  }
808
1354
  case "click":
@@ -1045,6 +1591,12 @@ async function runScenarioCheck(
1045
1591
  });
1046
1592
 
1047
1593
  let loaded = false;
1594
+ // Set when the top-level document turned out to be the proxy's
1595
+ // dev-server-down placeholder. Gates the screenshot write below: the frame is
1596
+ // the wrong pixels by definition, and overwriting a previously-correct PNG
1597
+ // with codeyam's placeholder card is the failure that made this guard
1598
+ // necessary — the stale image outlived the failed run and was believed.
1599
+ let devServerPlaceholder = false;
1048
1600
 
1049
1601
  try {
1050
1602
  // Application/route captures navigate at the top level so the
@@ -1067,7 +1619,26 @@ async function runScenarioCheck(
1067
1619
  const response = loadResult.response;
1068
1620
  loaded = true;
1069
1621
 
1070
- if (response && response.status() >= 400) {
1622
+ // The proxy's dev-server-down placeholder is a 200 carrying codeyam's own
1623
+ // sentinel header, so it is checked BEFORE the status branches below —
1624
+ // status alone cannot tell it from the app. Left unchecked, the capture
1625
+ // photographs codeyam's "Ready to scaffold" card, reports success, and
1626
+ // leaves that frame on disk to be shown later as the app's real state.
1627
+ if (isDevServerPlaceholderResponse(response)) {
1628
+ devServerPlaceholder = true;
1629
+ pushIssue(
1630
+ issues,
1631
+ createIssue(
1632
+ "navigation",
1633
+ "Captured the editor's dev-server placeholder, not the app. The proxy " +
1634
+ `answered with \`${DEV_SERVER_DOWN_HEADER}\`, which means the app's dev ` +
1635
+ "server was not serving this request. The screenshot would be of " +
1636
+ "codeyam's own placeholder card. Start the app's dev server and re-run " +
1637
+ "the capture.",
1638
+ { url, devServerDown: true },
1639
+ ),
1640
+ );
1641
+ } else if (response && response.status() >= 400) {
1071
1642
  pushIssue(
1072
1643
  issues,
1073
1644
  createIssue("navigation", `Navigation returned HTTP ${response.status()}`, {
@@ -1118,12 +1689,37 @@ async function runScenarioCheck(
1118
1689
  config.hydrationTimeoutMs > 0
1119
1690
  ? config.hydrationTimeoutMs
1120
1691
  : 10000;
1692
+ // A scenario's declared `captureTiming` moment, as a FLOOR on when the
1693
+ // frame may be taken. Distinct from `stableTimeoutMs` above, which is a
1694
+ // CAP: `waitForStablePage` returns the moment two consecutive polls match,
1695
+ // so a cap can only ever make a capture happen *sooner*, never later. That
1696
+ // asymmetry is what made a declared capture moment unreachable on a canvas
1697
+ // surface — a scripted terminal replay paints into a <canvas>, so
1698
+ // `document.body.innerHTML` never changes while it plays, the page reads as
1699
+ // stable on the first two polls, and the frame was taken ~1.2s in no matter
1700
+ // what the scenario declared. A scenario whose interesting state arrives
1701
+ // later than that (a `blocked` banner scripted at 1800ms) could never be
1702
+ // captured, and raising `atMs` did nothing because it only raised the cap.
1703
+ const captureAtMs =
1704
+ typeof config.captureAtMs === "number" && config.captureAtMs > 0
1705
+ ? config.captureAtMs
1706
+ : null;
1707
+ const settleStartedAt = Date.now();
1121
1708
  const stableOutcome = await waitForStablePage(
1122
1709
  page,
1123
1710
  frame,
1124
1711
  stableTimeoutMs,
1125
1712
  loadingMarkers,
1126
1713
  );
1714
+ // Held here — after stability, before the content/hydration assertions and
1715
+ // the screenshot — so every downstream check sees the same frame the
1716
+ // scenario asked for rather than an earlier one.
1717
+ if (captureAtMs !== null) {
1718
+ const remainingMs = captureAtMs - (Date.now() - settleStartedAt);
1719
+ if (remainingMs > 0) {
1720
+ await page.waitForTimeout(remainingMs);
1721
+ }
1722
+ }
1127
1723
 
1128
1724
  // DOM-stable does not mean done: a client-side data fetch can still be in
1129
1725
  // flight (the loading skeleton cleared but its replacement content hasn't
@@ -1257,22 +1853,44 @@ async function runScenarioCheck(
1257
1853
  // attaching. Stack-gated and fail-safe: a timed-out wait yields
1258
1854
  // `hydrated: false` (so a truly dead page still classifies `unhydrated`),
1259
1855
  // and a non-interactive stack returns instantly.
1856
+ // The hydration verdict is about the CONTENT document, and under the iframe
1857
+ // harness `page.url()` is the harness — with the preview mount present only
1858
+ // percent-encoded, so every `/__codeyam_preview` test on it is false and the
1859
+ // whole escalation path silently never fires. `frame` is the content frame
1860
+ // in every load shape, so its own URL is the one both the message and the
1861
+ // counterpart probe must see.
1862
+ const contentUrl = safeFrameUrl(frame) || page.url() || url;
1863
+ // Built once and shared with `runFlowSteps` below, so a route that dies one
1864
+ // navigation deep gets the same two-origin experiment this wait runs.
1865
+ const probeCounterpart = buildCounterpartProbe(page);
1260
1866
  const hydration = await waitForHydration(frame, {
1261
- url: page.url() || url,
1867
+ url: contentUrl,
1262
1868
  timeoutMs: hydrationTimeoutMs,
1263
1869
  // `undefined` in production → waitForHydration reads .codeyam/stack.json;
1264
1870
  // a test injects a stack to force the interactive path without a real file.
1265
1871
  stack: config.stack,
1872
+ // Fires ONLY on a proven-dead verdict, so a healthy capture pays nothing.
1873
+ // Answers "is it my page or is it this origin?" before the failure is
1874
+ // reported, instead of leaving every session to establish it by hand.
1875
+ probeCounterpart,
1266
1876
  });
1267
1877
  if (hydration.issue) {
1268
1878
  pushIssue(issues, hydration.issue);
1269
1879
  }
1270
1880
 
1881
+ // The two-sided verdict — dead under `/__codeyam_preview`, alive at the
1882
+ // app's own origin — is proof the MOUNT is at fault, not the page. Hand it
1883
+ // to the editor so the next `/api/config` serves this preview from the
1884
+ // origin that works. Gated on the exact shape (see
1885
+ // `subpathHydrationEscalation`); every other verdict, including
1886
+ // `dead-on-both`, reports nothing and the hydration veto stands.
1887
+ await escalateSubpathHydration(hydration);
1888
+
1271
1889
  // Assert the injected seed actually landed in the capture browser, at rest
1272
1890
  // and BEFORE any interaction can legitimately mutate storage. A non-empty
1273
1891
  // seed that didn't reach localStorage means the screenshot will show
1274
1892
  // default/empty state — fail loudly instead of emitting a misleading frame.
1275
- const seedNotLandedIssue = await verifySeededStorageLanded(frame, config);
1893
+ const seedNotLandedIssue = await verifySeedLanded(frame, config);
1276
1894
  if (seedNotLandedIssue) {
1277
1895
  pushIssue(issues, seedNotLandedIssue);
1278
1896
  }
@@ -1296,6 +1914,7 @@ async function runScenarioCheck(
1296
1914
  warnings: interactionWarnings,
1297
1915
  hydrationTimeoutMs,
1298
1916
  settleMs: config.settleMs,
1917
+ probeCounterpart,
1299
1918
  });
1300
1919
  } else if (config.interaction) {
1301
1920
  // Record fingerprint before interaction
@@ -1370,7 +1989,13 @@ async function runScenarioCheck(
1370
1989
  await centerCaptureWrapper(frame).catch(() => {});
1371
1990
  }
1372
1991
 
1373
- if (outputPath && loaded) {
1992
+ // A placeholder capture writes NOTHING. The false `success: true` was the
1993
+ // visible half of this bug; the expensive half was the PNG of codeyam's
1994
+ // "Ready to scaffold" card left on disk, which outlived the run and was
1995
+ // later sent to a user as evidence of the app's real state. Leaving the
1996
+ // previous (or absent) screenshot untouched is what makes the failure
1997
+ // recoverable rather than destructive.
1998
+ if (outputPath && loaded && !devServerPlaceholder) {
1374
1999
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1375
2000
  await page.screenshot({ path: outputPath, fullPage: false });
1376
2001
  }
@@ -1384,6 +2009,10 @@ async function runScenarioCheck(
1384
2009
  unmockedRoutes,
1385
2010
  mockUsage: { used: mockObserver.used, unused: mockObserver.unused },
1386
2011
  externalRequests: mockObserver.externalRequests,
2012
+ // Optional: supplied only by callers that know where the viewer's own
2013
+ // browser is served. Absent on every path that does not, which keeps the
2014
+ // reported verdict `false` rather than a guess.
2015
+ viewerOrigin: config.viewerOrigin ?? null,
1387
2016
  });
1388
2017
 
1389
2018
  if (config.interaction) {
@@ -1391,6 +2020,27 @@ async function runScenarioCheck(
1391
2020
  result.interactionRetried = interactionRetried;
1392
2021
  }
1393
2022
 
2023
+ // Forward the hydration password census so the in-place auth-gate guard can
2024
+ // fire on a route the project never declared. Attached ONLY when the probe
2025
+ // actually took a census: a backend/static/unknown stack returns a null
2026
+ // census having looked at nothing, and emitting hardcoded `false`s there
2027
+ // would read downstream as positive "no password field", "not a credential
2028
+ // form" claims the probe never made.
2029
+ //
2030
+ // Emitted as ONE object rather than three sibling flags because the three
2031
+ // facts are only meaningful together — the two corroborating shapes exist
2032
+ // precisely to qualify the first, and a consumer that received one without
2033
+ // the others would be back to inferring a gate from a bare password field.
2034
+ // `hasPasswordInput` is the discriminator for whether a census happened at
2035
+ // all; the other two are booleans whenever it is.
2036
+ if (typeof hydration.hasPasswordInput === "boolean") {
2037
+ result.passwordCensus = {
2038
+ hasPasswordInput: hydration.hasPasswordInput,
2039
+ credentialForm: hydration.credentialForm === true,
2040
+ formIsThePage: hydration.formIsThePage === true,
2041
+ };
2042
+ }
2043
+
1394
2044
  // Surface any non-fatal interaction/flow warnings (e.g. an ambiguous
1395
2045
  // substring text match) so the CLI can print them and the agent can switch
1396
2046
  // to a precise selector. Only attached when present — a clean run is
@@ -1431,6 +2081,7 @@ async function runScenarioCheck(
1431
2081
  // to the failure is often exactly what explains the failure.
1432
2082
  mockUsage: { used: mockObserver.used, unused: mockObserver.unused },
1433
2083
  externalRequests: mockObserver.externalRequests,
2084
+ viewerOrigin: config.viewerOrigin ?? null,
1434
2085
  });
1435
2086
  } finally {
1436
2087
  await browser.close();
@@ -1462,15 +2113,27 @@ module.exports = {
1462
2113
  mergeVisibleTextLength,
1463
2114
  runFlowSteps,
1464
2115
  dumpPageState,
1465
- verifySeededStorageLanded,
2116
+ verifySeedLanded,
2117
+ verifyProbeSeedLanded,
2118
+ classifySeedFrameState,
2119
+ readSeedLandingBudgetMs,
2120
+ surfaceCarries,
2121
+ describeProbeShape,
1466
2122
  readStackLoadingMarkers,
1467
2123
  scenarioScriptsLiveSocket,
1468
2124
  applyBrowserState,
1469
2125
  buildSessionTokenCookies,
2126
+ readSessionToken,
2127
+ subpathHydrationReportTarget,
2128
+ reportSubpathHydrationFailure,
2129
+ escalateSubpathHydration,
2130
+ safeFrameUrl,
1470
2131
  captureAuthSkewDetected,
1471
2132
  SESSION_COOKIE,
1472
2133
  isCrossOriginRequest,
1473
2134
  isCaptureFatalRequestFailure,
2135
+ isDevServerPlaceholderResponse,
2136
+ DEV_SERVER_DOWN_HEADER,
1474
2137
  stripMarkerHeaders,
1475
2138
  main,
1476
2139
  launchChromiumWithSelfHeal,