@intentius/behold 0.29.5 → 0.29.7
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/AGENTS.md +6 -1
- package/dist/cli.js +48 -6
- package/package.json +1 -1
- package/web/app.js +268 -2
- package/web/embed.js +20 -0
- package/web/embed.test.js +10 -1
- package/web/index.html +22 -0
- package/web/kiosk-popover.js +234 -0
- package/web/kiosk-popover.test.js +180 -0
- package/web/kiosk-roots.js +191 -0
- package/web/kiosk-roots.test.js +95 -0
- package/web/kiosk-runs.js +139 -18
- package/web/kiosk-runs.test.js +53 -1
- package/web/kiosk.html +21 -12
- package/web/kiosk.js +145 -38
- package/web/kiosk.test.js +80 -1
package/web/kiosk.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Nothing here is all-or-nothing, and nothing says so.
|
|
15
15
|
|
|
16
16
|
import { PICK_HOLD_MS, startRuns } from "./kiosk-runs.js";
|
|
17
|
+
import { cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootSegments, rootText } from "./kiosk-roots.js";
|
|
17
18
|
import { STATE_WORDS, changingRoots, costLine, pinsCommit, planFitsPin, pullPin, readEdges, rootsCount, totalsLine, waveRows, blastRows } from "./terragucci-change.js";
|
|
18
19
|
|
|
19
20
|
/** The scenarios terragucci#1014 numbers. An id not here is shown by the title demo.json carries. */
|
|
@@ -51,12 +52,38 @@ export const STEP_WORDS = {
|
|
|
51
52
|
stalled: "stalled",
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
|
|
55
|
+
const counted = (n) => Number.isInteger(n) && n > 0;
|
|
56
|
+
|
|
57
|
+
/** demo.json's `name`, the scenario's short label in plain words (terragucci#1050); null without one. */
|
|
58
|
+
export function scenarioName(demo) {
|
|
59
|
+
const n = demo && demo.name;
|
|
60
|
+
return typeof n === "string" && n.trim() ? n.trim() : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The kicker and the title over the card. behold#571: "Scenario 2 of 5" when
|
|
65
|
+
* demo.json counts the loop (`index` of `scenarios`), "Scenario 2" with the
|
|
66
|
+
* index alone, with the title in plain words. Without an index, today's
|
|
67
|
+
* kicker: "scenario 3 · B on waves" from SCENARIOS, demo.json's `name` in
|
|
68
|
+
* place of SCENARIOS' label when it has one; the driver's title alone for an
|
|
69
|
+
* id behold doesn't know.
|
|
70
|
+
*/
|
|
55
71
|
export function scenarioHead(demo) {
|
|
56
72
|
const known = Object.prototype.hasOwnProperty.call(SCENARIOS, demo.scenario) ? SCENARIOS[demo.scenario] : null;
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
73
|
+
const name = scenarioName(demo);
|
|
74
|
+
if (counted(demo.index)) {
|
|
75
|
+
const of = counted(demo.scenarios) && demo.index <= demo.scenarios ? ` of ${demo.scenarios}` : "";
|
|
76
|
+
return { kicker: `Scenario ${demo.index}${of}`, title: demo.title || name || (known ? known.name : demo.scenario) };
|
|
77
|
+
}
|
|
78
|
+
if (!known) return { kicker: name ? `scenario · ${name}` : `scenario ${demo.scenario}`, title: demo.title };
|
|
79
|
+
if (known.n === null) return { kicker: demo.title || name || "The base", title: demo.title || name || "The base" };
|
|
80
|
+
return { kicker: `scenario ${known.n} · ${name || known.name}`, title: demo.title };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** behold#571: demo.json's `intro`, one paragraph on the problem the demo solves; null without one. */
|
|
84
|
+
export function introText(demo) {
|
|
85
|
+
const t = demo && demo.intro;
|
|
86
|
+
return typeof t === "string" && t.trim() ? t.trim() : null;
|
|
60
87
|
}
|
|
61
88
|
|
|
62
89
|
/** The card's rows under the title, in order, with the labels the kiosk draws (behold#571). */
|
|
@@ -192,14 +219,17 @@ export function unreviewedLines(answer) {
|
|
|
192
219
|
|
|
193
220
|
/**
|
|
194
221
|
* The approval the driver gives at a waiting wave, as terragucci's
|
|
195
|
-
* stack/demo.sh gives it: `approve_wave` runs chant's approve
|
|
196
|
-
* the driver
|
|
197
|
-
*
|
|
198
|
-
*
|
|
222
|
+
* stack/demo.sh gives it: `approve_wave` runs chant's approve as the
|
|
223
|
+
* reviewer (terragucci#1052: the driver plays two people, demo-author who
|
|
224
|
+
* opens, merges and comments, and demo-reviewer who approves, since an
|
|
225
|
+
* approval only counts from someone who had no hand in the change), sealed
|
|
226
|
+
* with the reviewer's own signing key; then `resume_pull` posts
|
|
227
|
+
* `/terragucci apply` on the merged pull request. The actor and the key are
|
|
228
|
+
* placeholders: the kiosk never names a path or a secret.
|
|
199
229
|
*/
|
|
200
230
|
export function driverApproveLines(wave, pull) {
|
|
201
231
|
return {
|
|
202
|
-
approve: `chant approve tf-apply wave-${wave} --actor <
|
|
232
|
+
approve: `chant approve tf-apply wave-${wave} --actor <reviewer> --sign <reviewer key>`,
|
|
203
233
|
comment: "/terragucci apply",
|
|
204
234
|
on: pull !== null && pull !== undefined ? `pull request #${pull}` : "the merged pull request",
|
|
205
235
|
};
|
|
@@ -226,6 +256,17 @@ export function pendingApproval(demo, progress) {
|
|
|
226
256
|
};
|
|
227
257
|
}
|
|
228
258
|
|
|
259
|
+
/**
|
|
260
|
+
* behold#571: the approval in plain words, before the command, for a viewer
|
|
261
|
+
* who has never approved a pipeline: who the driver plays, what it signs
|
|
262
|
+
* with (terragucci#1052: a reviewer apart from the change's author). The
|
|
263
|
+
* plan's digest is on the wave's box in the view.
|
|
264
|
+
*/
|
|
265
|
+
export function approveGloss(appr) {
|
|
266
|
+
const roots = appr.roots.length ? ` (${appr.roots.join(", ")})` : "";
|
|
267
|
+
return `The driver plays a reviewer who had no hand in the change, and approves wave ${appr.wave}${roots} with that reviewer's own signing key.`;
|
|
268
|
+
}
|
|
269
|
+
|
|
229
270
|
/**
|
|
230
271
|
* #569: the roots the step is about, for the framed view to bring forward:
|
|
231
272
|
* the pull request's roots while it is planned; from the merge on, the wave
|
|
@@ -421,6 +462,15 @@ const el = (tag, cls, text) => {
|
|
|
421
462
|
return n;
|
|
422
463
|
};
|
|
423
464
|
|
|
465
|
+
/** behold#571: an element whose text puts a dot in its root's colour by each whole-word root name. */
|
|
466
|
+
const elRoots = (tag, cls, text, s) => {
|
|
467
|
+
const roots = (s && (s.rootNames || s.roots)) || [];
|
|
468
|
+
if (text === undefined || text === null || !rootSegments(String(text), roots).some((x) => x.root)) return el(tag, cls, text);
|
|
469
|
+
const n = el(tag, cls);
|
|
470
|
+
n.appendChild(rootText(document, String(text), roots, { light: !!(s && s.light) }));
|
|
471
|
+
return n;
|
|
472
|
+
};
|
|
473
|
+
|
|
424
474
|
/** Draw the bar and the foot from what the kiosk holds. Pure over its arguments, but for the DOM. */
|
|
425
475
|
export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
426
476
|
bar.replaceChildren();
|
|
@@ -435,45 +485,43 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
435
485
|
const demo = s.demo.demo;
|
|
436
486
|
const head = scenarioHead(demo);
|
|
437
487
|
|
|
488
|
+
const intro = introText(demo);
|
|
489
|
+
if (intro) bar.appendChild(el("p", "k-intro", intro));
|
|
490
|
+
|
|
491
|
+
// behold#571: the kicker on the title's line, so the bar has room for the intro.
|
|
438
492
|
const left = el("div", "k-left");
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
left.appendChild(
|
|
442
|
-
left.appendChild(el("h1", "k-title", head.title));
|
|
493
|
+
const title = el("h1", "k-title");
|
|
494
|
+
title.append(el("span", "k-kicker", head.kicker), el("span", "k-title-text", head.title));
|
|
495
|
+
left.appendChild(title);
|
|
443
496
|
const rows = aboutRows(demo);
|
|
444
497
|
if (rows.length) {
|
|
445
498
|
const card = el("dl", "k-about");
|
|
446
|
-
for (const r of rows) card.append(el("dt", "k-about-label", r.label),
|
|
499
|
+
for (const r of rows) card.append(el("dt", "k-about-label", r.label), elRoots("dd", "k-about-text", r.text, s));
|
|
447
500
|
left.appendChild(card);
|
|
448
501
|
}
|
|
449
|
-
const said = el("
|
|
450
|
-
said.
|
|
451
|
-
said.appendChild(el("p", "k-label", demo.label));
|
|
502
|
+
const said = el("p", `k-step k-step-${demo.step}`);
|
|
503
|
+
said.append(el("span", "k-step-word", STEP_WORDS[demo.step] || demo.step), elRoots("span", "k-label", demo.label, s));
|
|
452
504
|
left.appendChild(said);
|
|
453
505
|
bar.appendChild(left);
|
|
454
506
|
|
|
455
507
|
const right = el("div", "k-right");
|
|
456
508
|
const strip = el("ol", "k-strip");
|
|
457
509
|
for (const stop of stripStops(demo.step)) strip.appendChild(el("li", `k-stop k-${stop.state}`, stop.words));
|
|
458
|
-
|
|
510
|
+
const where = el("div", "k-where");
|
|
511
|
+
where.append(strip, el("span", "k-cycle", cycleWords(demo, now)));
|
|
512
|
+
right.appendChild(where);
|
|
459
513
|
|
|
460
514
|
const nx = nextWords(demo, now);
|
|
461
515
|
const next = el("div", "k-next");
|
|
462
516
|
next.appendChild(el("span", "k-next-word", "next, by the driver"));
|
|
463
|
-
next.appendChild(
|
|
517
|
+
next.appendChild(elRoots("span", "k-action", nx.action, s));
|
|
464
518
|
next.appendChild(el("span", "k-when", nx.when));
|
|
465
519
|
right.appendChild(next);
|
|
466
520
|
|
|
467
521
|
const appr = pendingApproval(demo, s.progress);
|
|
468
522
|
if (appr) {
|
|
469
523
|
const a = el("div", "k-approve");
|
|
470
|
-
a.appendChild(
|
|
471
|
-
el(
|
|
472
|
-
"p",
|
|
473
|
-
"k-approve-head",
|
|
474
|
-
`The driver approves wave ${appr.wave}${appr.roots.length ? ` (${appr.roots.join(", ")})` : ""}, signed with its own key${appr.digest ? `, for the plan ${appr.digest.replace(/^jcs1-sha256:/, "").slice(0, 12)}` : ""}:`,
|
|
475
|
-
),
|
|
476
|
-
);
|
|
524
|
+
a.appendChild(elRoots("p", "k-approve-gloss", approveGloss(appr), s));
|
|
477
525
|
a.appendChild(el("code", "k-command", appr.approve));
|
|
478
526
|
const then = el("p", "k-approve-then");
|
|
479
527
|
then.append(`then comments `, el("code", "k-inline", appr.comment), ` on ${appr.on}, and the waves run again.`);
|
|
@@ -481,24 +529,21 @@ export function renderKiosk(bar, s, now = new Date(), foot = null) {
|
|
|
481
529
|
right.appendChild(a);
|
|
482
530
|
} else {
|
|
483
531
|
const panel = runPanel(demo, s.answer, s.progress);
|
|
484
|
-
if (panel.note) right.appendChild(
|
|
485
|
-
else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(
|
|
532
|
+
if (panel.note) right.appendChild(elRoots("p", "k-note", panel.note, s));
|
|
533
|
+
else if (panel.stage === "pull-request") for (const u of unreviewedLines(s.answer)) right.appendChild(elRoots("p", "k-note k-unknown", u, s));
|
|
486
534
|
}
|
|
487
535
|
bar.appendChild(right);
|
|
488
536
|
|
|
489
537
|
if (foot) {
|
|
490
538
|
foot.appendChild(el("span", "k-legend", legendWords(s.view || "resources")));
|
|
491
|
-
|
|
492
|
-
foot.appendChild(
|
|
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`),
|
|
494
|
-
);
|
|
539
|
+
// behold#571: no line on where the narration was read from: a viewer new to the demo reads the facts.
|
|
540
|
+
for (const f of factLines(demo, s.answer)) foot.appendChild(elRoots("span", "k-fact", f, s));
|
|
495
541
|
}
|
|
496
542
|
}
|
|
497
543
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
};
|
|
544
|
+
/** behold#571: how a hovered root's name and wave's job row light. A background and a shadow: nothing moves. */
|
|
545
|
+
export const HOVER_ROOT_RULE = "background: color-mix(in srgb, var(--focus) 32%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus) 32%, transparent); border-radius: 3px;";
|
|
546
|
+
export const HOVER_JOB_RULE = "background: color-mix(in srgb, var(--focus) 24%, transparent); box-shadow: inset 4px 0 0 var(--focus);";
|
|
502
547
|
|
|
503
548
|
/** How often the kiosk asks for demo.json. The server shares one read between kiosks. */
|
|
504
549
|
export const DEMO_POLL_MS = 3_000;
|
|
@@ -511,7 +556,7 @@ export const DEMO_POLL_MS = 3_000;
|
|
|
511
556
|
* with a `behold:view` message; the frame takes them only from this origin.
|
|
512
557
|
*/
|
|
513
558
|
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" };
|
|
559
|
+
const s = { demo: null, answer: null, progress: null, components: false, pick: null, view: "resources", roots: [], waves: [], light: false, hover: null, frameHover: null };
|
|
515
560
|
let lastKey = "";
|
|
516
561
|
let lastFocus;
|
|
517
562
|
const shownView = () => kioskView(s.demo && s.demo.demo, { components: s.components, pick: s.pick, now: now().getTime() });
|
|
@@ -531,7 +576,59 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
531
576
|
s.pick = { view, at: now().getTime() };
|
|
532
577
|
draw();
|
|
533
578
|
};
|
|
579
|
+
// behold#571: the roots' colours and the hover links between the panes and the frame.
|
|
580
|
+
const rootList = () => (s.roots.length ? s.roots : s.answer && Array.isArray(s.answer.roots) ? s.answer.roots.map((r) => r.path) : []);
|
|
581
|
+
const glow = () => {
|
|
582
|
+
let style = document.getElementById("k-hover-style");
|
|
583
|
+
if (!style) {
|
|
584
|
+
style = document.createElement("style");
|
|
585
|
+
style.id = "k-hover-style";
|
|
586
|
+
document.head.appendChild(style);
|
|
587
|
+
}
|
|
588
|
+
const a = hoverLinks(s.hover, s.waves);
|
|
589
|
+
const b = hoverLinks(s.frameHover, s.waves);
|
|
590
|
+
const links = { roots: [...new Set([...a.roots, ...b.roots])], waves: [...new Set([...a.waves, ...b.waves])] };
|
|
591
|
+
style.textContent = [
|
|
592
|
+
hoverCss({ roots: links.roots, waves: [] }, { root: (r) => `.k-root[data-k-root=${cssString(r)}]` }, HOVER_ROOT_RULE),
|
|
593
|
+
hoverCss({ roots: [], waves: links.waves }, { wave: (w) => `.r-job[data-k-wave="${w}"]` }, HOVER_JOB_RULE),
|
|
594
|
+
].join("\n");
|
|
595
|
+
};
|
|
596
|
+
const tellHover = (h) => {
|
|
597
|
+
if (frame && frame.contentWindow) frame.contentWindow.postMessage(hoverMessage(h), origin);
|
|
598
|
+
};
|
|
599
|
+
const hoverAt = (target) => {
|
|
600
|
+
const t = target && target.closest ? target.closest("[data-k-root],[data-k-wave]") : null;
|
|
601
|
+
if (!t) return null;
|
|
602
|
+
if (t.dataset.kRoot) return { root: t.dataset.kRoot };
|
|
603
|
+
const w = Number(t.dataset.kWave);
|
|
604
|
+
return Number.isInteger(w) ? { wave: w } : null;
|
|
605
|
+
};
|
|
606
|
+
const setHover = (h) => {
|
|
607
|
+
if ((h && h.root) === (s.hover && s.hover.root) && (h && h.wave) === (s.hover && s.hover.wave)) return;
|
|
608
|
+
s.hover = h;
|
|
609
|
+
glow();
|
|
610
|
+
tellHover(h);
|
|
611
|
+
};
|
|
612
|
+
if (typeof document !== "undefined" && document.addEventListener) {
|
|
613
|
+
document.addEventListener("pointerover", (e) => setHover(hoverAt(e.target)));
|
|
614
|
+
document.documentElement.addEventListener("pointerleave", () => setHover(null));
|
|
615
|
+
// A click or Escape anywhere here is a click elsewhere for the frame's pinned popover.
|
|
616
|
+
const unpin = () => frame && frame.contentWindow && frame.contentWindow.postMessage({ type: "behold:unpin" }, origin);
|
|
617
|
+
document.addEventListener("pointerdown", unpin);
|
|
618
|
+
document.addEventListener("keydown", (e) => {
|
|
619
|
+
if (e.key === "Escape") unpin();
|
|
620
|
+
});
|
|
621
|
+
window.addEventListener("message", (e) => {
|
|
622
|
+
if (e.origin !== origin || !frame || e.source !== frame.contentWindow) return;
|
|
623
|
+
const h = hoverFromMessage(e.data);
|
|
624
|
+
if (h === undefined) return;
|
|
625
|
+
s.frameHover = h;
|
|
626
|
+
glow();
|
|
627
|
+
});
|
|
628
|
+
}
|
|
534
629
|
const draw = () => {
|
|
630
|
+
s.light = typeof document !== "undefined" && document.documentElement ? isLightBg(getComputedStyle(document.documentElement).getPropertyValue("--bg")) : false;
|
|
631
|
+
s.rootNames = rootList();
|
|
535
632
|
const shown = shownView();
|
|
536
633
|
s.view = shown.view;
|
|
537
634
|
renderKiosk(bar, s, now(), foot);
|
|
@@ -539,7 +636,10 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
539
636
|
tellFrame(false);
|
|
540
637
|
};
|
|
541
638
|
// #569: the pipeline's runs beside the view, CI-page style.
|
|
542
|
-
|
|
639
|
+
// behold#571: the titles and names demo.json has carried, by scenario, for the runs' subtitles.
|
|
640
|
+
const titles = {};
|
|
641
|
+
const names = {};
|
|
642
|
+
if (side && main) startRuns({ side, main }, { fetchJson, now, scenarios: SCENARIOS, titles: () => titles, names: () => names, roots: rootList, waves: () => s.waves, light: () => s.light });
|
|
543
643
|
// #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
644
|
const openFrame = () => {
|
|
545
645
|
if (!frame) return;
|
|
@@ -551,6 +651,11 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
551
651
|
};
|
|
552
652
|
void fetchJson("/api/project").then((p) => {
|
|
553
653
|
s.components = !!(p && !p.error && p.terragucciComponents);
|
|
654
|
+
// behold#571: the declared roots (their colours' order) and the wave jobs that apply them.
|
|
655
|
+
if (p && p.terragucciRoots) {
|
|
656
|
+
s.roots = Array.isArray(p.terragucciRoots.roots) ? p.terragucciRoots.roots : [];
|
|
657
|
+
s.waves = Array.isArray(p.terragucciRoots.waves) ? p.terragucciRoots.waves : [];
|
|
658
|
+
}
|
|
554
659
|
openFrame();
|
|
555
660
|
draw();
|
|
556
661
|
});
|
|
@@ -577,6 +682,8 @@ export function startKiosk({ bar, frame, foot, side, main, views }, { fetchJson
|
|
|
577
682
|
const demo = async () => {
|
|
578
683
|
s.demo = await fetchJson("/api/terragucci/demo");
|
|
579
684
|
const d = s.demo && s.demo.demo;
|
|
685
|
+
if (d && d.scenario && typeof d.title === "string" && d.title) titles[d.scenario] = d.title;
|
|
686
|
+
if (d && d.scenario && scenarioName(d)) names[d.scenario] = scenarioName(d);
|
|
580
687
|
const key = d ? `${d.cycle}|${d.step}|${d.commit}|${d.pull}` : "";
|
|
581
688
|
if (key !== lastKey) {
|
|
582
689
|
lastKey = key;
|
package/web/kiosk.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { STEP_VIEW, STEP_WORDS, aboutRows, focusRoots, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
|
|
2
|
+
import { STEP_VIEW, STEP_WORDS, aboutRows, approveGloss, focusRoots, introText, scenarioHead, frameSrc, factLines, kioskView, legendWords, matchingPull, pendingApproval, renderKiosk, renderViewToggle, runPanel, runPin, stepView, viewZoom } from "./kiosk.js";
|
|
3
3
|
import { PICK_HOLD_MS } from "./kiosk-runs.js";
|
|
4
4
|
|
|
5
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 });
|
|
@@ -220,3 +220,82 @@ describe("which view the frame shows (#571)", () => {
|
|
|
220
220
|
}
|
|
221
221
|
});
|
|
222
222
|
});
|
|
223
|
+
|
|
224
|
+
describe("the kiosk for a viewer new to the demo (behold#571)", () => {
|
|
225
|
+
const intro = "One change can touch many Terraform states; this demo shows how terragucci applies them in order.";
|
|
226
|
+
const fakeDocument = () => {
|
|
227
|
+
const make = (tag) => {
|
|
228
|
+
const n = { tagName: tag, className: "", textContent: "", children: [] };
|
|
229
|
+
n.appendChild = (c) => (n.children.push(c), c);
|
|
230
|
+
n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
|
|
231
|
+
n.replaceChildren = () => (n.children = []);
|
|
232
|
+
return n;
|
|
233
|
+
};
|
|
234
|
+
return { createElement: make };
|
|
235
|
+
};
|
|
236
|
+
const find = (n, cls) => (n.className.split(" ").includes(cls) ? [n] : []).concat(...(n.children || []).map((c) => (c.className !== undefined ? find(c, cls) : [])));
|
|
237
|
+
const draw = (d) => {
|
|
238
|
+
const was = globalThis.document;
|
|
239
|
+
globalThis.document = fakeDocument();
|
|
240
|
+
try {
|
|
241
|
+
const bar = document.createElement("header");
|
|
242
|
+
const foot = document.createElement("footer");
|
|
243
|
+
renderKiosk(bar, { demo: { source: "s3://demo-reports/demo", read: "2026-10-15T03:00:00Z", demo: d }, answer: null, progress: null }, new Date("2026-10-15T03:01:00Z"), foot);
|
|
244
|
+
return { bar, foot };
|
|
245
|
+
} finally {
|
|
246
|
+
globalThis.document = was;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it("shows the intro first in the bar, and none without one", () => {
|
|
251
|
+
const { bar } = draw(demo("planned", { intro }));
|
|
252
|
+
expect(bar.children[0].className).toBe("k-intro");
|
|
253
|
+
expect(bar.children[0].textContent).toBe(intro);
|
|
254
|
+
expect(find(draw(demo("planned")).bar, "k-intro")).toEqual([]);
|
|
255
|
+
expect(find(draw(demo("planned", { intro: null })).bar, "k-intro")).toEqual([]);
|
|
256
|
+
expect(introText(demo("planned", { intro: " " }))).toBe(null);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("says which scenario of how many, with the title and no short label", () => {
|
|
260
|
+
expect(scenarioHead(demo("planned", { index: 2, scenarios: 5, title: "The same change, read once it is built" }))).toEqual({ kicker: "Scenario 2 of 5", title: "The same change, read once it is built" });
|
|
261
|
+
const { bar } = draw(demo("planned", { index: 2, scenarios: 5, title: "The same change, read once it is built" }));
|
|
262
|
+
expect(find(bar, "k-kicker")[0].textContent).toBe("Scenario 2 of 5");
|
|
263
|
+
expect(find(bar, "k-title-text")[0].textContent).toBe("The same change, read once it is built");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("leaves the count out when scenarios is missing, 0 or less than the index", () => {
|
|
267
|
+
expect(scenarioHead(demo("planned", { index: 2, title: "t" })).kicker).toBe("Scenario 2");
|
|
268
|
+
expect(scenarioHead(demo("planned", { index: 2, scenarios: 0, title: "t" })).kicker).toBe("Scenario 2");
|
|
269
|
+
expect(scenarioHead(demo("planned", { index: 6, scenarios: 5, title: "t" })).kicker).toBe("Scenario 6");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("falls back to the short label without an index, demo.json's name before the table's", () => {
|
|
273
|
+
expect(scenarioHead(demo("planned", { title: "t" }))).toEqual({ kicker: "scenario 2 · A, read through .output", title: "t" });
|
|
274
|
+
expect(scenarioHead(demo("planned", { index: 0, scenarios: 5 })).kicker).toBe("scenario 2 · A, read through .output");
|
|
275
|
+
expect(scenarioHead(demo("planned", { index: null, scenarios: 0, name: "the same change, read late" })).kicker).toBe("scenario 2 · the same change, read late");
|
|
276
|
+
expect(scenarioHead(demo("reset", { scenario: "base", title: "The base", index: null, scenarios: 0, name: null }))).toEqual({ kicker: "The base", title: "The base" });
|
|
277
|
+
expect(scenarioHead(demo("planned", { scenario: "new-one", title: "A new one" }))).toEqual({ kicker: "scenario new-one", title: "A new one" });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("has no line on where the narration was read from", () => {
|
|
281
|
+
const { foot } = draw(demo("planned", { intro }));
|
|
282
|
+
expect(JSON.stringify(foot)).not.toMatch(/narration read|s3:\/\/|nothing here writes/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("says who the driver is before the command it runs", () => {
|
|
286
|
+
expect(approveGloss({ wave: 2, roots: ["app"], digest: "jcs1-sha256:0accc105ba06" })).toBe("The driver plays a reviewer who had no hand in the change, and approves wave 2 (app) with that reviewer's own signing key.");
|
|
287
|
+
expect(approveGloss({ wave: 3, roots: [], digest: null })).toBe("The driver plays a reviewer who had no hand in the change, and approves wave 3 with that reviewer's own signing key.");
|
|
288
|
+
const p = progress([{ number: 2, state: "waiting", roots: ["app"], reads: [], digest: "jcs1-sha256:0accc105ba06" }]);
|
|
289
|
+
const was = globalThis.document;
|
|
290
|
+
globalThis.document = fakeDocument();
|
|
291
|
+
try {
|
|
292
|
+
const bar = document.createElement("header");
|
|
293
|
+
renderKiosk(bar, { demo: { source: "fx", read: "2026-10-15T03:00:00Z", demo: demo("waiting", { next: { action: "approve wave-2", at: null } }) }, answer: null, progress: p }, new Date("2026-10-15T03:01:00Z"));
|
|
294
|
+
const card = find(bar, "k-approve")[0];
|
|
295
|
+
expect(card.children.map((c) => c.className)).toEqual(["k-approve-gloss", "k-command", "k-approve-then"]);
|
|
296
|
+
expect(card.children[1].textContent).toBe("chant approve tf-apply wave-2 --actor <reviewer> --sign <reviewer key>");
|
|
297
|
+
} finally {
|
|
298
|
+
globalThis.document = was;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|