@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.
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ROOT_HUES, cssString, hoverCss, hoverFromMessage, hoverLinks, hoverMessage, isLightBg, rootColour, rootOrder, rootSegments, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
3
+
4
+ // behold#571: the demo repo's roots and the wave jobs of its terragucci.components.json.
5
+ const ROOTS = ["network", "other", "app", "edge"];
6
+ const WAVES = [
7
+ { job: "apply-wave-1", wave: 1, roots: ["network", "other"] },
8
+ { job: "apply-wave-2", wave: 2, roots: ["app"] },
9
+ { job: "apply-wave-3", wave: 3, roots: ["edge"] },
10
+ ];
11
+ const names = (text) => rootSegments(text, ROOTS).filter((s) => s.root).map((s) => s.root);
12
+
13
+ describe("a colour per root (behold#571)", () => {
14
+ it("follows the sorted root list, so it is the same on every cycle and in any order", () => {
15
+ expect(rootOrder(ROOTS)).toEqual(["app", "edge", "network", "other"]);
16
+ const a = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, ROOTS)]));
17
+ const b = Object.fromEntries(ROOTS.map((r) => [r, rootColour(r, [...ROOTS].reverse())]));
18
+ expect(a).toEqual(b);
19
+ expect(a.app).toBe(ROOT_HUES.dark[0]);
20
+ expect(new Set(Object.values(a)).size).toBe(4);
21
+ expect(rootColour("app", ROOTS, { light: true })).toBe(ROOT_HUES.light[0]);
22
+ expect(rootColour("nope", ROOTS)).toBeNull();
23
+ });
24
+
25
+ it("uses no state colour: not the green, amber, red, blue or teal of the theme", () => {
26
+ const state = ["#a6e3a1", "#f9e2af", "#f38ba8", "#89b4fa", "#94e2d5", "#6d7187"];
27
+ for (const hue of [...ROOT_HUES.dark, ...ROOT_HUES.light]) expect(state).not.toContain(hue.toLowerCase());
28
+ expect(new Set(ROOT_HUES.dark).size).toBe(ROOT_HUES.dark.length);
29
+ expect(new Set(ROOT_HUES.light).size).toBe(ROOT_HUES.light.length);
30
+ });
31
+
32
+ it("tells a light background from a dark one", () => {
33
+ expect(isLightBg("#ffffff")).toBe(true);
34
+ expect(isLightBg(" #1e1e2e")).toBe(false);
35
+ expect(isLightBg("")).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe("root names in a line (behold#571)", () => {
40
+ it("finds whole-word names, possessives and names in brackets, and keeps the line's text", () => {
41
+ const line = "app reads subnet_id from network, known only after network applies: the review does not show the value app gets.";
42
+ expect(names(line)).toEqual(["app", "network", "network", "app"]);
43
+ expect(rootSegments(line, ROOTS).map((s) => s.text).join("")).toBe(line);
44
+ expect(names("approves wave 2 (app) with network's key")).toEqual(["app", "network"]);
45
+ expect(names("app: Plan: 0 to add, 1 to change, 0 to destroy.")).toEqual(["app"]);
46
+ });
47
+
48
+ it("leaves names inside addresses, hosts, URLs, paths, hashes and longer words alone", () => {
49
+ expect(names("terraform_data.app_x and data.terraform_remote_state.network")).toEqual([]);
50
+ expect(names("upstream http://app:9080 and app:8070")).toEqual([]);
51
+ expect(names("report copied to demo/forgejo:3000/terragucci-admin/demo/2026/10/ef95/tf-apply-wave-1")).toEqual([]);
52
+ expect(names("set digest jcs1-sha256:0accc105ba06 and commit a1b2c3d4e5")).toEqual([]);
53
+ expect(names("apply-wave-2 appliance networks edge-proxy network.tfstate")).toEqual([]);
54
+ expect(names("key=network")).toEqual([]);
55
+ });
56
+
57
+ it("says nothing without roots", () => {
58
+ expect(rootSegments("network", [])).toEqual([{ text: "network" }]);
59
+ expect(rootSegments("", ROOTS)).toEqual([]);
60
+ });
61
+ });
62
+
63
+ describe("hover links between the panes (behold#571)", () => {
64
+ it("ties a wave's job to its roots, from the declared jobs or the job's name", () => {
65
+ expect(waveOfJob("apply-wave-2", WAVES)).toBe(2);
66
+ expect(waveOfJob("apply-wave-7", [])).toBe(7);
67
+ expect(waveOfJob("check", WAVES)).toBeNull();
68
+ expect(rootsOfWave(1, WAVES)).toEqual(["network", "other"]);
69
+ expect(rootsOfWave(9, WAVES)).toEqual([]);
70
+ });
71
+
72
+ it("lights a root's mentions and its wave, and a wave's job and its roots", () => {
73
+ expect(hoverLinks({ root: "other" }, WAVES)).toEqual({ roots: ["other"], waves: [1] });
74
+ expect(hoverLinks({ wave: 1 }, WAVES)).toEqual({ roots: ["network", "other"], waves: [1] });
75
+ expect(hoverLinks({ root: "lonely" }, WAVES)).toEqual({ roots: ["lonely"], waves: [] });
76
+ expect(hoverLinks(null, WAVES)).toEqual({ roots: [], waves: [] });
77
+ });
78
+
79
+ it("carries a hover between the frame and the kiosk as a message, and takes only that message", () => {
80
+ expect(hoverMessage({ root: "app" })).toEqual({ type: "behold:hover", root: "app", wave: null });
81
+ expect(hoverMessage(null)).toEqual({ type: "behold:hover", root: null, wave: null });
82
+ expect(hoverFromMessage(hoverMessage({ wave: 2 }))).toEqual({ wave: 2 });
83
+ expect(hoverFromMessage(hoverMessage({ root: "app" }))).toEqual({ root: "app" });
84
+ expect(hoverFromMessage(hoverMessage(null))).toBeNull();
85
+ expect(hoverFromMessage({ type: "behold:view" })).toBeUndefined();
86
+ expect(hoverFromMessage("behold:hover")).toBeUndefined();
87
+ });
88
+
89
+ it("writes one rule for what a hover lights, quoting names", () => {
90
+ const css = hoverCss(hoverLinks({ wave: 2 }, WAVES), { root: (r) => `.k-root[data-k-root=${cssString(r)}]`, wave: (w) => `.r-job[data-k-wave="${w}"]` }, "outline: 1px solid");
91
+ expect(css).toBe('.k-root[data-k-root="app"],\n.r-job[data-k-wave="2"] { outline: 1px solid }');
92
+ expect(hoverCss({ roots: [], waves: [] }, { root: () => "x", wave: () => "y" }, "z")).toBe("");
93
+ expect(cssString('a"b')).toBe('"a\\"b"');
94
+ });
95
+ });
package/web/kiosk-runs.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // A job held at a terragucci gate is `waiting`: it waits for an approval, and
11
11
  // is never drawn as a failure. Step names are shown as the driver wrote them.
12
12
 
13
+ import { rootColour, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
14
+
13
15
  /** The run, job and step statuses, as a viewer reads them. */
14
16
  export const STATUS_WORDS = {
15
17
  queued: "queued",
@@ -227,12 +229,88 @@ export function agoWords(at, now = new Date()) {
227
229
  return `${Math.floor(s / 86400)} d ago`;
228
230
  }
229
231
 
230
- /** "a-output" as the kiosk names scenarios, with the cycle: "A, read through .output · cycle 90". */
231
- export function placeWords(run, scenarios) {
232
- const sc = run.scenario && scenarios && Object.prototype.hasOwnProperty.call(scenarios, run.scenario) ? scenarios[run.scenario].name : run.scenario;
232
+ /**
233
+ * behold#571: whether a scenario's short label reads as code to someone new
234
+ * to the demo: a lettered label ("A, read through .output", "B on waves"), a
235
+ * code token in it, or the bare id ("a-output").
236
+ */
237
+ export function codeish(label) {
238
+ return typeof label === "string" && (/^[A-Z]\b/.test(label) || /\.[A-Za-z]|[_`]/.test(label) || /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(label));
239
+ }
240
+
241
+ const MERGE_TITLE = /^Merge pull request '(.+)' \(#\d+\)/;
242
+
243
+ /**
244
+ * behold#571: each scenario's title in plain words, by id: from the merge
245
+ * runs' titles (the pull request is titled after its scenario), and from
246
+ * `known` (the titles demo.json has carried, which win).
247
+ */
248
+ export function scenarioTitles(runs, known = {}) {
249
+ const out = {};
250
+ for (const r of [...(runs || [])].reverse()) {
251
+ const m = r && r.scenario && typeof r.title === "string" ? MERGE_TITLE.exec(r.title) : null;
252
+ if (m) out[r.scenario] = m[1];
253
+ }
254
+ for (const [id, title] of Object.entries(known || {})) if (typeof title === "string" && title) out[id] = title;
255
+ return out;
256
+ }
257
+
258
+ /**
259
+ * The scenario and the cycle under a run: "a failure mid-apply · cycle 90".
260
+ * The scenario's name as demo.json last carried it (`names`, terragucci#1050)
261
+ * comes first, then the kiosk's SCENARIOS label; a label that reads as code
262
+ * gives way to the scenario's title when one is known (behold#571).
263
+ */
264
+ export function placeWords(run, scenarios, titles = {}, names = {}) {
265
+ const own = (m) => !!(run.scenario && m && Object.prototype.hasOwnProperty.call(m, run.scenario));
266
+ const short = own(names) && names[run.scenario] ? names[run.scenario] : own(scenarios) ? scenarios[run.scenario].name : run.scenario;
267
+ const title = own(titles) ? titles[run.scenario] : null;
268
+ const sc = title && (!short || codeish(short)) ? title : short;
233
269
  return [sc, run.cycle !== null && run.cycle !== undefined ? `cycle ${run.cycle}` : null].filter(Boolean).join(" · ");
234
270
  }
235
271
 
272
+ /**
273
+ * behold#571: a runner's or an action's set-up line, not terragucci's own:
274
+ * git's hints, the checkout's `[command]` lines and groups, the runner's
275
+ * docker and fetch lines.
276
+ */
277
+ export const SETUP_LINES = [
278
+ /^hint:/,
279
+ /^\[command\]/,
280
+ /^##\[(?:group|endgroup)\]/,
281
+ /^Initialized empty Git repository\b/,
282
+ /^::(?:add|remove)-matcher\b/,
283
+ /^\s*❓\s*add-matcher\b/u,
284
+ /^\s*🐳\s+docker\b/u,
285
+ /^\s*☁️\s+git fetch\b/u,
286
+ /^git version \d/,
287
+ /^Temporarily overriding HOME=/,
288
+ /^Adding repository directory to the temporary git global config\b/,
289
+ /^Deleting the contents of '/,
290
+ /^Syncing repository: /,
291
+ /^Working directory is '/,
292
+ ];
293
+
294
+ /** Whether a log line is set-up noise (SETUP_LINES). */
295
+ export function setupLine(line) {
296
+ return typeof line === "string" && SETUP_LINES.some((re) => re.test(line));
297
+ }
298
+
299
+ /**
300
+ * behold#571: a step's log as the kiosk draws it. The set-up lines fold into
301
+ * one row at the top, `{ setup: n }`, so terragucci's own lines come first;
302
+ * opened (`open`), the row heads the whole log in order. Every line keeps
303
+ * its number in the log. A log with no set-up lines is drawn as it came.
304
+ */
305
+ export function logRows(lines, { open = false, from = 0 } = {}) {
306
+ const all = (lines || []).map((text, i) => ({ n: from + i + 1, text, setup: setupLine(text) }));
307
+ const n = all.filter((l) => l.setup).length;
308
+ if (!n) return all.map(({ n: num, text }) => ({ n: num, text }));
309
+ const head = { setup: n, open };
310
+ const body = open ? all : all.filter((l) => !l.setup);
311
+ return [head, ...body.map(({ n: num, text, setup }) => (open && setup ? { n: num, text, quiet: true } : { n: num, text }))];
312
+ }
313
+
236
314
  // ---------------------------------------------------------------------------
237
315
  // Drawing.
238
316
 
@@ -277,7 +355,7 @@ export function statusIcon(status) {
277
355
  }
278
356
 
279
357
  /** The sidebar: the live run pinned, then the recent ones. `pick(id)` selects one; nothing here writes. */
280
- export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, pick = () => {}, merged = null } = {}) {
358
+ export function renderSidebar(host, index, selectedId, { now = new Date(), scenarios = {}, titles = {}, names = {}, pick = () => {}, merged = null } = {}) {
281
359
  host.replaceChildren();
282
360
  const head = el("div", "r-side-head");
283
361
  head.appendChild(el("div", "r-side-title", "Pipeline runs"));
@@ -290,6 +368,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
290
368
  // The selected run as its page draws it, when its own document is newer than the index.
291
369
  const runs = sidebarRuns(index.doc.runs).map((r) => (merged && merged.id === r.id ? merged : r));
292
370
  const live = runs[0];
371
+ const named = scenarioTitles(index.doc.runs, titles);
293
372
  const list = el("ol", "r-runs");
294
373
  runs.forEach((r, i) => {
295
374
  if (i === 0) list.appendChild(el("li", "r-group", LIVE.has(live.status) ? "Live" : "Latest"));
@@ -300,7 +379,7 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
300
379
  li.appendChild(statusIcon(r.status));
301
380
  const body = el("div", "r-run-body");
302
381
  body.appendChild(el("div", "r-run-title", triggerWords(r)));
303
- const place = placeWords(r, scenarios);
382
+ const place = placeWords(r, scenarios, named, names);
304
383
  if (place) body.appendChild(el("div", "r-run-place", place));
305
384
  const meta = el("div", "r-run-meta");
306
385
  meta.append(
@@ -328,7 +407,14 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
328
407
  * null for the defaults. Returns what it drew, so the caller can keep a log's
329
408
  * scroll.
330
409
  */
331
- export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, entry = null } = {}) {
410
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, entry = null, roots = [], waves = [], light = false } = {}) {
411
+ // behold#571: a root's name with its colour's dot, where the line names one.
412
+ const textOf = (cls, text) => {
413
+ if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
414
+ const n = el("span", cls);
415
+ n.appendChild(rootText(document, text, roots, { light }));
416
+ return n;
417
+ };
332
418
  host.replaceChildren();
333
419
  if (!answer || answer.error || answer.absent || !answer.doc) {
334
420
  host.appendChild(el("p", "r-note", answer ? answer.absent || answer.error : "Reading the run…"));
@@ -363,6 +449,20 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
363
449
  li.style.setProperty("--depth", String(j.depth));
364
450
  li.appendChild(statusIcon(j.status));
365
451
  li.appendChild(el("span", "r-job-name", j.name));
452
+ // behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
453
+ const wave = waveOfJob(j.name, waves);
454
+ const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
455
+ if (wave !== null && waves.length) li.dataset.kWave = String(wave);
456
+ if (applies.length) {
457
+ const box = el("span", "r-job-roots");
458
+ for (const r of applies) {
459
+ const name = el("span", "k-root", r);
460
+ name.dataset.kRoot = r;
461
+ name.style.setProperty("--k-root", rootColour(r, roots, { light }));
462
+ box.appendChild(name);
463
+ }
464
+ li.appendChild(box);
465
+ }
366
466
  if (j.needs && j.needs.length) li.appendChild(el("span", "r-job-needs", `after ${j.needs.join(", ")}`));
367
467
  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] || ""));
368
468
  li.addEventListener("click", () => pickJob(j.name));
@@ -398,12 +498,25 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
398
498
  if (i === open) {
399
499
  const pre = el("pre", "r-log");
400
500
  if (s.truncated) pre.appendChild(el("div", "r-log-cut", `… ${s.truncated} earlier lines not kept`));
401
- s.log.forEach((l, n) => {
402
- const ln = el("div", "r-log-line");
403
- const shown = logLineWords(job.status, l);
404
- ln.append(el("span", "r-ln", String((s.truncated || 0) + n + 1)), el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text));
501
+ const key = logKey(run.id, job.name, i);
502
+ for (const row of logRows(s.log, { open: ui.setup === key, from: s.truncated || 0 })) {
503
+ if (row.setup) {
504
+ const fold = el("div", `r-log-setup${row.open ? " r-log-setup-open" : ""}`, `${row.open ? "▾" : "▸"} setup (${row.setup} line${row.setup === 1 ? "" : "s"})`);
505
+ fold.setAttribute("role", "button");
506
+ fold.tabIndex = 0;
507
+ fold.setAttribute("aria-expanded", String(row.open));
508
+ fold.addEventListener("click", () => toggleSetup(key));
509
+ fold.addEventListener("keydown", (e) => {
510
+ if (e.key === "Enter" || e.key === " ") toggleSetup(key);
511
+ });
512
+ pre.appendChild(fold);
513
+ continue;
514
+ }
515
+ const ln = el("div", `r-log-line${row.quiet ? " r-log-quiet" : ""}`);
516
+ const shown = logLineWords(job.status, row.text);
517
+ ln.append(el("span", "r-ln", String(row.n)), row.quiet ? el("span", shown.held ? "r-lt r-wait" : "r-lt", shown.text) : textOf(shown.held ? "r-lt r-wait" : "r-lt", shown.text));
405
518
  pre.appendChild(ln);
406
- });
519
+ }
407
520
  if (!s.log.length) pre.appendChild(el("div", "r-log-cut", s.status === "running" ? "waiting for output…" : "no output"));
408
521
  if (s.status === "running") pre.appendChild(el("div", "r-log-live", "▍"));
409
522
  row.appendChild(pre);
@@ -447,8 +560,8 @@ export const PICK_HOLD_MS = 90_000;
447
560
  * is live), and redraw. The live run is selected until a viewer picks
448
561
  * another, and again {@link PICK_HOLD_MS} after their last pick.
449
562
  */
450
- export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, onLive = () => {} } = {}) {
451
- const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null } };
563
+ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), scenarios = {}, titles = () => ({}), names = () => ({}), onLive = () => {}, roots = () => [], waves = () => [], light = () => false } = {}) {
564
+ const s = { index: null, run: null, selected: null, picked: 0, ui: { job: null, open: null, setup: null } };
452
565
  let follow = true;
453
566
  let lastRunKey = "";
454
567
  // The open log and whether the pane follows its end; a viewer who scrolls up stops it until they come back.
@@ -470,7 +583,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
470
583
  const ans = s.run && (!s.run.doc || s.run.doc.id === id) ? s.run : null;
471
584
  // One merged read of the selected run for both the sidebar and its page.
472
585
  const merged = ans && ans.doc ? mergeRun(entry, ans.doc) : null;
473
- renderSidebar(side, s.index, id, { now: now(), scenarios, pick, merged });
586
+ renderSidebar(side, s.index, id, { now: now(), scenarios, titles: titles(), names: names(), pick, merged });
474
587
  // The redraw replaces the scrollers: keep where a viewer left them.
475
588
  const old = main.querySelector(".r-log");
476
589
  const oldSteps = main.querySelector(".r-steps");
@@ -479,13 +592,21 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
479
592
  const drawn = renderRun(main, ans, s.ui, {
480
593
  now: now(),
481
594
  entry,
595
+ roots: roots(),
596
+ waves: waves(),
597
+ light: light(),
482
598
  pickJob: (name) => {
483
- s.ui = { job: name, open: null };
599
+ s.ui = { job: name, open: null, setup: null };
484
600
  hold();
485
601
  draw();
486
602
  },
487
603
  toggleStep: (job, i) => {
488
- s.ui = { job, open: i };
604
+ s.ui = { job, open: i, setup: null };
605
+ hold();
606
+ draw();
607
+ },
608
+ toggleSetup: (key) => {
609
+ s.ui = { ...s.ui, setup: s.ui.setup === key ? null : key };
489
610
  hold();
490
611
  draw();
491
612
  },
@@ -528,7 +649,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
528
649
  s.selected = id;
529
650
  follow = false;
530
651
  s.picked = now().getTime();
531
- s.ui = { job: null, open: null };
652
+ s.ui = { job: null, open: null, setup: null };
532
653
  void readRun();
533
654
  };
534
655
  const readIndex = async () => {
@@ -545,7 +666,7 @@ export function startRuns({ side, main }, { fetchJson, now = () => new Date(), s
545
666
  // A run that ended reads once; one still going reads on every tick.
546
667
  const key = `${id}|${entry ? entry.status : ""}|${entry ? entry.ended_at : ""}`;
547
668
  if (key === lastRunKey && entry && !LIVE.has(entry.status) && s.run && s.run.doc && s.run.doc.id === id) return draw();
548
- if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null };
669
+ if (s.run && s.run.doc && s.run.doc.id !== id) s.ui = { job: null, open: null, setup: null };
549
670
  lastRunKey = key;
550
671
  s.run = await fetchJson(`/api/terragucci/runs/${id}`);
551
672
  draw();
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
- import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, logLineWords, placeWords, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
4
+ import { FOLLOW_LINES, followsLog, logKey, nearEnd, mergeRun, newerOf, agoWords, defaultJob, defaultStep, durationWords, jobOrder, liveRun, codeish, logLineWords, logRows, placeWords, scenarioTitles, setupLine, sidebarRuns, splitSkipped, STATUS_TONE, triggerWords } from "./kiosk-runs.js";
5
5
 
6
6
  const FIX = join(import.meta.dirname, "..", "src", "__fixtures__", "terragucci-demo", "runs");
7
7
  const run = (id) => JSON.parse(readFileSync(join(FIX, `${id}.json`), "utf8"));
@@ -131,3 +131,55 @@ describe("the log pane follows its end, like a CI page's log", () => {
131
131
  expect(nearEnd({ scrollTop: 0, clientHeight: 400, scrollHeight: 1000 }, slack)).toBe(false);
132
132
  });
133
133
  });
134
+
135
+ describe("the runs for a viewer new to the demo (behold#571)", () => {
136
+ const SC = { "a-output": { name: "A, read through .output" }, "b-waves": { name: "B on waves" }, failure: { name: "a failure mid-apply" } };
137
+ it("prefers the scenario's title when its short label reads as code", () => {
138
+ expect(codeish("A, read through .output")).toBe(true);
139
+ expect(codeish("B on waves")).toBe(true);
140
+ expect(codeish("a-output")).toBe(true);
141
+ expect(codeish("a failure mid-apply")).toBe(false);
142
+ expect(codeish("the base")).toBe(false);
143
+ const titles = { "a-output": "The same change, read once it is built", failure: "One root fails partway" };
144
+ expect(placeWords({ scenario: "a-output", cycle: 90 }, SC, titles)).toBe("The same change, read once it is built · cycle 90");
145
+ expect(placeWords({ scenario: "failure", cycle: 92 }, SC, titles)).toBe("a failure mid-apply · cycle 92");
146
+ expect(placeWords({ scenario: "b-waves", cycle: 91 }, SC, titles)).toBe("B on waves · cycle 91");
147
+ expect(placeWords({ scenario: "c-new", cycle: 93 }, SC, { "c-new": "A new one" })).toBe("A new one · cycle 93");
148
+ expect(placeWords({ scenario: "a-output", cycle: 90 }, SC)).toBe("A, read through .output · cycle 90");
149
+ });
150
+ it("takes demo.json's name for a scenario before the table's label (terragucci#1050)", () => {
151
+ const names = { "b-waves": "a new subnet", failure: "a failure halfway" };
152
+ expect(placeWords({ scenario: "b-waves", cycle: 12 }, SC, {}, names)).toBe("a new subnet · cycle 12");
153
+ expect(placeWords({ scenario: "failure", cycle: 12 }, SC, { failure: "One root fails" }, names)).toBe("a failure halfway · cycle 12");
154
+ expect(placeWords({ scenario: "a-output", cycle: 12 }, SC, {}, names)).toBe("A, read through .output · cycle 12");
155
+ });
156
+ it("learns titles from the merge runs, and demo.json's win", () => {
157
+ const runs = index().runs;
158
+ const t = scenarioTitles(runs);
159
+ expect(t["a-known"]).toBe("A port change, known when it plans");
160
+ expect(scenarioTitles(runs, { "a-known": "A plain title" })["a-known"]).toBe("A plain title");
161
+ expect(scenarioTitles([], {})).toEqual({});
162
+ });
163
+ it("knows the runner's set-up lines from terragucci's own", () => {
164
+ for (const l of ["hint: Using 'master' as the name", "[command]/usr/bin/git init /workspace/demo", "##[group]Initializing the repository", "##[endgroup]", "Initialized empty Git repository in /workspace/demo/.git/"]) expect(setupLine(l)).toBe(true);
165
+ for (const l of ["wave 2 of 3: planning app", "app: Plan: 0 to add, 1 to change, 0 to destroy.", " terragucci approve wave-2 --plan jcs1-sha256:0a", "⚙️ [runner]: exitcode '3': failure"]) expect(setupLine(l)).toBe(false);
166
+ });
167
+ it("folds the set-up lines into one row at the top, and opens it in order", () => {
168
+ const log = ["##[group]Init", "[command]/usr/bin/git init", "wave 1 of 3: planning network", "hint: x", "##[endgroup]", "wave 1 of 3: applied"];
169
+ expect(logRows(log)).toEqual([{ setup: 4, open: false }, { n: 3, text: "wave 1 of 3: planning network" }, { n: 6, text: "wave 1 of 3: applied" }]);
170
+ const open = logRows(log, { open: true });
171
+ expect(open[0]).toEqual({ setup: 4, open: true });
172
+ expect(open.slice(1).map((r) => r.n)).toEqual([1, 2, 3, 4, 5, 6]);
173
+ expect(open.filter((r) => r.quiet).map((r) => r.n)).toEqual([1, 2, 4, 5]);
174
+ expect(logRows(["a", "b"], { from: 10 })).toEqual([{ n: 11, text: "a" }, { n: 12, text: "b" }]);
175
+ });
176
+ it("folds the checkout's set-up in a real held job's log, and leaves terragucci's step as it came", () => {
177
+ const job = run(444).jobs.find((j) => j.status === "waiting");
178
+ const checkout = job.steps.find((s) => s.name.includes("checkout") && !s.name.startsWith("Post"));
179
+ const rows = logRows(checkout.log);
180
+ expect(rows[0].setup).toBeGreaterThan(40);
181
+ expect(rows.slice(1).some((r) => /^hint:|^\[command\]|^##\[/.test(r.text))).toBe(false);
182
+ const own = job.steps.find((s) => /^Apply wave/.test(s.name));
183
+ expect(logRows(own.log)).toEqual(own.log.map((text, i) => ({ n: i + 1, text })));
184
+ });
185
+ });
package/web/kiosk.html CHANGED
@@ -58,6 +58,9 @@
58
58
  .r-job.r-selected { background: var(--active); }
59
59
  .r-job-name { font: 600 0.92em var(--font-mono); }
60
60
  .r-job-needs { color: var(--muted); font-size: 0.78em; }
61
+ /* behold#571: each root's colour, as a dot by its name; never a text or fill colour, which mean a state. */
62
+ .k-root::before { content: ""; display: inline-block; width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--k-root); margin: 0 0.28em 0.1em 0.05em; vertical-align: middle; }
63
+ .r-job-roots { display: inline-flex; gap: 0.75em; font-size: 0.82em; color: var(--fg); }
61
64
  .r-dur { margin-left: auto; color: var(--muted); font-size: 0.8em; white-space: nowrap; }
62
65
  .r-job-skipped { color: var(--muted); font-size: 0.78em; cursor: default; }
63
66
  .r-steps { display: flex; flex-direction: column; min-height: 0; flex: 1; overflow-y: auto; padding-bottom: 0.5em; }
@@ -74,30 +77,36 @@
74
77
  white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; max-height: 52vh; min-height: 6em; }
75
78
  .r-log-line { display: grid; grid-template-columns: 3.2em 1fr; gap: 0.8em; padding: 0 0.8em 0 0; }
76
79
  .r-ln { color: var(--muted); text-align: right; user-select: none; opacity: 0.7; }
80
+ /* behold#571: the runner's set-up lines, folded into one row so terragucci's own lines come first. */
81
+ .r-log-setup { color: var(--muted); padding: 0 0.8em 0.2em 4em; cursor: pointer; user-select: none; }
82
+ .r-log-setup:hover { color: var(--fg); }
83
+ .r-log-quiet .r-lt { color: var(--muted); }
77
84
  .r-log-cut { color: var(--muted); padding: 0 0.8em 0.2em 4em; font-style: italic; }
78
85
  .r-log-live { color: var(--foreign); padding-left: 4em; }
79
86
  @media (prefers-reduced-motion: no-preference) { .r-log-live { animation: r-blink 1s steps(2) infinite; } }
80
87
  @keyframes r-blink { 50% { opacity: 0; } }
81
88
  .r-note { color: var(--muted); padding: 0 1em; font-size: 0.88em; }
82
- #bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 1.6em; align-items: start;
83
- padding: 0.7em 1.4em 0.7em; background: var(--panel); border-bottom: 1px solid var(--rule); }
89
+ #bar { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 0.45em 1.6em; align-items: start;
90
+ padding: 0.55em 1.4em 0.6em; background: var(--panel); border-bottom: 1px solid var(--rule); }
84
91
  #bar > .k-empty { grid-column: 1 / -1; }
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
- .k-cycle { text-transform: none; letter-spacing: 0; font-weight: 400; }
87
- .k-title { margin: 0.05em 0 0.25em; font-size: 1.45em; line-height: 1.15; }
92
+ /* behold#571: the problem the demo solves, always on, across the bar's top. */
93
+ .k-intro { grid-column: 1 / -1; margin: 0; font-size: 1em; line-height: 1.35; color: var(--fg); }
94
+ .k-kicker { font: 600 0.58em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.9em; vertical-align: 0.2em; white-space: nowrap; }
95
+ .k-cycle { color: var(--muted); font: 0.78em var(--font-mono); white-space: nowrap; }
96
+ .k-title { margin: 0 0 0.2em; font-size: 1.35em; line-height: 1.2; }
88
97
  /* 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; }
98
+ .k-about { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 0.08em 0.9em; align-items: baseline; margin: 0 0 0.35em; font-size: 0.88em; line-height: 1.25; }
90
99
  .k-about-label { font: 600 0.74em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
91
100
  .k-about-text { margin: 0; color: var(--fg); }
92
101
  .k-muted { color: var(--muted); margin: 0.25em 0; }
93
- .k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; }
94
- .k-step-word { font: 600 0.75em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
95
- .k-label { font-size: 1.02em; margin: 0.1em 0 0; }
102
+ .k-step { border-left: 4px solid var(--pending); padding: 0.1em 0 0.1em 0.8em; margin: 0; font-size: 0.95em; line-height: 1.3; }
103
+ .k-step-word { font: 600 0.72em var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin-right: 0.7em; }
96
104
  .k-step-waiting { border-left-color: var(--foreign); }
97
105
  .k-step-failed, .k-step-stalled { border-left-color: var(--degraded); }
98
106
  .k-step-applied { border-left-color: var(--managed); }
99
107
  .k-step-reset { border-left-color: var(--neutral); }
100
- .k-right { display: grid; gap: 0.6em; align-content: start; }
108
+ .k-right { display: grid; gap: 0.45em; align-content: start; }
109
+ .k-where { display: flex; flex-wrap: wrap; gap: 0.35em 1em; align-items: center; }
101
110
  .k-strip { list-style: none; display: flex; flex-wrap: wrap; gap: 0.35em; padding: 0; margin: 0; }
102
111
  .k-stop { font: 0.82em var(--font-mono); padding: 0.25em 0.7em; border-radius: 999px; border: 1px solid var(--rule); color: var(--muted); }
103
112
  .k-done { color: var(--fg); border-color: var(--managed); }
@@ -109,7 +118,8 @@
109
118
  .k-when { color: var(--foreign); font-family: var(--font-mono); }
110
119
  .k-approve { border-left: 4px solid var(--foreign); padding-left: 0.8em; }
111
120
  .k-approve p { margin: 0.15em 0; }
112
- .k-approve-head, .k-approve-then { color: var(--muted); font-size: 0.92em; }
121
+ .k-approve-gloss { color: var(--fg); font-size: 0.92em; }
122
+ .k-approve-then { color: var(--muted); font-size: 0.92em; }
113
123
  .k-command { display: block; margin: 0.25em 0; padding: 0.45em 0.7em; background: var(--well); border-radius: 6px; font: 0.88em var(--font-mono); overflow-wrap: anywhere; user-select: none; }
114
124
  .k-inline { font-family: var(--font-mono); color: var(--fg); background: var(--well); padding: 0.05em 0.35em; border-radius: 4px; user-select: none; }
115
125
  .k-note { color: var(--muted); margin: 0; font-size: 0.92em; }
@@ -129,7 +139,6 @@
129
139
  #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; }
130
140
  .k-legend { color: var(--muted); flex-basis: 100%; }
131
141
  .k-fact { color: var(--fg); }
132
- .k-source { color: var(--muted); margin-left: auto; }
133
142
  @media (max-width: 900px) { #bar { grid-template-columns: 1fr; } #stage { grid-template-columns: 1fr; } }
134
143
  </style>
135
144
  </head>