@intentius/behold 0.29.3 → 0.29.5

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.
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
- import { mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
4
+ import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, logLineWords, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
5
5
 
6
6
  const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
7
7
  const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
@@ -21,6 +21,20 @@ describe("the runs, as a CI page lists them (#569)", () => {
21
21
  expect(STATUS_TONE.waiting).toBe("wait");
22
22
  expect(STATUS_TONE.waiting).not.toBe(STATUS_TONE.failure);
23
23
  });
24
+ it("shows the runner's exit 3 under a job held at the gate as the hold, not a failure", () => {
25
+ const line = "⚙️ [runner]: exitcode '3': failure";
26
+ expect(logLineWords("waiting", line)).toEqual({ text: "[runner]: exit code 3: held at the gate, waiting for an approval", held: true });
27
+ // The fixture's held job ends its log with that line.
28
+ const held = run(444).jobs.find((j) => j.status === "waiting");
29
+ const last = held.steps.find((s) => s.status === "waiting").log.at(-1);
30
+ expect(logLineWords(held.status, last).held).toBe(true);
31
+ // A real failure keeps its line, whatever the code.
32
+ expect(logLineWords("failure", line)).toEqual({ text: line, held: false });
33
+ expect(logLineWords("failure", "⚙️ [runner]: exitcode '1': failure")).toEqual({ text: "⚙️ [runner]: exitcode '1': failure", held: false });
34
+ // A held job's other lines, and another code, are kept as they came.
35
+ expect(logLineWords("waiting", "⚙️ [runner]: exitcode '1': failure").held).toBe(false);
36
+ expect(logLineWords("waiting", "wave 2 of 3: approval ledger (the default)").held).toBe(false);
37
+ });
24
38
  it("follows the newest run still going, pinned first", () => {
25
39
  const runs = index().runs;
26
40
  expect(liveRun(runs).id).toBe(448);
@@ -96,3 +110,24 @@ describe("one run, from the index and its own document (#569)", () => {
96
110
  expect(mergeRun(entry, null)).toBe(entry);
97
111
  });
98
112
  });
113
+
114
+ describe("the log pane follows its end, like a CI page's log", () => {
115
+ const key = logKey(448, "apply-wave-2", 2);
116
+ it("follows a log that just opened: another run, job or step", () => {
117
+ expect(followsLog(null, key, false)).toBe(true);
118
+ expect(followsLog(logKey(447, "apply-wave-2", 2), key, false)).toBe(true);
119
+ expect(followsLog(logKey(448, "apply-wave-1", 2), key, false)).toBe(true);
120
+ expect(followsLog(logKey(448, "apply-wave-2", 1), key, false)).toBe(true);
121
+ });
122
+ it("on the same log, keeps what the viewer chose", () => {
123
+ expect(followsLog(key, key, true)).toBe(true);
124
+ expect(followsLog(key, key, false)).toBe(false);
125
+ });
126
+ it("counts a view within a few lines of the end as at the end", () => {
127
+ const slack = 18 * FOLLOW_LINES;
128
+ expect(nearEnd({ scrollTop: 600, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(true);
129
+ expect(nearEnd({ scrollTop: 600 - slack, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(true);
130
+ expect(nearEnd({ scrollTop: 600 - slack - 1, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
131
+ expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
132
+ });
133
+ });
package/web/kiosk.html CHANGED
@@ -85,6 +85,10 @@
85
85
  .k-kicker { display: flex; gap: 1.2em; font: 600 0.78em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
86
86
  .k-cycle { text-transform: none; letter-spacing: 0; font-weight: 400; }
87
87
  .k-title { margin: 0.05em 0 0.25em; font-size: 1.45em; line-height: 1.15; }
88
+ /* behold#571: the scenario's card, three labelled rows under the title. */
89
+ .k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.12em 0.9em; align-items: baseline; margin: 0 0 0.45em; font-size: 0.9em; line-height: 1.3; }
90
+ .k-about-label { font: 600 0.74em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
91
+ .k-about-text { margin: 0; color: var(--fg); }
88
92
  .k-muted { color: var(--muted); margin: 0.25em 0; }
89
93
  .k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; }
90
94
  .k-step-word { font: 600 0.75em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
@@ -110,7 +114,18 @@
110
114
  .k-inline { font-family: var(--font-mono); color: var(--fg); background: var(--well); padding: 0.05em 0.35em; border-radius: 4px; user-select: none; }
111
115
  .k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
112
116
  .k-unknown { color: var(--foreign); }
113
- #view { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
117
+ #view-pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
118
+ #view { display: block; width: 100%; flex: 1; min-height: 0; border: 0; background: var(--bg); }
119
+ /* #571: the toggle between the two views, over the frame by the view's caption. */
120
+ #views { display: flex; align-items: center; gap: 0.9em; padding: 0.45em 1em; border-bottom: 1px solid var(--rule); background: var(--panel); }
121
+ #views[hidden] { display: none; }
122
+ .k-views { display: inline-flex; border: 1px solid var(--rule); border-radius: 999px; overflow: hidden; }
123
+ .k-view { font: 600 0.85em var(--font-ui); color: var(--muted); background: transparent; border: 0; padding: 0.3em 0.95em; cursor: pointer; }
124
+ .k-view + .k-view { border-left: 1px solid var(--rule); }
125
+ .k-view:hover { color: var(--fg); }
126
+ .k-view-on { color: var(--bg); background: var(--pending); }
127
+ .k-view-on:hover { color: var(--bg); }
128
+ .k-view-note { color: var(--muted); font-size: 0.8em; }
114
129
  #foot { display: flex; flex-wrap: wrap; gap: 0.2em 1.6em; padding: 0.55em 1.6em; border-top: 1px solid var(--rule); background: var(--panel); font-size: 0.85em; }
115
130
  .k-legend { color: var(--muted); flex-basis: 100%; }
116
131
  .k-fact { color: var(--fg); }
@@ -123,14 +138,17 @@
123
138
  <div id="stage">
124
139
  <nav id="runs-side" aria-label="pipeline runs"></nav>
125
140
  <section id="runs-main" aria-live="polite"></section>
126
- <iframe id="view" title="behold's view of the change"></iframe>
141
+ <section id="view-pane">
142
+ <div id="views" hidden></div>
143
+ <iframe id="view" title="behold's view of the change"></iframe>
144
+ </section>
127
145
  </div>
128
146
  <footer id="foot"></footer>
129
147
  <script type="module">
130
148
  import { initTheme } from "/theme.js";
131
149
  import { startKiosk } from "/kiosk.js";
132
150
  initTheme();
133
- startKiosk({ bar: document.getElementById("bar"), frame: document.getElementById("view"), foot: document.getElementById("foot"), side: document.getElementById("runs-side"), main: document.getElementById("runs-main") });
151
+ startKiosk({ bar: document.getElementById("bar"), frame: document.getElementById("view"), foot: document.getElementById("foot"), side: document.getElementById("runs-side"), main: document.getElementById("runs-main"), views: document.getElementById("views") });
134
152
  </script>
135
153
  </body>
136
154
  </html>
package/web/kiosk.js CHANGED
@@ -13,8 +13,8 @@
13
13
  // A wave that failed says which waves applied before it and stay applied.
14
14
  // Nothing here is all-or-nothing, and nothing says so.
15
15
 
16
- import { startRuns } from "./kiosk-runs.js";
17
- import { STATE_WORDS, changingRoots, costLine, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
16
+ import { PICK_HOLD_MS, startRuns } from "./kiosk-runs.js";
17
+ import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
18
18
 
19
19
  /** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
20
20
  export const SCENARIOS = {
@@ -59,6 +59,24 @@ export function scenarioHead(demo) {
59
59
  return { kicker: `scenario ${known.n} · ${known.name}`, title: demo.title };
60
60
  }
61
61
 
62
+ /** The card's rows under the title, in order, with the labels the kiosk draws (behold#571). */
63
+ export const ABOUT_ROWS = [
64
+ { field: "changes", label: "What changes" },
65
+ { field: "watch", label: "Watch for" },
66
+ { field: "shows", label: "Shows" },
67
+ ];
68
+
69
+ /**
70
+ * behold#571: the scenario's card, always open under the title so a TV or a
71
+ * link opened cold reads it without a click. The text is demo.json's `about`,
72
+ * as terragucci wrote it; no card when it has none.
73
+ */
74
+ export function aboutRows(demo) {
75
+ const about = demo && demo.about;
76
+ if (!about) return [];
77
+ return ABOUT_ROWS.filter((r) => typeof about[r.field] === "string" && about[r.field]).map((r) => ({ label: r.label, text: about[r.field] }));
78
+ }
79
+
62
80
  /** The strip: each stop, and whether it is done, current or ahead. A stalled cycle marks no stop current. */
63
81
  export function stripStops(step) {
64
82
  const at = STRIP.findIndex((s) => s.steps.includes(step));
@@ -112,13 +130,15 @@ const runOf = (progress) => (progress && !progress.error && progress.run && prog
112
130
 
113
131
  /**
114
132
  * The newest pull request's plan, when it is this step's: the pull request
115
- * demo.json names. A forge never reuses a number, so an older cycle's plan is
116
- * never shown as this one's.
133
+ * demo.json names, of its commit before the merge. A forge never reuses a
134
+ * number, so an older cycle's plan is never shown as this one's.
117
135
  */
118
136
  export function matchingPull(answer, demo) {
119
137
  const pr = pullOf(answer);
120
138
  if (!pr || demo.pull === null) return null;
121
- return String(pr.run.pull_request) === String(demo.pull) ? pr : null;
139
+ // From the merge on, demo.json's commit is the merge's, not the plan's.
140
+ const before = demo.step === "pull-request" || demo.step === "planned";
141
+ return planFitsPin(pr, before ? pullPin(planPin(demo)) : { pull: String(demo.pull), commit: null }) ? pr : null;
122
142
  }
123
143
 
124
144
  /**
@@ -241,12 +261,24 @@ export function focusRoots(demo, answer, progress) {
241
261
  return null;
242
262
  }
243
263
 
264
+ /**
265
+ * #569: the pin on this cycle's plan, `pull-<n>-<sha>` (or `pull-<n>`, or bare
266
+ * `pull` while none is open): the frame draws that plan or none, never the
267
+ * plan of another cycle's pull request.
268
+ */
269
+ export function planPin(demo) {
270
+ if (demo.pull === null || demo.pull === undefined || !/^\d+$/.test(String(demo.pull))) return "pull";
271
+ const sha = demo.commit && /^[0-9a-f]{7,40}$/.test(demo.commit) ? `-${demo.commit}` : "";
272
+ return `pull-${demo.pull}${sha}`;
273
+ }
274
+
244
275
  /**
245
276
  * #569: which run the framed view draws for this step, so the graph stays on
246
277
  * the cycle the narration describes while the loop's next runs (the reset's
247
278
  * push back to the base) already go:
248
279
  *
249
- * - before the merge, `none`: no run view, the pull request's plan;
280
+ * - before the merge, `pull-<n>-<sha>` (planPin): no run view, and only this
281
+ * cycle's plan; until the plan job writes it, the roots with no marks;
250
282
  * - from the merge to `applied`, the cycle's commit (demo.json's `commit`:
251
283
  * the merge, whose run view the `/terragucci apply` resume rewrites, or
252
284
  * scenario 5's push);
@@ -257,17 +289,112 @@ export function focusRoots(demo, answer, progress) {
257
289
  export function runPin(demo) {
258
290
  if (!demo) return null;
259
291
  if (demo.scenario === "base" || demo.step === "reset") return "base";
260
- if (demo.step === "pull-request" || demo.step === "planned") return "none";
292
+ if (demo.step === "pull-request" || demo.step === "planned") return planPin(demo);
261
293
  if (demo.step === "stalled") return demo.commit ? demo.commit : "none";
262
294
  return demo.commit || "none";
263
295
  }
264
296
 
265
- /** The framed view's address: behold's own page, bare, at the resources zoom, told who its host is. */
266
- export function frameSrc(origin) {
267
- const q = new URLSearchParams({ embed: "1", bare: "1", zoom: "resources", host: origin });
297
+ /** The framed view's address: behold's own page, bare, at a zoom (the resources zoom unless told), told who its host is. */
298
+ export function frameSrc(origin, zoom = "resources") {
299
+ const q = new URLSearchParams({ embed: "1", bare: "1", zoom, host: origin });
268
300
  return `/?${q.toString()}`;
269
301
  }
270
302
 
303
+ // ---------------------------------------------------------------------------
304
+ // #571: which of behold's two views the frame shows. Before the merge, the
305
+ // resources zoom: what the pull request changes and the reads it plans
306
+ // before their values are known. From the merge on, the components zoom of a
307
+ // terragucci repo (`terragucci.components.json`): one card per root in a box
308
+ // per wave, titled with the job that applies it, in step with the jobs pane.
309
+ // A viewer's click on the toggle holds a view for PICK_HOLD_MS after their
310
+ // last click, as a run picked in the sidebar does; then the step leads again.
311
+
312
+ /** The two views the toggle offers, with behold's zoom for each. */
313
+ export const VIEWS = [
314
+ { id: "resources", words: "Resources", zoom: "resources" },
315
+ { id: "components", words: "Roots by wave", zoom: "components" },
316
+ ];
317
+
318
+ /**
319
+ * The view a step shows: the pull request's steps the resources (what
320
+ * changes, the unknown reads), every step from the merge on the components
321
+ * (the jobs running), and the reset the components at rest. A step behold
322
+ * doesn't know, and `stalled`, keep the components: the jobs pane says where
323
+ * the pipeline stopped.
324
+ */
325
+ export const STEP_VIEW = {
326
+ "pull-request": "resources",
327
+ planned: "resources",
328
+ merged: "components",
329
+ applying: "components",
330
+ waiting: "components",
331
+ approved: "components",
332
+ failed: "components",
333
+ resumed: "components",
334
+ applied: "components",
335
+ reset: "components",
336
+ stalled: "components",
337
+ };
338
+
339
+ /** The view for a step. */
340
+ export function stepView(step) {
341
+ return Object.prototype.hasOwnProperty.call(STEP_VIEW, step) ? STEP_VIEW[step] : "components";
342
+ }
343
+
344
+ /**
345
+ * The view the frame shows, and why: `{ view, pinned, secs }`. Only the
346
+ * resources view when the repo offers no components zoom (no
347
+ * `terragucci.components.json`). A viewer's pick (`{ view, at }`, `at` in ms)
348
+ * holds until PICK_HOLD_MS after it; `secs` is how long it still holds.
349
+ * Without a demo step yet, the resources view.
350
+ */
351
+ export function kioskView(demo, { components = false, pick = null, now = Date.now() } = {}) {
352
+ if (!components) return { view: "resources", pinned: false, secs: null };
353
+ if (pick && VIEWS.some((v) => v.id === pick.view)) {
354
+ const left = pick.at + PICK_HOLD_MS - now;
355
+ if (left > 0) return { view: pick.view, pinned: true, secs: Math.ceil(left / 1000) };
356
+ }
357
+ return { view: demo ? stepView(demo.step) : "resources", pinned: false, secs: null };
358
+ }
359
+
360
+ /** The line under the view saying how to read it, for the view shown. */
361
+ export function legendWords(view) {
362
+ if (view === "components") {
363
+ return "Each card is a Terraform root with its own state, in a box per wave titled with the pipeline job that applies it; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.";
364
+ }
365
+ return "Each box is a Terraform root with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads.";
366
+ }
367
+
368
+ /** The behold zoom for a view. */
369
+ export function viewZoom(view) {
370
+ const v = VIEWS.find((x) => x.id === view);
371
+ return v ? v.zoom : "resources";
372
+ }
373
+
374
+ /**
375
+ * The toggle, "Resources | Roots by wave", over the frame: the view shown
376
+ * pressed, and a line saying whether the step leads or a click holds it.
377
+ * Not drawn at all when the repo offers no components zoom.
378
+ */
379
+ export function renderViewToggle(host, shown, { components = false, onPick = () => {} } = {}) {
380
+ if (!host) return;
381
+ host.replaceChildren();
382
+ host.hidden = !components;
383
+ if (!components) return;
384
+ const group = el("div", "k-views");
385
+ group.setAttribute("role", "group");
386
+ group.setAttribute("aria-label", "the view");
387
+ for (const v of VIEWS) {
388
+ const b = el("button", `k-view${v.id === shown.view ? " k-view-on" : ""}`, v.words);
389
+ b.type = "button";
390
+ b.setAttribute("aria-pressed", String(v.id === shown.view));
391
+ b.addEventListener("click", () => onPick(v.id));
392
+ group.appendChild(b);
393
+ }
394
+ host.appendChild(group);
395
+ host.appendChild(el("span", "k-view-note", shown.pinned ? `held by a click; the step leads again in ${shown.secs} s` : "follows the step"));
396
+ }
397
+
271
398
  /** The one-line facts under the view: the pull request's totals, cost and blast radius, as #568 has them. */
272
399
  export function factLines(demo, answer) {
273
400
  const pr = demo ? matchingPull(answer, demo) : null;
@@ -313,6 +440,12 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
313
440
  kick.append(el("span", null, head.kicker), el("span", "k-cycle", cycleWords(demo, now)));
314
441
  left.appendChild(kick);
315
442
  left.appendChild(el("h1", "k-title", head.title));
443
+ const rows = aboutRows(demo);
444
+ if (rows.length) {
445
+ const card = el("dl", "k-about");
446
+ for (const r of rows) card.append(el("dt", "k-about-label", r.label), el("dd", "k-about-text", r.text));
447
+ left.appendChild(card);
448
+ }
316
449
  const said = el("div", `k-step k-step-${demo.step}`);
317
450
  said.appendChild(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step));
318
451
  said.appendChild(el("p", "k-label", demo.label));
@@ -354,7 +487,7 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
354
487
  bar.appendChild(right);
355
488
 
356
489
  if (foot) {
357
- foot.appendChild(el("span", "k-legend", "Each box is a Terraform root with its own state; an arrow is one root reading another's outputs, dashed while it reads planned values. A wave applies its roots together, after the waves it reads."));
490
+ foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
358
491
  for (const f of factLines(demo, s.answer)) foot.appendChild(el("span", "k-fact", f));
359
492
  foot.appendChild(
360
493
  el("span", "k-source", `narration read ${clockOf(s.demo.read)} from ${s.demo.source}${demo.pull !== null ? ` · pull request #${demo.pull}` : ""}${demo.commit ? ` · commit ${demo.commit.slice(0, 8)}` : ""} · nothing here writes`),
@@ -377,34 +510,50 @@ export const DEMO_POLL_MS = 3_000;
377
510
  * countdown. When the roots the step is about change, move the framed view
378
511
  * with a `behold:view` message; the frame takes them only from this origin.
379
512
  */
380
- export function startKiosk({ bar, frame, foot, side, main }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
381
- const s = { demo: null, answer: null, progress: null };
513
+ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson = defaultFetch, now = () => new Date(), origin = location.origin } = {}) {
514
+ const s = { demo: null, answer: null, progress: null, components: false, pick: null, view: "resources" };
382
515
  let lastKey = "";
383
516
  let lastFocus;
517
+ const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
384
518
  const tellFrame = (force) => {
385
519
  const d = s.demo && s.demo.demo;
386
520
  if (!d) return; // nothing said yet: the frame keeps its default until the narration is read
387
521
  const roots = focusRoots(d, s.answer, s.progress);
388
522
  const focus = roots ? roots.join(",") : null;
389
523
  const run = runPin(d);
390
- const key = `${focus}|${run}`;
524
+ const zoom = viewZoom(shownView().view);
525
+ const key = `${focus}|${run}|${zoom}`;
391
526
  if (!force && key === lastFocus) return;
392
527
  lastFocus = key;
393
- if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:view", member: null, focus, run }, origin);
528
+ if (frame && frame.contentWindow) frame.contentWindow.postMessage({ type: "behold:view", member: null, focus, run, zoom }, origin);
529
+ };
530
+ const pickView = (view) => {
531
+ s.pick = { view, at: now().getTime() };
532
+ draw();
394
533
  };
395
534
  const draw = () => {
535
+ const shown = shownView();
536
+ s.view = shown.view;
396
537
  renderKiosk(bar, s, now(), foot);
538
+ renderViewToggle(views, shown, { components: s.components, onPick: pickView });
397
539
  tellFrame(false);
398
540
  };
399
541
  // #569: the pipeline's runs beside the view, CI-page style.
400
542
  if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS });
401
- if (frame) {
402
- frame.src = frameSrc(origin);
543
+ // #571: whether the repo offers the components zoom (a terragucci.components.json), read before the frame opens so it opens on the step's view.
544
+ const openFrame = () => {
545
+ if (!frame) return;
546
+ frame.src = frameSrc(origin, viewZoom(shownView().view));
403
547
  // The frame reads its graph after load; tell it again once it has had the time.
404
548
  frame.addEventListener("load", () => {
405
549
  for (const ms of [1500, 4000, 8000]) setTimeout(() => tellFrame(true), ms);
406
550
  });
407
- }
551
+ };
552
+ void fetchJson("/api/project").then((p) => {
553
+ s.components = !!(p && !p.error && p.terragucciComponents);
554
+ openFrame();
555
+ draw();
556
+ });
408
557
 
409
558
  const reports = async (fresh) => {
410
559
  s.answer = await fetchJson(`/api/terragucci${fresh ? "?fresh=1" : ""}`);
@@ -413,7 +562,7 @@ export function startKiosk({ bar, frame, foot, side, main }, { fetchJson = defau
413
562
  // #569: the run view of the cycle's commit, the same one the frame draws.
414
563
  const pinnedCommit = () => {
415
564
  const pin = runPin(s.demo && s.demo.demo);
416
- return pin && pin !== "none" && pin !== "base" ? pin : null;
565
+ return pinsCommit(pin) ? pin : null;
417
566
  };
418
567
  const progress = async (fresh) => {
419
568
  const pin = pinnedCommit();
package/web/kiosk.test.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { focusRoots, frameSrc, factLines, pendingApproval, runPin } from "./kiosk.js";
2
+ import { STEP_VIEW, STEP_WORDS, aboutRows, focusRoots, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
3
+ import { PICK_HOLD_MS } from "./kiosk-runs.js";
3
4
 
4
5
  const demo = (step, extra = {}) => ({ cycle: 1, scenario: "a-output", title: "t", step, label: "", started_at: "2026-10-15T03:00:00Z", next: null, pull: 7, commit: "abc12345", ...extra });
5
6
  const answer = {
@@ -54,8 +55,10 @@ describe("factLines (#569)", () => {
54
55
 
55
56
  describe("runPin: the frame stays on the cycle's run (#569)", () => {
56
57
  it("draws the pull request's plan, then the cycle's commit up to applied, then the base", () => {
57
- expect(runPin(demo("pull-request"))).toBe("none");
58
- expect(runPin(demo("planned"))).toBe("none");
58
+ expect(runPin(demo("pull-request"))).toBe("pull-7-abc12345");
59
+ expect(runPin(demo("planned"))).toBe("pull-7-abc12345");
60
+ expect(runPin(demo("pull-request", { commit: null }))).toBe("pull-7");
61
+ expect(runPin(demo("pull-request", { pull: null, commit: null }))).toBe("pull");
59
62
  for (const step of ["merged", "applying", "waiting", "approved", "failed", "resumed", "applied"]) expect(runPin(demo(step))).toBe("abc12345");
60
63
  expect(runPin(demo("reset"))).toBe("base");
61
64
  expect(runPin(demo("applied", { scenario: "base" }))).toBe("base");
@@ -63,3 +66,157 @@ describe("runPin: the frame stays on the cycle's run (#569)", () => {
63
66
  expect(runPin(null)).toBe(null);
64
67
  });
65
68
  });
69
+
70
+ describe("before the merge, no other cycle's plan (#569)", () => {
71
+ // Cycle 441's narration at pull-request 989, while the reports' newest plan is cycle 439's pull request 987.
72
+ const older = { ...answer, pull_request: { ...answer.pull_request, run: { pull_request: "987", commit: "365a4f0d" } } };
73
+ const sameNumberOtherCommit = { ...answer, pull_request: { ...answer.pull_request, run: { pull_request: "989", commit: "0badc0de" } } };
74
+ const ours = { ...answer, pull_request: { ...answer.pull_request, run: { pull_request: "989", commit: "659dee43" } } };
75
+ const now = (step) => demo(step, { pull: 989, commit: "659dee43" });
76
+ it("pins the frame to this cycle's pull request and commit at pull-request and planned", () => {
77
+ for (const step of ["pull-request", "planned"]) {
78
+ expect(runPin(now(step))).toBe("pull-989-659dee43");
79
+ expect(matchingPull(older, now(step))).toBe(null);
80
+ expect(matchingPull(sameNumberOtherCommit, now(step))).toBe(null);
81
+ expect(matchingPull(ours, now(step))).toBe(ours.pull_request);
82
+ expect(focusRoots(now(step), older, null)).toBe(null);
83
+ expect(factLines(now(step), older)).toEqual([]);
84
+ expect(runPanel(now(step), older, null).note).toBe("The plan of pull request #989 isn't in the reports yet: the pipeline is planning it.");
85
+ }
86
+ });
87
+ it("from the merge on, takes the plan by its number: demo.json's commit is the merge's", () => {
88
+ expect(matchingPull(ours, demo("merged", { pull: 989, commit: "53c1e5d2" }))).toBe(ours.pull_request);
89
+ expect(matchingPull(older, demo("merged", { pull: 989, commit: "53c1e5d2" }))).toBe(null);
90
+ });
91
+ });
92
+
93
+ describe("the scenario's card under the title (behold#571)", () => {
94
+ const about = { changes: "Network's port goes from 8080 to 9090.", watch: "Wave 2 fails partway.", shows: "Nothing here is atomic." };
95
+
96
+ it("has three rows in order, labelled as the kiosk draws them", () => {
97
+ expect(aboutRows(demo("failed", { about }))).toEqual([
98
+ { label: "What changes", text: about.changes },
99
+ { label: "Watch for", text: about.watch },
100
+ { label: "Shows", text: about.shows },
101
+ ]);
102
+ });
103
+
104
+ it("has no rows when demo.json has no card", () => {
105
+ expect(aboutRows(demo("reset", { scenario: "base", about: null }))).toEqual([]);
106
+ expect(aboutRows(demo("reset"))).toEqual([]);
107
+ });
108
+
109
+ // A DOM just big enough for renderKiosk: elements with a class, text and children.
110
+ const fakeDocument = () => {
111
+ const make = (tag) => {
112
+ const n = { tagName: tag, className: "", textContent: "", children: [] };
113
+ n.appendChild = (c) => (n.children.push(c), c);
114
+ n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
115
+ n.replaceChildren = () => (n.children = []);
116
+ return n;
117
+ };
118
+ return { createElement: make };
119
+ };
120
+ const find = (n, cls) => (n.className.split(" ").includes(cls) ? [n] : []).concat(...(n.children || []).map((c) => (c.className !== undefined ? find(c, cls) : [])));
121
+
122
+ it("draws the card right under the title, and none without about", () => {
123
+ const was = globalThis.document;
124
+ globalThis.document = fakeDocument();
125
+ try {
126
+ const bar = document.createElement("header");
127
+ const s = { demo: { source: "fx", read: "2026-10-15T03:00:00Z", demo: demo("failed", { scenario: "failure", about }) }, answer: null, progress: null };
128
+ renderKiosk(bar, s, new Date("2026-10-15T03:01:00Z"));
129
+ const left = find(bar, "k-left")[0];
130
+ const kinds = left.children.map((c) => c.className.split(" ")[0]);
131
+ expect(kinds.indexOf("k-about")).toBe(kinds.indexOf("k-title") + 1);
132
+ const card = find(bar, "k-about")[0];
133
+ expect(card.tagName).toBe("dl");
134
+ expect(card.children.map((c) => [c.tagName, c.textContent])).toEqual([
135
+ ["dt", "What changes"], ["dd", about.changes],
136
+ ["dt", "Watch for"], ["dd", about.watch],
137
+ ["dt", "Shows"], ["dd", about.shows],
138
+ ]);
139
+
140
+ s.demo.demo = demo("reset", { scenario: "base", about: null });
141
+ renderKiosk(bar, s, new Date("2026-10-15T03:01:00Z"));
142
+ expect(find(bar, "k-about")).toEqual([]);
143
+ } finally {
144
+ globalThis.document = was;
145
+ }
146
+ });
147
+ });
148
+
149
+ describe("which view the frame shows (#571)", () => {
150
+ it("shows the resources for the pull request's steps and the roots by wave from the merge on", () => {
151
+ for (const step of ["pull-request", "planned"]) expect(stepView(step)).toBe("resources");
152
+ for (const step of ["merged", "applying", "waiting", "approved", "failed", "resumed", "applied", "reset"]) expect(stepView(step)).toBe("components");
153
+ expect(Object.keys(STEP_VIEW).sort()).toEqual(Object.keys(STEP_WORDS).sort());
154
+ });
155
+
156
+ it("follows the step, and only the resources when the repo offers no components zoom", () => {
157
+ expect(kioskView(demo("planned"), { components: true })).toEqual({ view: "resources", pinned: false, secs: null });
158
+ expect(kioskView(demo("waiting"), { components: true })).toEqual({ view: "components", pinned: false, secs: null });
159
+ expect(kioskView(demo("waiting"), { components: false })).toEqual({ view: "resources", pinned: false, secs: null });
160
+ expect(kioskView(demo("waiting"), { components: false, pick: { view: "components", at: 0 }, now: 1000 }).view).toBe("resources");
161
+ expect(kioskView(null, { components: true }).view).toBe("resources");
162
+ });
163
+
164
+ it("holds a clicked view for 90 s after the last click, then follows the step again", () => {
165
+ const pick = { view: "resources", at: 1_000_000 };
166
+ expect(PICK_HOLD_MS).toBe(90_000);
167
+ expect(kioskView(demo("applying"), { components: true, pick, now: 1_000_000 })).toEqual({ view: "resources", pinned: true, secs: 90 });
168
+ expect(kioskView(demo("applying"), { components: true, pick, now: 1_000_000 + 89_500 })).toEqual({ view: "resources", pinned: true, secs: 1 });
169
+ expect(kioskView(demo("applying"), { components: true, pick, now: 1_000_000 + 90_000 })).toEqual({ view: "components", pinned: false, secs: null });
170
+ // A pick of a view the toggle doesn't offer holds nothing.
171
+ expect(kioskView(demo("applying"), { components: true, pick: { view: "logical", at: 1_000_000 }, now: 1_000_000 }).view).toBe("components");
172
+ });
173
+
174
+ it("opens the frame on a view's zoom", () => {
175
+ expect(frameSrc("https://demo.example")).toContain("zoom=resources");
176
+ expect(frameSrc("https://demo.example", viewZoom("components"))).toContain("zoom=components");
177
+ expect(viewZoom("resources")).toBe("resources");
178
+ expect(viewZoom("nonsense")).toBe("resources");
179
+ });
180
+
181
+ it("says how to read the view shown", () => {
182
+ expect(legendWords("components")).toMatch(/^Each card is a Terraform root .* titled with the pipeline job that applies it/);
183
+ expect(legendWords("resources")).toMatch(/^Each box is a Terraform root/);
184
+ });
185
+
186
+ // A DOM for the toggle: elements with attributes and click listeners.
187
+ const toggleDocument = () => ({
188
+ createElement: (tag) => {
189
+ const n = { tagName: tag, className: "", textContent: "", children: [], attrs: {}, hidden: false, listeners: {} };
190
+ n.appendChild = (c) => (n.children.push(c), c);
191
+ n.replaceChildren = () => (n.children = []);
192
+ n.setAttribute = (k, v) => (n.attrs[k] = v);
193
+ n.addEventListener = (k, f) => (n.listeners[k] = f);
194
+ return n;
195
+ },
196
+ });
197
+
198
+ it("draws the toggle with the view shown pressed, says what leads, and hides without a components zoom", () => {
199
+ const was = globalThis.document;
200
+ globalThis.document = toggleDocument();
201
+ try {
202
+ const host = document.createElement("div");
203
+ const picked = [];
204
+ renderViewToggle(host, { view: "components", pinned: false, secs: null }, { components: true, onPick: (v) => picked.push(v) });
205
+ expect(host.hidden).toBe(false);
206
+ const buttons = host.children[0].children;
207
+ expect(buttons.map((b) => [b.textContent, b.attrs["aria-pressed"]])).toEqual([["Resources", "false"], ["Roots by wave", "true"]]);
208
+ expect(host.children[1].textContent).toBe("follows the step");
209
+ buttons[0].listeners.click();
210
+ expect(picked).toEqual(["resources"]);
211
+
212
+ renderViewToggle(host, { view: "resources", pinned: true, secs: 42 }, { components: true });
213
+ expect(host.children[1].textContent).toBe("held by a click; the step leads again in 42 s");
214
+
215
+ renderViewToggle(host, { view: "resources", pinned: false, secs: null }, { components: false });
216
+ expect(host.hidden).toBe(true);
217
+ expect(host.children).toEqual([]);
218
+ } finally {
219
+ globalThis.document = was;
220
+ }
221
+ });
222
+ });
@@ -553,6 +553,55 @@ export function readLabel(e) {
553
553
  return `reads ${e.to}'s applied state`;
554
554
  }
555
555
 
556
+ /**
557
+ * #569: a `run` pin on one pull request's plan, as the kiosk sends it before
558
+ * the merge: `pull-<n>`, or `pull-<n>-<sha>` with the commit the plan is of;
559
+ * bare `pull` while no pull request is open. Null for any other pin.
560
+ */
561
+ export function pullPin(run) {
562
+ const m = /^pull(?:-(\d+)(?:-([0-9a-f]{7,40}))?)?$/.exec(String(run || ""));
563
+ return m ? { pull: m[1] || null, commit: m[2] || null } : null;
564
+ }
565
+
566
+ /** #569: whether a pin names a commit's run view (not `none`, `base` or a pull request's plan). */
567
+ export function pinsCommit(run) {
568
+ return !!run && run !== "none" && run !== "base" && !pullPin(run);
569
+ }
570
+
571
+ const sameSha = (a, b) => !!a && !!b && (a.startsWith(b) || b.startsWith(a));
572
+
573
+ /** #569: whether a pull request's plan is the one a `pull-…` pin names: its number, and its commit when the pin has one. */
574
+ export function planFitsPin(pr, pin) {
575
+ if (!pr || !pr.run || !pin || pin.pull === null) return false;
576
+ if (String(pr.run.pull_request) !== pin.pull) return false;
577
+ return !pin.commit || sameSha(pr.run.commit, pin.commit);
578
+ }
579
+
580
+ /**
581
+ * #569: the reports as the view draws them under its `run` pin. At `base`
582
+ * nothing is in flight; at `pull-…` only that pull request's plan, so another
583
+ * cycle's plan is never drawn as this one's. Anything else draws them as read.
584
+ */
585
+ export function pinnedAnswer(answer, run) {
586
+ if (!answer || answer.error) return answer;
587
+ const pin = pullPin(run);
588
+ if (run === "base" || (pin && !planFitsPin(answer.pull_request, pin))) return { ...answer, pull_request: null };
589
+ return answer;
590
+ }
591
+
592
+ /**
593
+ * #569: the sentence above the boxes when the pin holds the view off every
594
+ * plan and run: the base, or a pull request whose plan isn't written yet.
595
+ * Null when the view draws a run, and viewCaption says which.
596
+ */
597
+ export function pinnedCaption(answer, run) {
598
+ if (run === "base") return "Nothing in flight: the states went back to the base";
599
+ const pin = pullPin(run);
600
+ if (!pin || (answer && !answer.error && planFitsPin(answer.pull_request, pin))) return null;
601
+ if (pin.pull === null) return "The pull request isn't open yet; its plan shows here once the plan job writes it";
602
+ return `Pull request #${pin.pull} is being planned; its plan shows here once the plan job writes it`;
603
+ }
604
+
556
605
  /** Which run the view's marks come from, dated: "the plan of pull request #201, 03:11 UTC". */
557
606
  export function viewCaption(answer, progress, now = new Date()) {
558
607
  const stage = changeStage(answer, progress);