@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.
- package/npm/cloud.js +494 -86
- package/npm/editor-dev.js +133 -52
- package/npm/editor.js +35 -17
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-cleanup.js +189 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +39 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +88 -10
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +25 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +100 -46
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-Bnghtj8a.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
- package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-KDFAgt2e.js} +2 -2
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
- package/ui/dist/assets/index-DVr5rY2d.css +0 -1
package/npm/fleet-reset.js
CHANGED
|
@@ -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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 = {
|
|
217
|
+
module.exports = {
|
|
218
|
+
codeyamDeepCleanSteps,
|
|
219
|
+
codeyamBrokerEnsureStep,
|
|
220
|
+
resetClientScript,
|
|
221
|
+
resetEditorStateScript,
|
|
222
|
+
};
|
package/npm/launch-config.js
CHANGED
|
@@ -58,10 +58,54 @@ function validateLaunchConfig(cfg) {
|
|
|
58
58
|
return { ok: true };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
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
|
|
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}; [ -
|
|
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
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* Pure; no I/O.
|
|
350
|
-
*
|
|
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");
|
package/npm/scenario-check.js
CHANGED
|
@@ -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
|
|
96
|
-
// attachment signal — the dead-hydration case), or `null` (no
|
|
97
|
-
// this framework
|
|
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
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
};
|