@codeyam-editor/codeyam-editor 0.1.2 → 0.1.3

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.
@@ -31,30 +31,97 @@
31
31
  // Best-effort: a no-remote / queue-disabled project legitimately exits
32
32
  // non-zero, and we should not block the reset.
33
33
  //
34
- // Between the cp-asides and the `rm -rf` we cooperatively pause the editor
35
- // server (`stop-server --hold-for-restart`). Without that, the server keeps
36
- // writing to `.codeyam/logs/editor-server.log` and `.codeyam/run/` between
37
- // when `rm -rf` enumerates the dir and when it tries to `rmdir` the empty
38
- // directory — it surfaces as `rm: cannot remove '.codeyam': Directory not
39
- // empty`, which the dashboard reports as `error`. SIGUSR2 closes listeners +
40
- // stops the dev-server and parks the PID; the parent script's trailing
41
- // `restart-server --force-in-container` then resurrects via the existing
42
- // SIGUSR1 in-place reexec. Fail-soft `|| true` so a server that's already
43
- // dead (e.g. the reset is rerun after a partial failure) doesn't block the
44
- // chain.
34
+ // Before the wipe we QUIESCE ALL WRITERS: park the editor server
35
+ // (`stop-server --hold-for-restart`) AND stop the PTY broker
36
+ // (`pty-broker stop`). Parking only the editor server is insufficient — the
37
+ // broker is a SEPARATE process (and hooks / agent subprocesses can fire), so
38
+ // it keeps re-creating `.codeyam/logs/` and `.codeyam/run/` between when the
39
+ // wipe enumerates the dir and when it removes it. That race surfaced live
40
+ // (VM-1, 2026-06-01) as `rm: cannot remove '.codeyam': Directory not empty`,
41
+ // aborting the whole reset. Both quiesce calls are fail-soft `|| true` so a
42
+ // server/broker that's already dead (e.g. the reset is rerun after a partial
43
+ // failure) doesn't block the chain.
44
+ //
45
+ // The wipe itself is RETRY-RESILIENT instead of a single `rm -rf .codeyam`
46
+ // that aborts the &&-chain on one transient re-creation: a bounded loop of
47
+ // `find .codeyam -mindepth 1 -delete` empties the contents and tolerates a
48
+ // brief re-create, then proceeds even if `.codeyam` is still non-empty after
49
+ // best-effort (a soft retry, never a hard failure — `git checkout`/`init`
50
+ // repopulates over whatever remains). Emptying contents (not removing the dir)
51
+ // also sidesteps the `rmdir` race entirely.
52
+ //
53
+ // The destructive region (park → broker-stop → wipe → restore) is made
54
+ // FAILURE-SAFE with an EXIT trap installed before the park and disarmed after
55
+ // a successful restore. If ANY step in that region aborts the chain, the trap
56
+ // still restores tracked `.codeyam` (or re-inits), copies the two preserved
57
+ // infra files back, and runs `restart-server --force-in-container` before
58
+ // exiting non-zero — so a failed deep-clean self-recovers to a working
59
+ // (un-reset, but UP) VM instead of leaving it parked-and-down with a
60
+ // half-wiped `.codeyam`, and still reports the failure. The happy path is
61
+ // unchanged: the restore steps run, the trap is disarmed, and the caller's
62
+ // own trailing `restart-server` resurrects the parked server.
45
63
  function codeyamDeepCleanSteps(E) {
64
+ // Restore primitives shared by the happy path and the trap recovery body.
65
+ const restoreCodeyam = `git checkout HEAD -- .codeyam 2>/dev/null || ${E} init`;
66
+ const restoreElj =
67
+ 'test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json';
68
+ const restoreSs =
69
+ 'test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json';
70
+ // Recovery body: restore .codeyam + the two infra backups, then resurrect the
71
+ // parked server. Runs ONLY on the failure path (the happy path's own restore
72
+ // steps remove the backups; recovery here intentionally leaves them in place
73
+ // because on a mid-region abort those steps never ran).
74
+ const recover =
75
+ `${restoreCodeyam}; ( ${restoreElj}; true ); ( ${restoreSs}; true ); ` +
76
+ `${E} editor restart-server --force-in-container || true`;
46
77
  return [
47
78
  `( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`,
48
79
  '( cp .codeyam/editor.local.json /tmp/cy-elj.bak 2>/dev/null; true )',
49
80
  '( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )',
81
+ // A brace group (NOT a subshell) so the function + trap persist into the
82
+ // current shell where the rest of the &&-chain runs.
83
+ `{ __cy_recover() { ${recover}; }; trap '__cy_recover; exit 1' EXIT; }`,
50
84
  `(${E} editor stop-server --hold-for-restart --force-in-container || true)`,
51
- 'rm -rf .codeyam',
52
- `( git checkout HEAD -- .codeyam 2>/dev/null || ${E} init )`,
53
- '( test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json && rm -f /tmp/cy-elj.bak; true )',
54
- '( test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json && rm -f /tmp/cy-ss.bak; true )',
85
+ `(${E} editor pty-broker stop || true)`,
86
+ '( for i in 1 2 3 4 5; do find .codeyam -mindepth 1 -delete 2>/dev/null; [ -z "$(ls -A .codeyam 2>/dev/null)" ] && break; sleep 0.2; done; true )',
87
+ `( ${restoreCodeyam} )`,
88
+ `( ${restoreElj} && rm -f /tmp/cy-elj.bak; true )`,
89
+ `( ${restoreSs} && rm -f /tmp/cy-ss.bak; true )`,
90
+ // Restore succeeded — disarm the failure-safe trap so normal completion
91
+ // (and the caller's trailing session-reset + restart) doesn't re-trigger it.
92
+ 'trap - EXIT',
55
93
  ];
56
94
  }
57
95
 
96
+ // Belt-and-suspenders broker-ensure, appended after the trailing
97
+ // `restart-server` in every reset recipe. The PRIMARY fix lives in the
98
+ // editor server's `ensure_broker_running`: it now `rm -f`s the stale
99
+ // socket and retries the spawn, so the reexec'd `start` (which
100
+ // restart-server triggers) reliably respawns the broker with no
101
+ // down-window. This step is the safety net for an OLDER in-container
102
+ // binary whose `restart-server` predates that fix — it mirrors the
103
+ // vm-watchdog's recovery so a reset still ends broker-up regardless of
104
+ // which binary is installed.
105
+ //
106
+ // Shape: status-gated and idempotent. `pty-broker status` exits non-zero
107
+ // only when no broker is reachable; ONLY then does the `||` branch fire
108
+ // (`rm -f` the stale socket — mirroring the watchdog — then `nohup` a
109
+ // detached daemon). When a broker is already alive the status succeeds
110
+ // and the respawn branch never runs, so a healthy broker is NEVER killed.
111
+ // Wrapped in `( …; true )` so it can never break the reset's &&-chain
112
+ // (a down broker that fails to respawn must not abort the rest of the
113
+ // reset). The socket glob + `pty-broker daemon` invocation match the
114
+ // watchdog (`scripts/operator/vm-watchdog.sh`) and the per-project
115
+ // `$TMPDIR`/`/tmp` socket layout (`socket_path_for`).
116
+ function codeyamBrokerEnsureStep(E) {
117
+ return (
118
+ `( ${E} editor pty-broker status >/dev/null 2>&1 || ` +
119
+ `( rm -f /tmp/codeyam-pty-broker-*.sock; ` +
120
+ `nohup ${E} editor pty-broker daemon </dev/null >/tmp/broker-spawn.log 2>&1 & disown ); ` +
121
+ 'true )'
122
+ );
123
+ }
124
+
58
125
  // Build the in-container bash for "Reset client" — hard-clean the CLIENT
59
126
  // project (/workspace) on ANY VM shape. The decisions encoded here:
60
127
  // - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted
@@ -85,6 +152,9 @@ function resetClientScript({ E, gitReseedBody }) {
85
152
  `${E} editor session-reset`,
86
153
  'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"',
87
154
  `(${E} editor restart-server --force-in-container || true)`,
155
+ // Belt-and-suspenders: ensure a broker is live even on an older binary
156
+ // whose restart-server doesn't yet respawn it. Idempotent / status-gated.
157
+ codeyamBrokerEnsureStep(E),
88
158
  'echo ">> [5/5] re-seeding git credential cache"',
89
159
  `( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`,
90
160
  ].join(' && ');
@@ -138,7 +208,15 @@ function resetEditorStateScript({ E }) {
138
208
  // docker-exec returns cleanly, so `|| true` keeps a broker-down VM green.
139
209
  'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"',
140
210
  `(${E} editor restart-server --force-in-container || true)`,
211
+ // Belt-and-suspenders: ensure a broker is live even on an older binary
212
+ // whose restart-server doesn't yet respawn it. Idempotent / status-gated.
213
+ codeyamBrokerEnsureStep(E),
141
214
  ].join(' && ');
142
215
  }
143
216
 
144
- module.exports = { codeyamDeepCleanSteps, resetClientScript, resetEditorStateScript };
217
+ module.exports = {
218
+ codeyamDeepCleanSteps,
219
+ codeyamBrokerEnsureStep,
220
+ resetClientScript,
221
+ resetEditorStateScript,
222
+ };
@@ -58,10 +58,54 @@ function validateLaunchConfig(cfg) {
58
58
  return { ok: true };
59
59
  }
60
60
 
61
- /** Phase-1 gate mirror of cloud.js::assertVariantProvisionable — only `dev`
62
- * provisions today; staging/production are Phase 2. */
61
+ /** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants
62
+ * can be provisioned onto a cloud VM. All three variants now provision: `dev`
63
+ * builds the editor image from source, while `staging`/`production` run the
64
+ * published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off
65
+ * the variant). An unknown variant is excluded so a typo never reaches a
66
+ * launch. Pure; no I/O. */
63
67
  function isVariantProvisionable(variant) {
64
- return variant === "dev";
68
+ return EDITOR_VARIANTS.includes(variant);
69
+ }
70
+
71
+ /** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild
72
+ * binary" / "Update & Reset Everything" actions, and reconfigure's trailing
73
+ * binary refresh). Historically distinct from `isVariantProvisionable` because a
74
+ * packaged variant's rebuild is an npm-reinstall (rebuildStrategy ===
75
+ * "npm-reinstall") and that host-script path was not wired — so this stayed
76
+ * dev-only and packaged rebuild buttons failed loud. With the npm-reinstall
77
+ * branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm
78
+ * image at the variant's dist-tag instead of source-building Dockerfile), every
79
+ * provisionable variant is also rebuildable, so this now mirrors
80
+ * `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */
81
+ function isVariantRebuildable(variant) {
82
+ return isVariantProvisionable(variant);
83
+ }
84
+
85
+ /** Map an editor variant to the npm dist-tag its package was published under.
86
+ * CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com`
87
+ * → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has
88
+ * no published package (it builds the image from source), so return null and let
89
+ * callers reject it. This is the single source of truth for the variant→dist-tag
90
+ * contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm`
91
+ * mode, and the future reconfigure path all derive from it so they can't drift.
92
+ * Pure; no I/O. */
93
+ function variantNpmDistTag(variant) {
94
+ if (variant === "production") return "latest";
95
+ if (variant === "staging") return "staging";
96
+ return null; // dev / unknown — no packaged dist-tag
97
+ }
98
+
99
+ /** Map a packaged editor variant to the GHCR image-tag suffix the packaged
100
+ * image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside
101
+ * `variantNpmDistTag` so the registry naming convention and the dist-tag mapping
102
+ * stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts
103
+ * the literal string this returns. `dev` has no packaged image (built from
104
+ * source), so return null. Pure; no I/O. */
105
+ function variantImageTag(variant) {
106
+ if (variant === "production") return "production-latest";
107
+ if (variant === "staging") return "staging-latest";
108
+ return null; // dev / unknown — no packaged image
65
109
  }
66
110
 
67
111
  /** Build the `cloud:up` flag array for a launch config + instance name. The
@@ -258,6 +302,25 @@ function resolveCardIdentity(live, historyCfg) {
258
302
  return { project, clientBranch, editorLabel, source };
259
303
  }
260
304
 
305
+ /** Sticky-merge a VM poll's `clientProject` against the last-known-good value
306
+ * so a transient null poll (timeout / partial probe) does not clobber a
307
+ * populated identity. The live `clientProject.branch` is the fleet
308
+ * classifier's first-choice branch source (`plan_actions` in
309
+ * fleet-roll-to-branch.sh); dropping it to null on a missed cycle would
310
+ * regress classification to the stale launch-history branch. Mirrors how the
311
+ * other live fields tolerate a missed poll: a populated `prev` survives a
312
+ * null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O.
313
+ *
314
+ * @param prev the previous `state.vms[n].clientProject` (null/undefined ok)
315
+ * @param info the fresh poll result object (null when the probe failed)
316
+ * @returns the clientProject to store: fresh value when present, else the
317
+ * last-known-good `prev`, else null. */
318
+ function stickyClientProject(prev, info) {
319
+ const fresh = info && info.clientProject;
320
+ if (fresh) return fresh;
321
+ return prev || null;
322
+ }
323
+
261
324
  /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
262
325
  * identity reported in `/api/session-info` (`clientProject.repo`), with
263
326
  * launch-history `clientRepo` as a fallback when live is absent. A guest is
@@ -328,11 +391,21 @@ function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) {
328
391
  * `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with
329
392
  * `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34
330
393
  * 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. */
394
+ * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O.
395
+ *
396
+ * The primary clone is kept only when it is actually USABLE as an editor source,
397
+ * not merely when a `.git` directory exists. A half-cloned / corrupt tree has a
398
+ * `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so
399
+ * `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the
400
+ * improve39 VM-9 bug). The gate therefore requires a valid work tree AND a
401
+ * Dockerfile; otherwise it falls back to the canonical image clone
402
+ * `$HOME/codeyam-editor` (the authoritative source provisioning built the image
403
+ * from). This preserves the prefer-credentialed-clone behavior, but only when the
404
+ * credentialed clone actually works. */
332
405
  function editorSourceShellResolve(src) {
333
406
  const primary = `ESRC="${src.path}"`;
334
407
  if (!src.fallbackPath) return primary;
335
- return `${primary}; [ -d "$ESRC/.git" ] || ESRC="${src.fallbackPath}"`;
408
+ return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`;
336
409
  }
337
410
 
338
411
  /** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor
@@ -343,11 +416,12 @@ function editorSourceShellResolve(src) {
343
416
  * - "image-rebuild" — non-self-host `dev`: the binary ships in the
344
417
  * `codeyam-editor:local` image, so it needs a host-side
345
418
  * `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
419
+ * - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary
420
+ * is the published editor npm package, so a rebuild is a
421
+ * reinstall, not a compile.
422
+ * Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line
423
+ * (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's
424
+ * dist-tag); the self-host axis is detected at runtime
351
425
  * on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even
352
426
  * for VMs the dashboard has no launch-history record for). */
353
427
  function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) {
@@ -522,6 +596,9 @@ module.exports = {
522
596
  normalizeLaunchConfig,
523
597
  validateLaunchConfig,
524
598
  isVariantProvisionable,
599
+ isVariantRebuildable,
600
+ variantNpmDistTag,
601
+ variantImageTag,
525
602
  cloudUpArgs,
526
603
  classifyContainerWait,
527
604
  decideAddSlot,
@@ -532,6 +609,7 @@ module.exports = {
532
609
  defaultConfig,
533
610
  configForVm,
534
611
  resolveCardIdentity,
612
+ stickyClientProject,
535
613
  classifyProjectKind,
536
614
  resolveCredsSource,
537
615
  buildCredsSteps,
@@ -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 {
@@ -303,6 +306,11 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
303
306
  await context.addInitScript(getInitScript());
304
307
 
305
308
  const page = await context.newPage();
309
+ // Attach the network tracker BEFORE navigation so every request (the document
310
+ // and every client-side data fetch) is counted. Used after DOM stability to
311
+ // wait out an in-flight fetch that would otherwise be screenshotted as a
312
+ // loading skeleton.
313
+ const networkTracker = createNetworkTracker(page);
306
314
  await attachHttpMocks(page, httpMocks);
307
315
 
308
316
  page.on("pageerror", (error) => {
@@ -361,6 +369,13 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
361
369
  : readStackLoadingMarkers();
362
370
  await waitForStablePage(page, frame, 10000, loadingMarkers);
363
371
 
372
+ // DOM-stable does not mean done: a client-side data fetch can still be in
373
+ // flight (the loading skeleton cleared but its replacement content hasn't
374
+ // landed). Wait for the network to go quiet — bounded, so a streaming /
375
+ // long-poll endpoint that never idles caps out and captures anyway rather
376
+ // than hanging.
377
+ await waitForNetworkQuiet(networkTracker);
378
+
364
379
  const rejectionMessages = await frame.evaluate(
365
380
  () => window.__codeyamUnhandledRejections || [],
366
381
  );
@@ -430,6 +445,16 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
430
445
  pushIssue(issues, hydrationIssue);
431
446
  }
432
447
 
448
+ // Drive the requested interaction (if any) against the settled frame,
449
+ // then re-settle, so `preview-interact` captures the RESULT of a click /
450
+ // fill / press (expanded accordion, open modal) without editing app
451
+ // source. A no-match target throws here and is caught below as a failed
452
+ // capture with the candidate-labels hint — never a silent blank shot.
453
+ if (config.interaction) {
454
+ await performInteraction(frame, config.interaction);
455
+ await waitForStablePage(page, frame, 5000, loadingMarkers);
456
+ }
457
+
433
458
  if (outputPath && loaded) {
434
459
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
435
460
  await page.screenshot({ path: outputPath, fullPage: false });
@@ -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
  }
@@ -219,6 +219,73 @@ async function waitForAnimationsSettled(
219
219
  return { settled: false, elapsedMs: Date.now() - started };
220
220
  }
221
221
 
222
+ // Track in-flight network requests on a Playwright page so a capture can wait
223
+ // for client-side data fetches to settle before screenshotting. The
224
+ // resource-timing API (`performance.getEntriesByType("resource")`) only records
225
+ // COMPLETED requests, so it cannot see a fetch that is still in flight — the
226
+ // exact window where a client-fetch page shows a loading skeleton. We count
227
+ // request starts against finishes/failures instead. Returns a live view:
228
+ // `inFlight()` is the current outstanding count and `lastActivityMs()` is the
229
+ // timestamp of the most recent request start OR completion (0 when the page has
230
+ // made no requests since the tracker attached). Attach BEFORE navigation so
231
+ // every request is counted. Stack-agnostic — it observes raw HTTP activity, not
232
+ // any framework's fetch wrapper.
233
+ function createNetworkTracker(page) {
234
+ let inFlight = 0;
235
+ let lastActivityMs = 0;
236
+ const bump = () => {
237
+ lastActivityMs = Date.now();
238
+ };
239
+ if (page && typeof page.on === "function") {
240
+ page.on("request", () => {
241
+ inFlight += 1;
242
+ bump();
243
+ });
244
+ const settle = () => {
245
+ inFlight = Math.max(0, inFlight - 1);
246
+ bump();
247
+ };
248
+ page.on("requestfinished", settle);
249
+ page.on("requestfailed", settle);
250
+ }
251
+ return {
252
+ inFlight: () => inFlight,
253
+ lastActivityMs: () => lastActivityMs,
254
+ };
255
+ }
256
+
257
+ // Bounded network-quiet wait: after the DOM is stable a client-side data fetch
258
+ // can still be in flight — the loading skeleton is gone but the fetched rows
259
+ // haven't replaced it yet, so a screenshot here catches the in-between frame.
260
+ // Wait until no request has been outstanding for `quietWindowMs`, hard-capped at
261
+ // `overallTimeoutMs`. Two properties matter:
262
+ // - A page that made NO requests (lastActivityMs stays 0) is already quiet and
263
+ // returns on the first poll, so server-rendered captures incur no extra wait.
264
+ // - A streaming / long-poll endpoint that never goes idle hits the cap and the
265
+ // caller captures anyway — the wait can never hang the capture.
266
+ async function waitForNetworkQuiet(
267
+ tracker,
268
+ { quietWindowMs = 500, overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
269
+ ) {
270
+ const started = Date.now();
271
+ while (Date.now() - started < overallTimeoutMs) {
272
+ const idleForMs = Date.now() - tracker.lastActivityMs();
273
+ if (tracker.inFlight() === 0 && idleForMs >= quietWindowMs) {
274
+ const elapsedMs = Date.now() - started;
275
+ logCaptureTiming("network-quiet", { outcome: "quiet", elapsedMs });
276
+ return { quiet: true, elapsedMs };
277
+ }
278
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
279
+ }
280
+ const elapsedMs = Date.now() - started;
281
+ logCaptureTiming("network-quiet", {
282
+ outcome: "capped",
283
+ elapsedMs,
284
+ inFlight: tracker.inFlight(),
285
+ });
286
+ return { quiet: false, elapsedMs };
287
+ }
288
+
222
289
  // `loadingMarkers` are the project's app-specific loading strings (from
223
290
  // stack.json `capture.loadingMarkers`); they extend the codeyam-harness
224
291
  // defaults so a stable-but-still-loading app screen counts as "not ready"
@@ -233,15 +300,41 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
233
300
  while (Date.now() - started < timeoutMs) {
234
301
  await page.waitForTimeout(500);
235
302
 
236
- const pageState = await target.evaluate(() => ({
237
- bodyText: document.body?.innerText ?? "",
238
- html: document.body?.innerHTML ?? "",
239
- }));
303
+ const pageState = await target.evaluate(() => {
304
+ const getById = document.getElementById;
305
+ const root =
306
+ typeof getById === "function" ? document.getElementById("root") : null;
307
+ return {
308
+ bodyText: document.body?.innerText ?? "",
309
+ html: document.body?.innerHTML ?? "",
310
+ // Whether the SPA mount point exists AND has painted anything. An
311
+ // existing-but-empty `<div id="root">` is the pre-paint window of a
312
+ // slow-first-paint scenario (e.g. a live-session app that spends a few
313
+ // seconds connecting before the gate UI mounts). `rootExists` lets us
314
+ // distinguish that from a mid-redirect/teardown `null` body, where
315
+ // there is no root to wait on and a stable-empty page is legitimately
316
+ // settled.
317
+ rootExists: !!root,
318
+ rootChildCount: root ? root.childElementCount : 0,
319
+ };
320
+ });
240
321
 
241
322
  lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
242
323
  lastHtmlChanged = pageState.html !== lastHtml;
243
324
 
244
- if (!lastHadLoadingMarkers && !lastHtmlChanged) {
325
+ // A mounted-but-unpainted root is "still loading", not "settled": the HTML
326
+ // can sit byte-stable for a second or two while the SPA boots, which would
327
+ // otherwise satisfy the stability check and capture a blank frame before
328
+ // first paint. Treat an existing root with zero children AND no body text
329
+ // as not-ready so the loop keeps polling until the app actually paints (or
330
+ // the overall timeout fires, by which point real content is present). A
331
+ // `null` body (rootExists=false) is unaffected — it stays trivially stable.
332
+ const rootUnpainted =
333
+ pageState.rootExists &&
334
+ pageState.rootChildCount === 0 &&
335
+ (pageState.bodyText ?? "").trim().length === 0;
336
+
337
+ if (!lastHadLoadingMarkers && !lastHtmlChanged && !rootUnpainted) {
245
338
  stableCount += 1;
246
339
  if (stableCount >= 2) {
247
340
  const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
@@ -339,6 +432,88 @@ async function loadScenarioTopLevel(
339
432
  return { frame: page.mainFrame(), response };
340
433
  }
341
434
 
435
+ // Collect up to 20 distinct visible labels of interactive elements on the
436
+ // page — buttons, links, role=button, form controls, <summary>, and anything
437
+ // with an onclick. Used to build an ACTIONABLE error when an interaction's
438
+ // target matches nothing: the agent reliably knows a label it rendered, so
439
+ // listing the real candidates turns a silent blank capture into a "did you
440
+ // mean one of these?" hint. Pure read (no clicks); falls back to value /
441
+ // aria-label / placeholder when an element has no text.
442
+ async function collectInteractiveLabels(frame) {
443
+ return frame.evaluate(() => {
444
+ const selector =
445
+ "button, a[href], [role=button], input, select, textarea, summary, [onclick]";
446
+ const nodes = Array.from(document.querySelectorAll(selector));
447
+ const labels = nodes
448
+ .map((node) => {
449
+ const text =
450
+ (node.innerText || node.textContent || "").trim() ||
451
+ (typeof node.value === "string" ? node.value.trim() : "") ||
452
+ (node.getAttribute && node.getAttribute("aria-label")) ||
453
+ (node.getAttribute && node.getAttribute("placeholder")) ||
454
+ "";
455
+ return String(text).trim();
456
+ })
457
+ .filter((label) => label.length > 0);
458
+ return Array.from(new Set(labels)).slice(0, 20);
459
+ });
460
+ }
461
+
462
+ // Drive a single user-style interaction against the settled frame before the
463
+ // screenshot, so an interactive state (expanded accordion, open modal, filled
464
+ // field) can be captured without editing app source.
465
+ //
466
+ // The target is matched by visible `text` (preferred — the agent reliably
467
+ // knows the label it rendered) or a CSS `selector`. `action` is click / fill /
468
+ // press; `value` carries the text for `fill` or the key for `press` (e.g.
469
+ // `Enter`). On a no-match target this THROWS with the list of candidate
470
+ // interactive labels — the capture script's outer catch turns that into a
471
+ // failed capture with an actionable message, never a silent blank screenshot.
472
+ async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
473
+ const { action, selector, text, value } = interaction || {};
474
+
475
+ let locator;
476
+ let targetDesc;
477
+ if (typeof text === "string" && text.length > 0) {
478
+ locator = frame.getByText(text, { exact: false }).first();
479
+ targetDesc = `text "${text}"`;
480
+ } else if (typeof selector === "string" && selector.length > 0) {
481
+ locator = frame.locator(selector).first();
482
+ targetDesc = `selector "${selector}"`;
483
+ } else {
484
+ throw new Error(
485
+ "preview-interact: interaction requires a `text` or `selector` target",
486
+ );
487
+ }
488
+
489
+ const matchCount = await locator.count();
490
+ if (matchCount === 0) {
491
+ const candidates = await collectInteractiveLabels(frame);
492
+ const candidateList =
493
+ candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
494
+ throw new Error(
495
+ `preview-interact: no element matched ${targetDesc}. ` +
496
+ `Candidate interactive labels: ${candidateList}`,
497
+ );
498
+ }
499
+
500
+ switch (action) {
501
+ case "click":
502
+ await locator.click({ timeout: timeoutMs });
503
+ break;
504
+ case "fill":
505
+ await locator.fill(value ?? "", { timeout: timeoutMs });
506
+ break;
507
+ case "press":
508
+ await locator.press(value || "Enter", { timeout: timeoutMs });
509
+ break;
510
+ default:
511
+ throw new Error(
512
+ `preview-interact: unknown action "${action}" (expected click | fill | press)`,
513
+ );
514
+ }
515
+ }
516
+
342
517
  module.exports = {
343
518
  logCaptureTiming,
344
519
  resolveTcpTarget,
@@ -349,7 +524,11 @@ module.exports = {
349
524
  collectImageStates,
350
525
  waitForImagesSettled,
351
526
  waitForAnimationsSettled,
527
+ createNetworkTracker,
528
+ waitForNetworkQuiet,
352
529
  waitForStablePage,
353
530
  loadScenarioInIframe,
354
531
  loadScenarioTopLevel,
532
+ collectInteractiveLabels,
533
+ performInteraction,
355
534
  };