@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.
- package/npm/cloud.js +779 -116
- package/npm/container.js +11 -0
- package/npm/disk-pressure.js +89 -0
- package/npm/editor-dev.js +144 -52
- package/npm/editor.js +47 -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-build-data.js +81 -0
- package/npm/fleet-cleanup.js +216 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-reconcile.js +58 -1
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +197 -14
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +106 -1
- package/npm/scenario-handlers.js +16 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +238 -53
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
- package/ui/dist/assets/index-BGqairFV.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
- 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/launch-config.js
CHANGED
|
@@ -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
|
-
/**
|
|
62
|
-
*
|
|
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
|
|
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.
|
|
71
|
-
|
|
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}; [ -
|
|
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
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* Pure; no I/O.
|
|
350
|
-
*
|
|
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");
|
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 {
|
|
@@ -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
|
-
|
|
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,
|
package/npm/scenario-handlers.js
CHANGED
|
@@ -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
|
|
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
|
}
|