@codeyam-editor/codeyam-editor 0.1.8 → 0.1.10

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 (41) hide show
  1. package/npm/cloud.js +377 -21
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +355 -21
  5. package/npm/fleet-auth-account.js +61 -0
  6. package/npm/fleet-auth.js +69 -1
  7. package/npm/fleet-branch.js +124 -1
  8. package/npm/fleet-build-data.js +12 -1
  9. package/npm/fleet-cleanup.js +84 -3
  10. package/npm/fleet-datastore-health.js +107 -0
  11. package/npm/fleet-public-url.js +81 -12
  12. package/npm/fleet-reconcile.js +46 -11
  13. package/npm/fleet-revision.js +108 -0
  14. package/npm/fleet-run-setup.js +66 -0
  15. package/npm/fleet-session-activity.js +162 -0
  16. package/npm/fleet-vm-readiness.js +57 -16
  17. package/npm/group-bounded-spawn.js +111 -0
  18. package/npm/launch-config.js +216 -23
  19. package/npm/orphan-ssh-reaper.js +81 -0
  20. package/npm/scenario-check.js +697 -34
  21. package/npm/scenario-interactivity.js +477 -23
  22. package/npm/scenario-issues.js +61 -0
  23. package/npm/scenario-playwright.js +174 -20
  24. package/npm/utils.js +262 -22
  25. package/package.json +5 -5
  26. package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-DiN91foX.js} +1 -1
  27. package/ui/dist/assets/EditorShell-BEIdHnHd.css +1 -0
  28. package/ui/dist/assets/EditorShell-CPV5vhjo.js +330 -0
  29. package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +18 -0
  30. package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-CHIWip3n.js} +1 -1
  31. package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +21 -0
  32. package/ui/dist/assets/index--cE0hUJw.css +1 -0
  33. package/ui/dist/assets/{index-BPLKgwon.js → index-sW8wcg7v.js} +9 -9
  34. package/ui/dist/index.html +2 -2
  35. package/ui/dist/preview-subdir-demo/index.html +62 -0
  36. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  37. package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
  38. package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
  39. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
  40. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
  41. package/ui/dist/assets/index-DKpgJLo3.css +0 -1
@@ -7,6 +7,19 @@ function createIssue(kind, message, extra = {}) {
7
7
  };
8
8
  if (extra.matchedPattern != null) issue.matchedPattern = extra.matchedPattern;
9
9
  if (extra.contextSnippet != null) issue.contextSnippet = extra.contextSnippet;
10
+ // The hydration gate's cross-origin ATTRIBUTION, carried as data rather than
11
+ // only as prose. A consumer that must act on the verdict — escalating the
12
+ // preview off the subpath when the counterpart origin hydrates — would
13
+ // otherwise have to regex the English message, which breaks the moment the
14
+ // wording is improved. Same conditional shape as the two fields above so an
15
+ // issue that carries no attribution serializes exactly as it did before.
16
+ if (extra.crossOrigin != null) issue.crossOrigin = extra.crossOrigin;
17
+ if (extra.counterpartUrl != null) issue.counterpartUrl = extra.counterpartUrl;
18
+ // The dev-server-down verdict, carried as data for the same reason as the
19
+ // attribution fields above: a consumer that must distinguish "the app served
20
+ // an error" from "the app was not serving at all" should branch on a field,
21
+ // not on the prose of the message.
22
+ if (extra.devServerDown != null) issue.devServerDown = extra.devServerDown;
10
23
  return issue;
11
24
  }
12
25
 
@@ -17,6 +30,42 @@ function pushIssue(issues, issue) {
17
30
  }
18
31
  }
19
32
 
33
+ // Derive which origin a capture actually loaded from, and whether that differs
34
+ // from the origin the VIEWER's browser is served.
35
+ //
36
+ // The two can legitimately differ: the capture browser runs where the app is,
37
+ // the viewer may not, and aiming them independently is what makes a capture
38
+ // work on a cloud VM at all. A capture that renders perfectly from the app's
39
+ // own origin while the user's pane sits on `/__codeyam_preview` is a USEFUL
40
+ // result — but only if the difference travels with it instead of having to be
41
+ // inferred from a URL the reader must parse themselves.
42
+ //
43
+ // Both unknowns resolve toward "no split", never toward a guess. A caller that
44
+ // supplies no `viewerOrigin` (every pre-existing call site) and a `url` with no
45
+ // parseable origin both yield `false`, so no existing consumer sees a changed
46
+ // verdict and an unclassifiable capture is never reported as diverging.
47
+ //
48
+ // Deliberately not reusing `isCrossOriginRequest` from scenario-check.js: that
49
+ // module imports THIS one, so depending on it would invert the dependency, and
50
+ // its fail-safe answers a different question (never strip markers) than this
51
+ // one does (never claim a split).
52
+ function deriveOriginSplit(url, viewerOrigin) {
53
+ let captureOrigin = null;
54
+ try {
55
+ captureOrigin = new URL(url).origin;
56
+ } catch (_) {
57
+ /* a malformed or relative capture URL has no origin to report */
58
+ }
59
+ return {
60
+ captureOrigin,
61
+ viewerOrigin: viewerOrigin ?? null,
62
+ captureOriginDiffersFromViewer:
63
+ captureOrigin != null &&
64
+ viewerOrigin != null &&
65
+ captureOrigin !== viewerOrigin,
66
+ };
67
+ }
68
+
20
69
  function buildResult({
21
70
  loaded,
22
71
  hasContent,
@@ -26,7 +75,13 @@ function buildResult({
26
75
  unmockedRoutes = [],
27
76
  mockUsage = { used: [], unused: [] },
28
77
  externalRequests = [],
78
+ viewerOrigin = null,
29
79
  }) {
80
+ const { captureOrigin, captureOriginDiffersFromViewer } = deriveOriginSplit(
81
+ url,
82
+ viewerOrigin,
83
+ );
84
+
30
85
  return {
31
86
  ok: loaded && hasContent && issues.length === 0,
32
87
  loaded,
@@ -48,6 +103,11 @@ function buildResult({
48
103
  // Requests grouped by origin, split mocked/unmocked and same/cross-origin.
49
104
  // The line that ends a "why is this page blank" misdiagnosis.
50
105
  externalRequests,
106
+ // The two hops, named. See `deriveOriginSplit` for why they can differ and
107
+ // why reporting the difference is not optional.
108
+ captureOrigin,
109
+ viewerOrigin,
110
+ captureOriginDiffersFromViewer,
51
111
  };
52
112
  }
53
113
 
@@ -55,4 +115,5 @@ module.exports = {
55
115
  createIssue,
56
116
  pushIssue,
57
117
  buildResult,
118
+ deriveOriginSplit,
58
119
  };
@@ -345,9 +345,18 @@ async function collectVisibleTextLength(target) {
345
345
  // style id) and best-effort. Returns true when the style is present after the
346
346
  // call.
347
347
  //
348
- // The reveal is per-element and conditional ON THE ELEMENT BEING INVISIBLE,
349
- // never a blanket `opacity: 1 !important` / `transform: none !important` over
350
- // `*`. A blanket rule cannot tell an entrance animation's `opacity: 0` from
348
+ // The reveal is conditional on ANIMATION EVIDENCE, not on transparency alone:
349
+ // an element is revealed only when, before the reset lands, an animation or a
350
+ // transition was actually holding it back. An element resting at `opacity: 0`
351
+ // with neither is hidden BY INTENT — a closed dropdown, a hover-only action, an
352
+ // empty toast slot, an off-screen drawer — and is left exactly as the app
353
+ // rendered it. Elements carrying an explicit hidden-intent signal
354
+ // (`aria-hidden="true"`, `inert`, or `pointer-events: none`), on themselves or
355
+ // on an ancestor, are skipped even when a transition is declared — that is the
356
+ // resting shape of a closed popover which animates on open.
357
+ //
358
+ // It is never a blanket `opacity: 1 !important` / `transform: none !important`
359
+ // over `*`. A blanket rule cannot tell an entrance animation's `opacity: 0` from
351
360
  // DELIBERATE, resting state — a disabled control's dim, a muted row, a
352
361
  // collapsed chevron's rotation — so it silently flattened every one of them out
353
362
  // of every screenshot the capture pipeline produced. Two scenarios differing
@@ -385,14 +394,104 @@ async function forceFinalVisualState(target) {
385
394
  "}";
386
395
  const head = document.head || document.documentElement;
387
396
  if (!head || typeof head.appendChild !== "function") return false;
397
+
398
+ // Which elements is an animation or a transition ACTUALLY holding back?
399
+ // Snapshot that BEFORE the stylesheet lands, because injecting it destroys
400
+ // the evidence: `animation: none` / `transition: none` clears
401
+ // `animationName`, empties the running-animation list, and zeroes every
402
+ // duration. Only elements in this set are eligible for the reveal below.
403
+ const isAnimationHeld = (el) => {
404
+ let animations = null;
405
+ if (typeof el.getAnimations === "function") {
406
+ try {
407
+ animations = el.getAnimations();
408
+ } catch (_) {
409
+ animations = null;
410
+ }
411
+ }
412
+ if (animations && animations.length > 0) return true;
413
+ // No `getAnimations` (older engines) falls through to the computed-style
414
+ // evidence below — never to "reveal every invisible element".
415
+ let computed = null;
416
+ try {
417
+ computed = getComputedStyle(el);
418
+ } catch (_) {
419
+ return false;
420
+ }
421
+ if (!computed) return false;
422
+ const animationName = computed.animationName;
423
+ if (
424
+ typeof animationName === "string" &&
425
+ animationName !== "" &&
426
+ animationName !== "none"
427
+ ) {
428
+ return true;
429
+ }
430
+ const properties = computed.transitionProperty;
431
+ if (
432
+ typeof properties !== "string" ||
433
+ properties === "" ||
434
+ properties === "none"
435
+ ) {
436
+ return false;
437
+ }
438
+ const coversVisibility = properties
439
+ .split(",")
440
+ .map((property) => property.trim())
441
+ .some(
442
+ (property) =>
443
+ property === "opacity" ||
444
+ property === "transform" ||
445
+ property === "all",
446
+ );
447
+ if (!coversVisibility) return false;
448
+ const durations = computed.transitionDuration;
449
+ if (typeof durations !== "string") return false;
450
+ // A declared transition with a zero duration animates nothing, so it is
451
+ // not holding anything back.
452
+ return durations.split(",").some((duration) => parseFloat(duration) > 0);
453
+ };
454
+
455
+ // Explicit "this is hidden on purpose" signals, which outrank animation
456
+ // evidence. A closed popover carries its open-transition while resting
457
+ // closed, so a transition alone cannot distinguish it from an entrance the
458
+ // capture should finish. An ancestor counts: hiding a whole subtree is the
459
+ // common spelling.
460
+ const hasHiddenIntent = (el, computed) => {
461
+ if (computed && computed.pointerEvents === "none") return true;
462
+ if (typeof el.closest === "function") {
463
+ try {
464
+ if (el.closest('[aria-hidden="true"], [inert]')) return true;
465
+ } catch (_) {
466
+ // Fall through to the self-only checks below.
467
+ }
468
+ }
469
+ if (el.inert === true) return true;
470
+ if (
471
+ typeof el.getAttribute === "function" &&
472
+ el.getAttribute("aria-hidden") === "true"
473
+ ) {
474
+ return true;
475
+ }
476
+ return false;
477
+ };
478
+
479
+ const heldByAnimation = new Set();
480
+ if (typeof document.querySelectorAll === "function") {
481
+ for (const el of document.querySelectorAll("*")) {
482
+ if (isAnimationHeld(el)) heldByAnimation.add(el);
483
+ }
484
+ }
485
+
388
486
  head.appendChild(style);
389
487
 
390
488
  // With animations disabled above, an element held back by an entrance
391
489
  // animation now computes to its pre-animation resting state — typically
392
490
  // `opacity: 0`, often paired with a translate/scale that parks it offscreen.
393
- // Reveal exactly those, and only those: a fully transparent element shows
394
- // nothing either way, so forcing it can hide no real state, while an element
395
- // at any visible opacity is left untouched.
491
+ // Reveal exactly those, and only those: membership in the pre-reset
492
+ // `heldByAnimation` snapshot is what separates them from UI the app is
493
+ // deliberately holding at `opacity: 0`, which stays hidden. An element at
494
+ // any visible opacity is left untouched either way.
396
495
  //
397
496
  // The transform half of the reveal STOPS at the SVG boundary. A CSS
398
497
  // `transform` overrides the SVG `transform` presentation attribute, so
@@ -417,6 +516,8 @@ async function forceFinalVisualState(target) {
417
516
  if (!computed) continue;
418
517
  const opacity = parseFloat(computed.opacity);
419
518
  if (!Number.isFinite(opacity) || opacity > INVISIBLE_EPSILON) continue;
519
+ if (!heldByAnimation.has(el)) continue;
520
+ if (hasHiddenIntent(el, computed)) continue;
420
521
  if (!el.style || typeof el.style.setProperty !== "function") continue;
421
522
  const inSvg =
422
523
  el.ownerSVGElement != null ||
@@ -928,6 +1029,59 @@ async function collectInteractiveLabels(frame) {
928
1029
  }, INTERACTIVE_SELECTOR);
929
1030
  }
930
1031
 
1032
+ // How many candidate labels an error message may echo. Deliberately tighter
1033
+ // than `collectInteractiveLabels`' own 20-label read cap: the labels are the
1034
+ // USER'S page content, so every one printed is a line of their app leaked into
1035
+ // the terminal. Enough to answer "did you mean one of these?", not enough to
1036
+ // transcribe the page.
1037
+ const CANDIDATE_LABEL_CAP = 8;
1038
+
1039
+ // Render the candidate labels for an error message, capped and counted. Shared
1040
+ // by the no-match and action-failed paths so the echo policy lives in one place.
1041
+ async function candidateLabelSummary(frame) {
1042
+ const candidates = await collectInteractiveLabels(frame);
1043
+ if (candidates.length === 0) return "(none found on page)";
1044
+ const shown = candidates.slice(0, CANDIDATE_LABEL_CAP);
1045
+ const rest = candidates.length - shown.length;
1046
+ return rest > 0 ? `${shown.join(", ")} (+${rest} more)` : shown.join(", ");
1047
+ }
1048
+
1049
+ // Compose the "no element matched" error, branched on the form the caller
1050
+ // ACTUALLY passed. Pure and string-returning so both branches are asserted
1051
+ // without a browser.
1052
+ //
1053
+ // The substring advice ("prefer an exact CSS selector") only applies to a text
1054
+ // label. Printing it at a caller who already passed `#id` contradicts its own
1055
+ // input and reads as misdirection — the failure mode that sent two sessions
1056
+ // hunting a selector bug when the page had simply not hydrated. The candidate
1057
+ // labels ride with the text branch for the same reason: they answer "did you
1058
+ // mean one of these?", a question only a text match can have asked, and every
1059
+ // label printed is a line of the user's own page echoed into the terminal.
1060
+ function noMatchGuidance({ targetDesc, matchedByText, candidateLabels }) {
1061
+ const queryParamHint =
1062
+ `Many filter/status/sort states are also reachable directly via a ` +
1063
+ `URL query param (e.g. add "?status=active" to the path) with no ` +
1064
+ `interaction at all.`;
1065
+ if (matchedByText) {
1066
+ return (
1067
+ `preview-interact: no element matched ${targetDesc}. ` +
1068
+ `Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
1069
+ `over-specific label matches nothing — prefer an exact/role/testid CSS ` +
1070
+ `selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
1071
+ `target. ${queryParamHint} ` +
1072
+ `Candidate interactive labels: ${candidateLabels}`
1073
+ );
1074
+ }
1075
+ return (
1076
+ `preview-interact: no element matched ${targetDesc}. ` +
1077
+ `That is already an exact CSS selector, so nothing on the page matches ` +
1078
+ `it as written — confirm the element is present in THIS state (it may be ` +
1079
+ `behind a tab, dialog, or conditional render), that the id/attribute is ` +
1080
+ `spelled as the markup emits it, and that the page rendered and hydrated ` +
1081
+ `at all. ${queryParamHint}`
1082
+ );
1083
+ }
1084
+
931
1085
  // Describe the elements a substring text match resolved, so an ambiguity
932
1086
  // warning can name the competing controls (e.g. the preset button "Bet" vs the
933
1087
  // disclosure button "…or bet on"). Reads each matched element's inner text via
@@ -1075,17 +1229,17 @@ async function performInteraction(
1075
1229
  }
1076
1230
 
1077
1231
  if (matchCount === 0) {
1078
- const candidates = await collectInteractiveLabels(frame);
1079
- const candidateList =
1080
- candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
1232
+ // Candidate labels are collected ONLY for the text branch — see
1233
+ // `noMatchGuidance` for why a CSS-selector miss neither needs them nor
1234
+ // should echo the user's page content back at them.
1081
1235
  throw new Error(
1082
- `preview-interact: no element matched ${targetDesc}. ` +
1083
- `Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
1084
- `over-specific label matches nothing — prefer an exact/role/testid CSS ` +
1085
- `selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
1086
- `target. Many filter/status/sort states are also reachable directly via a ` +
1087
- `URL query param (e.g. add "?status=active" to the path) with no ` +
1088
- `interaction at all. Candidate interactive labels: ${candidateList}`,
1236
+ noMatchGuidance({
1237
+ targetDesc,
1238
+ matchedByText,
1239
+ candidateLabels: matchedByText
1240
+ ? await candidateLabelSummary(frame)
1241
+ : null,
1242
+ }),
1089
1243
  );
1090
1244
  }
1091
1245
 
@@ -1127,12 +1281,9 @@ async function performInteraction(
1127
1281
  );
1128
1282
  }
1129
1283
  } catch (error) {
1130
- const candidates = await collectInteractiveLabels(frame);
1131
- const candidateList =
1132
- candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
1133
1284
  throw new Error(
1134
1285
  `preview-interact: action "${action}" failed against ${targetDesc}: ${error.message || String(error)}. ` +
1135
- `Candidate interactive labels: ${candidateList}`,
1286
+ `Candidate interactive labels: ${await candidateLabelSummary(frame)}`,
1136
1287
  );
1137
1288
  }
1138
1289
  }
@@ -1209,6 +1360,7 @@ async function performInteractionSequence(
1209
1360
 
1210
1361
  module.exports = {
1211
1362
  logCaptureTiming,
1363
+ defaultReadServerState,
1212
1364
  resolveTcpTarget,
1213
1365
  assertAppPortReachable,
1214
1366
  escapeHtmlAttribute,
@@ -1230,6 +1382,8 @@ module.exports = {
1230
1382
  loadScenarioInIframe,
1231
1383
  loadScenarioTopLevel,
1232
1384
  collectInteractiveLabels,
1385
+ candidateLabelSummary,
1386
+ noMatchGuidance,
1233
1387
  describeMatchCandidates,
1234
1388
  performInteraction,
1235
1389
  waitForPredicate,