@intentius/behold 0.29.0 → 0.29.2

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,485 @@
1
+ // #569: the kiosk's pipeline runs, laid out the way a CI page lays them out:
2
+ // a sidebar of recent runs (the live one pinned on top), the selected run's
3
+ // jobs in the order their `needs` give, each job's steps, and the step that
4
+ // matters expanded with its log, following it while it runs.
5
+ //
6
+ // The runs are the demo repo's real pipeline runs, which the loop driver
7
+ // copies from its forge into the reports bucket (`demo.runs/v1`,
8
+ // `demo.run/v1`); behold serves them at /api/terragucci/runs(/:id). All GET.
9
+ //
10
+ // A job held at a terragucci gate is `waiting`: it waits for an approval, and
11
+ // is never drawn as a failure. Step names are shown as the driver wrote them.
12
+
13
+ /** The run, job and step statuses, as a viewer reads them. */
14
+ export const STATUS_WORDS = {
15
+ queued: "queued",
16
+ running: "running",
17
+ waiting: "waiting for an approval",
18
+ success: "succeeded",
19
+ failure: "failed",
20
+ cancelled: "cancelled",
21
+ skipped: "skipped",
22
+ };
23
+
24
+ /** The tone a status is drawn in. `waiting` has its own, never failure's. */
25
+ export const STATUS_TONE = { queued: "idle", running: "busy", waiting: "wait", success: "ok", failure: "bad", cancelled: "idle", skipped: "idle" };
26
+
27
+ const LIVE = new Set(["queued", "running", "waiting"]);
28
+
29
+ /** A step named by an action's address (`https://…/checkout@v4`, `actions/checkout@v4`), not by the workflow. */
30
+ const looksLikeAction = (name) => /^[a-z][a-z0-9+.-]*:\/\//i.test(name) || /^[\w.-]+\/[\w.-]+@/.test(name);
31
+ /** The runner's post step of an action: "Post " and the action's address. */
32
+ const isPostStep = (name) => /^Post /.test(name) && looksLikeAction(name.slice(5));
33
+
34
+ /**
35
+ * What started a run, in words, with the ref it ran on: "Merge of #278 to
36
+ * main", "Plan of pull request #278", "/terragucci apply on #278", "Push to
37
+ * branch demo-a-known". The ref comes from the run (index.json carries it; a
38
+ * run's own document may not, see mergeRun).
39
+ */
40
+ export function triggerWords(run) {
41
+ const pr = run.pull !== null && run.pull !== undefined ? `#${run.pull}` : null;
42
+ const ref = run.ref || null;
43
+ const onMain = !ref || ref === "main" || ref === "master";
44
+ switch (run.trigger) {
45
+ case "plan":
46
+ return pr ? `Plan of pull request ${pr}` : `Plan of a pull request${ref ? ` from ${ref}` : ""}`;
47
+ case "merge":
48
+ return pr ? `Merge of ${pr} to ${ref || "main"}` : `Merge to ${ref || "main"}`;
49
+ case "resume":
50
+ return pr ? `/terragucci apply on ${pr}` : "/terragucci apply";
51
+ case "reset":
52
+ return `Reset ${ref || "main"} to the base`;
53
+ case "base":
54
+ return `The base, pushed to ${ref || "main"}`;
55
+ case "push":
56
+ return onMain ? `Push to ${ref || "main"}` : `Push to branch ${ref}`;
57
+ default:
58
+ return run.event ? `${run.event} run${ref ? ` on ${ref}` : ""}` : "a run";
59
+ }
60
+ }
61
+
62
+ /** How far along a status is: a run or job only moves up this order, so the higher of two reads is the newer. */
63
+ export const STATUS_RANK = { queued: 0, running: 1, waiting: 2, success: 3, failure: 3, cancelled: 3, skipped: 3 };
64
+
65
+ /** Of two reads of the same run or job, the one that is further along; the second on a tie. */
66
+ export function newerOf(a, b) {
67
+ if (!a) return b;
68
+ if (!b) return a;
69
+ const ra = STATUS_RANK[a.status] ?? -1;
70
+ const rb = STATUS_RANK[b.status] ?? -1;
71
+ if (ra !== rb) return ra > rb ? a : b;
72
+ if (a.ended_at && !b.ended_at) return a;
73
+ return b;
74
+ }
75
+
76
+ /**
77
+ * One run as the kiosk draws it, from its index.json entry and its own
78
+ * document, read at different moments: every field the document lacks
79
+ * (`ref`, say) from the entry, and the run's and each job's status and times
80
+ * from whichever read is further along. The sidebar and the run's page draw
81
+ * this same object, so they never disagree.
82
+ */
83
+ export function mergeRun(entry, doc) {
84
+ if (!doc) return entry || null;
85
+ if (!entry || entry.id !== doc.id) return doc;
86
+ const out = { ...doc };
87
+ for (const [k, v] of Object.entries(entry)) if ((out[k] === undefined || out[k] === null) && k !== "jobs") out[k] = v;
88
+ const run = newerOf(doc, entry);
89
+ out.status = run.status;
90
+ out.started_at = run.started_at ?? doc.started_at ?? entry.started_at ?? null;
91
+ out.ended_at = run.ended_at ?? null;
92
+ const ix = new Map((entry.jobs || []).map((j) => [j.id ?? j.name, j]));
93
+ const jobs = (doc.jobs || []).map((j) => {
94
+ const e = ix.get(j.id ?? j.name);
95
+ ix.delete(j.id ?? j.name);
96
+ const n = newerOf(j, e);
97
+ return n === j ? j : { ...j, status: n.status, started_at: n.started_at ?? j.started_at, ended_at: n.ended_at ?? null };
98
+ });
99
+ for (const e of ix.values()) jobs.push({ ...e, steps: [] });
100
+ out.jobs = jobs;
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * The run the kiosk follows: the newest one queued or running, else the
106
+ * newest run. A run held at a gate is followed while it is the newest; once a
107
+ * newer run came after it (the resume, the next cycle), the demo has moved on
108
+ * and it is a recent run like the others. Null with no runs.
109
+ */
110
+ export function liveRun(runs) {
111
+ if (!runs || !runs.length) return null;
112
+ return runs.find((r) => r.status === "queued" || r.status === "running") || runs[0];
113
+ }
114
+
115
+ /** The sidebar's order: the live run first, then the rest newest first, as the index has them. */
116
+ export function sidebarRuns(runs) {
117
+ const live = liveRun(runs);
118
+ if (!live) return [];
119
+ return [live, ...runs.filter((r) => r !== live)];
120
+ }
121
+
122
+ /**
123
+ * Jobs in the order their `needs` give (a job after every job it needs),
124
+ * keeping the forge's order among equals. A `needs` naming a job the run
125
+ * doesn't have is ignored; a cycle still lists every job once.
126
+ */
127
+ export function jobOrder(jobs) {
128
+ const byName = new Map(jobs.map((j) => [j.name, j]));
129
+ const depth = new Map();
130
+ const visiting = new Set();
131
+ const d = (j) => {
132
+ if (depth.has(j.name)) return depth.get(j.name);
133
+ if (visiting.has(j.name)) return 0;
134
+ visiting.add(j.name);
135
+ const needs = (j.needs || []).map((n) => byName.get(n)).filter(Boolean);
136
+ const v = needs.length ? 1 + Math.max(...needs.map(d)) : 0;
137
+ visiting.delete(j.name);
138
+ depth.set(j.name, v);
139
+ return v;
140
+ };
141
+ return jobs.map((j, i) => ({ j, i, k: d(j) })).sort((a, b) => a.k - b.k || a.i - b.i).map((x) => ({ ...x.j, depth: x.k }));
142
+ }
143
+
144
+ /** Split jobs into those that ran or will run, and the ones the run skipped (shown folded). */
145
+ export function splitSkipped(jobs) {
146
+ return { shown: jobs.filter((j) => j.status !== "skipped"), skipped: jobs.filter((j) => j.status === "skipped") };
147
+ }
148
+
149
+ /** The job shown by default: running, then waiting, then failed, then the last one that started. */
150
+ export function defaultJob(jobs) {
151
+ for (const s of ["running", "waiting", "failure"]) {
152
+ const j = jobs.find((x) => x.status === s);
153
+ if (j) return j;
154
+ }
155
+ // A run that ended: the job with the most of its own to say (the plan, the apply), else the last one that started.
156
+ const said = jobs.map((j) => ({ j, n: ownLines(j.steps || []) })).filter((x) => x.n > 0);
157
+ if (said.length) return said.reduce((a, b) => (b.n > a.n ? b : a)).j;
158
+ const started = jobs.filter((j) => j.started_at).sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at));
159
+ return started[0] || jobs.find((j) => j.status !== "skipped") || jobs[0] || null;
160
+ }
161
+
162
+ /** The workflow's own steps of a job: not the runner's set-up or trailer, a post step, or an action's (named by its address). */
163
+ function ownSteps(steps) {
164
+ return steps.map((s, i) => ({ s, i })).filter(({ s, i }) => i > 0 && i < steps.length - 1 && !isPostStep(s.name) && !looksLikeAction(s.name));
165
+ }
166
+
167
+ /** How many log lines a job's own steps wrote. */
168
+ function ownLines(steps) {
169
+ return ownSteps(steps).reduce((n, { s }) => n + (s.log ? s.log.length : 0), 0);
170
+ }
171
+
172
+ /**
173
+ * The step expanded by default: the one running, waiting or failed (where the
174
+ * job's own output is). Otherwise the step with the most to say among the
175
+ * workflow's own steps: not the runner's set-up, its trailer or a post step,
176
+ * and not an action's (named by its address), the job's first step when there
177
+ * is nothing else. Index into `steps`, or -1 with no steps.
178
+ */
179
+ export function defaultStep(steps) {
180
+ if (!steps || !steps.length) return -1;
181
+ for (const s of ["running", "waiting", "failure"]) {
182
+ const i = steps.findIndex((x) => x.status === s);
183
+ if (i >= 0) return i;
184
+ }
185
+ const own = ownSteps(steps).filter(({ s }) => s.log && s.log.length);
186
+ if (own.length) return own.reduce((a, b) => (b.s.log.length > a.s.log.length ? b : a)).i;
187
+ const any = steps.map((s, i) => ({ s, i })).filter(({ s }) => s.log && s.log.length);
188
+ return any.length ? any[any.length - 1].i : 0;
189
+ }
190
+
191
+ /** "4 s", "1 m 05 s", "1 h 02 m"; null without a start. A thing still going counts to now. */
192
+ export function durationWords(started, ended, now = new Date()) {
193
+ if (!started) return null;
194
+ const a = Date.parse(started);
195
+ const b = ended ? Date.parse(ended) : now.getTime();
196
+ if (Number.isNaN(a) || Number.isNaN(b)) return null;
197
+ const s = Math.max(0, Math.round((b - a) / 1000));
198
+ if (s < 60) return `${s} s`;
199
+ if (s < 3600) return `${Math.floor(s / 60)} m ${String(s % 60).padStart(2, "0")} s`;
200
+ return `${Math.floor(s / 3600)} h ${String(Math.floor((s % 3600) / 60)).padStart(2, "0")} m`;
201
+ }
202
+
203
+ /** "just now", "40 s ago", "3 m ago", "2 h ago". */
204
+ export function agoWords(at, now = new Date()) {
205
+ if (!at) return "";
206
+ const s = Math.max(0, Math.round((now.getTime() - Date.parse(at)) / 1000));
207
+ if (s < 5) return "just now";
208
+ if (s < 60) return `${s} s ago`;
209
+ if (s < 3600) return `${Math.floor(s / 60)} m ago`;
210
+ if (s < 172800) return `${Math.floor(s / 3600)} h ago`;
211
+ return `${Math.floor(s / 86400)} d ago`;
212
+ }
213
+
214
+ /** "a-output" as the kiosk names scenarios, with the cycle: "A, read through .output · cycle 90". */
215
+ export function placeWords(run, scenarios) {
216
+ const sc = run.scenario && scenarios && Object.prototype.hasOwnProperty.call(scenarios, run.scenario) ? scenarios[run.scenario].name : run.scenario;
217
+ return [sc, run.cycle !== null && run.cycle !== undefined ? `cycle ${run.cycle}` : null].filter(Boolean).join(" · ");
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Drawing.
222
+
223
+ const NS = "http://www.w3.org/2000/svg";
224
+ const el = (tag, cls, text) => {
225
+ const n = document.createElement(tag);
226
+ if (cls) n.className = cls;
227
+ if (text !== undefined && text !== null) n.textContent = text;
228
+ return n;
229
+ };
230
+
231
+ /** A status icon, the way a CI page draws one. */
232
+ export function statusIcon(status) {
233
+ const svg = document.createElementNS(NS, "svg");
234
+ svg.setAttribute("viewBox", "0 0 16 16");
235
+ svg.setAttribute("class", `r-icon r-${STATUS_TONE[status] || "idle"}${status === "running" ? " r-spin" : ""}`);
236
+ svg.setAttribute("aria-label", STATUS_WORDS[status] || status);
237
+ const add = (tag, attrs) => {
238
+ const n = document.createElementNS(NS, tag);
239
+ for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
240
+ svg.appendChild(n);
241
+ };
242
+ if (status === "success") {
243
+ add("circle", { cx: 8, cy: 8, r: 7, fill: "currentColor" });
244
+ add("path", { d: "M4.6 8.3 7 10.6l4.5-5", fill: "none", stroke: "var(--bg)", "stroke-width": 1.8, "stroke-linecap": "round", "stroke-linejoin": "round" });
245
+ } else if (status === "failure") {
246
+ add("circle", { cx: 8, cy: 8, r: 7, fill: "currentColor" });
247
+ add("path", { d: "M5.5 5.5l5 5m0-5-5 5", stroke: "var(--bg)", "stroke-width": 1.8, "stroke-linecap": "round" });
248
+ } else if (status === "running") {
249
+ add("circle", { cx: 8, cy: 8, r: 6, fill: "none", stroke: "currentColor", "stroke-opacity": 0.3, "stroke-width": 2 });
250
+ add("path", { d: "M8 2a6 6 0 0 1 6 6", fill: "none", stroke: "currentColor", "stroke-width": 2, "stroke-linecap": "round" });
251
+ } else if (status === "waiting") {
252
+ add("circle", { cx: 8, cy: 8, r: 7, fill: "currentColor" });
253
+ add("path", { d: "M6.2 5v6M9.8 5v6", stroke: "var(--bg)", "stroke-width": 1.8, "stroke-linecap": "round" });
254
+ } else if (status === "skipped" || status === "cancelled") {
255
+ add("circle", { cx: 8, cy: 8, r: 6.2, fill: "none", stroke: "currentColor", "stroke-width": 1.5 });
256
+ add("path", { d: "M4 12 12 4", stroke: "currentColor", "stroke-width": 1.5 });
257
+ } else {
258
+ add("circle", { cx: 8, cy: 8, r: 6.2, fill: "none", stroke: "currentColor", "stroke-width": 1.5, "stroke-dasharray": "2.4 2" });
259
+ }
260
+ return svg;
261
+ }
262
+
263
+ /** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
264
+ export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, pick = () => {}, merged = null } = {}) {
265
+ host.replaceChildren();
266
+ const head = el("div", "r-side-head");
267
+ head.appendChild(el("div", "r-side-title", "Pipeline runs"));
268
+ head.appendChild(el("div", "r-side-note", "Real CI runs of the demo repo, copied from its forge as they go. Newest first."));
269
+ host.appendChild(head);
270
+ if (!index || index.error || index.absent || !index.doc) {
271
+ host.appendChild(el("p", "r-note", index ? index.absent || index.error : "Reading the runs…"));
272
+ return;
273
+ }
274
+ // The selected run as its page draws it, when its own document is newer than the index.
275
+ const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
276
+ const live = runs[0];
277
+ const list = el("ol", "r-runs");
278
+ runs.forEach((r, i) => {
279
+ if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
280
+ if (i === 1) list.appendChild(el("li", "r-group", "Recent"));
281
+ const li = el("li", `r-run${r.id === selectedId ? " r-selected" : ""}`);
282
+ li.tabIndex = 0;
283
+ li.dataset.run = String(r.id);
284
+ li.appendChild(statusIcon(r.status));
285
+ const body = el("div", "r-run-body");
286
+ body.appendChild(el("div", "r-run-title", triggerWords(r)));
287
+ const place = placeWords(r, scenarios);
288
+ if (place) body.appendChild(el("div", "r-run-place", place));
289
+ const meta = el("div", "r-run-meta");
290
+ meta.append(
291
+ el("span", `r-word r-${STATUS_TONE[r.status] || "idle"}`, r.status === "waiting" ? "held at the gate" : STATUS_WORDS[r.status] || r.status),
292
+ el("span", null, `#${r.id}`),
293
+ el("span", null, agoWords(r.started_at || r.created_at, now)),
294
+ );
295
+ const dur = durationWords(r.started_at, r.ended_at, now);
296
+ if (dur) meta.appendChild(el("span", null, dur));
297
+ body.appendChild(meta);
298
+ li.appendChild(body);
299
+ li.addEventListener("click", () => pick(r.id));
300
+ li.addEventListener("keydown", (e) => {
301
+ if (e.key === "Enter" || e.key === " ") pick(r.id);
302
+ });
303
+ list.appendChild(li);
304
+ });
305
+ host.appendChild(list);
306
+ }
307
+
308
+ /**
309
+ * The run's page: its head, its jobs in `needs` order (skipped ones folded
310
+ * into a line), the selected job's steps, and the expanded step's log.
311
+ * `ui` holds the viewer's picks: `job` (a name) and `open` (a step index),
312
+ * null for the defaults. Returns what it drew, so the caller can keep a log's
313
+ * scroll.
314
+ */
315
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, entry = null } = {}) {
316
+ host.replaceChildren();
317
+ if (!answer || answer.error || answer.absent || !answer.doc) {
318
+ host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
319
+ return { log: null };
320
+ }
321
+ const run = mergeRun(entry, answer.doc);
322
+ const head = el("div", "r-head");
323
+ const t = el("div", "r-head-title");
324
+ t.append(statusIcon(run.status), el("span", null, triggerWords(run)));
325
+ head.appendChild(t);
326
+ head.appendChild(el("div", "r-head-sub", run.title));
327
+ const meta = el("div", "r-head-meta");
328
+ const bits = [
329
+ `#${run.id}`,
330
+ run.status === "waiting" ? "held at a terragucci gate, waiting for an approval" : STATUS_WORDS[run.status] || run.status,
331
+ run.ref ? `on ${run.ref}` : null,
332
+ run.commit ? `commit ${run.commit.slice(0, 8)}` : null,
333
+ run.started_at ? `started ${agoWords(run.started_at, now)}` : null,
334
+ durationWords(run.started_at, run.ended_at, now),
335
+ ].filter(Boolean);
336
+ meta.textContent = bits.join(" · ");
337
+ head.appendChild(meta);
338
+ host.appendChild(head);
339
+
340
+ const ordered = jobOrder(run.jobs);
341
+ const { shown, skipped } = splitSkipped(ordered);
342
+ const job = (ui.job && run.jobs.find((j) => j.name === ui.job)) || defaultJob(shown.length ? shown : run.jobs);
343
+ host.appendChild(el("div", "r-label", "Jobs, in the order they run"));
344
+ const jobsBox = el("ol", "r-jobs");
345
+ for (const j of shown) {
346
+ const li = el("li", `r-job${job && j.name === job.name ? " r-selected" : ""}`);
347
+ li.style.setProperty("--depth", String(j.depth));
348
+ li.appendChild(statusIcon(j.status));
349
+ li.appendChild(el("span", "r-job-name", j.name));
350
+ if (j.needs && j.needs.length) li.appendChild(el("span", "r-job-needs", `after ${j.needs.join(", ")}`));
351
+ li.appendChild(el("span", "r-dur", j.status === "waiting" ? "held at the gate" : durationWords(j.started_at, j.ended_at, now) || STATUS_WORDS[j.status] || ""));
352
+ li.addEventListener("click", () => pickJob(j.name));
353
+ jobsBox.appendChild(li);
354
+ }
355
+ if (skipped.length) jobsBox.appendChild(el("li", "r-job r-job-skipped", `${skipped.length} skipped, not for this event: ${skipped.map((j) => j.name).join(", ")}`));
356
+ host.appendChild(jobsBox);
357
+
358
+ if (!job) return { log: null };
359
+ host.appendChild(el("div", "r-label", "Steps of the job, with its log"));
360
+ const stepsBox = el("div", "r-steps");
361
+ const sh = el("div", "r-steps-head");
362
+ sh.append(statusIcon(job.status), el("span", "r-steps-job", job.name), el("span", "r-steps-word", job.status === "waiting" ? "waits for an approval at the gate" : STATUS_WORDS[job.status] || job.status));
363
+ stepsBox.appendChild(sh);
364
+ const steps = job.steps || [];
365
+ if (!steps.length) {
366
+ const words =
367
+ job.status === "queued"
368
+ ? "Queued: it starts once the jobs it needs finish."
369
+ : job.status === "running"
370
+ ? "Started: its steps and log appear here as the loop driver copies them from the forge, every few seconds."
371
+ : "No steps recorded for this job.";
372
+ stepsBox.appendChild(el("p", "r-note", words));
373
+ }
374
+ const open = ui.open !== null && ui.open !== undefined && ui.job === job.name ? ui.open : defaultStep(steps);
375
+ let logEl = null;
376
+ steps.forEach((s, i) => {
377
+ const row = el("div", `r-step${i === open ? " r-open" : ""}`);
378
+ const line = el("div", "r-step-line");
379
+ line.append(el("span", "r-caret", i === open ? "▾" : "▸"), statusIcon(s.status), el("span", "r-step-name", s.name), el("span", "r-dur", durationWords(s.started_at, s.ended_at, now) || ""));
380
+ line.addEventListener("click", () => toggleStep(job.name, i === open ? -1 : i));
381
+ row.appendChild(line);
382
+ if (i === open) {
383
+ const pre = el("pre", "r-log");
384
+ if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
385
+ s.log.forEach((l, n) => {
386
+ const ln = el("div", "r-log-line");
387
+ ln.append(el("span", "r-ln", String((s.truncated || 0) + n + 1)), el("span", "r-lt", l));
388
+ pre.appendChild(ln);
389
+ });
390
+ if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
391
+ if (s.status === "running") pre.appendChild(el("div", "r-log-live", "▍"));
392
+ row.appendChild(pre);
393
+ logEl = pre;
394
+ }
395
+ stepsBox.appendChild(row);
396
+ });
397
+ host.appendChild(stepsBox);
398
+ return { log: logEl, live: LIVE.has(job.status) };
399
+ }
400
+
401
+ /** How long a viewer's pick holds before the kiosk follows the live run again. */
402
+ export const PICK_HOLD_MS = 90_000;
403
+
404
+ /**
405
+ * Boot the runs panel: poll the index, read the selected run (often while it
406
+ * is live), and redraw. The live run is selected until a viewer picks
407
+ * another, and again {@link PICK_HOLD_MS} after their last pick.
408
+ */
409
+ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, onLive = () => {} } = {}) {
410
+ const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null } };
411
+ let follow = true;
412
+ let lastRunKey = "";
413
+ const following = () => follow || now().getTime() - s.picked > PICK_HOLD_MS;
414
+ const selectedId = () => {
415
+ const runs = s.index && s.index.doc ? s.index.doc.runs : [];
416
+ const live = liveRun(runs);
417
+ if (following()) {
418
+ follow = true;
419
+ return live ? live.id : null;
420
+ }
421
+ return s.selected;
422
+ };
423
+ const draw = () => {
424
+ const id = selectedId();
425
+ const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) || null : null;
426
+ const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
427
+ // One merged read of the selected run for both the sidebar and its page.
428
+ const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
429
+ renderSidebar(side, s.index, id, { now: now(), scenarios, pick, merged });
430
+ // Keep the log's scroll unless it was at the bottom, so a live log follows.
431
+ const old = main.querySelector(".r-log");
432
+ const atBottom = !old || old.scrollTop + old.clientHeight >= old.scrollHeight - 8;
433
+ const prevTop = old ? old.scrollTop : 0;
434
+ const drawn = renderRun(main, ans, s.ui, {
435
+ now: now(),
436
+ entry,
437
+ pickJob: (name) => {
438
+ s.ui = { job: name, open: null };
439
+ hold();
440
+ draw();
441
+ },
442
+ toggleStep: (job, i) => {
443
+ s.ui = { job, open: i };
444
+ hold();
445
+ draw();
446
+ },
447
+ });
448
+ if (drawn.log) drawn.log.scrollTop = atBottom ? drawn.log.scrollHeight : prevTop;
449
+ };
450
+ const hold = () => {
451
+ follow = false;
452
+ s.selected = selectedId() ?? s.selected;
453
+ s.picked = now().getTime();
454
+ };
455
+ const pick = (id) => {
456
+ s.selected = id;
457
+ follow = false;
458
+ s.picked = now().getTime();
459
+ s.ui = { job: null, open: null };
460
+ void readRun();
461
+ };
462
+ const readIndex = async () => {
463
+ s.index = await fetchJson("/api/terragucci/runs");
464
+ const runs = s.index && s.index.doc ? s.index.doc.runs : [];
465
+ const live = liveRun(runs);
466
+ onLive(live);
467
+ await readRun();
468
+ };
469
+ const readRun = async () => {
470
+ const id = selectedId();
471
+ if (id === null) return draw();
472
+ const entry = s.index && s.index.doc ? s.index.doc.runs.find((r) => r.id === id) : null;
473
+ // A run that ended reads once; one still going reads on every tick.
474
+ const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
475
+ if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
476
+ if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null };
477
+ lastRunKey = key;
478
+ s.run = await fetchJson(`/api/terragucci/runs/${id}`);
479
+ draw();
480
+ };
481
+ void readIndex();
482
+ setInterval(() => void readIndex(), 3_000);
483
+ setInterval(draw, 1_000);
484
+ return s;
485
+ }
@@ -0,0 +1,98 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
5
+
6
+ const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
7
+ const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
8
+ const index = () => JSON.parse(readFileSync(join(FIX, "index.json"), "utf8"));
9
+
10
+ describe("the runs, as a CI page lists them (#569)", () => {
11
+ it("names what started each run", () => {
12
+ expect(triggerWords({ trigger: "plan", pull: 278 })).toBe("Plan of pull request #278");
13
+ expect(triggerWords({ trigger: "merge", pull: 278, ref: "main" })).toBe("Merge of #278 to main");
14
+ expect(triggerWords({ trigger: "resume", pull: 278, ref: "main" })).toBe("/terragucci apply on #278");
15
+ expect(triggerWords({ trigger: "reset", pull: null, ref: "main" })).toBe("Reset main to the base");
16
+ expect(triggerWords({ trigger: "base", pull: null, ref: "main" })).toBe("The base, pushed to main");
17
+ expect(triggerWords({ trigger: "push", pull: null, ref: "main" })).toBe("Push to main");
18
+ expect(triggerWords({ trigger: "push", pull: null, ref: "demo-a-known" })).toBe("Push to branch demo-a-known");
19
+ });
20
+ it("never draws a wave held at the gate as a failure", () => {
21
+ expect(STATUS_TONE.waiting).toBe("wait");
22
+ expect(STATUS_TONE.waiting).not.toBe(STATUS_TONE.failure);
23
+ });
24
+ it("follows the newest run still going, pinned first", () => {
25
+ const runs = index().runs;
26
+ expect(liveRun(runs).id).toBe(448);
27
+ expect(liveRun(runs.filter((r) => r.id !== 448)).id).toBe(447); // nothing going: the newest
28
+ // Held at a gate: followed while it is the newest, a recent run once another came after it.
29
+ expect(liveRun([{ id: 8, status: "waiting" }, { id: 7, status: "success" }]).id).toBe(8);
30
+ expect(liveRun([{ id: 9, status: "success" }, { id: 8, status: "waiting" }]).id).toBe(9);
31
+ expect(sidebarRuns([{ id: 9, status: "success" }, { id: 8, status: "running" }]).map((r) => r.id)).toEqual([8, 9]);
32
+ expect(liveRun([])).toBe(null);
33
+ });
34
+ it("orders jobs by their needs and folds the skipped ones", () => {
35
+ const jobs = jobOrder(run(444).jobs);
36
+ const { shown, skipped } = splitSkipped(jobs);
37
+ expect(shown.map((j) => `${j.name}@${j.depth}`)).toEqual(["check@0", "apply-wave-1@1", "apply-wave-2@2"]);
38
+ expect(skipped.map((j) => j.name)).toContain("apply-wave-3");
39
+ expect(jobOrder([{ name: "b", needs: ["a"] }, { name: "a", needs: ["b"] }]).map((j) => j.name).sort()).toEqual(["a", "b"]); // a cycle still lists every job
40
+ });
41
+ it("shows the job that runs, waits or failed, else the one with the most to say", () => {
42
+ expect(defaultJob(run(448).jobs).name).toBe("apply-wave-2");
43
+ expect(defaultJob(run(444).jobs).name).toBe("apply-wave-2");
44
+ expect(defaultJob(run(447).jobs).status).toBe("failure");
45
+ expect(defaultJob(run(443).jobs).name).toBe("plan");
46
+ });
47
+ it("opens the step with the job's own output, not the first one", () => {
48
+ const step = (id, job) => {
49
+ const steps = run(id).jobs.find((j) => j.name === job).steps;
50
+ return steps[defaultStep(steps)];
51
+ };
52
+ expect(step(444, "apply-wave-2")).toMatchObject({ name: "Apply wave 2 of 3", status: "waiting" });
53
+ expect(step(448, "apply-wave-2")).toMatchObject({ name: "Apply wave 2 of 3", status: "running" });
54
+ expect(step(447, "apply-wave-2").status).toBe("failure");
55
+ expect(step(443, "plan").name).toBe("Plan the roots the change reaches and write the plan report");
56
+ expect(step(444, "apply-wave-1").name).toBe("Apply wave 1 of 3");
57
+ expect(defaultStep([])).toBe(-1);
58
+ });
59
+ it("says durations and ages", () => {
60
+ const now = new Date("2026-10-15T04:10:00Z");
61
+ expect(durationWords("2026-10-15T04:09:28Z", "2026-10-15T04:09:45Z", now)).toBe("17 s");
62
+ expect(durationWords("2026-10-15T04:07:55Z", null, now)).toBe("2 m 05 s");
63
+ expect(durationWords(null, null, now)).toBe(null);
64
+ expect(agoWords("2026-10-15T04:09:58Z", now)).toBe("just now");
65
+ expect(agoWords("2026-10-15T04:07:00Z", now)).toBe("3 m ago");
66
+ expect(placeWords({ scenario: "a-output", cycle: 90 }, { "a-output": { name: "A, read through .output" } })).toBe("A, read through .output · cycle 90");
67
+ expect(placeWords({ scenario: null, cycle: null }, {})).toBe("");
68
+ });
69
+ });
70
+
71
+ describe("one run, from the index and its own document (#569)", () => {
72
+ // The host's #528: index.json says running on demo-a-known; the run's own
73
+ // document, written later, says success and carries no ref.
74
+ const entry = { id: 528, trigger: "push", ref: "demo-a-known", status: "running", started_at: "2026-10-15T04:49:19Z", ended_at: null, jobs: [{ id: 1, name: "check", needs: [], status: "running", started_at: "2026-10-15T04:49:19Z", ended_at: null }] };
75
+ const doc = { id: 528, trigger: "push", status: "success", started_at: "2026-10-15T04:49:19Z", ended_at: "2026-10-15T04:49:25Z", jobs: [{ id: 1, name: "check", needs: [], status: "success", started_at: "2026-10-15T04:49:19Z", ended_at: "2026-10-15T04:49:25Z", steps: [{ name: "Set up job", status: "success", log: [] }] }] };
76
+ it("names the run's real ref and takes the newer status", () => {
77
+ const m = mergeRun(entry, doc);
78
+ expect(m.ref).toBe("demo-a-known");
79
+ expect(triggerWords(m)).toBe("Push to branch demo-a-known");
80
+ expect(m.status).toBe("success");
81
+ expect(m.ended_at).toBe("2026-10-15T04:49:25Z");
82
+ expect(m.jobs[0].steps.length).toBe(1);
83
+ });
84
+ it("takes the index's status when the document lags it, keeping the document's steps", () => {
85
+ const lagging = { ...doc, status: "running", ended_at: null, jobs: [{ ...doc.jobs[0], status: "running", ended_at: null }] };
86
+ const ahead = { ...entry, status: "failure", ended_at: "2026-10-15T04:49:30Z", jobs: [{ ...entry.jobs[0], status: "failure", ended_at: "2026-10-15T04:49:30Z" }] };
87
+ const m = mergeRun(ahead, lagging);
88
+ expect(m.status).toBe("failure");
89
+ expect(m.jobs[0]).toMatchObject({ status: "failure", ended_at: "2026-10-15T04:49:30Z" });
90
+ expect(m.jobs[0].steps.length).toBe(1);
91
+ });
92
+ it("never moves a status back, and a held run is ahead of a running one", () => {
93
+ expect(newerOf({ status: "success" }, { status: "running" }).status).toBe("success");
94
+ expect(newerOf({ status: "running" }, { status: "waiting" }).status).toBe("waiting");
95
+ expect(mergeRun(null, doc)).toBe(doc);
96
+ expect(mergeRun(entry, null)).toBe(entry);
97
+ });
98
+ });