@codeyam-editor/codeyam-editor 0.1.1 → 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.
Files changed (53) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1929 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +368 -37
  6. package/npm/editor.js +133 -52
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-branch.js +49 -0
  10. package/npm/fleet-broker-death.js +44 -0
  11. package/npm/fleet-broker-debounce.js +63 -0
  12. package/npm/fleet-broker-status.js +45 -0
  13. package/npm/fleet-cleanup.js +297 -0
  14. package/npm/fleet-effect-status.js +179 -0
  15. package/npm/fleet-env.js +125 -0
  16. package/npm/fleet-grouping.js +56 -0
  17. package/npm/fleet-job-badge.js +39 -0
  18. package/npm/fleet-memory.js +157 -0
  19. package/npm/fleet-pool-policy.js +78 -0
  20. package/npm/fleet-pool.js +401 -0
  21. package/npm/fleet-queue.js +40 -0
  22. package/npm/fleet-reconcile.js +36 -0
  23. package/npm/fleet-reset.js +222 -0
  24. package/npm/fleet-roster.js +81 -0
  25. package/npm/launch-config.js +622 -0
  26. package/npm/preflight-guidance.js +77 -0
  27. package/npm/scenario-check.js +323 -14
  28. package/npm/scenario-handlers.js +16 -5
  29. package/npm/scenario-interactivity.js +245 -0
  30. package/npm/scenario-metrics.js +16 -2
  31. package/npm/scenario-mocks.js +62 -25
  32. package/npm/scenario-playwright.js +350 -8
  33. package/npm/utils.js +562 -22
  34. package/npm/vm-urls.js +117 -0
  35. package/package.json +8 -7
  36. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  37. package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
  38. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  39. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  40. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
  41. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
  42. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
  43. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  44. package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
  45. package/ui/dist/assets/index-KDFAgt2e.js +27 -0
  46. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  47. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  48. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  49. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  50. package/ui/dist/favicon.ico +0 -0
  51. package/ui/dist/favicon.png +0 -0
  52. package/ui/dist/index.html +16 -0
  53. package/npm/postinstall.js +0 -132
@@ -0,0 +1,245 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { createIssue } = require("./scenario-issues");
4
+
5
+ // Hydration / interactivity probe for the headless capture browser.
6
+ //
7
+ // Background: a page can return HTTP 200, render visible content, log no
8
+ // console errors, and still be completely dead — the client framework never
9
+ // hydrated, so every button and handler is inert. The status / render /
10
+ // console / image gates all pass and the broken page sails through to the
11
+ // user (the catalog whose filter buttons did nothing). This probe asserts the
12
+ // page actually became interactive before the capture gate reports success,
13
+ // so a non-hydrating page can no longer pass `client-errors`.
14
+ //
15
+ // Stack assumption: WHICH framework's hydration we look for is data-driven
16
+ // from `.codeyam/stack.json` (or an explicit override) — never hardcoded into
17
+ // the capture flow. Stacks with no client runtime (backend services, CLIs,
18
+ // static HTML) are a documented no-op pass. Frameworks for which we have no
19
+ // reliable in-page attachment signal are ALSO a conservative pass: we never
20
+ // flag a page we cannot prove is dead, so a healthy Svelte / Solid / vanilla
21
+ // page is never a false negative. A new framework detector is a new entry in
22
+ // `detectors` below plus a `KNOWN_FRAMEWORKS` mapping — not an edit here.
23
+
24
+ // Frameworks we have an in-page attachment detector for. Inference only
25
+ // resolves to one of these; an unrecognised framework yields `null`, which
26
+ // the caller treats as "cannot determine" (conservative pass).
27
+ const KNOWN_FRAMEWORKS = ["react", "vue"];
28
+
29
+ // Read `.codeyam/stack.json` relative to the capture script's cwd (the project
30
+ // dir — `scenario_check.rs` sets `.current_dir(project_dir)`). Never throws: a
31
+ // missing or malformed file yields `null` so the probe degrades to a no-op
32
+ // rather than breaking a capture.
33
+ function readStackJson() {
34
+ try {
35
+ const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8");
36
+ return JSON.parse(raw);
37
+ } catch (_) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // Scan a stack descriptor's identity fields for a framework we can probe.
43
+ // Pure — no I/O — so the matching rules are unit-tested without a stack.json.
44
+ function inferFramework(stack) {
45
+ if (!stack) return null;
46
+ const haystack = [
47
+ stack.id,
48
+ stack.name,
49
+ ...(Array.isArray(stack.technologies) ? stack.technologies : []),
50
+ ]
51
+ .filter((s) => typeof s === "string")
52
+ .join(" ")
53
+ .toLowerCase();
54
+ for (const fw of KNOWN_FRAMEWORKS) {
55
+ if (haystack.includes(fw)) return fw;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // Decide, from a stack descriptor, whether the capture should expect a
61
+ // hydrated client runtime and which framework to probe for. Pure.
62
+ //
63
+ // `capture.interactivity === false` is an explicit opt-out for stacks that
64
+ // render no client runtime. `capture.interactivity.framework` is an explicit
65
+ // override when inference can't see the framework in the identity fields.
66
+ // Otherwise we infer: a known client framework, or `routing.type ===
67
+ // "client-side"`, implies a runtime that must hydrate; backend / static / CLI
68
+ // stacks match neither and no-op.
69
+ function resolveInteractivityExpectation(stack) {
70
+ const capture = (stack && stack.capture) || {};
71
+ if (capture.interactivity === false) {
72
+ return { expectInteractive: false, framework: null };
73
+ }
74
+ const explicit =
75
+ capture.interactivity && typeof capture.interactivity === "object"
76
+ ? capture.interactivity.framework
77
+ : null;
78
+ if (typeof explicit === "string" && explicit.length > 0) {
79
+ return { expectInteractive: true, framework: explicit.toLowerCase() };
80
+ }
81
+ const framework = inferFramework(stack);
82
+ const routingType =
83
+ stack && stack.routing && typeof stack.routing.type === "string"
84
+ ? stack.routing.type
85
+ : null;
86
+ const expectInteractive = framework != null || routingType === "client-side";
87
+ return { expectInteractive, framework };
88
+ }
89
+
90
+ // Run the in-page detection inside the loaded frame. Read-only: it inspects
91
+ // DOM-node properties left by a framework's hydration but never clicks or
92
+ // mutates anything, so it is safe to run before the screenshot is taken.
93
+ //
94
+ // Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is
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).
99
+ async function collectHydrationState(frame, { framework } = {}) {
100
+ return frame.evaluate((fw) => {
101
+ const SELECTOR =
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
+
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));
122
+
123
+ // Per-framework attachment detectors. Each returns true when the framework
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".
127
+ const detectors = {
128
+ react: (els) =>
129
+ els.some(
130
+ (el) =>
131
+ !!el &&
132
+ Object.keys(el).some(
133
+ (k) =>
134
+ k.startsWith("__reactFiber$") ||
135
+ k.startsWith("__reactProps$") ||
136
+ k.startsWith("__reactContainer$"),
137
+ ),
138
+ ),
139
+ vue: (els) =>
140
+ els.some(
141
+ (el) =>
142
+ !!el &&
143
+ (el.__vue__ != null ||
144
+ el.__vnode != null ||
145
+ el.__vueParentComponent != null),
146
+ ) || !!document.querySelector("[data-v-app]"),
147
+ };
148
+
149
+ const detector = detectors[fw];
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
+
178
+ return { controlCount: controls.length, frameworkAttached };
179
+ }, framework || null);
180
+ }
181
+
182
+ // Turn a collected state into a `hydration` issue, or `null` to pass. Pure, so
183
+ // every branch is unit-tested without a browser.
184
+ //
185
+ // Pass when: no client runtime expected (no-op stacks); no interactive control
186
+ // to probe (a static content page in a client app is legitimately inert); the
187
+ // framework attached; OR we couldn't determine attachment (`null` — never
188
+ // false-positive). Flag only the proven-dead case: controls exist AND the
189
+ // framework's runtime is demonstrably not attached.
190
+ function interpretHydration({
191
+ expectInteractive,
192
+ controlCount,
193
+ frameworkAttached,
194
+ framework,
195
+ url,
196
+ }) {
197
+ if (!expectInteractive) return null;
198
+ if (!(controlCount > 0)) return null;
199
+ if (frameworkAttached !== false) return null;
200
+ const fw = framework || "the client framework";
201
+ const plural = controlCount === 1 ? "" : "s";
202
+ return createIssue(
203
+ "hydration",
204
+ `Page rendered ${controlCount} interactive control${plural} but ${fw} never attached ` +
205
+ `event handlers — the page is not interactive (hydration did not run). Client JS may ` +
206
+ `not be executing; check the preview proxy and the browser console. Run ` +
207
+ "`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " +
208
+ `HTML-injection blocker.`,
209
+ { url: url ?? null },
210
+ );
211
+ }
212
+
213
+ // Orchestrator called from the capture flow: resolve the expectation (from the
214
+ // project's stack.json unless the caller injects a `stack`), short-circuit when
215
+ // no client runtime is expected, collect the in-page state, and interpret it.
216
+ // Never throws — a probe failure must not break an otherwise-good capture.
217
+ async function probeInteractivity(frame, { url, stack } = {}) {
218
+ const descriptor = stack !== undefined ? stack : readStackJson();
219
+ const { expectInteractive, framework } =
220
+ resolveInteractivityExpectation(descriptor);
221
+ if (!expectInteractive) return null;
222
+ let state;
223
+ try {
224
+ state = await collectHydrationState(frame, { framework });
225
+ } catch (_) {
226
+ return null;
227
+ }
228
+ return interpretHydration({
229
+ expectInteractive: true,
230
+ controlCount: state.controlCount,
231
+ frameworkAttached: state.frameworkAttached,
232
+ framework,
233
+ url,
234
+ });
235
+ }
236
+
237
+ module.exports = {
238
+ KNOWN_FRAMEWORKS,
239
+ readStackJson,
240
+ inferFramework,
241
+ resolveInteractivityExpectation,
242
+ collectHydrationState,
243
+ interpretHydration,
244
+ probeInteractivity,
245
+ };
@@ -5,8 +5,22 @@ const LOADING_MARKERS = [
5
5
  "disconnected",
6
6
  ];
7
7
 
8
- function hasLoadingMarkers(text) {
9
- return LOADING_MARKERS.some((marker) => text.includes(marker));
8
+ // `extraMarkers` are project-supplied (from stack.json `capture.loadingMarkers`)
9
+ // because an app's own loading copy ("Loading…", "Please wait") is
10
+ // app-specific and must NOT be hardcoded into the shared harness — only the
11
+ // four codeyam-harness markers above are universal. Matching is
12
+ // case-insensitive so "Loading…" and "loading…" both count; without the
13
+ // project markers a stable app loading screen looks "ready" to
14
+ // waitForStablePage and gets captured mid-hydration.
15
+ function hasLoadingMarkers(text, extraMarkers = []) {
16
+ if (!text) return false;
17
+ const lower = text.toLowerCase();
18
+ const markers = LOADING_MARKERS.concat(
19
+ Array.isArray(extraMarkers) ? extraMarkers : [],
20
+ );
21
+ return markers.some(
22
+ (marker) => marker && lower.includes(String(marker).toLowerCase()),
23
+ );
10
24
  }
11
25
 
12
26
  function hasRenderableContent(state) {
@@ -19,41 +19,76 @@ function findHttpMock(httpMocks, request) {
19
19
  return null;
20
20
  }
21
21
 
22
+ // The set of path/URL targets declared by the mock keys. A key is
23
+ // `"<METHOD> <target>"` (e.g. `"GET /api/plans"`); we strip the method so the
24
+ // route matcher can decide whether a request *could* match any mock without
25
+ // knowing the method (the handler re-checks method via findHttpMock).
26
+ function mockedTargets(httpMocks) {
27
+ const targets = new Set();
28
+ for (const key of Object.keys(httpMocks)) {
29
+ const spaceIdx = key.indexOf(" ");
30
+ if (spaceIdx === -1) continue;
31
+ targets.add(key.slice(spaceIdx + 1));
32
+ }
33
+ return targets;
34
+ }
35
+
36
+ // True when a request URL matches one of the declared mock targets. Accepts
37
+ // either a string or a WHATWG URL (Playwright's URL-matcher passes a URL).
38
+ function requestTargetsMock(targets, url) {
39
+ const href = typeof url === "string" ? url : url.href;
40
+ return normalizeMockCandidates(href).some((candidate) =>
41
+ targets.has(candidate),
42
+ );
43
+ }
44
+
22
45
  async function attachHttpMocks(page, httpMocks) {
23
46
  if (!httpMocks || Object.keys(httpMocks).length === 0) return;
24
47
 
25
- await page.route("**/*", async (route) => {
26
- const mock = findHttpMock(httpMocks, route.request());
27
- if (!mock) {
28
- await route.continue();
29
- return;
30
- }
48
+ // Intercept ONLY requests whose path matches a declared mock — not every
49
+ // request. A blanket `page.route("**/*")` intercepts the dev server's ESM
50
+ // module/script/style requests too, and routing them through
51
+ // `route.continue()` breaks Vite dev-mode module loading: the lazy app
52
+ // chunks never resolve and the SPA renders blank. Scoping the matcher to
53
+ // mocked targets lets those requests load natively while still mocking the API.
54
+ const targets = mockedTargets(httpMocks);
55
+ await page.route(
56
+ (url) => requestTargetsMock(targets, url),
57
+ async (route) => {
58
+ const mock = findHttpMock(httpMocks, route.request());
59
+ if (!mock) {
60
+ await route.continue();
61
+ return;
62
+ }
31
63
 
32
- const headers = { ...(mock.headers || {}) };
33
- let body;
34
- if (mock.body !== undefined) {
35
- body =
36
- typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body);
37
- const hasContentType = Object.keys(headers).some(
38
- (key) => key.toLowerCase() === "content-type",
39
- );
40
- if (!hasContentType) {
41
- headers["content-type"] = "application/json";
64
+ const headers = { ...(mock.headers || {}) };
65
+ let body;
66
+ if (mock.body !== undefined) {
67
+ body =
68
+ typeof mock.body === "string"
69
+ ? mock.body
70
+ : JSON.stringify(mock.body);
71
+ const hasContentType = Object.keys(headers).some(
72
+ (key) => key.toLowerCase() === "content-type",
73
+ );
74
+ if (!hasContentType) {
75
+ headers["content-type"] = "application/json";
76
+ }
42
77
  }
43
- }
44
78
 
45
- await route.fulfill({
46
- status: mock.status || 200,
47
- headers,
48
- body,
49
- });
50
- });
79
+ await route.fulfill({
80
+ status: mock.status || 200,
81
+ headers,
82
+ body,
83
+ });
84
+ },
85
+ );
51
86
 
52
87
  // Disable the in-page fetch mock by returning an empty active-mocks.json.
53
88
  // The HTML injects a script that synchronously loads this file and
54
89
  // monkey-patches window.fetch, which would bypass Playwright's route
55
- // interception. This route is registered AFTER **/* so it takes priority
56
- // (Playwright uses LIFO ordering for route handlers).
90
+ // interception. This route is registered AFTER the mock matcher so it takes
91
+ // priority for that path (Playwright uses LIFO ordering for route handlers).
57
92
  await page.route("**/active-mocks.json", async (route) => {
58
93
  await route.fulfill({
59
94
  status: 200,
@@ -66,5 +101,7 @@ async function attachHttpMocks(page, httpMocks) {
66
101
  module.exports = {
67
102
  normalizeMockCandidates,
68
103
  findHttpMock,
104
+ mockedTargets,
105
+ requestTargetsMock,
69
106
  attachHttpMocks,
70
107
  };