@codeyam-editor/codeyam-editor 0.1.8 → 0.1.10

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 (41) hide show
  1. package/npm/cloud.js +377 -21
  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-auth-account.js +61 -0
  6. package/npm/fleet-auth.js +69 -1
  7. package/npm/fleet-branch.js +124 -1
  8. package/npm/fleet-build-data.js +12 -1
  9. package/npm/fleet-cleanup.js +84 -3
  10. package/npm/fleet-datastore-health.js +107 -0
  11. package/npm/fleet-public-url.js +81 -12
  12. package/npm/fleet-reconcile.js +46 -11
  13. package/npm/fleet-revision.js +108 -0
  14. package/npm/fleet-run-setup.js +66 -0
  15. package/npm/fleet-session-activity.js +162 -0
  16. package/npm/fleet-vm-readiness.js +57 -16
  17. package/npm/group-bounded-spawn.js +111 -0
  18. package/npm/launch-config.js +216 -23
  19. package/npm/orphan-ssh-reaper.js +81 -0
  20. package/npm/scenario-check.js +697 -34
  21. package/npm/scenario-interactivity.js +477 -23
  22. package/npm/scenario-issues.js +61 -0
  23. package/npm/scenario-playwright.js +174 -20
  24. package/npm/utils.js +262 -22
  25. package/package.json +5 -5
  26. package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-DiN91foX.js} +1 -1
  27. package/ui/dist/assets/EditorShell-BEIdHnHd.css +1 -0
  28. package/ui/dist/assets/EditorShell-CPV5vhjo.js +330 -0
  29. package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +18 -0
  30. package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-CHIWip3n.js} +1 -1
  31. package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +21 -0
  32. package/ui/dist/assets/index--cE0hUJw.css +1 -0
  33. package/ui/dist/assets/{index-BPLKgwon.js → index-sW8wcg7v.js} +9 -9
  34. package/ui/dist/index.html +2 -2
  35. package/ui/dist/preview-subdir-demo/index.html +62 -0
  36. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  37. package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
  38. package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
  39. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
  40. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
  41. package/ui/dist/assets/index-DKpgJLo3.css +0 -1
@@ -28,6 +28,47 @@ const { createIssue } = require("./scenario-issues");
28
28
  // the caller treats as "cannot determine" (conservative pass).
29
29
  const KNOWN_FRAMEWORKS = ["react", "vue"];
30
30
 
31
+ // The same-origin mount the editor's reverse proxy serves the preview under.
32
+ // Two places in this module need it — the counterpart probe's origin map, and
33
+ // the hydration message's "are we under the prefix?" test — and they must never
34
+ // disagree about the spelling, since a message that fails to recognise the
35
+ // mount is exactly the misdiagnosis this constant supports fixing.
36
+ //
37
+ // Agreeing on the spelling is necessary and NOT sufficient: the consumers must
38
+ // also agree on the TEST. Both gates once asked `url.includes(PREVIEW_SUBPATH)`,
39
+ // which is a scan of the whole URL rather than of the route, and that is what
40
+ // `isUnderPreviewSubpath` below exists to replace.
41
+ const PREVIEW_SUBPATH = "/__codeyam_preview";
42
+
43
+ // Is this URL under the preview mount? Judged on the PATHNAME, never as a
44
+ // substring of the whole URL.
45
+ //
46
+ // A substring scan finds the mount wherever it appears — including
47
+ // percent-encoded inside a query parameter — and that silently disabled this
48
+ // entire escalation path for every isolated-component capture. Those load
49
+ // through the iframe harness, whose document URL is
50
+ // `/__codeyam_harness?src=…%2F__codeyam_preview%2F…`: the literal prefix is
51
+ // absent, so both gates below answered "no" and the answer was decided by
52
+ // encoding rather than by the route. Testing the pathname makes the harness
53
+ // document correctly not-under-the-mount and the decoded preview URL correctly
54
+ // under it, in both directions.
55
+ //
56
+ // Fails CLOSED on a non-string, empty, or unparseable (e.g. relative) URL: this
57
+ // runs inside a capture whose finding is already recorded, so it must never
58
+ // throw.
59
+ function isUnderPreviewSubpath(url) {
60
+ if (typeof url !== "string" || !url) return false;
61
+ let pathname;
62
+ try {
63
+ pathname = new URL(url).pathname;
64
+ } catch (_) {
65
+ return false;
66
+ }
67
+ return (
68
+ pathname === PREVIEW_SUBPATH || pathname.startsWith(`${PREVIEW_SUBPATH}/`)
69
+ );
70
+ }
71
+
31
72
  // Meta-frameworks mapped to the underlying runtime whose hydration detector
32
73
  // applies. These do not carry their runtime's name in their stack identity: a
33
74
  // Next.js app is `id: "nextjs-prisma-sqlite"`, `name: "Next.js + Prisma +
@@ -117,11 +158,20 @@ function resolveInteractivityExpectation(stack) {
117
158
  // DOM-node properties left by a framework's hydration but never clicks or
118
159
  // mutates anything, so it is safe to run before the screenshot is taken.
119
160
  //
120
- // Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is
121
- // `true` (runtime demonstrably attached), `false` (framework-owned controls
122
- // exist but no attachment signal — the dead-hydration case), or `null` (no
123
- // detector for this framework, OR every interactive control is delegated to a
124
- // terminal/canvas widget with no hydration marker → cannot judge).
161
+ // Returns `{ controlCount, frameworkAttached, hasPasswordInput, credentialForm,
162
+ // formIsThePage }` where `frameworkAttached` is `true` (runtime demonstrably
163
+ // attached), `false` (framework-owned controls exist but no attachment signal —
164
+ // the dead-hydration case), or `null` (no detector for this framework, OR every
165
+ // interactive control is delegated to a terminal/canvas widget with no hydration
166
+ // marker → cannot judge).
167
+ //
168
+ // The last three are the password census — the un-declared auth-gate signal,
169
+ // read only by the auth-gate guard and deliberately never by the hydration
170
+ // verdict. `hasPasswordInput` is whether the census contains a `type="password"`
171
+ // input; `credentialForm` and `formIsThePage` are the two corroborating shapes
172
+ // that separate a password GATE from a page that merely owns a password FIELD.
173
+ // They travel together: a caller that reads one without the others is back to
174
+ // the bare-boolean inference the corroboration exists to narrow.
125
175
  async function collectHydrationState(frame, { framework } = {}) {
126
176
  return frame.evaluate((fw) => {
127
177
  const SELECTOR =
@@ -201,10 +251,145 @@ async function collectHydrationState(frame, { framework } = {}) {
201
251
  frameworkAttached = false;
202
252
  }
203
253
 
204
- return { controlCount: controls.length, frameworkAttached };
254
+ // The password census: a separate concern from hydration detection above,
255
+ // sharing only the `controls` array. Declared INSIDE this closure and not at
256
+ // module scope on purpose — `frame.evaluate` serializes its callback to the
257
+ // browser, so the body cannot reference anything outside itself. An inner
258
+ // function is the whole decomposition available here; hoisting it would make
259
+ // the collector throw a ReferenceError in the page.
260
+ //
261
+ // Derived from the already-collected `controls` rather than a second
262
+ // `querySelectorAll`, so the gate reasons about exactly the set the census
263
+ // counted — a separate query is free to disagree with it. `SELECTOR` already
264
+ // admits password inputs via `input:not([type="hidden"])`.
265
+ function collectPasswordCensus(controls) {
266
+ // Attributes are matched case-insensitively because `type="PASSWORD"` is
267
+ // valid HTML.
268
+ const attr = (el, name) =>
269
+ String((el && el.getAttribute(name)) || "").toLowerCase();
270
+ // `autocomplete` is a space-separated TOKEN LIST, not a single value:
271
+ // `autocomplete="section-login username"` is valid and must match.
272
+ const hasToken = (el, name, token) =>
273
+ attr(el, name).split(/\s+/).includes(token);
274
+ const isPassword = (el) =>
275
+ !!el && el.tagName === "INPUT" && attr(el, "type") === "password";
276
+ const passwordInputs = controls.filter(isPassword);
277
+ const passwordInput = passwordInputs[0] || null;
278
+ if (!passwordInput) {
279
+ return {
280
+ hasPasswordInput: false,
281
+ credentialForm: false,
282
+ formIsThePage: false,
283
+ };
284
+ }
285
+
286
+ // A password FIELD is not a password GATE: a change-password card, a
287
+ // set-password pair and an API-key form all own one legitimately, and
288
+ // reporting each as a gate is what pushed a downstream project into
289
+ // declaring `expectedAuthGate: true` on scenarios that depict no gate.
290
+ //
291
+ // `credentialForm` reads the page's OWN credential semantics rather than a
292
+ // threshold: the web platform already has a vocabulary for "this is a
293
+ // sign-in form". A change-password card declares `new-password` and
294
+ // carries no identity field; an API-key form is a lone password input. No
295
+ // magic numbers and nothing stack-specific — it is HTML semantics, so it
296
+ // holds on Next.js, Svelte, Astro or plain HTML alike.
297
+ //
298
+ // `formIsThePage` is the weaker disjunct that keeps the original
299
+ // motivating case working: a bare password-protected admin route may be a
300
+ // single password input with no username and no `autocomplete`, which
301
+ // credential semantics alone would stop detecting. "The form IS the page"
302
+ // — the census found nothing beyond the form's own controls — is that
303
+ // shape. It is deliberately imprecise, and affordable only because the
304
+ // inferred signal is now an advisory rather than a capture failure.
305
+ const nearestForm = (el) =>
306
+ el && typeof el.closest === "function" ? el.closest("form") : null;
307
+ const passwordForm = nearestForm(passwordInput);
308
+ // Two controls share a form when their nearest `<form>` ancestor is the
309
+ // same node — including when both have none, which is the ordinary
310
+ // div-based login markup on component frameworks.
311
+ const sharesPasswordForm = (el) => nearestForm(el) === passwordForm;
312
+ const isIdentityField = (el) =>
313
+ !!el &&
314
+ el.tagName === "INPUT" &&
315
+ (attr(el, "type") === "email" ||
316
+ hasToken(el, "autocomplete", "username") ||
317
+ hasToken(el, "autocomplete", "email"));
318
+
319
+ return {
320
+ hasPasswordInput: true,
321
+ credentialForm:
322
+ passwordInputs.some((el) =>
323
+ hasToken(el, "autocomplete", "current-password"),
324
+ ) ||
325
+ controls.some((el) => isIdentityField(el) && sharesPasswordForm(el)),
326
+ // With no `<form>` ancestor there is no form to be the page, so the
327
+ // shape only qualifies when the password input is the ONLY control —
328
+ // otherwise every formless page carrying a password field would match.
329
+ formIsThePage: passwordForm
330
+ ? controls.every(sharesPasswordForm)
331
+ : controls.every((el) => el === passwordInput),
332
+ };
333
+ }
334
+
335
+ return {
336
+ controlCount: controls.length,
337
+ frameworkAttached,
338
+ ...collectPasswordCensus(controls),
339
+ };
205
340
  }, framework || null);
206
341
  }
207
342
 
343
+ // The census as "no claim was made" — every fact `null`, never `false`. Returned
344
+ // whenever no census was taken at all: a non-interactive stack, or a probe that
345
+ // threw. One definition rather than a literal at each of the four seams, because
346
+ // the three facts must be `null` TOGETHER; a seam that nulls one and defaults
347
+ // another to `false` hands the gate a corroboration it never observed.
348
+ const NO_PASSWORD_CENSUS = {
349
+ hasPasswordInput: null,
350
+ credentialForm: null,
351
+ formIsThePage: null,
352
+ };
353
+
354
+ // Project the three census facts out of a `collectHydrationState` result, so
355
+ // both probe seams pass through exactly the same set. `state` may be the
356
+ // null-census placeholder, in which case this is a no-op projection.
357
+ function passwordCensusOf(state) {
358
+ return {
359
+ hasPasswordInput: state.hasPasswordInput,
360
+ credentialForm: state.credentialForm,
361
+ formIsThePage: state.formIsThePage,
362
+ };
363
+ }
364
+
365
+ // Map a capture URL to the SAME route on the OTHER preview origin.
366
+ //
367
+ // A preview route reaches the browser one of two ways: through the editor's
368
+ // same-origin subpath proxy (`<proxyOrigin><previewPrefix>/foo`) or directly at
369
+ // the app's own origin (`<appOrigin>/foo`). Given either, return the other, so
370
+ // the hydration gate can re-probe the IDENTICAL route across the origin
371
+ // boundary — the control experiment that tells "this ORIGIN cannot hydrate"
372
+ // apart from "this PAGE is dead". Two sessions built this probe by hand before
373
+ // concluding their own change was innocent; building it once is cheaper than
374
+ // every session rebuilding it.
375
+ //
376
+ // Returns `null` when the URL belongs to neither origin (an external scenario
377
+ // URL has no counterpart) or when the origin inputs are incomplete — a probe we
378
+ // cannot aim is simply not run, never a guess.
379
+ function counterpartOriginUrl(url, { appOrigin, proxyOrigin, previewPrefix }) {
380
+ if (!url || !appOrigin || !proxyOrigin || !previewPrefix) return null;
381
+ const proxyRoot = `${proxyOrigin}${previewPrefix}`;
382
+ const rejoin = (base, rest) =>
383
+ `${base}${rest.startsWith("/") ? rest : `/${rest}`}`;
384
+ if (url.startsWith(proxyRoot)) {
385
+ return rejoin(appOrigin, url.slice(proxyRoot.length) || "/");
386
+ }
387
+ if (url.startsWith(appOrigin)) {
388
+ return rejoin(proxyRoot, url.slice(appOrigin.length) || "/");
389
+ }
390
+ return null;
391
+ }
392
+
208
393
  // Turn a collected state into a `hydration` issue, or `null` to pass. Pure, so
209
394
  // every branch is unit-tested without a browser.
210
395
  //
@@ -213,40 +398,239 @@ async function collectHydrationState(frame, { framework } = {}) {
213
398
  // framework attached; OR we couldn't determine attachment (`null` — never
214
399
  // false-positive). Flag only the proven-dead case: controls exist AND the
215
400
  // framework's runtime is demonstrably not attached.
401
+ //
402
+ // `crossOrigin` carries the control-probe verdict and changes only the
403
+ // ATTRIBUTION, never the detection above — the detector is deliberately
404
+ // conservative and stays exactly as strict:
405
+ // "counterpart-hydrates" — the same route hydrates on the other origin, so
406
+ // the finding is about THIS origin and the app is exonerated.
407
+ // "dead-on-both" — it fails on both, so the page really is dead and
408
+ // the `diagnose-preview` proxy steer is a known dead end; don't offer it.
409
+ // null/undefined — no probe ran; the original message stands.
216
410
  function interpretHydration({
217
411
  expectInteractive,
218
412
  controlCount,
219
413
  frameworkAttached,
220
414
  framework,
221
415
  url,
416
+ crossOrigin,
417
+ counterpartUrl,
222
418
  }) {
223
419
  if (!expectInteractive) return null;
224
420
  if (!(controlCount > 0)) return null;
225
421
  if (frameworkAttached !== false) return null;
226
- const fw = framework || "the client framework";
227
- const plural = controlCount === 1 ? "" : "s";
228
422
  return createIssue(
229
423
  "hydration",
424
+ hydrationMessage({
425
+ controlCount,
426
+ framework,
427
+ crossOrigin,
428
+ counterpartUrl,
429
+ url,
430
+ }),
431
+ {
432
+ url: url ?? null,
433
+ // The attribution travels as DATA alongside the prose. A consumer that
434
+ // must act on `counterpart-hydrates` — the editor's preview-origin
435
+ // escalation — reads this field rather than matching the message text.
436
+ crossOrigin: crossOrigin ?? null,
437
+ counterpartUrl: counterpartUrl ?? null,
438
+ },
439
+ );
440
+ }
441
+
442
+ // Compose the prose for a proven-dead hydration verdict. Split out of
443
+ // `interpretHydration` so the two concerns are separable: that function decides
444
+ // WHETHER this is a failure (three deliberately conservative guards that must
445
+ // not be weakened), this one decides HOW it is described. Pure and
446
+ // string-returning, so every attribution branch is asserted without
447
+ // constructing a verdict or a browser.
448
+ function hydrationMessage({
449
+ controlCount,
450
+ framework,
451
+ crossOrigin,
452
+ counterpartUrl,
453
+ url,
454
+ }) {
455
+ const fw = framework || "the client framework";
456
+ const plural = controlCount === 1 ? "" : "s";
457
+ const rendered =
230
458
  `Page rendered ${controlCount} interactive control${plural} but ${fw} never attached ` +
231
- `event handlers — the page is not interactive (hydration did not run). Client JS may ` +
232
- `not be executing; check the preview proxy and the browser console. Run ` +
233
- "`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " +
234
- `HTML-injection blocker.`,
235
- { url: url ?? null },
459
+ `event handlers`;
460
+ const counterpart = counterpartUrl || "the other preview origin";
461
+
462
+ if (crossOrigin === "counterpart-hydrates") {
463
+ // This used to end with "Re-run the capture against the origin that works",
464
+ // which described a capability the editor did not have: no flag or command
465
+ // re-aimed a capture, so the only way to act on it was to hand-edit
466
+ // `previewOrigin` and restart the server — which blanks the user's pane and
467
+ // drops their session. Now that the capture origin resolves independently
468
+ // of the viewer's, say what actually happens and name the key that
469
+ // overrides it, rather than prescribing a manual step. The
470
+ // `diagnose-preview` steer is real and stays.
471
+ return (
472
+ `${rendered} on THIS preview origin — but the same route DOES hydrate at ` +
473
+ `${counterpart}. The page is fine and your change did not break it; this ` +
474
+ `ORIGIN is not serving working client JS. Captures resolve their own ` +
475
+ `origin and aim at the app directly when the editor runs alongside it, so ` +
476
+ `this usually needs no action; set \`captureOrigin\` in .codeyam/editor.json ` +
477
+ `to aim them somewhere else. Run ` +
478
+ "`codeyam-editor editor diagnose-preview --path <route>` to pinpoint what " +
479
+ `the proxy hop is breaking.`
480
+ );
481
+ }
482
+
483
+ if (crossOrigin === "dead-on-both") {
484
+ return (
485
+ `${rendered} — the page is not interactive (hydration did not run). The ` +
486
+ `identical failure reproduces at ${counterpart}, so this is NOT the preview ` +
487
+ `proxy and \`diagnose-preview\` will not explain it: client JS is genuinely ` +
488
+ `not executing. Check the browser console for a module or runtime error.`
489
+ );
490
+ }
491
+
492
+ // No counterpart probe ran, so nothing here is attributed — but the URL still
493
+ // carries one strong, checkable suspect. Under `/__codeyam_preview` the
494
+ // browser sits at a PREFIXED pathname while the app's server rendered for the
495
+ // bare one, and a client router that reconciles `window.location.pathname`
496
+ // against the route embedded in its SSR payload refuses to hydrate on exactly
497
+ // that mismatch. Naming it is the difference between a lead and a dead end:
498
+ // the generic "check the browser console" steer below sent a real session
499
+ // hunting a module error on a page whose client JS was executing perfectly,
500
+ // and which hydrated on the first try at the app's own origin.
501
+ if (isUnderPreviewSubpath(url)) {
502
+ return (
503
+ `${rendered} — the page is not interactive (hydration did not run), and it was ` +
504
+ `loaded under the \`${PREVIEW_SUBPATH}\` preview mount. That prefix is the prime ` +
505
+ `suspect: the browser is at a prefixed pathname while the app rendered for the ` +
506
+ `bare one, and a client-routed app that reconciles the two refuses to hydrate — ` +
507
+ `client JS is running fine. Load the same route directly on the app's own origin ` +
508
+ `to confirm, then run ` +
509
+ "`codeyam-editor editor diagnose-preview --path <route>`, which probes exactly " +
510
+ `this. Do NOT start with the browser console; the console is clean in this case.`
511
+ );
512
+ }
513
+
514
+ return (
515
+ `${rendered} — the page is not interactive (hydration did not run). Client JS may ` +
516
+ `not be executing; check the preview proxy and the browser console. Run ` +
517
+ "`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " +
518
+ `HTML-injection blocker.`
236
519
  );
237
520
  }
238
521
 
522
+ // Decide whether a hydration verdict proves the PREVIEW SUBPATH is what broke
523
+ // the page — the one shape that justifies escalating the preview onto the app's
524
+ // own origin. Returns the escalation payload, or `null` for every other verdict.
525
+ //
526
+ // Both halves are required and neither is sufficient:
527
+ // - the failing load must have been UNDER `/__codeyam_preview`, and
528
+ // - the counterpart (the app's own origin) must have HYDRATED.
529
+ //
530
+ // The direction matters and is easy to get backwards. `counterpartOriginUrl`
531
+ // maps both ways, so `counterpart-hydrates` also fires when a capture aimed at
532
+ // the app origin died and the *proxied* copy lived. Escalating on that would
533
+ // move the preview onto the origin that just failed. `dead-on-both` is excluded
534
+ // for the reason spelled out in `hydrationMessage`: the page is genuinely
535
+ // broken, and escalating would swap a false veto for a false pass.
536
+ //
537
+ // Pure, so every branch is asserted without a browser or a server.
538
+ function subpathHydrationEscalation({ crossOrigin, url, counterpartUrl }) {
539
+ if (crossOrigin !== "counterpart-hydrates") return null;
540
+ if (!isUnderPreviewSubpath(url)) return null;
541
+ return { failedUrl: url, counterpartUrl: counterpartUrl ?? null };
542
+ }
543
+
544
+ // Build the hydration gate's cross-origin control probe, or `null` when this
545
+ // project has no second origin to probe.
546
+ //
547
+ // The gate's finding — "controls rendered but the framework never attached" —
548
+ // is real but says nothing about WHOSE fault it is. Loading the identical route
549
+ // on the other preview origin answers exactly that, and it is the experiment two
550
+ // sessions each ran by hand (against an untouched component with a committed
551
+ // passing screenshot) purely to establish that their own change was innocent.
552
+ //
553
+ // Lives here rather than in the capture orchestrator because its only two
554
+ // dependencies — `counterpartOriginUrl` and `collectHydrationState` — are both
555
+ // in this module, so co-locating them needs no new cross-module edge.
556
+ //
557
+ // Runs in a throwaway page in the SAME browser context, so it inherits the
558
+ // capture's cookies and storage and probes like-for-like. Fail-soft everywhere:
559
+ // any missing port, unmappable URL, or navigation error yields `null`, which
560
+ // leaves the reported message exactly as it was. `readServerState` is
561
+ // injectable so the port/origin wiring is unit-testable without disk.
562
+ function buildCounterpartProbe(page, { readServerState = null } = {}) {
563
+ let state;
564
+ try {
565
+ state = readServerState
566
+ ? readServerState()
567
+ : JSON.parse(
568
+ fs.readFileSync(
569
+ path.join(process.cwd(), ".codeyam", "server-state.json"),
570
+ "utf8",
571
+ ),
572
+ );
573
+ } catch (_) {
574
+ return null;
575
+ }
576
+ const appPort = state && state.appPort;
577
+ const controlPort = state && state.controlPort;
578
+ if (!(appPort > 0) || !(controlPort > 0)) return null;
579
+
580
+ // These spellings must match what `resolve_dev_mode_preview_url` emits:
581
+ // `browser_facing_origin` uses `localhost`, the proxy hop uses the
582
+ // `PROXY_CAPTURE_LOOPBACK` dotted form.
583
+ const origins = {
584
+ appOrigin: `http://localhost:${appPort}`,
585
+ proxyOrigin: `http://127.0.0.1:${controlPort}`,
586
+ previewPrefix: PREVIEW_SUBPATH,
587
+ };
588
+
589
+ return async (currentUrl, { framework } = {}) => {
590
+ const target = counterpartOriginUrl(currentUrl, origins);
591
+ if (!target) return null;
592
+ let probePage;
593
+ try {
594
+ probePage = await page.context().newPage();
595
+ await probePage.goto(target, {
596
+ waitUntil: "domcontentloaded",
597
+ timeout: 15000,
598
+ });
599
+ // Give the counterpart the same chance to attach that the primary got,
600
+ // rather than reading it the instant the document arrives.
601
+ await probePage.waitForTimeout(2000);
602
+ // Same framework detector as the primary read, so the two verdicts are
603
+ // like-for-like and a difference means the ORIGIN, not the detector.
604
+ const probed = await collectHydrationState(probePage.mainFrame(), {
605
+ framework,
606
+ });
607
+ return { url: target, hydrated: probed.frameworkAttached };
608
+ } catch (_) {
609
+ return null;
610
+ } finally {
611
+ if (probePage) await probePage.close().catch(() => {});
612
+ }
613
+ };
614
+ }
615
+
239
616
  // Orchestrator called from the capture flow: resolve the expectation (from the
240
617
  // project's stack.json unless the caller injects a `stack`), short-circuit when
241
618
  // no client runtime is expected, collect the in-page state, and interpret it.
242
619
  // Never throws — a probe failure must not break an otherwise-good capture.
243
620
  //
244
- // Returns `{ hydrated, issue }`:
621
+ // Returns `{ hydrated, issue, hasPasswordInput, credentialForm, formIsThePage }`:
245
622
  // hydrated — `true` (runtime demonstrably attached), `false` (PROVEN dead:
246
623
  // framework-owned controls rendered but nothing attached), or `null`
247
624
  // ("cannot determine" — no client runtime expected, no control to probe, no
248
625
  // detector for the framework, or the probe threw).
249
626
  // issue — the `hydration` issue to surface, or `null` to pass.
627
+ // hasPasswordInput / credentialForm / formIsThePage — the password census, or
628
+ // `null` for ALL THREE when no census was taken at all. `null` is not
629
+ // `false`: it is the absence of a claim, and only a `true`
630
+ // `hasPasswordInput` corroborated by one of the other two may fire the
631
+ // inferred auth-gate signal downstream. The three are `null` together or
632
+ // populated together — a partially-threaded census is the bare-boolean
633
+ // inference all over again.
250
634
  //
251
635
  // `hydrated` is deliberately three-valued rather than a bare boolean: the
252
636
  // capture flow branches a page to `interactionEffect: "unhydrated"` ONLY on a
@@ -256,12 +640,13 @@ async function probeHydrationState(frame, { url, stack } = {}) {
256
640
  const descriptor = stack !== undefined ? stack : readStackJson();
257
641
  const { expectInteractive, framework } =
258
642
  resolveInteractivityExpectation(descriptor);
259
- if (!expectInteractive) return { hydrated: null, issue: null };
643
+ if (!expectInteractive)
644
+ return { hydrated: null, issue: null, ...NO_PASSWORD_CENSUS };
260
645
  let state;
261
646
  try {
262
647
  state = await collectHydrationState(frame, { framework });
263
648
  } catch (_) {
264
- return { hydrated: null, issue: null };
649
+ return { hydrated: null, issue: null, ...NO_PASSWORD_CENSUS };
265
650
  }
266
651
  const issue = interpretHydration({
267
652
  expectInteractive: true,
@@ -273,7 +658,11 @@ async function probeHydrationState(frame, { url, stack } = {}) {
273
658
  // `frameworkAttached` is already the three-valued signal `hydrated` needs —
274
659
  // pass it through rather than re-deriving it from the presence of an issue,
275
660
  // which would conflate "no issue" (a pass) with "hydrated" (a positive).
276
- return { hydrated: state.frameworkAttached, issue };
661
+ return {
662
+ hydrated: state.frameworkAttached,
663
+ issue,
664
+ ...passwordCensusOf(state),
665
+ };
277
666
  }
278
667
 
279
668
  // Sleep for `ms` — a promisified `setTimeout`, so the poll loop yields the
@@ -310,11 +699,21 @@ function sleep(ms) {
310
699
  // present for the SSR case this guards; a pure-CSR shell that renders zero
311
700
  // controls reads `null` and passes instantly, unchanged from today.
312
701
  //
313
- // Returns `{ hydrated, issue, timedOut, waitedMs }` where `hydrated` is the
314
- // same three-valued signal `probeHydrationState` returns.
702
+ // Returns `{ hydrated, issue, timedOut, waitedMs, hasPasswordInput,
703
+ // credentialForm, formIsThePage }` where `hydrated` is the same three-valued
704
+ // signal `probeHydrationState` returns and the last three are the same password
705
+ // census it passes through — `null` for all three whenever no census was taken
706
+ // (a non-interactive stack, or a probe that threw on its very first poll).
315
707
  async function waitForHydration(
316
708
  frame,
317
- { url, stack, framework, timeoutMs = 10000, pollIntervalMs = 150 } = {},
709
+ {
710
+ url,
711
+ stack,
712
+ framework,
713
+ timeoutMs = 10000,
714
+ pollIntervalMs = 150,
715
+ probeCounterpart,
716
+ } = {},
318
717
  ) {
319
718
  let resolvedFramework;
320
719
  let expectInteractive;
@@ -328,11 +727,21 @@ async function waitForHydration(
328
727
  resolvedFramework = resolution.framework;
329
728
  }
330
729
  if (!expectInteractive) {
331
- return { hydrated: null, issue: null, timedOut: false, waitedMs: 0 };
730
+ return {
731
+ hydrated: null,
732
+ issue: null,
733
+ timedOut: false,
734
+ waitedMs: 0,
735
+ ...NO_PASSWORD_CENSUS,
736
+ };
332
737
  }
333
738
 
334
739
  const start = Date.now();
335
- let state = { controlCount: 0, frameworkAttached: null };
740
+ let state = {
741
+ controlCount: 0,
742
+ frameworkAttached: null,
743
+ ...NO_PASSWORD_CENSUS,
744
+ };
336
745
  let timedOut = false;
337
746
  for (;;) {
338
747
  try {
@@ -342,7 +751,13 @@ async function waitForHydration(
342
751
  } catch (_) {
343
752
  // Detached frame mid-navigation (or any probe throw): treat as "cannot
344
753
  // determine" and pass — a probe failure must never fail a capture.
345
- state = { controlCount: 0, frameworkAttached: null };
754
+ // A null census for the same reason: a throw means no census was taken,
755
+ // which is not the claim that no password field is present.
756
+ state = {
757
+ controlCount: 0,
758
+ frameworkAttached: null,
759
+ ...NO_PASSWORD_CENSUS,
760
+ };
346
761
  break;
347
762
  }
348
763
  // `true` (attached) and `null` (cannot judge) are both terminal — the
@@ -357,28 +772,67 @@ async function waitForHydration(
357
772
  await sleep(pollIntervalMs);
358
773
  }
359
774
 
775
+ // Control experiment, run ONLY on a proven-dead verdict: re-probe the same
776
+ // route on the other preview origin before reporting. This is the cheapest
777
+ // moment to answer "is it my page or is it this origin?", and it costs a page
778
+ // load only on a run that was already going to fail. Never throws and never
779
+ // changes the verdict — a probe that cannot run leaves the message exactly as
780
+ // it was.
781
+ let crossOrigin;
782
+ let counterpartUrl;
783
+ if (
784
+ state.frameworkAttached === false &&
785
+ typeof probeCounterpart === "function"
786
+ ) {
787
+ try {
788
+ const result = await probeCounterpart(url, {
789
+ framework: resolvedFramework,
790
+ });
791
+ if (result && result.url) {
792
+ counterpartUrl = result.url;
793
+ if (result.hydrated === true) crossOrigin = "counterpart-hydrates";
794
+ if (result.hydrated === false) crossOrigin = "dead-on-both";
795
+ }
796
+ } catch (_) {
797
+ // A failed control probe is not evidence either way — fall through to the
798
+ // original, un-attributed message.
799
+ }
800
+ }
801
+
360
802
  const issue = interpretHydration({
361
803
  expectInteractive: true,
362
804
  controlCount: state.controlCount,
363
805
  frameworkAttached: state.frameworkAttached,
364
806
  framework: resolvedFramework,
365
807
  url,
808
+ crossOrigin,
809
+ counterpartUrl,
366
810
  });
367
811
  return {
368
812
  hydrated: state.frameworkAttached,
369
813
  issue,
370
814
  timedOut,
371
815
  waitedMs: Date.now() - start,
816
+ ...passwordCensusOf(state),
817
+ crossOrigin: crossOrigin ?? null,
818
+ counterpartUrl: counterpartUrl ?? null,
372
819
  };
373
820
  }
374
821
 
375
822
  module.exports = {
376
823
  KNOWN_FRAMEWORKS,
824
+ PREVIEW_SUBPATH,
825
+ isUnderPreviewSubpath,
826
+ subpathHydrationEscalation,
377
827
  readStackJson,
378
828
  inferFramework,
379
829
  resolveInteractivityExpectation,
830
+ buildCounterpartProbe,
380
831
  collectHydrationState,
832
+ counterpartOriginUrl,
833
+ hydrationMessage,
381
834
  interpretHydration,
835
+ passwordCensusOf,
382
836
  probeHydrationState,
383
837
  waitForHydration,
384
838
  };