@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
@@ -2,6 +2,92 @@ const {
2
2
  hasLoadingMarkers,
3
3
  shouldStopWaitingForImages,
4
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
+ }
5
91
 
6
92
  function escapeHtmlAttribute(value) {
7
93
  return String(value).replaceAll("&", "&amp;").replaceAll('"', "&quot;");
@@ -62,7 +148,7 @@ async function collectContentState(target) {
62
148
  }
63
149
  }
64
150
  return {
65
- bodyTextLength: document.body.innerText.trim().length,
151
+ bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
66
152
  rootChildCount: root ? root.childElementCount : 0,
67
153
  rootTextLength: root ? (root.textContent || "").trim().length : 0,
68
154
  imageCount: imgs.length,
@@ -99,6 +185,18 @@ async function waitForImagesSettled(
99
185
  }
100
186
  const elapsedMs = Date.now() - started;
101
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
+ });
102
200
  return { settled: allComplete, images, elapsedMs };
103
201
  }
104
202
 
@@ -121,20 +219,122 @@ async function waitForAnimationsSettled(
121
219
  return { settled: false, elapsedMs: Date.now() - started };
122
220
  }
123
221
 
124
- async function waitForStablePage(page, target, timeoutMs = 10000) {
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
+
289
+ // `loadingMarkers` are the project's app-specific loading strings (from
290
+ // stack.json `capture.loadingMarkers`); they extend the codeyam-harness
291
+ // defaults so a stable-but-still-loading app screen counts as "not ready"
292
+ // and the loop keeps waiting instead of capturing the loading flash.
293
+ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers = []) {
125
294
  const started = Date.now();
126
295
  let lastHtml = "";
127
296
  let stableCount = 0;
297
+ let lastHadLoadingMarkers = false;
298
+ let lastHtmlChanged = false;
128
299
 
129
300
  while (Date.now() - started < timeoutMs) {
130
301
  await page.waitForTimeout(500);
131
302
 
132
- const pageState = await target.evaluate(() => ({
133
- bodyText: document.body.innerText,
134
- html: document.body.innerHTML,
135
- }));
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
+ });
321
+
322
+ lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
323
+ lastHtmlChanged = pageState.html !== lastHtml;
136
324
 
137
- if (!hasLoadingMarkers(pageState.bodyText) && pageState.html === lastHtml) {
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) {
138
338
  stableCount += 1;
139
339
  if (stableCount >= 2) {
140
340
  const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
@@ -142,6 +342,10 @@ async function waitForStablePage(page, target, timeoutMs = 10000) {
142
342
  timeoutMs: Math.min(2000, remaining()),
143
343
  });
144
344
  await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
345
+ logCaptureTiming("stable-page", {
346
+ outcome: "stabilized",
347
+ elapsedMs: Date.now() - started,
348
+ });
145
349
  return;
146
350
  }
147
351
  } else {
@@ -150,9 +354,26 @@ async function waitForStablePage(page, target, timeoutMs = 10000) {
150
354
 
151
355
  lastHtml = pageState.html;
152
356
  }
357
+ // Hit the cap without stabilizing — record WHY: a persistent loading marker
358
+ // (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
359
+ logCaptureTiming("stable-page", {
360
+ outcome: "timed-out",
361
+ elapsedMs: Date.now() - started,
362
+ timeoutMs,
363
+ lastHadLoadingMarkers,
364
+ lastHtmlStillChanging: lastHtmlChanged,
365
+ });
153
366
  }
154
367
 
155
- async function loadScenarioInIframe(page, url, { background } = {}) {
368
+ // `preflight` is injectable so unit tests that drive a mock page can stay
369
+ // network-free; production callers use the default real reachability check.
370
+ async function loadScenarioInIframe(
371
+ page,
372
+ url,
373
+ { background, preflight = assertAppPortReachable } = {},
374
+ ) {
375
+ await preflight(url);
376
+ const navStarted = Date.now();
156
377
  const responsePromise = page
157
378
  .waitForResponse(
158
379
  (response) =>
@@ -177,16 +398,137 @@ async function loadScenarioInIframe(page, url, { background } = {}) {
177
398
 
178
399
  await frame.waitForLoadState("load", { timeout: 30000 });
179
400
  const response = await responsePromise;
401
+ logCaptureTiming("navigate-iframe", {
402
+ elapsedMs: Date.now() - navStarted,
403
+ status: response ? response.status() : null,
404
+ url,
405
+ });
180
406
  return { frame, response };
181
407
  }
182
408
 
409
+ // Load the scenario as a top-level navigation instead of embedding it in
410
+ // the iframe harness. A top-level document is a first-party context, so a
411
+ // `SameSite=Lax` session cookie is sent on the navigation — which is what
412
+ // auth-gated application routes need to render the authenticated page
413
+ // rather than redirecting to /login. The returned `frame` is the page's
414
+ // main frame so callers can treat it uniformly with the iframe path
415
+ // (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`).
416
+ async function loadScenarioTopLevel(
417
+ page,
418
+ url,
419
+ { preflight = assertAppPortReachable } = {},
420
+ ) {
421
+ await preflight(url);
422
+ const navStarted = Date.now();
423
+ const response = await page.goto(url, {
424
+ waitUntil: "load",
425
+ timeout: 30000,
426
+ });
427
+ logCaptureTiming("navigate-toplevel", {
428
+ elapsedMs: Date.now() - navStarted,
429
+ status: response ? response.status() : null,
430
+ url,
431
+ });
432
+ return { frame: page.mainFrame(), response };
433
+ }
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
+
183
517
  module.exports = {
518
+ logCaptureTiming,
519
+ resolveTcpTarget,
520
+ assertAppPortReachable,
184
521
  escapeHtmlAttribute,
185
522
  buildIframeHarness,
186
523
  collectContentState,
187
524
  collectImageStates,
188
525
  waitForImagesSettled,
189
526
  waitForAnimationsSettled,
527
+ createNetworkTracker,
528
+ waitForNetworkQuiet,
190
529
  waitForStablePage,
191
530
  loadScenarioInIframe,
531
+ loadScenarioTopLevel,
532
+ collectInteractiveLabels,
533
+ performInteraction,
192
534
  };