@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.
- package/npm/cloud.js +361 -18
- package/npm/editor-dev.js +24 -2
- package/npm/editor.js +15 -5
- package/npm/fleet-add-failure.js +355 -21
- package/npm/fleet-branch.js +124 -1
- package/npm/fleet-build-data.js +12 -1
- package/npm/fleet-cleanup.js +84 -3
- package/npm/fleet-datastore-health.js +107 -0
- package/npm/fleet-revision.js +108 -0
- package/npm/fleet-run-setup.js +66 -0
- package/npm/fleet-session-activity.js +162 -0
- package/npm/fleet-vm-readiness.js +57 -16
- package/npm/launch-config.js +126 -2
- package/npm/scenario-check.js +697 -34
- package/npm/scenario-interactivity.js +477 -23
- package/npm/scenario-issues.js +61 -0
- package/npm/scenario-playwright.js +67 -14
- package/npm/utils.js +262 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-C7I9wctz.js} +1 -1
- package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
- package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
- package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
- package/ui/dist/assets/index-BRoAenc1.css +1 -0
- package/ui/dist/assets/{index-BPLKgwon.js → index-iMR5siiG.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/preview-subdir-demo/index.html +62 -0
- package/ui/dist/preview-subdir-demo/sibling.html +48 -0
- package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
- 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
|
|
121
|
-
// `
|
|
122
|
-
// exist but no attachment signal —
|
|
123
|
-
// detector for this framework, OR every
|
|
124
|
-
// terminal/canvas widget with no hydration
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
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 {
|
|
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
|
|
314
|
-
//
|
|
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
|
-
{
|
|
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 {
|
|
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 = {
|
|
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
|
-
|
|
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
|
};
|