@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/npm/cloud.js +779 -116
  2. package/npm/container.js +11 -0
  3. package/npm/disk-pressure.js +89 -0
  4. package/npm/editor-dev.js +144 -52
  5. package/npm/editor.js +47 -17
  6. package/npm/fleet-branch.js +49 -0
  7. package/npm/fleet-broker-death.js +44 -0
  8. package/npm/fleet-broker-debounce.js +63 -0
  9. package/npm/fleet-broker-status.js +45 -0
  10. package/npm/fleet-build-data.js +81 -0
  11. package/npm/fleet-cleanup.js +216 -16
  12. package/npm/fleet-effect-status.js +179 -0
  13. package/npm/fleet-grouping.js +56 -0
  14. package/npm/fleet-job-badge.js +83 -0
  15. package/npm/fleet-memory.js +35 -5
  16. package/npm/fleet-plans.js +96 -0
  17. package/npm/fleet-reconcile.js +58 -1
  18. package/npm/fleet-reset.js +94 -16
  19. package/npm/launch-config.js +197 -14
  20. package/npm/preflight-guidance.js +9 -0
  21. package/npm/scenario-check.js +106 -1
  22. package/npm/scenario-handlers.js +16 -0
  23. package/npm/scenario-interactivity.js +53 -7
  24. package/npm/scenario-playwright.js +184 -5
  25. package/npm/utils.js +238 -53
  26. package/package.json +5 -5
  27. package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
  28. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
  29. package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
  30. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
  31. package/ui/dist/assets/index-BGqairFV.css +1 -0
  32. package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
  33. package/ui/dist/index.html +2 -2
  34. package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
  35. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
  37. package/ui/dist/assets/index-DVr5rY2d.css +0 -1
@@ -8,7 +8,14 @@
8
8
  // runs on a VM; the history is the cache that makes the last choice the default
9
9
  // and past launches one click to re-run.
10
10
 
11
- const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
11
+ const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
12
+
13
+ /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
14
+ * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
15
+ * source of truth with the agent→API-key-env-var mapping (cloud.js) and the
16
+ * gcp-bootstrap CLI installer, so adding an agent is still a one-line change in
17
+ * AGENT_ENV_VARS rather than a list to keep in sync here. */
18
+ const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS));
12
19
 
13
20
  const EMPTY_CONFIG = Object.freeze({
14
21
  clientRepo: "",
@@ -18,6 +25,9 @@ const EMPTY_CONFIG = Object.freeze({
18
25
  newProjectName: "",
19
26
  editorVariant: "dev",
20
27
  editorBranch: "",
28
+ // Build agent provider the VM's editor terminal runs (claude | codex | gemini).
29
+ // Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
30
+ agent: "claude",
21
31
  });
22
32
 
23
33
  /** Coerce a raw form/query object into the canonical launch-config shape: trim
@@ -26,12 +36,17 @@ const EMPTY_CONFIG = Object.freeze({
26
36
  function normalizeLaunchConfig(input = {}) {
27
37
  const str = (v) => (typeof v === "string" ? v.trim() : "");
28
38
  const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
39
+ // Coerce an unknown/absent agent to the default rather than rejecting — the
40
+ // form's agent selector only ever submits a known value, so an out-of-set
41
+ // agent is a stale query param, not an error.
42
+ const agent = AGENTS.includes(input.agent) ? input.agent : "claude";
29
43
  return {
30
44
  clientRepo: str(input.clientRepo),
31
45
  clientBranch: str(input.clientBranch),
32
46
  newProjectName: str(input.newProjectName),
33
47
  editorVariant,
34
48
  editorBranch: str(input.editorBranch),
49
+ agent,
35
50
  };
36
51
  }
37
52
 
@@ -58,22 +73,78 @@ function validateLaunchConfig(cfg) {
58
73
  return { ok: true };
59
74
  }
60
75
 
61
- /** Phase-1 gate mirror of cloud.js::assertVariantProvisionable — only `dev`
62
- * provisions today; staging/production are Phase 2. */
76
+ /** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants
77
+ * can be provisioned onto a cloud VM. All three variants now provision: `dev`
78
+ * builds the editor image from source, while `staging`/`production` run the
79
+ * published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off
80
+ * the variant). An unknown variant is excluded so a typo never reaches a
81
+ * launch. Pure; no I/O. */
63
82
  function isVariantProvisionable(variant) {
64
- return variant === "dev";
83
+ return EDITOR_VARIANTS.includes(variant);
84
+ }
85
+
86
+ /** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild
87
+ * binary" / "Update & Reset Everything" actions, and reconfigure's trailing
88
+ * binary refresh). Historically distinct from `isVariantProvisionable` because a
89
+ * packaged variant's rebuild is an npm-reinstall (rebuildStrategy ===
90
+ * "npm-reinstall") and that host-script path was not wired — so this stayed
91
+ * dev-only and packaged rebuild buttons failed loud. With the npm-reinstall
92
+ * branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm
93
+ * image at the variant's dist-tag instead of source-building Dockerfile), every
94
+ * provisionable variant is also rebuildable, so this now mirrors
95
+ * `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */
96
+ function isVariantRebuildable(variant) {
97
+ return isVariantProvisionable(variant);
98
+ }
99
+
100
+ /** Map an editor variant to the npm dist-tag its package was published under.
101
+ * CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com`
102
+ * → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has
103
+ * no published package (it builds the image from source), so return null and let
104
+ * callers reject it. This is the single source of truth for the variant→dist-tag
105
+ * contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm`
106
+ * mode, and the future reconfigure path all derive from it so they can't drift.
107
+ * Pure; no I/O. */
108
+ function variantNpmDistTag(variant) {
109
+ if (variant === "production") return "latest";
110
+ if (variant === "staging") return "staging";
111
+ return null; // dev / unknown — no packaged dist-tag
112
+ }
113
+
114
+ /** Map a packaged editor variant to the GHCR image-tag suffix the packaged
115
+ * image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside
116
+ * `variantNpmDistTag` so the registry naming convention and the dist-tag mapping
117
+ * stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts
118
+ * the literal string this returns. `dev` has no packaged image (built from
119
+ * source), so return null. Pure; no I/O. */
120
+ function variantImageTag(variant) {
121
+ if (variant === "production") return "production-latest";
122
+ if (variant === "staging") return "staging-latest";
123
+ return null; // dev / unknown — no packaged image
65
124
  }
66
125
 
67
126
  /** Build the `cloud:up` flag array for a launch config + instance name. The
68
127
  * editor source is the canonical codeyam-editor repo (dev variant builds the
69
128
  * image from editorBranch); the client repo/branch is what lands in
70
- * /workspace. Decoupled exactly as cloud.js consumes them. */
71
- function cloudUpArgs(cfg, instanceName, agent = "claude") {
129
+ * /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
130
+ * guarantees a known value, defaulting to claude), so a single config object
131
+ * carries every launch choice. Decoupled exactly as cloud.js consumes them. */
132
+ function cloudUpArgs(cfg, instanceName, project) {
72
133
  const args = ["--instance-name", instanceName];
134
+ // Pass the authoritative GCP project (the dashboard's resolved PROJECT)
135
+ // explicitly so cloud:up never relies on the spawning shell's ambient
136
+ // `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES
137
+ // a persisted .codeyam/cloud.json `project` (cloud.js merges
138
+ // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
139
+ // gcloud fallback — the sticky-wrong-project failure mode where a stale
140
+ // "gcp-proj" in cloud.json wedges every re-provision with a firewall-create
141
+ // PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape
142
+ // (cloud.js then falls back to the gcloud default, as before).
143
+ if (project) args.push("--project", project);
73
144
  // Greenfield (new folder) vs clone an existing repo.
74
145
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
75
146
  else args.push("--repo", cfg.clientRepo);
76
- args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
147
+ args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
77
148
  if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
78
149
  if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
79
150
  // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
@@ -193,6 +264,40 @@ function distinctConfigs(history, limit = 12) {
193
264
  return out;
194
265
  }
195
266
 
267
+ /** Display-side companion to `distinctConfigs`: derive the two label strings the
268
+ * "recent launches" picker renders for one config, so two configs that differ
269
+ * only by build agent (or greenfield project name) look different instead of
270
+ * collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
271
+ * the rows are genuinely distinct launches — the old inline label just never
272
+ * surfaced the distinguishing fields (agent, greenfield name).
273
+ *
274
+ * Returns { primary, secondary }:
275
+ * - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
276
+ * two different greenfield projects no longer both read `(none)`;
277
+ * else `repoBasename(clientRepo)` + (`@<branch>` when set); else
278
+ * `(none)` when there's neither a repo nor a project name.
279
+ * - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
280
+ *
281
+ * Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
282
+ * `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
283
+ * semantics stay single-sourced. Pure; no I/O. */
284
+ function recentLaunchDisplay(config) {
285
+ const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
286
+ let primary;
287
+ if (!cfg.clientRepo && cfg.newProjectName) {
288
+ primary = `new: ${cfg.newProjectName}`;
289
+ } else if (cfg.clientRepo) {
290
+ primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
291
+ } else {
292
+ primary = "(none)";
293
+ }
294
+ const secondary =
295
+ `editor ${cfg.editorVariant || "dev"}` +
296
+ (cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
297
+ ` · ${cfg.agent || "claude"}`;
298
+ return { primary, secondary };
299
+ }
300
+
196
301
  /** The default config to pre-fill the launch form: the most recent launch's
197
302
  * config, or an empty config when there's no history. */
198
303
  function defaultConfig(history) {
@@ -258,6 +363,25 @@ function resolveCardIdentity(live, historyCfg) {
258
363
  return { project, clientBranch, editorLabel, source };
259
364
  }
260
365
 
366
+ /** Sticky-merge a VM poll's `clientProject` against the last-known-good value
367
+ * so a transient null poll (timeout / partial probe) does not clobber a
368
+ * populated identity. The live `clientProject.branch` is the fleet
369
+ * classifier's first-choice branch source (`plan_actions` in
370
+ * fleet-roll-to-branch.sh); dropping it to null on a missed cycle would
371
+ * regress classification to the stale launch-history branch. Mirrors how the
372
+ * other live fields tolerate a missed poll: a populated `prev` survives a
373
+ * null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O.
374
+ *
375
+ * @param prev the previous `state.vms[n].clientProject` (null/undefined ok)
376
+ * @param info the fresh poll result object (null when the probe failed)
377
+ * @returns the clientProject to store: fresh value when present, else the
378
+ * last-known-good `prev`, else null. */
379
+ function stickyClientProject(prev, info) {
380
+ const fresh = info && info.clientProject;
381
+ if (fresh) return fresh;
382
+ return prev || null;
383
+ }
384
+
261
385
  /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
262
386
  * identity reported in `/api/session-info` (`clientProject.repo`), with
263
387
  * launch-history `clientRepo` as a fallback when live is absent. A guest is
@@ -328,11 +452,21 @@ function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) {
328
452
  * `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with
329
453
  * `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34
330
454
  * bug). These paths are fixed constants, not user input — no injection surface, so
331
- * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O. */
455
+ * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O.
456
+ *
457
+ * The primary clone is kept only when it is actually USABLE as an editor source,
458
+ * not merely when a `.git` directory exists. A half-cloned / corrupt tree has a
459
+ * `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so
460
+ * `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the
461
+ * improve39 VM-9 bug). The gate therefore requires a valid work tree AND a
462
+ * Dockerfile; otherwise it falls back to the canonical image clone
463
+ * `$HOME/codeyam-editor` (the authoritative source provisioning built the image
464
+ * from). This preserves the prefer-credentialed-clone behavior, but only when the
465
+ * credentialed clone actually works. */
332
466
  function editorSourceShellResolve(src) {
333
467
  const primary = `ESRC="${src.path}"`;
334
468
  if (!src.fallbackPath) return primary;
335
- return `${primary}; [ -d "$ESRC/.git" ] || ESRC="${src.fallbackPath}"`;
469
+ return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`;
336
470
  }
337
471
 
338
472
  /** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor
@@ -343,11 +477,12 @@ function editorSourceShellResolve(src) {
343
477
  * - "image-rebuild" — non-self-host `dev`: the binary ships in the
344
478
  * `codeyam-editor:local` image, so it needs a host-side
345
479
  * `docker build` + container recreate.
346
- * - "npm-reinstall" — a packaged variant (`staging`/`production`, Phase 2):
347
- * the binary is the published editor npm package, so a
348
- * rebuild is a reinstall, not a compile.
349
- * Pure; no I/O. The dashboard reuses `isVariantProvisionable` to gate the
350
- * not-yet-wired npm-reinstall path; the self-host axis is detected at runtime
480
+ * - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary
481
+ * is the published editor npm package, so a rebuild is a
482
+ * reinstall, not a compile.
483
+ * Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line
484
+ * (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's
485
+ * dist-tag); the self-host axis is detected at runtime
351
486
  * on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even
352
487
  * for VMs the dashboard has no launch-history record for). */
353
488
  function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) {
@@ -517,11 +652,55 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
517
652
  return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
518
653
  }
519
654
 
655
+ /** Get authenticated Git URL for checking client branch. */
656
+ function getAuthRepoUrl(repo, token) {
657
+ if (!repo) return "";
658
+ if (!token) return repo;
659
+ const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
660
+ const sshMatch = repo.match(sshRegex);
661
+ if (sshMatch) {
662
+ return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
663
+ }
664
+ if (repo.startsWith("https://github.com/")) {
665
+ return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
666
+ }
667
+ return repo;
668
+ }
669
+
670
+ /** Interpret the output of git ls-remote to see if a branch exists. */
671
+ function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
672
+ const b = (branch || "").trim();
673
+ if (!b) {
674
+ return { ok: true, msg: "using repo default branch" };
675
+ }
676
+ if (err || code !== 0) {
677
+ const errMsg = (err && (err.message || String(err))) || "command failed";
678
+ return {
679
+ ok: false,
680
+ soft: true,
681
+ msg: `couldn't verify branch: ${errMsg.trim()}`
682
+ };
683
+ }
684
+ const trimmed = (stdout || "").trim();
685
+ if (trimmed) {
686
+ return { ok: true };
687
+ } else {
688
+ return {
689
+ ok: false,
690
+ msg: `branch '${b}' not found in ${repo || "repository"}`
691
+ };
692
+ }
693
+ }
694
+
520
695
  module.exports = {
696
+ AGENTS,
521
697
  EMPTY_CONFIG,
522
698
  normalizeLaunchConfig,
523
699
  validateLaunchConfig,
524
700
  isVariantProvisionable,
701
+ isVariantRebuildable,
702
+ variantNpmDistTag,
703
+ variantImageTag,
525
704
  cloudUpArgs,
526
705
  classifyContainerWait,
527
706
  decideAddSlot,
@@ -529,9 +708,11 @@ module.exports = {
529
708
  queuedStatusForAction,
530
709
  pushHistory,
531
710
  distinctConfigs,
711
+ recentLaunchDisplay,
532
712
  defaultConfig,
533
713
  configForVm,
534
714
  resolveCardIdentity,
715
+ stickyClientProject,
535
716
  classifyProjectKind,
536
717
  resolveCredsSource,
537
718
  buildCredsSteps,
@@ -541,4 +722,6 @@ module.exports = {
541
722
  editorSourceShellResolve,
542
723
  rebuildStrategy,
543
724
  decideStartJob,
725
+ getAuthRepoUrl,
726
+ interpretLsRemote,
544
727
  };
@@ -10,6 +10,15 @@
10
10
  * Mirrors the heuristic in `migration_status::is_legacy_config`: a
11
11
  * `.codeyam/config.json` whose JSON has a top-level `webapps` key marks a
12
12
  * legacy project. Anything else with no `editor.json` is greenfield.
13
+ *
14
+ * Rust-side behavior note (improve39): `start_preflight` no longer bails on an
15
+ * EMPTY greenfield folder — it auto-inits in place and boots the editor's wired
16
+ * scaffold flow. It still bails (exit 2) for an existing-source `none` folder
17
+ * (the `greenfieldGuidance` text below) and for legacy. This module is consumed
18
+ * by the launcher's bail/guidance path only; `classifyProject` does not itself
19
+ * decide empty-vs-non-empty (the launcher's `planPreflightAction` /
20
+ * `postLaunchInitBanner` in editor-dev.js own that split and intentionally
21
+ * auto-init all `none` projects as a local-dev convenience).
13
22
  */
14
23
 
15
24
  const fs = require("node:fs");
@@ -78,7 +78,10 @@ const {
78
78
  loadScenarioInIframe,
79
79
  loadScenarioTopLevel,
80
80
  waitForStablePage,
81
+ createNetworkTracker,
82
+ waitForNetworkQuiet,
81
83
  collectContentState,
84
+ performInteraction,
82
85
  } = require("./scenario-playwright");
83
86
 
84
87
  const {
@@ -272,6 +275,76 @@ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config
272
275
  );
273
276
  }
274
277
 
278
+ // Read-only page-state snapshot for `capture-state`: the full localStorage
279
+ // map, a bounded sample of visible text nodes (document order), and — when a
280
+ // selector is given — that element's text. Evaluated in-page against the
281
+ // settled frame so it reflects exactly what a real capture saw (the proxy
282
+ // already injected the scenario's seed into the served HTML). Every read is
283
+ // individually guarded so a sandboxed/cross-origin localStorage never throws
284
+ // the whole capture; the worst case is an empty section, not a failure.
285
+ async function dumpPageState(frame, selector) {
286
+ return frame.evaluate((sel) => {
287
+ const localStorage = {};
288
+ try {
289
+ for (let i = 0; i < window.localStorage.length; i++) {
290
+ const key = window.localStorage.key(i);
291
+ if (key != null) localStorage[key] = window.localStorage.getItem(key);
292
+ }
293
+ } catch (_) {
294
+ /* localStorage may be unavailable (sandboxed/opaque origin) */
295
+ }
296
+
297
+ const visibleText = [];
298
+ try {
299
+ // Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
300
+ // injected proxy script or inline CSS never masquerades as on-screen
301
+ // text — that noise is exactly what makes a state dump misleading.
302
+ const SKIP_TAGS = new Set([
303
+ "SCRIPT",
304
+ "STYLE",
305
+ "NOSCRIPT",
306
+ "TEMPLATE",
307
+ "HEAD",
308
+ "TITLE",
309
+ ]);
310
+ const walker = document.createTreeWalker(
311
+ document.body,
312
+ NodeFilter.SHOW_TEXT,
313
+ {
314
+ acceptNode(node) {
315
+ const parent = node.parentElement;
316
+ if (parent && SKIP_TAGS.has(parent.tagName)) {
317
+ return NodeFilter.FILTER_REJECT;
318
+ }
319
+ return (node.textContent || "").trim()
320
+ ? NodeFilter.FILTER_ACCEPT
321
+ : NodeFilter.FILTER_REJECT;
322
+ },
323
+ },
324
+ );
325
+ let node;
326
+ while ((node = walker.nextNode()) && visibleText.length < 40) {
327
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
328
+ if (text) visibleText.push(text);
329
+ }
330
+ } catch (_) {
331
+ /* no body / detached document */
332
+ }
333
+
334
+ let selectorText = null;
335
+ if (sel) {
336
+ try {
337
+ const el = document.querySelector(sel);
338
+ if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
339
+ } catch (_) {
340
+ /* invalid selector — leave selectorText null */
341
+ }
342
+ }
343
+
344
+ return { localStorage, visibleText, selectorText };
345
+ }, selector || null);
346
+ }
347
+
275
348
  // `preflight` is injectable (defaulting to the real app-port reachability
276
349
  // check) so unit tests that mock the browser can stay network-free.
277
350
  async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
@@ -303,6 +376,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
303
376
  await context.addInitScript(getInitScript());
304
377
 
305
378
  const page = await context.newPage();
379
+ // Attach the network tracker BEFORE navigation so every request (the document
380
+ // and every client-side data fetch) is counted. Used after DOM stability to
381
+ // wait out an in-flight fetch that would otherwise be screenshotted as a
382
+ // loading skeleton.
383
+ const networkTracker = createNetworkTracker(page);
306
384
  await attachHttpMocks(page, httpMocks);
307
385
 
308
386
  page.on("pageerror", (error) => {
@@ -361,6 +439,13 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
361
439
  : readStackLoadingMarkers();
362
440
  await waitForStablePage(page, frame, 10000, loadingMarkers);
363
441
 
442
+ // DOM-stable does not mean done: a client-side data fetch can still be in
443
+ // flight (the loading skeleton cleared but its replacement content hasn't
444
+ // landed). Wait for the network to go quiet — bounded, so a streaming /
445
+ // long-poll endpoint that never idles caps out and captures anyway rather
446
+ // than hanging.
447
+ await waitForNetworkQuiet(networkTracker);
448
+
364
449
  const rejectionMessages = await frame.evaluate(
365
450
  () => window.__codeyamUnhandledRejections || [],
366
451
  );
@@ -430,18 +515,37 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
430
515
  pushIssue(issues, hydrationIssue);
431
516
  }
432
517
 
518
+ // Drive the requested interaction (if any) against the settled frame,
519
+ // then re-settle, so `preview-interact` captures the RESULT of a click /
520
+ // fill / press (expanded accordion, open modal) without editing app
521
+ // source. A no-match target throws here and is caught below as a failed
522
+ // capture with the candidate-labels hint — never a silent blank shot.
523
+ if (config.interaction) {
524
+ await performInteraction(frame, config.interaction);
525
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
526
+ }
527
+
433
528
  if (outputPath && loaded) {
434
529
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
435
530
  await page.screenshot({ path: outputPath, fullPage: false });
436
531
  }
437
532
 
438
- return buildResult({
533
+ const result = buildResult({
439
534
  loaded,
440
535
  hasContent,
441
536
  issues,
442
537
  outputPath,
443
538
  url: frame.url() || url,
444
539
  });
540
+
541
+ // `capture-state` mode: attach the read-only page-state snapshot so the
542
+ // backend can report localStorage + rendered text. Off by default, so a
543
+ // normal error-check capture is byte-for-byte unchanged.
544
+ if (config.captureState) {
545
+ result.state = await dumpPageState(frame, config.stateSelector);
546
+ }
547
+
548
+ return result;
445
549
  } catch (error) {
446
550
  pushIssue(
447
551
  issues,
@@ -481,6 +585,7 @@ async function main() {
481
585
 
482
586
  module.exports = {
483
587
  runScenarioCheck,
588
+ dumpPageState,
484
589
  readStackLoadingMarkers,
485
590
  applyBrowserState,
486
591
  isCrossOriginRequest,
@@ -48,6 +48,22 @@ function handleConsoleMessage(message) {
48
48
  return null;
49
49
  }
50
50
 
51
+ // Ignore the browser's blocked-script warning for sandboxed mockup-preview
52
+ // frames. Mockup previews render untrusted AI-generated HTML inside a
53
+ // `sandbox=""` iframe; the HTML-injection proxy injects an error-capture
54
+ // <script> tag, which the browser then refuses to run, emitting
55
+ // "Blocked script execution ... because the frame is sandboxed". That block
56
+ // is the capture's own injected script being denied — benign for capture
57
+ // purposes. Match narrowly on BOTH the block phrase and the "sandboxed"
58
+ // signature so a genuine non-sandbox CSP block ("Blocked script execution"
59
+ // without "sandboxed") still surfaces as a real issue.
60
+ if (
61
+ text.includes("Blocked script execution") &&
62
+ text.includes("sandboxed")
63
+ ) {
64
+ return null;
65
+ }
66
+
51
67
  return createIssue("console", text);
52
68
  }
53
69
 
@@ -92,19 +92,38 @@ function resolveInteractivityExpectation(stack) {
92
92
  // mutates anything, so it is safe to run before the screenshot is taken.
93
93
  //
94
94
  // Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is
95
- // `true` (runtime demonstrably attached), `false` (controls exist but no
96
- // attachment signal — the dead-hydration case), or `null` (no detector for
97
- // this framework → cannot judge).
95
+ // `true` (runtime demonstrably attached), `false` (framework-owned controls
96
+ // exist but no attachment signal — the dead-hydration case), or `null` (no
97
+ // detector for this framework, OR every interactive control is delegated to a
98
+ // terminal/canvas widget with no hydration marker → cannot judge).
98
99
  async function collectHydrationState(frame, { framework } = {}) {
99
100
  return frame.evaluate((fw) => {
100
101
  const SELECTOR =
101
102
  'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, summary, [onclick]';
103
+ // Roots of third-party terminal/canvas widgets that mount imperatively and
104
+ // wire their own (non-framework) event handlers — xterm's `.xterm`, any
105
+ // `<canvas>`, or an element a component explicitly flags terminal-backed.
106
+ // Interactive controls inside these (e.g. xterm's hidden helper
107
+ // `<textarea>`) never carry framework attachment keys even on a fully
108
+ // hydrated page, so judging hydration from them yields a false "not
109
+ // interactive" verdict. Stack assumption: `.xterm` is xterm-specific; the
110
+ // `<canvas>` and `[data-terminal-backed]` entries are framework-agnostic
111
+ // escape hatches any widget-embedding component can use.
112
+ const WIDGET_ROOT_SELECTOR = ".xterm, canvas, [data-terminal-backed]";
113
+
102
114
  const controls = Array.from(document.querySelectorAll(SELECTOR));
115
+ const inWidget = (el) =>
116
+ !!el &&
117
+ typeof el.closest === "function" &&
118
+ el.closest(WIDGET_ROOT_SELECTOR) != null;
119
+ // Framework-owned controls: those NOT delegated to a terminal/canvas
120
+ // widget. Only these can prove or disprove that the framework hydrated.
121
+ const frameworkControls = controls.filter((el) => !inWidget(el));
103
122
 
104
123
  // Per-framework attachment detectors. Each returns true when the framework
105
- // has demonstrably attached its client runtime to a live interactive node.
106
- // The presence of a detector for `fw` is itself the signal that we CAN
107
- // judge this framework; the default (no detector) means "cannot determine".
124
+ // has demonstrably attached its client runtime to a live node. The
125
+ // presence of a detector for `fw` is itself the signal that we CAN judge
126
+ // this framework; the default (no detector) means "cannot determine".
108
127
  const detectors = {
109
128
  react: (els) =>
110
129
  els.some(
@@ -128,7 +147,34 @@ async function collectHydrationState(frame, { framework } = {}) {
128
147
  };
129
148
 
130
149
  const detector = detectors[fw];
131
- const frameworkAttached = detector ? detector(controls) : null;
150
+
151
+ // Terminal/canvas scenarios prove hydration with an explicit, framework-
152
+ // rendered `data-codeyam-hydrated` marker. It counts only when the
153
+ // framework actually attached to it — a marker that exists in static HTML
154
+ // but never hydrated fails the same detector and does not count.
155
+ const markerEl = document.querySelector("[data-codeyam-hydrated]");
156
+ const markerAttached = detector
157
+ ? detector(markerEl ? [markerEl] : [])
158
+ : false;
159
+
160
+ let frameworkAttached;
161
+ if (!detector) {
162
+ // No detector for this framework — cannot judge (conservative pass).
163
+ frameworkAttached = null;
164
+ } else if (detector(frameworkControls) || markerAttached) {
165
+ // A genuinely framework-owned control attached, or an explicit hydration
166
+ // marker proves the framework ran — definitively hydrated.
167
+ frameworkAttached = true;
168
+ } else if (frameworkControls.length === 0) {
169
+ // Controls exist but every one lives inside a terminal/canvas widget and
170
+ // there is no marker — we cannot prove the page is dead, so never flag.
171
+ frameworkAttached = null;
172
+ } else {
173
+ // Framework-owned controls rendered but none attached — the dead-island
174
+ // signal the gate exists to catch.
175
+ frameworkAttached = false;
176
+ }
177
+
132
178
  return { controlCount: controls.length, frameworkAttached };
133
179
  }, framework || null);
134
180
  }