@intentius/behold 0.29.7 → 0.29.8

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.
@@ -0,0 +1,380 @@
1
+ // behold#571: a kiosk page per tool, `/<tool>`, that the tool's maker can
2
+ // share without it promoting a competitor (COMPETES). It shows that tool's scenarios
3
+ // alone: its name (and its logo where its owner allows one), its runs only,
4
+ // and a footer saying who makes it and that they don't endorse the demo.
5
+ //
6
+ // Live when demo.json's `tool` is this tool, as /kiosk shows it. Otherwise a
7
+ // replay of the tool's newest cycle that ran to its reset
8
+ // (`cycles/<tool>/latest.json`, which the loop driver publishes), stepped at
9
+ // the pace it was recorded, until the tool's scenario starts again.
10
+ //
11
+ // Pure functions here; kiosk.js draws them.
12
+
13
+ /**
14
+ * Each tool with a page: its name as its owner writes it, its owner, its
15
+ * official site, its logo when its owner's published rules allow one (else
16
+ * null, the name as text), and the footer's credit. `terms` are the words
17
+ * that name the tool or its owner. `kind` is what it competes as (see
18
+ * COMPETES): an `engine` plans and applies, an `orchestrator` drives an
19
+ * engine. Keep the keys in step with VENDOR_TOOLS in src/terragucci-demo.ts.
20
+ *
21
+ * - Terraform: HashiCorp's rules allow the name as text, with the mark noted.
22
+ * - Terragrunt: Gruntwork publishes logo files, but a logo on a page like
23
+ * this reads as a partnership, so the name is text.
24
+ * - CDK Terrain: the Open Construct Foundation's continuation of CDKTF; its
25
+ * site, cdktn.io, publishes no rules for its logo, so the name is text.
26
+ * - choudoufu: ours, logo and all.
27
+ */
28
+ export const VENDORS = {
29
+ terraform: {
30
+ name: "Terraform",
31
+ owner: "HashiCorp",
32
+ url: "https://developer.hashicorp.com/terraform",
33
+ logo: null,
34
+ credit: "Terraform is a trademark of HashiCorp. HashiCorp makes Terraform and does not endorse this demo.",
35
+ kind: "engine",
36
+ terms: ["Terraform", "HashiCorp"],
37
+ // The demo's Terraform roots run in OpenTofu's image (terragucci's stack/demo.sh).
38
+ runsWith: ["opentofu"],
39
+ },
40
+ terragrunt: {
41
+ name: "Terragrunt",
42
+ owner: "Gruntwork, Inc.",
43
+ url: "https://terragrunt.com/",
44
+ logo: null,
45
+ credit: "Terragrunt is made by Gruntwork, Inc., who don't endorse this demo.",
46
+ kind: "orchestrator",
47
+ terms: ["Terragrunt", "Gruntwork"],
48
+ runsWith: [],
49
+ },
50
+ "cdk-terrain": {
51
+ name: "CDK Terrain",
52
+ owner: "the Open Construct Foundation",
53
+ url: "https://cdktn.io/",
54
+ logo: null,
55
+ credit: "CDK Terrain is made by the Open Construct Foundation, who don't endorse this demo.",
56
+ kind: "orchestrator",
57
+ terms: ["CDK Terrain", "CDKTN", "Open Construct"],
58
+ runsWith: [],
59
+ },
60
+ choudoufu: {
61
+ name: "choudoufu",
62
+ owner: "Intentius",
63
+ url: "https://intentius.io/choudoufu/",
64
+ logo: { src: "/vendors/choudoufu-64.png", src2x: "/vendors/choudoufu-128.png" },
65
+ credit: "choudoufu is made by Intentius, who also make this demo. It supports AWS and Kubernetes.",
66
+ kind: "engine",
67
+ terms: ["choudoufu"],
68
+ runsWith: [],
69
+ },
70
+ };
71
+
72
+ /** What a tool's scenarios run on, said on its page as the owner's rules allow ("works with OpenTofu"). */
73
+ export const RUNS_WITH = {
74
+ opentofu: {
75
+ name: "OpenTofu",
76
+ url: "https://opentofu.org/",
77
+ words: "The pipeline here works with OpenTofu, which runs these roots.",
78
+ kind: "engine",
79
+ terms: ["OpenTofu"],
80
+ },
81
+ };
82
+
83
+ /**
84
+ * Tools with no page yet that a page still must not promote: orchestrators
85
+ * that compete with Terragrunt and CDK Terrain.
86
+ */
87
+ export const COMPETITORS = {
88
+ atmos: { kind: "orchestrator", terms: ["Atmos", "Cloud Posse"] },
89
+ terramate: { kind: "orchestrator", terms: ["Terramate"] },
90
+ };
91
+
92
+ /**
93
+ * What competes with what. Engines compete with engines (Terraform, OpenTofu,
94
+ * choudoufu); orchestrators with orchestrators (Terragrunt, CDK Terrain,
95
+ * Atmos, Terramate). An orchestrator runs on an engine, so its page names the
96
+ * engine freely; an engine's page names no other engine, but for the one its
97
+ * scenarios really run with (`runsWith`).
98
+ */
99
+ export const COMPETES = { engine: "engine", orchestrator: "orchestrator" };
100
+
101
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
102
+
103
+ /** A tool's entry, or null for a tool with no page. */
104
+ export function vendorOf(tool) {
105
+ return typeof tool === "string" && own(VENDORS, tool) ? VENDORS[tool] : null;
106
+ }
107
+
108
+ /** The tool the page was served for (`<meta name="kiosk-tool">`, src/terragucci-demo.ts), or null on /kiosk. */
109
+ export function pageTool(doc) {
110
+ const m = doc && doc.querySelector ? doc.querySelector('meta[name="kiosk-tool"]') : null;
111
+ const t = m ? m.getAttribute("content") : null;
112
+ return vendorOf(t) ? t : null;
113
+ }
114
+
115
+ /**
116
+ * The words no page for `tool` may show: each competitor's name and maker's,
117
+ * the competitors being the other tools of its kind, less what its scenarios
118
+ * run with. A page for no tool (/kiosk) has none.
119
+ */
120
+ export function otherTerms(tool) {
121
+ const v = vendorOf(tool);
122
+ if (!v) return [];
123
+ const rivals = COMPETES[v.kind];
124
+ const out = [];
125
+ for (const [id, x] of [...Object.entries(VENDORS), ...Object.entries(RUNS_WITH), ...Object.entries(COMPETITORS)]) {
126
+ if (id === tool || x.kind !== rivals || v.runsWith.includes(id)) continue;
127
+ out.push(...x.terms);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
+
134
+ /**
135
+ * Whether `text` names a competitor of `tool` or its maker, as a whole word
136
+ * in any case. A word inside an identifier is no brand: `terraform_data` is a
137
+ * resource type and `demo-terraform-state` a bucket.
138
+ */
139
+ export function namesOther(text, tool) {
140
+ if (typeof text !== "string" || !text) return false;
141
+ return otherTerms(tool).some((t) => new RegExp(`(?<![\\w./-])${escapeRe(t)}(?![\\w/-])`, "i").test(text));
142
+ }
143
+
144
+ /**
145
+ * demo.json as a tool's page shows it: no count of the loop's scenarios or
146
+ * place in it (they would hint at the others), and any text the driver wrote
147
+ * that names a competitor left out: the intro, a card row, the title (the
148
+ * scenario's name instead), the step's line, the next action.
149
+ */
150
+ export function scrubDemo(demo, tool) {
151
+ if (!demo) return demo;
152
+ const keep = (t) => (namesOther(t, tool) ? null : t);
153
+ const about = demo.about
154
+ ? Object.fromEntries(Object.entries(demo.about).filter(([, t]) => !namesOther(t, tool)))
155
+ : null;
156
+ const name = keep(demo.name);
157
+ return {
158
+ ...demo,
159
+ scenarios: null,
160
+ index: null,
161
+ intro: keep(demo.intro),
162
+ name,
163
+ title: keep(demo.title) ?? name ?? "",
164
+ label: keep(demo.label) ?? "",
165
+ about: about && Object.keys(about).length ? about : null,
166
+ next: demo.next ? { ...demo.next, action: keep(demo.next.action) ?? "the next step" } : null,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * The tool a demo.json write is about. A driver before behold#571 wrote no
172
+ * `tool`, and ran Terraform scenarios only: a scenario the kiosk knows
173
+ * (`known`, kiosk.js SCENARIOS) is Terraform's then.
174
+ */
175
+ export function demoTool(demo, known = {}) {
176
+ if (!demo) return null;
177
+ if (typeof demo.tool === "string" && demo.tool) return demo.tool;
178
+ return own(known, demo.scenario) ? "terraform" : null;
179
+ }
180
+
181
+ /** Whether demo.json is live for this tool's page: its scenario is the tool's, and it is no base. */
182
+ export function liveFor(demo, tool, known = {}) {
183
+ return !!demo && demo.scenario !== "base" && demoTool(demo, known) === tool;
184
+ }
185
+
186
+ /**
187
+ * Whether a run belongs on the tool's page: a run of its repo. A run with no
188
+ * `repo` is matched by its `tool`; a run with neither comes from a driver
189
+ * before behold#571, which ran Terraform's repo alone.
190
+ */
191
+ export function keepRun(run, { tool, repo }) {
192
+ if (!run) return false;
193
+ if (typeof run.repo === "string" && run.repo) return !!repo && run.repo === repo;
194
+ if (typeof run.tool === "string" && run.tool) return run.tool === tool;
195
+ return tool === "terraform";
196
+ }
197
+
198
+ /** The runs index with only the tool's runs; any other answer as it came. */
199
+ export function filterIndex(index, which) {
200
+ if (!index || !index.doc || !Array.isArray(index.doc.runs)) return index;
201
+ return { ...index, doc: { ...index.doc, runs: index.doc.runs.filter((r) => keepRun(r, which)) } };
202
+ }
203
+
204
+ /**
205
+ * Whether this behold serves the repo: the reports' project (`<host>/<path>`)
206
+ * ends with the repo's `<owner>/<name>`. The graph frame and the plan's facts
207
+ * are the served repo's, so a page for another repo shows neither.
208
+ */
209
+ export function servesRepo(project, repo) {
210
+ if (typeof project !== "string" || typeof repo !== "string" || !project || !repo) return false;
211
+ const p = project.replace(/\.git$/, "").toLowerCase();
212
+ const r = repo.toLowerCase();
213
+ return p === r || p.endsWith(`/${r}`);
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // The replay.
218
+
219
+ /** How long the replay holds its last step (the reset) before it starts again: the driver's hold. */
220
+ export const REPLAY_LAST_HOLD_MS = 20_000;
221
+
222
+ /** How long each step of a cycle was shown, in ms, from the `at` of the next one; the last one the hold. */
223
+ export function stepDurations(steps) {
224
+ return steps.map((s, i) => {
225
+ if (i === steps.length - 1) return REPLAY_LAST_HOLD_MS;
226
+ const d = Date.parse(steps[i + 1].at) - Date.parse(s.at);
227
+ return Number.isFinite(d) && d > 0 ? d : 0;
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Where the replay is, `elapsed` ms after it started, looping: the step's
233
+ * index, and the time it stands for (the step's `at` plus the time into it),
234
+ * which the page reads as now while it replays. `{ index, at, loop }`.
235
+ */
236
+ export function replayAt(steps, elapsed) {
237
+ const ds = stepDurations(steps);
238
+ const loop = ds.reduce((a, b) => a + b, 0);
239
+ let t = loop > 0 ? ((elapsed % loop) + loop) % loop : 0;
240
+ for (let i = 0; i < steps.length; i++) {
241
+ if (t < ds[i] || i === steps.length - 1) return { index: i, at: new Date(Date.parse(steps[i].at) + Math.min(t, ds[i])), loop };
242
+ t -= ds[i];
243
+ }
244
+ return { index: 0, at: new Date(Date.parse(steps[0].at)), loop };
245
+ }
246
+
247
+ const ms = (v) => (v ? Date.parse(v) : NaN);
248
+
249
+ /** A job's or step's status at time `t`: queued before it started, running before it ended, then as it ended. */
250
+ function statusAt(o, t) {
251
+ if (o.status === "skipped") return { status: "skipped", started_at: o.started_at ?? null, ended_at: o.ended_at ?? null };
252
+ const s = ms(o.started_at);
253
+ const e = ms(o.ended_at);
254
+ if (!Number.isNaN(s) && t < s) return { status: "queued", started_at: null, ended_at: null };
255
+ if (!Number.isNaN(e) && t < e) return { status: "running", started_at: o.started_at, ended_at: null };
256
+ return { status: o.status, started_at: o.started_at ?? null, ended_at: o.ended_at ?? null };
257
+ }
258
+
259
+ /**
260
+ * A run as it stood at time `t` (ms), from the copy made when it ended: each
261
+ * job and step queued before it started and running until it ended, a
262
+ * running step's log cut to the share of its time gone by.
263
+ */
264
+ export function runAsOf(run, t) {
265
+ if (!run) return run;
266
+ const out = { ...run, ...statusAt(run, t) };
267
+ if (Array.isArray(run.jobs)) {
268
+ out.jobs = run.jobs.map((j) => {
269
+ const jo = { ...j, ...statusAt(j, t) };
270
+ if (Array.isArray(j.steps)) {
271
+ jo.steps = j.steps.map((st) => {
272
+ const so = { ...st, ...statusAt(st, t) };
273
+ const log = Array.isArray(st.log) ? st.log : [];
274
+ if (so.status === "queued") so.log = [];
275
+ else if (so.status === "running") {
276
+ const a = ms(st.started_at);
277
+ const b = ms(st.ended_at);
278
+ const share = b > a ? Math.min(1, Math.max(0, (t - a) / (b - a))) : 1;
279
+ so.log = log.slice(0, Math.ceil(log.length * share));
280
+ }
281
+ return so;
282
+ });
283
+ }
284
+ return jo;
285
+ });
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /** The replay's runs index at time `t`: the cycle's runs that had been created by then, newest first, as they stood. */
291
+ export function replayIndex(runs, t) {
292
+ const shown = runs.filter((r) => {
293
+ const c = ms(r.created_at || r.started_at);
294
+ return Number.isNaN(c) || c <= t;
295
+ });
296
+ return { doc: { schema: "demo.runs/v1", runs: shown.sort((a, b) => b.id - a.id).map((r) => runAsOf(r, t)) } };
297
+ }
298
+
299
+ /** "14:05", a time's hours and minutes in UTC; null for no time. */
300
+ export function hhmm(iso) {
301
+ const t = Date.parse(iso || "");
302
+ if (Number.isNaN(t)) return null;
303
+ const d = new Date(t);
304
+ return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
305
+ }
306
+
307
+ const counted = (n) => Number.isInteger(n) && n > 0;
308
+
309
+ /**
310
+ * About how many minutes until the tool's scenario is live again, from the
311
+ * loop's order and the length of the tool's latest cycle: what is left of
312
+ * the scenario live now, and a cycle's length for each scenario between it
313
+ * and the tool's. Null when the loop's order isn't known (no index, a base,
314
+ * or a loop of another length than the one the cycle ran in).
315
+ */
316
+ export function liveEstimate(live, cycle, now = new Date()) {
317
+ if (!live || !cycle || !cycle.steps || !cycle.steps.length) return null;
318
+ const first = cycle.steps[0];
319
+ const n = live.scenarios;
320
+ const i = live.index;
321
+ const j = first.index;
322
+ if (!counted(n) || !counted(i) || !counted(j) || first.scenarios !== n || j > n || i > n) return null;
323
+ const len = ms(cycle.ended) - ms(cycle.started);
324
+ if (!(len > 0)) return null;
325
+ const into = now.getTime() - ms(live.started_at);
326
+ if (Number.isNaN(into)) return null;
327
+ const ahead = (j - i - 1 + n) % n;
328
+ const left = Math.max(0, len - into);
329
+ return Math.max(1, Math.round((left + ahead * len) / 60_000));
330
+ }
331
+
332
+ /** The replay's banner: "Replay of the run at 14:05 UTC · live again in about 6 min", without the estimate when there is none. */
333
+ export function replayBanner(cycle, minutes) {
334
+ const at = hhmm(cycle && (cycle.started || (cycle.steps && cycle.steps[0] && cycle.steps[0].at)));
335
+ const head = at ? `Replay of the run at ${at} UTC` : "Replay of the latest run";
336
+ return Number.isInteger(minutes) && minutes > 0 ? `${head} · live again in about ${minutes} min` : head;
337
+ }
338
+
339
+ /** The page's words when the tool's scenario has no cycle yet. */
340
+ export function notRunWords(vendor) {
341
+ return {
342
+ title: "This scenario hasn't run yet",
343
+ text: `The ${vendor.name} scenario shows here once it has run through the demo's pipeline.`,
344
+ };
345
+ }
346
+
347
+ /** The legend's words, without naming a competing engine on an engine's page. */
348
+ export function rootWord(tool) {
349
+ return namesOther("Terraform", tool) ? "a root" : "a Terraform root";
350
+ }
351
+
352
+ /**
353
+ * A line of text with names in it made links: `[{ text }, { text, url }, …]`,
354
+ * the first time each name appears. Every name a page shows links to its
355
+ * official site.
356
+ */
357
+ export function linkSegments(text, links) {
358
+ let out = [{ text }];
359
+ for (const l of links) {
360
+ const i = out.findIndex((x) => !x.url && x.text.includes(l.name));
361
+ if (i < 0) continue;
362
+ const x = out[i].text;
363
+ const at = x.indexOf(l.name);
364
+ const parts = [{ text: x.slice(0, at) }, { text: l.name, url: l.url }, { text: x.slice(at + l.name.length) }].filter((p) => p.text);
365
+ out = [...out.slice(0, i), ...parts, ...out.slice(i + 1)];
366
+ }
367
+ return out;
368
+ }
369
+
370
+ /** The footer's lines on a tool's page: the credit, and what its scenarios run with, each with its names linked. */
371
+ export function creditLines(tool) {
372
+ const v = vendorOf(tool);
373
+ if (!v) return [];
374
+ const lines = [linkSegments(v.credit, [{ name: v.name, url: v.url }])];
375
+ for (const id of v.runsWith) {
376
+ const r = RUNS_WITH[id];
377
+ if (r) lines.push(linkSegments(r.words, [{ name: r.name, url: r.url }]));
378
+ }
379
+ return lines;
380
+ }
@@ -0,0 +1,265 @@
1
+ // behold#571: the per-tool pages' pure parts: live or replay, the replay's pace, the runs kept, and the branding.
2
+ import { describe, expect, it } from "vitest";
3
+ import { VENDOR_TOOLS } from "../src/terragucci-demo.ts";
4
+ import { logRows } from "./kiosk-runs.js";
5
+ import { NO_GRAPH_WORDS, SCENARIOS, legendWords, scenarioHead } from "./kiosk.js";
6
+ import {
7
+ REPLAY_LAST_HOLD_MS,
8
+ RUNS_WITH,
9
+ VENDORS,
10
+ creditLines,
11
+ demoTool,
12
+ filterIndex,
13
+ keepRun,
14
+ liveEstimate,
15
+ liveFor,
16
+ linkSegments,
17
+ namesOther,
18
+ notRunWords,
19
+ otherTerms,
20
+ replayAt,
21
+ replayBanner,
22
+ replayIndex,
23
+ rootWord,
24
+ runAsOf,
25
+ scrubDemo,
26
+ servesRepo,
27
+ stepDurations,
28
+ } from "./kiosk-vendor.js";
29
+
30
+ const demo = (extra = {}) => ({ cycle: 3, scenario: "b-waves", title: "A new subnet", step: "waiting", label: "Wave 2 waits", started_at: "2026-10-20T10:00:00Z", next: { action: "approve wave-2", at: null }, pull: 41, commit: "abc", about: null, intro: null, name: "a new subnet", scenarios: 3, index: 2, ...extra });
31
+
32
+ describe("which tool a page is live for (behold#571)", () => {
33
+ it("is demo.json's tool, and Terraform's for a driver before it that ran a scenario the kiosk knows", () => {
34
+ expect(demoTool(demo({ tool: "terragrunt" }), SCENARIOS)).toBe("terragrunt");
35
+ expect(demoTool(demo(), SCENARIOS)).toBe("terraform");
36
+ expect(demoTool(demo({ scenario: "tg-new" }), SCENARIOS)).toBe(null);
37
+ });
38
+ it("is live only for the tool whose scenario runs, never on the base", () => {
39
+ expect(liveFor(demo({ tool: "terragrunt" }), "terragrunt", SCENARIOS)).toBe(true);
40
+ expect(liveFor(demo({ tool: "terragrunt" }), "terraform", SCENARIOS)).toBe(false);
41
+ expect(liveFor(demo({ tool: "terraform", scenario: "base" }), "terraform", SCENARIOS)).toBe(false);
42
+ expect(liveFor(null, "terraform", SCENARIOS)).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("the runs a tool's page keeps (behold#571)", () => {
47
+ const index = { doc: { schema: "demo.runs/v1", runs: [{ id: 3, repo: "admin/demo-terragrunt", tool: "terragrunt" }, { id: 2, repo: "admin/demo", tool: "terraform" }, { id: 1, tool: "terragrunt" }, { id: 0 }] } };
48
+ it("keeps the repo's runs, a run's tool without a repo, and a run with neither on Terraform's page alone", () => {
49
+ expect(filterIndex(index, { tool: "terragrunt", repo: "admin/demo-terragrunt" }).doc.runs.map((r) => r.id)).toEqual([3, 1]);
50
+ expect(filterIndex(index, { tool: "terraform", repo: "admin/demo" }).doc.runs.map((r) => r.id)).toEqual([2, 0]);
51
+ expect(keepRun({ id: 9, repo: "admin/demo" }, { tool: "terraform", repo: null })).toBe(false);
52
+ });
53
+ it("leaves an answer that is no index as it came", () => {
54
+ const absent = { doc: null, absent: "no runs" };
55
+ expect(filterIndex(absent, { tool: "terraform", repo: "x" })).toBe(absent);
56
+ });
57
+ it("shows the graph only for the repo this behold serves", () => {
58
+ expect(servesRepo("forgejo:3000/admin/demo", "admin/demo")).toBe(true);
59
+ expect(servesRepo("forgejo:3000/admin/demo", "admin/demo-terragrunt")).toBe(false);
60
+ expect(servesRepo("forgejo:3000/xadmin/demo", "admin/demo")).toBe(false);
61
+ expect(servesRepo(null, "admin/demo")).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("the replay's pace (behold#571)", () => {
66
+ const steps = [
67
+ { step: "pull-request", at: "2026-10-20T10:00:00Z" },
68
+ { step: "planned", at: "2026-10-20T10:00:40Z" },
69
+ { step: "merged", at: "2026-10-20T10:01:30Z" },
70
+ { step: "reset", at: "2026-10-20T10:03:00Z" },
71
+ ];
72
+ it("holds each step as long as it was recorded, and the last for the driver's hold", () => {
73
+ expect(stepDurations(steps)).toEqual([40_000, 50_000, 90_000, REPLAY_LAST_HOLD_MS]);
74
+ });
75
+ it("steps through at that pace, standing for the step's time plus the time into it, and loops", () => {
76
+ expect(replayAt(steps, 0).index).toBe(0);
77
+ expect(replayAt(steps, 39_999).index).toBe(0);
78
+ const p = replayAt(steps, 45_000);
79
+ expect(p.index).toBe(1);
80
+ expect(p.at.toISOString()).toBe("2026-10-20T10:00:45.000Z");
81
+ expect(replayAt(steps, 180_000).index).toBe(3);
82
+ const loop = 180_000 + REPLAY_LAST_HOLD_MS;
83
+ expect(replayAt(steps, 0).loop).toBe(loop);
84
+ expect(replayAt(steps, loop + 45_000).index).toBe(1);
85
+ });
86
+ });
87
+
88
+ describe("a run as it stood in the replay (behold#571)", () => {
89
+ const run = {
90
+ id: 7,
91
+ status: "success",
92
+ created_at: "2026-10-20T10:00:00Z",
93
+ started_at: "2026-10-20T10:00:00Z",
94
+ ended_at: "2026-10-20T10:01:40Z",
95
+ jobs: [
96
+ { id: 1, name: "plan", needs: [], status: "success", started_at: "2026-10-20T10:00:00Z", ended_at: "2026-10-20T10:01:40Z", steps: [{ name: "Plan", status: "success", started_at: "2026-10-20T10:00:00Z", ended_at: "2026-10-20T10:01:40Z", log: ["1", "2", "3", "4"] }] },
97
+ { id: 2, name: "fmt", needs: [], status: "skipped", started_at: null, ended_at: null, steps: [] },
98
+ ],
99
+ };
100
+ const t = (iso) => Date.parse(iso);
101
+ it("is queued before it started, running until it ended with its log so far, then as it ended", () => {
102
+ expect(runAsOf(run, t("2026-10-20T09:59:00Z")).jobs[0].status).toBe("queued");
103
+ const mid = runAsOf(run, t("2026-10-20T10:00:50Z"));
104
+ expect([mid.status, mid.ended_at, mid.jobs[0].status, mid.jobs[0].steps[0].log]).toEqual(["running", null, "running", ["1", "2"]]);
105
+ expect(mid.jobs[1].status).toBe("skipped");
106
+ const end = runAsOf(run, t("2026-10-20T10:02:00Z"));
107
+ expect([end.status, end.jobs[0].steps[0].log.length]).toEqual(["success", 4]);
108
+ });
109
+ it("lists the cycle's runs created by then, newest first", () => {
110
+ const later = { ...run, id: 8, created_at: "2026-10-20T10:05:00Z", started_at: "2026-10-20T10:05:00Z", ended_at: "2026-10-20T10:06:00Z" };
111
+ expect(replayIndex([run, later], t("2026-10-20T10:01:00Z")).doc.runs.map((r) => r.id)).toEqual([7]);
112
+ expect(replayIndex([run, later], t("2026-10-20T10:07:00Z")).doc.runs.map((r) => r.id)).toEqual([8, 7]);
113
+ });
114
+ });
115
+
116
+ describe("the replay's banner (behold#571)", () => {
117
+ const cycle = { started: "2026-10-20T14:05:00Z", ended: "2026-10-20T14:09:00Z", steps: [{ index: 3, scenarios: 3, at: "2026-10-20T14:05:00Z" }] };
118
+ it("says when the replayed run was, and about when the tool is live again", () => {
119
+ expect(replayBanner(cycle, 6)).toBe("Replay of the run at 14:05 UTC · live again in about 6 min");
120
+ expect(replayBanner(cycle, null)).toBe("Replay of the run at 14:05 UTC");
121
+ });
122
+ it("estimates from the loop's order and the cycle's length, and not without them", () => {
123
+ const now = new Date("2026-10-20T15:01:00Z");
124
+ // Scenario 1 of 3 started a minute ago; scenario 2 runs before the tool's 3rd: 3 min left, then 4.
125
+ expect(liveEstimate({ index: 1, scenarios: 3, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(7);
126
+ // Scenario 2 is live: the tool's is next.
127
+ expect(liveEstimate({ index: 2, scenarios: 3, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(3);
128
+ expect(liveEstimate({ index: null, scenarios: 3, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(null);
129
+ expect(liveEstimate({ index: 1, scenarios: 4, started_at: "2026-10-20T15:00:00Z" }, cycle, now)).toBe(null);
130
+ });
131
+ });
132
+
133
+ describe("the branding table (behold#571)", () => {
134
+ it("has a page for every tool the server routes, and nothing else", () => {
135
+ expect(Object.keys(VENDORS)).toEqual([...VENDOR_TOOLS]);
136
+ });
137
+ it("links every name to an official https site, and shows a logo for choudoufu alone", () => {
138
+ for (const v of Object.values(VENDORS)) expect(v.url).toMatch(/^https:\/\//);
139
+ expect(Object.entries(VENDORS).filter(([, v]) => v.logo).map(([id]) => id)).toEqual(["choudoufu"]);
140
+ expect(VENDORS["cdk-terrain"].url).toBe("https://cdktn.io/");
141
+ expect(VENDORS.terraform.credit).toContain("Terraform is a trademark of HashiCorp.");
142
+ });
143
+ it("says on each page who makes the tool and that they don't endorse the demo, naming no other tool or maker", () => {
144
+ for (const tool of VENDOR_TOOLS) {
145
+ const v = VENDORS[tool];
146
+ const words = [v.name, ...creditLines(tool).map((l) => l.map((s) => s.text).join("")), ...Object.values(notRunWords(v)), legendWords("components", tool), legendWords("resources", tool)];
147
+ for (const w of words) expect(namesOther(w, tool), `${tool}: ${w}`).toBe(false);
148
+ // choudoufu is ours: its credit says so instead.
149
+ if (tool !== "choudoufu") expect(v.credit).toMatch(/(does not|don't) endorse this demo\./);
150
+ }
151
+ expect(VENDORS.choudoufu.credit).toMatch(/AWS and Kubernetes/);
152
+ expect(VENDORS.choudoufu.credit).not.toMatch(/experimental/i);
153
+ });
154
+ it("links each name in the footer, the tool's to its site and OpenTofu's where its scenarios run on it", () => {
155
+ const tf = creditLines("terraform");
156
+ expect(tf.flat().filter((s) => s.url).map((s) => [s.text, s.url])).toEqual([
157
+ ["Terraform", VENDORS.terraform.url],
158
+ ["OpenTofu", RUNS_WITH.opentofu.url],
159
+ ]);
160
+ expect(creditLines("terragrunt").flat().filter((s) => s.url).map((s) => s.text)).toEqual(["Terragrunt"]);
161
+ expect(linkSegments("a b a", [{ name: "a", url: "u" }])).toEqual([{ text: "a", url: "u" }, { text: " b a" }]);
162
+ });
163
+ it("counts as a competitor each other tool of the same kind: engines with engines, orchestrators with orchestrators", () => {
164
+ const matrix = Object.fromEntries(VENDOR_TOOLS.map((t) => [t, otherTerms(t)]));
165
+ expect(matrix).toEqual({
166
+ // OpenTofu is an engine too, but Terraform's scenarios really run on it.
167
+ terraform: ["choudoufu"],
168
+ terragrunt: ["CDK Terrain", "CDKTN", "Open Construct", "Atmos", "Cloud Posse", "Terramate"],
169
+ "cdk-terrain": ["Terragrunt", "Gruntwork", "Atmos", "Cloud Posse", "Terramate"],
170
+ choudoufu: ["Terraform", "HashiCorp", "OpenTofu"],
171
+ });
172
+ expect(otherTerms(null)).toEqual([]);
173
+ });
174
+ it("lets an orchestrator's page name the engines it runs on, and keeps an engine's page from naming another engine", () => {
175
+ expect(namesOther("Big Terraform setups run on OpenTofu", "terragrunt")).toBe(false);
176
+ expect(namesOther("Big Terraform setups", "cdk-terrain")).toBe(false);
177
+ expect(namesOther("compare with Atmos", "terragrunt")).toBe(true);
178
+ expect(namesOther("Terragrunt units", "cdk-terrain")).toBe(true);
179
+ expect(namesOther("Big Terraform setups", "choudoufu")).toBe(true);
180
+ expect(namesOther("Terraform.", "choudoufu")).toBe(true);
181
+ expect(namesOther("runs on choudoufu", "terraform")).toBe(true);
182
+ expect(namesOther("works with OpenTofu", "terraform")).toBe(false);
183
+ expect(rootWord("choudoufu")).toBe("a root");
184
+ expect(rootWord("terragrunt")).toBe("a Terraform root");
185
+ });
186
+ it("never counts a resource address, a bucket or a path as a brand", () => {
187
+ for (const t of ["terraform_data.app", "data.terraform_remote_state.network", "state in demo-terraform-state", "s3://demo-terraform-state/edge.tfstate", "developer.hashicorp.com/terraform", ".terraform.lock.hcl", "terragrunt-cache"]) {
188
+ expect(namesOther(t, "choudoufu"), t).toBe(false);
189
+ expect(namesOther(t, "cdk-terrain"), t).toBe(false);
190
+ }
191
+ });
192
+ it("leaves out the driver's words that name a competitor, and the loop's count", () => {
193
+ const d = scrubDemo(demo({ intro: "Big Terraform setups split", about: { changes: "A unit changes", watch: "Terramate plans", shows: "It waits" }, label: "Atmos applies wave 2", title: "CDK Terrain roots" }), "terragrunt");
194
+ expect([d.intro, d.label, d.title, d.scenarios, d.index]).toEqual(["Big Terraform setups split", "", "a new subnet", null, null]);
195
+ expect(d.about).toEqual({ changes: "A unit changes", shows: "It waits" });
196
+ expect(scenarioHead(d, { vendor: "terragrunt" })).toEqual({ kicker: "Scenario · a new subnet", title: "a new subnet" });
197
+ expect(scenarioHead(demo(), { vendor: "terraform" }).kicker).not.toMatch(/of 3|\d/);
198
+ expect(scrubDemo(demo({ intro: "Big Terraform setups split" }), "choudoufu").intro).toBe(null);
199
+ });
200
+ it("leaves a log line naming a competitor out, says how many, and keeps the engine's lines on an orchestrator's page", () => {
201
+ const rows = logRows(["plan ok", "Terramate binary", "Terraform v1.9", "edge: s3://demo-terraform-state/edge.tfstate"], { hide: (l) => namesOther(l, "terragrunt") });
202
+ expect(rows).toEqual([{ n: 1, text: "plan ok" }, { n: 3, text: "Terraform v1.9" }, { n: 4, text: "edge: s3://demo-terraform-state/edge.tfstate" }, { hidden: 1 }]);
203
+ });
204
+ });
205
+
206
+ describe("a tool's page, drawn (behold#571)", () => {
207
+ const fakeDocument = () => {
208
+ const make = (tag) => {
209
+ const n = { tagName: tag, className: "", textContent: "", children: [] };
210
+ n.appendChild = (c) => (n.children.push(c), c);
211
+ n.append = (...cs) => cs.forEach((c) => n.children.push(typeof c === "string" ? { tagName: "#text", textContent: c, children: [] } : c));
212
+ n.replaceChildren = () => (n.children = []);
213
+ return n;
214
+ };
215
+ return { createElement: make };
216
+ };
217
+ const text = (n) => (n.children && n.children.length ? n.children.map(text).join("") : n.textContent || "");
218
+ const all = (n) => [n, ...(n.children || []).flatMap(all)];
219
+ it("puts the tool's name before the scenario, the banner over it, and the credit in the foot, with no count", async () => {
220
+ const { renderKiosk } = await import("./kiosk.js");
221
+ const was = globalThis.document;
222
+ globalThis.document = fakeDocument();
223
+ try {
224
+ const bar = document.createElement("header");
225
+ const foot = document.createElement("footer");
226
+ const about = { changes: "A unit changes", watch: "Terramate would plan it too", shows: "It waits" };
227
+ const s = { vendor: "terragrunt", mode: "replay", replay: { banner: "Replay of the run at 14:05 UTC" }, noReports: true, demo: { demo: scrubDemo(demo({ tool: "terragrunt", intro: "Big Terraform setups", about }), "terragrunt") }, answer: null, progress: null };
228
+ renderKiosk(bar, s, new Date("2026-10-20T10:01:00Z"), foot);
229
+ expect(bar.children[0].className).toBe("k-replay");
230
+ const title = all(bar).find((n) => n.className === "k-title");
231
+ expect(title.children.map((c) => c.className)).toEqual(["k-brand", "k-kicker", "k-title-text"]);
232
+ const brand = title.children[0];
233
+ expect([brand.href, brand.target, brand.rel, text(brand)]).toEqual([VENDORS.terragrunt.url, "_blank", "noopener", "Terragrunt"]);
234
+ expect(text(bar)).not.toMatch(/of 3|Terramate/);
235
+ // behold#571: no intro band at a live step; the intro fills the card's box at reset.
236
+ expect(text(bar)).not.toContain("Big Terraform setups");
237
+ // The engine Terragrunt runs on is no competitor: the shared intro stays.
238
+ const reset = { ...s, demo: { demo: { ...s.demo.demo, step: "reset" } } };
239
+ const bar2 = document.createElement("header");
240
+ renderKiosk(bar2, reset, new Date("2026-10-20T10:01:00Z"), document.createElement("footer"));
241
+ expect(text(bar2)).toContain("Big Terraform setups");
242
+ // No graph of this repo here: one line in the header says so.
243
+ expect(all(bar).filter((n) => n.className === "k-nograph-note").map(text)).toEqual([NO_GRAPH_WORDS]);
244
+ expect(all(foot).filter((n) => n.className === "k-credit").map(text)).toEqual([VENDORS.terragrunt.credit]);
245
+ } finally {
246
+ globalThis.document = was;
247
+ }
248
+ });
249
+ it("says calmly that a tool's scenario hasn't run yet, with its name and link", async () => {
250
+ const { renderKiosk } = await import("./kiosk.js");
251
+ const was = globalThis.document;
252
+ globalThis.document = fakeDocument();
253
+ try {
254
+ const bar = document.createElement("header");
255
+ const foot = document.createElement("footer");
256
+ renderKiosk(bar, { vendor: "choudoufu", mode: "none", demo: null }, new Date(), foot);
257
+ expect(text(bar)).toContain("This scenario hasn't run yet");
258
+ const brand = all(bar).find((n) => n.className === "k-brand");
259
+ expect(brand.href).toBe(VENDORS.choudoufu.url);
260
+ expect(all(brand).some((n) => n.className === "k-brand-logo" && n.src === VENDORS.choudoufu.logo.src)).toBe(true);
261
+ } finally {
262
+ globalThis.document = was;
263
+ }
264
+ });
265
+ });