@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.
- package/npm/cloud.js +377 -21
- package/npm/editor-dev.js +24 -2
- package/npm/editor.js +15 -5
- package/npm/fleet-add-failure.js +355 -21
- package/npm/fleet-auth-account.js +61 -0
- package/npm/fleet-auth.js +69 -1
- 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-public-url.js +81 -12
- package/npm/fleet-reconcile.js +46 -11
- 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/group-bounded-spawn.js +111 -0
- package/npm/launch-config.js +216 -23
- package/npm/orphan-ssh-reaper.js +81 -0
- 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 +174 -20
- package/npm/utils.js +262 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-DiN91foX.js} +1 -1
- package/ui/dist/assets/EditorShell-BEIdHnHd.css +1 -0
- package/ui/dist/assets/EditorShell-CPV5vhjo.js +330 -0
- package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +18 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-CHIWip3n.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +21 -0
- package/ui/dist/assets/index--cE0hUJw.css +1 -0
- package/ui/dist/assets/{index-BPLKgwon.js → index-sW8wcg7v.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
package/npm/scenario-check.js
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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 <
|
|
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
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
//
|
|
687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|