@intentius/behold 0.29.3 → 0.29.4
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.
- package/dist/cli.js +840 -292
- package/package.json +1 -1
- package/web/app.js +306 -21
- package/web/kiosk-runs.js +61 -6
- package/web/kiosk-runs.test.js +22 -1
- package/web/kiosk.html +21 -3
- package/web/kiosk.js +168 -19
- package/web/kiosk.test.js +160 -3
- package/web/terragucci-change.js +49 -0
- package/web/terragucci-change.test.js +36 -1
- package/web/terragucci-components-live.js +166 -0
- package/web/terragucci-components-live.test.js +145 -0
- package/web/view-url.js +5 -4
- package/web/view-url.test.js +4 -0
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:
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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, `
|
|
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
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
|
|
402
|
-
|
|
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
|
|
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("
|
|
58
|
-
expect(runPin(demo("planned"))).toBe("
|
|
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
|
+
});
|
package/web/terragucci-change.js
CHANGED
|
@@ -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);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { readCardIds, readLabel, rootStates, viewCaption } from "./terragucci-change.js";
|
|
2
|
+
import { pinnedAnswer, pinnedCaption, pinsCommit, pullPin, readCardIds, readLabel, rootStates, viewCaption } from "./terragucci-change.js";
|
|
3
3
|
|
|
4
4
|
const roots = ["app", "edge", "network", "other"].map((name) => ({ member: "demo", name, path: name }));
|
|
5
5
|
const prAnswer = {
|
|
@@ -97,3 +97,38 @@ describe("viewCaption (#569)", () => {
|
|
|
97
97
|
expect(viewCaption(prAnswer, run([{ number: 1, state: "applied", roots: ["network"], reads: [] }], []), now)).toBe("the run of merge 6b9f7e25, as of 03:20 UTC");
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("a pin on one pull request's plan (#569)", () => {
|
|
102
|
+
it("reads pull-<n>[-<sha>] and bare pull, and tells them from a commit's run view", () => {
|
|
103
|
+
expect(pullPin("pull-201-c9dfc649")).toEqual({ pull: "201", commit: "c9dfc649" });
|
|
104
|
+
expect(pullPin("pull-201")).toEqual({ pull: "201", commit: null });
|
|
105
|
+
expect(pullPin("pull")).toEqual({ pull: null, commit: null });
|
|
106
|
+
expect(pullPin("c9dfc649")).toBe(null);
|
|
107
|
+
expect(pinsCommit("c9dfc649")).toBe(true);
|
|
108
|
+
for (const p of ["pull-201", "none", "base", null]) expect(pinsCommit(p)).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
it("draws the pinned pull request's plan, of its commit", () => {
|
|
111
|
+
expect(pinnedAnswer(prAnswer, "pull-201-c9dfc649")).toBe(prAnswer);
|
|
112
|
+
expect(pinnedAnswer(prAnswer, "pull-201")).toBe(prAnswer);
|
|
113
|
+
expect(pinnedCaption(prAnswer, "pull-201-c9dfc649")).toBe(null);
|
|
114
|
+
expect(rootStates(pinnedAnswer(prAnswer, "pull-201"), null).stage).toBe("pull-request");
|
|
115
|
+
});
|
|
116
|
+
it("draws no other cycle's plan before this cycle's is written: no marks, and says it is being planned", () => {
|
|
117
|
+
for (const pin of ["pull-202", "pull-202-c9dfc649", "pull-201-0123abcd", "pull"]) {
|
|
118
|
+
const drawn = pinnedAnswer(prAnswer, pin);
|
|
119
|
+
expect(drawn.pull_request).toBe(null);
|
|
120
|
+
expect(rootStates(drawn, null)).toEqual({ stage: "none", roots: {} });
|
|
121
|
+
expect(viewCaption(drawn, null)).toBe(null);
|
|
122
|
+
}
|
|
123
|
+
expect(pinnedCaption(prAnswer, "pull-202-c9dfc649")).toBe("Pull request #202 is being planned; its plan shows here once the plan job writes it");
|
|
124
|
+
expect(pinnedCaption({ roots }, "pull-202")).toBe("Pull request #202 is being planned; its plan shows here once the plan job writes it");
|
|
125
|
+
expect(pinnedCaption(prAnswer, "pull")).toBe("The pull request isn't open yet; its plan shows here once the plan job writes it");
|
|
126
|
+
});
|
|
127
|
+
it("leaves the newest plan to an unpinned view, and none to the base", () => {
|
|
128
|
+
expect(pinnedAnswer(prAnswer, null)).toBe(prAnswer);
|
|
129
|
+
expect(pinnedAnswer(prAnswer, "none")).toBe(prAnswer);
|
|
130
|
+
expect(pinnedAnswer(prAnswer, "base").pull_request).toBe(null);
|
|
131
|
+
expect(pinnedCaption(prAnswer, "none")).toBe(null);
|
|
132
|
+
expect(pinnedCaption(prAnswer, "base")).toBe("Nothing in flight: the states went back to the base");
|
|
133
|
+
});
|
|
134
|
+
});
|