@codeyam-editor/codeyam-editor 0.1.0 → 0.1.2

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 (47) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1521 -0
  4. package/npm/container.js +28 -5
  5. package/npm/editor-dev.js +378 -0
  6. package/npm/editor.js +199 -12
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-cleanup.js +124 -0
  10. package/npm/fleet-env.js +125 -0
  11. package/npm/fleet-memory.js +127 -0
  12. package/npm/fleet-pool-policy.js +78 -0
  13. package/npm/fleet-pool.js +401 -0
  14. package/npm/fleet-queue.js +40 -0
  15. package/npm/fleet-reconcile.js +36 -0
  16. package/npm/fleet-reset.js +144 -0
  17. package/npm/fleet-roster.js +81 -0
  18. package/npm/launch-config.js +544 -0
  19. package/npm/preflight-guidance.js +68 -0
  20. package/npm/scenario-check.js +355 -290
  21. package/npm/scenario-handlers.js +82 -0
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-issues.js +35 -0
  24. package/npm/scenario-metrics.js +108 -0
  25. package/npm/scenario-mocks.js +107 -0
  26. package/npm/scenario-playwright.js +355 -0
  27. package/npm/utils.js +548 -22
  28. package/npm/vm-urls.js +117 -0
  29. package/package.json +13 -6
  30. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  31. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  32. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  33. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  34. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  35. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  37. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  38. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  39. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  40. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  41. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  42. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  43. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  44. package/ui/dist/favicon.ico +0 -0
  45. package/ui/dist/favicon.png +0 -0
  46. package/ui/dist/index.html +16 -0
  47. package/npm/postinstall.js +0 -95
@@ -0,0 +1,355 @@
1
+ const {
2
+ hasLoadingMarkers,
3
+ shouldStopWaitingForImages,
4
+ } = require("./scenario-metrics");
5
+ const fs = require("fs");
6
+
7
+ // PROTOTYPE (improve35 capture diagnostics): append a per-phase timing line to
8
+ // a file so we can see WHERE a slow/timed-out capture spends its budget — even
9
+ // when the editor kills this script on timeout (stderr is lost then, but the
10
+ // file survives because each line is flushed synchronously). The cwd is the
11
+ // project dir (scenario_check.rs sets `.current_dir(project_dir)`), so this
12
+ // lands at `<project>/.codeyam/logs/capture-timing.log`. Diagnostics only —
13
+ // never throws, never affects the capture result.
14
+ function logCaptureTiming(phase, data) {
15
+ try {
16
+ const line = `[${new Date().toISOString()}] [capture-timing] phase=${phase} ${JSON.stringify(
17
+ data,
18
+ )}\n`;
19
+ fs.appendFileSync(".codeyam/logs/capture-timing.log", line);
20
+ } catch (_) {
21
+ /* diagnostics must never break a capture */
22
+ }
23
+ }
24
+
25
+ const net = require("net");
26
+
27
+ // Resolve a URL to the TCP {host, port} a pre-flight connect should target, or
28
+ // null when there is nothing to pre-check — an unparseable URL, or a non-http(s)
29
+ // target like `data:`/blank that the capture renders with no network origin.
30
+ // Pure (no socket) so the parse, protocol gate, and default-port rules are
31
+ // unit-tested without opening a connection.
32
+ function resolveTcpTarget(url) {
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(url);
36
+ } catch (_) {
37
+ return null;
38
+ }
39
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
40
+ return {
41
+ host: parsed.hostname,
42
+ port: Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 80),
43
+ };
44
+ }
45
+
46
+ // Fast pre-flight (improve35): is anything accepting TCP on the app port? A
47
+ // refused connection (the editor's reverse proxy is down) fails in milliseconds;
48
+ // without this, the iframe's `waitForLoadState("load")` hangs the FULL 30s on a
49
+ // dead origin (the observed capture failure — capture-timing showed
50
+ // navigate-iframe elapsedMs=30004, status=null). A connection that ACCEPTS is
51
+ // good enough to proceed — a slow HTTP response *after* connect is a cold
52
+ // compile, handled by the normal load wait + the editor's retry. So we only bail
53
+ // on a hard refusal/timeout, never on slowness.
54
+ async function assertAppPortReachable(url, { timeoutMs = 2500 } = {}) {
55
+ const target = resolveTcpTarget(url);
56
+ if (!target) return; // non-http target (data:, blank) — nothing to pre-check
57
+ const { host, port } = target;
58
+ const started = Date.now();
59
+ await new Promise((resolve, reject) => {
60
+ const socket = net.connect({ host, port });
61
+ const finish = (err) => {
62
+ socket.destroy();
63
+ const elapsedMs = Date.now() - started;
64
+ logCaptureTiming("app-port-reachable", {
65
+ host,
66
+ port,
67
+ reachable: !err,
68
+ elapsedMs,
69
+ error: err ? err.message : null,
70
+ });
71
+ if (err) reject(err);
72
+ else resolve();
73
+ };
74
+ socket.setTimeout(timeoutMs, () =>
75
+ finish(
76
+ new Error(
77
+ `app port unreachable: TCP connect to ${host}:${port} timed out after ${timeoutMs}ms — the editor's reverse proxy is not accepting connections (proxy down?)`,
78
+ ),
79
+ ),
80
+ );
81
+ socket.once("connect", () => finish());
82
+ socket.once("error", (e) =>
83
+ finish(
84
+ new Error(
85
+ `app port unreachable: ${host}:${port} ${e.code || e.message} — is the editor's reverse proxy up? (a capture cannot render a dead app port)`,
86
+ ),
87
+ ),
88
+ );
89
+ });
90
+ }
91
+
92
+ function escapeHtmlAttribute(value) {
93
+ return String(value).replaceAll("&", "&amp;").replaceAll('"', "&quot;");
94
+ }
95
+
96
+ // The harness background defaults to transparent so the iframe's own
97
+ // <body> background paints through — matching what users see in the Live
98
+ // Preview. Callers (via scenario-check.js) pass a concrete color when the
99
+ // UI has detected a background it wants the capture to paint behind the
100
+ // iframe, e.g. `var(--bg-deep)` from the editor shell.
101
+ function buildIframeHarness(url, { background = "transparent" } = {}) {
102
+ const escapedUrl = escapeHtmlAttribute(url);
103
+ const bg = String(background);
104
+ return `<!doctype html>
105
+ <html>
106
+ <head>
107
+ <meta charset="utf-8">
108
+ <style>
109
+ html, body {
110
+ margin: 0;
111
+ width: 100%;
112
+ height: 100%;
113
+ overflow: hidden;
114
+ background: ${bg};
115
+ }
116
+
117
+ iframe {
118
+ display: block;
119
+ width: 100%;
120
+ height: 100%;
121
+ border: 0;
122
+ background: ${bg};
123
+ }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
128
+ </body>
129
+ </html>`;
130
+ }
131
+
132
+ async function collectContentState(target) {
133
+ return target.evaluate(() => {
134
+ const root = document.getElementById("root");
135
+ const imgs = Array.from(document.images || []);
136
+ const loadedImageCount = imgs.filter(
137
+ (img) => img.complete && img.naturalWidth > 0,
138
+ ).length;
139
+ const mediaSelectors = ["svg", "canvas", "video"];
140
+ let mediaBboxCount = 0;
141
+ for (const selector of mediaSelectors) {
142
+ const nodes = document.querySelectorAll(selector);
143
+ for (const node of nodes) {
144
+ const rect = node.getBoundingClientRect();
145
+ if (rect.width > 0 && rect.height > 0) {
146
+ mediaBboxCount += 1;
147
+ }
148
+ }
149
+ }
150
+ return {
151
+ bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
152
+ rootChildCount: root ? root.childElementCount : 0,
153
+ rootTextLength: root ? (root.textContent || "").trim().length : 0,
154
+ imageCount: imgs.length,
155
+ loadedImageCount,
156
+ mediaBboxCount,
157
+ };
158
+ });
159
+ }
160
+
161
+ async function collectImageStates(target) {
162
+ return target.evaluate(() =>
163
+ Array.from(document.images || []).map((img) => ({
164
+ complete: img.complete,
165
+ naturalWidth: img.naturalWidth,
166
+ src: img.currentSrc || img.src || "",
167
+ })),
168
+ );
169
+ }
170
+
171
+ async function waitForImagesSettled(
172
+ target,
173
+ { overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
174
+ ) {
175
+ const started = Date.now();
176
+ let images = await collectImageStates(target);
177
+ while (
178
+ !shouldStopWaitingForImages(images, {
179
+ elapsedMs: Date.now() - started,
180
+ overallTimeoutMs,
181
+ })
182
+ ) {
183
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
184
+ images = await collectImageStates(target);
185
+ }
186
+ const elapsedMs = Date.now() - started;
187
+ const allComplete = images.every((img) => img && img.complete === true);
188
+ const incompleteSrcs = images
189
+ .filter((img) => !img || img.complete !== true || !(img.naturalWidth > 0))
190
+ .map((img) => (img && img.src) || "")
191
+ .slice(0, 6);
192
+ logCaptureTiming("images-settled", {
193
+ elapsedMs,
194
+ settled: allComplete,
195
+ total: images.length,
196
+ incompleteCount: incompleteSrcs.length,
197
+ incompleteSrcs,
198
+ overallTimeoutMs,
199
+ });
200
+ return { settled: allComplete, images, elapsedMs };
201
+ }
202
+
203
+ async function waitForAnimationsSettled(
204
+ target,
205
+ { timeoutMs = 2000, pollIntervalMs = 100 } = {},
206
+ ) {
207
+ const started = Date.now();
208
+ while (Date.now() - started < timeoutMs) {
209
+ const runningCount = await target.evaluate(() =>
210
+ document
211
+ .getAnimations()
212
+ .filter((a) => a.playState === "running").length,
213
+ );
214
+ if (runningCount === 0) {
215
+ return { settled: true, elapsedMs: Date.now() - started };
216
+ }
217
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
218
+ }
219
+ return { settled: false, elapsedMs: Date.now() - started };
220
+ }
221
+
222
+ // `loadingMarkers` are the project's app-specific loading strings (from
223
+ // stack.json `capture.loadingMarkers`); they extend the codeyam-harness
224
+ // defaults so a stable-but-still-loading app screen counts as "not ready"
225
+ // and the loop keeps waiting instead of capturing the loading flash.
226
+ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers = []) {
227
+ const started = Date.now();
228
+ let lastHtml = "";
229
+ let stableCount = 0;
230
+ let lastHadLoadingMarkers = false;
231
+ let lastHtmlChanged = false;
232
+
233
+ while (Date.now() - started < timeoutMs) {
234
+ await page.waitForTimeout(500);
235
+
236
+ const pageState = await target.evaluate(() => ({
237
+ bodyText: document.body?.innerText ?? "",
238
+ html: document.body?.innerHTML ?? "",
239
+ }));
240
+
241
+ lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
242
+ lastHtmlChanged = pageState.html !== lastHtml;
243
+
244
+ if (!lastHadLoadingMarkers && !lastHtmlChanged) {
245
+ stableCount += 1;
246
+ if (stableCount >= 2) {
247
+ const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
248
+ await waitForAnimationsSettled(target, {
249
+ timeoutMs: Math.min(2000, remaining()),
250
+ });
251
+ await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
252
+ logCaptureTiming("stable-page", {
253
+ outcome: "stabilized",
254
+ elapsedMs: Date.now() - started,
255
+ });
256
+ return;
257
+ }
258
+ } else {
259
+ stableCount = 0;
260
+ }
261
+
262
+ lastHtml = pageState.html;
263
+ }
264
+ // Hit the cap without stabilizing — record WHY: a persistent loading marker
265
+ // (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
266
+ logCaptureTiming("stable-page", {
267
+ outcome: "timed-out",
268
+ elapsedMs: Date.now() - started,
269
+ timeoutMs,
270
+ lastHadLoadingMarkers,
271
+ lastHtmlStillChanging: lastHtmlChanged,
272
+ });
273
+ }
274
+
275
+ // `preflight` is injectable so unit tests that drive a mock page can stay
276
+ // network-free; production callers use the default real reachability check.
277
+ async function loadScenarioInIframe(
278
+ page,
279
+ url,
280
+ { background, preflight = assertAppPortReachable } = {},
281
+ ) {
282
+ await preflight(url);
283
+ const navStarted = Date.now();
284
+ const responsePromise = page
285
+ .waitForResponse(
286
+ (response) =>
287
+ response.request().resourceType() === "document" &&
288
+ response.url() === url,
289
+ { timeout: 30000 },
290
+ )
291
+ .catch(() => null);
292
+
293
+ await page.setContent(buildIframeHarness(url, { background }), {
294
+ waitUntil: "domcontentloaded",
295
+ });
296
+
297
+ const frameHandle = await page.waitForSelector("#scenario-frame", {
298
+ state: "attached",
299
+ timeout: 30000,
300
+ });
301
+ const frame = await frameHandle.contentFrame();
302
+ if (!frame) {
303
+ throw new Error("Scenario iframe did not attach");
304
+ }
305
+
306
+ await frame.waitForLoadState("load", { timeout: 30000 });
307
+ const response = await responsePromise;
308
+ logCaptureTiming("navigate-iframe", {
309
+ elapsedMs: Date.now() - navStarted,
310
+ status: response ? response.status() : null,
311
+ url,
312
+ });
313
+ return { frame, response };
314
+ }
315
+
316
+ // Load the scenario as a top-level navigation instead of embedding it in
317
+ // the iframe harness. A top-level document is a first-party context, so a
318
+ // `SameSite=Lax` session cookie is sent on the navigation — which is what
319
+ // auth-gated application routes need to render the authenticated page
320
+ // rather than redirecting to /login. The returned `frame` is the page's
321
+ // main frame so callers can treat it uniformly with the iframe path
322
+ // (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`).
323
+ async function loadScenarioTopLevel(
324
+ page,
325
+ url,
326
+ { preflight = assertAppPortReachable } = {},
327
+ ) {
328
+ await preflight(url);
329
+ const navStarted = Date.now();
330
+ const response = await page.goto(url, {
331
+ waitUntil: "load",
332
+ timeout: 30000,
333
+ });
334
+ logCaptureTiming("navigate-toplevel", {
335
+ elapsedMs: Date.now() - navStarted,
336
+ status: response ? response.status() : null,
337
+ url,
338
+ });
339
+ return { frame: page.mainFrame(), response };
340
+ }
341
+
342
+ module.exports = {
343
+ logCaptureTiming,
344
+ resolveTcpTarget,
345
+ assertAppPortReachable,
346
+ escapeHtmlAttribute,
347
+ buildIframeHarness,
348
+ collectContentState,
349
+ collectImageStates,
350
+ waitForImagesSettled,
351
+ waitForAnimationsSettled,
352
+ waitForStablePage,
353
+ loadScenarioInIframe,
354
+ loadScenarioTopLevel,
355
+ };