@intentius/behold 0.29.15 → 0.29.17

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,235 @@
1
+ // behold#571: the forge's mark on each wave box of the Components view, so the
2
+ // CI projection says which forge runs each wave. One mark is an icon and the
3
+ // forge's name beside it, in the box's title row, linked to the wave's job
4
+ // when behold has that job's URL. app.js draws it; nothing here touches the DOM.
5
+ //
6
+ // Which forge, in this order, from what behold already reads:
7
+ //
8
+ // 1. a forge the pipeline names: terragucci.components.json's
9
+ // `pipeline.forge`, or the forge chant's own CI projection was generated
10
+ // for (`/api/ci`'s `forge`);
11
+ // 2. the shape of a run's URLs in the reports (`job_url`, `commit_url`,
12
+ // `pull_request_url`): a known host, or GitLab's `/-/jobs/`, GitHub's
13
+ // `/actions/runs/<n>/job/<n>`, Forgejo's `/actions/runs/<n>/jobs/<n>`;
14
+ // 3. the pipeline file's path (`pipeline.path`), or the one workflow
15
+ // directory the repo has (`.forgejo/workflows`, `.github/workflows`,
16
+ // `.gitlab-ci.yml`), as the server found it (src/terragucci-components.ts).
17
+ //
18
+ // None of them, or URLs that disagree, is no forge, and no mark is drawn.
19
+ // The link is always a URL a report carried; behold never builds one.
20
+
21
+ /**
22
+ * The marks. Each is drawn in a `size` x `size` square from its own viewBox.
23
+ *
24
+ * Forgejo: the official logo by Caesar Schinas, CC BY-SA 4.0, from
25
+ * https://codeberg.org/forgejo/meta/src/branch/readme/branding (logo/forgejo.svg),
26
+ * its geometry and colours as published.
27
+ *
28
+ * GitHub: Octicons `mark-github` (16px), MIT, Copyright (c) GitHub Inc., from
29
+ * https://github.com/primer/octicons/blob/ea8e6bb79894cc7e85564ee9b53d86b418738d04/icons/mark-github-16.svg,
30
+ * filled in currentColor so it reads on either theme.
31
+ *
32
+ * GitLab: the tanuki behold has drawn since #58, one filled silhouette in GitLab's orange.
33
+ */
34
+ export const FORGES = {
35
+ forgejo: {
36
+ label: "Forgejo",
37
+ viewBox: "0 0 212 212",
38
+ transform: "translate(6,6)",
39
+ shapes: [
40
+ { tag: "path", d: "M58 168 v-98 a50 50 0 0 1 50-50 h20", fill: "none", stroke: "#ff6600", "stroke-width": 25 },
41
+ { tag: "path", d: "M58 168 v-30 a50 50 0 0 1 50-50 h20", fill: "none", stroke: "#d40000", "stroke-width": 25 },
42
+ { tag: "circle", cx: 142, cy: 20, r: 18, fill: "none", stroke: "#ff6600", "stroke-width": 15 },
43
+ { tag: "circle", cx: 142, cy: 88, r: 18, fill: "none", stroke: "#d40000", "stroke-width": 15 },
44
+ { tag: "circle", cx: 58, cy: 180, r: 18, fill: "none", stroke: "#d40000", "stroke-width": 15 },
45
+ ],
46
+ },
47
+ github: {
48
+ label: "GitHub",
49
+ viewBox: "0 0 16 16",
50
+ shapes: [
51
+ {
52
+ tag: "path",
53
+ fill: "currentColor",
54
+ d: "M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656",
55
+ },
56
+ ],
57
+ },
58
+ gitlab: {
59
+ label: "GitLab",
60
+ viewBox: "0 0 24 24",
61
+ shapes: [
62
+ {
63
+ tag: "path",
64
+ fill: "#FC6D26",
65
+ d: "M23.6004 9.5927l-.0337-.0862L20.3.9814a.851.851 0 00-.3362-.405.8748.8748 0 00-.9997.0539.8748.8748 0 00-.29.4399l-2.2055 6.748H7.5375l-2.2055-6.748a.8573.8573 0 00-.29-.4412.8748.8748 0 00-.9997-.0537.8585.8585 0 00-.3362.405L.4332 9.5065l-.0325.0862a6.0657 6.0657 0 002.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 001.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 002.0094-7.003z",
66
+ },
67
+ ],
68
+ },
69
+ };
70
+
71
+ /** The icon's size and the label's, in the svg's units: about the tanuki's 17px. */
72
+ export const MARK = { size: 17, font: 11, gap: 4, pad: 12 };
73
+
74
+ /** A forge named outright ("forgejo", "GitHub Actions", "gitlab-ci"), or null. */
75
+ export function forgeOfName(name) {
76
+ const s = String(name || "").trim().toLowerCase();
77
+ if (!s) return null;
78
+ if (s.startsWith("forgejo") || s === "codeberg") return "forgejo";
79
+ if (s.startsWith("github")) return "github";
80
+ if (s.startsWith("gitlab")) return "gitlab";
81
+ return null;
82
+ }
83
+
84
+ /** The forge a pipeline file's path belongs to, or null. */
85
+ export function forgeOfPath(path) {
86
+ const p = String(path || "").replace(/\\/g, "/").replace(/^\.\/+/, "");
87
+ if (/(^|\/)\.forgejo\/workflows(\/|$)/.test(p)) return "forgejo";
88
+ if (/(^|\/)\.github\/workflows(\/|$)/.test(p)) return "github";
89
+ if (/(^|\/)\.gitlab-ci\.ya?ml$/.test(p)) return "gitlab";
90
+ return null;
91
+ }
92
+
93
+ /** The forge a run's URL is on, from its host or its path, or null. */
94
+ export function forgeOfUrl(url) {
95
+ let u;
96
+ try {
97
+ u = new URL(String(url || ""));
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null;
102
+ const host = u.hostname.toLowerCase();
103
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
104
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
105
+ if (host === "codeberg.org" || host.endsWith(".codeberg.org")) return "forgejo";
106
+ const p = u.pathname;
107
+ if (/\/-\/(jobs|pipelines|merge_requests|commit)\//.test(p)) return "gitlab";
108
+ if (/\/actions\/runs\/\d+\/job\/\d+/.test(p)) return "github";
109
+ if (/\/actions\/runs\/\d+\/jobs\/\d+/.test(p)) return "forgejo";
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * The forge that runs the pipeline, or null when none of what behold reads
115
+ * says. `declared` is a forge the pipeline names, `urls` are run URLs from the
116
+ * reports, `path` is the pipeline file's path, and `found` a forge the server
117
+ * found from the repo's one workflow directory.
118
+ */
119
+ export function detectForge({ declared = null, urls = [], path = null, found = null } = {}) {
120
+ const named = forgeOfName(declared);
121
+ if (named) return named;
122
+ const fromUrls = new Set(urls.map(forgeOfUrl).filter(Boolean));
123
+ if (fromUrls.size === 1) return [...fromUrls][0];
124
+ if (fromUrls.size > 1) return null;
125
+ return forgeOfPath(path) || forgeOfName(found);
126
+ }
127
+
128
+ /**
129
+ * The forge of a terragucci repo's components graph: `facets` is its
130
+ * `meta.terragucciComponents` (`forge` and what said so, `forgeFrom`:
131
+ * `declared` and `config` name it, `pipeline-path` and `workflow-dir` found it),
132
+ * `answer` the terragucci read whose run URLs come second.
133
+ */
134
+ export function componentsForge(facets, answer) {
135
+ if (!facets) return null;
136
+ const from = facets.forgeFrom;
137
+ return detectForge({
138
+ declared: from === "declared" || from === "config" ? facets.forge : null,
139
+ urls: answerUrls(answer),
140
+ path: facets.pipeline || null,
141
+ found: from === "pipeline-path" || from === "workflow-dir" ? facets.forge : null,
142
+ });
143
+ }
144
+
145
+ /** Every run URL a terragucci read carries, for {@link detectForge}. */
146
+ export function answerUrls(answer) {
147
+ if (!answer || answer.error) return [];
148
+ const out = [];
149
+ const add = (r) => {
150
+ if (!r) return;
151
+ for (const k of ["job_url", "commit_url", "pull_request_url"]) if (typeof r[k] === "string" && r[k]) out.push(r[k]);
152
+ };
153
+ for (const r of Array.isArray(answer.roots) ? answer.roots : []) for (const s of ["plan", "drift", "apply"]) add(r[s]);
154
+ for (const w of Array.isArray(answer.waiting) ? answer.waiting : []) add(w);
155
+ if (answer.pull_request) add(answer.pull_request.run);
156
+ return out;
157
+ }
158
+
159
+ const sameSha = (a, b) => !!a && !!b && (a.startsWith(b) || b.startsWith(a));
160
+
161
+ /**
162
+ * The URL of the job that applied wave `n`, as a report carried it: the
163
+ * wave's waiting gate, or a root's newest apply in that wave. With a
164
+ * `commit`, only a run on that commit counts. Null when no report has one.
165
+ */
166
+ export function waveJobUrl(answer, n, commit = null) {
167
+ if (!answer || answer.error) return null;
168
+ const found = [];
169
+ for (const w of Array.isArray(answer.waiting) ? answer.waiting : []) {
170
+ if (w.wave === n && w.job_url) found.push({ url: w.job_url, at: w.since || "", commit: w.commit });
171
+ }
172
+ for (const r of Array.isArray(answer.roots) ? answer.roots : []) {
173
+ const a = r && r.apply;
174
+ if (a && a.wave === n && a.job_url) found.push({ url: a.job_url, at: a.finished || "", commit: a.commit });
175
+ }
176
+ const runs = commit ? found.filter((f) => sameSha(f.commit, commit)) : found;
177
+ runs.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
178
+ return runs.length ? runs[0].url : null;
179
+ }
180
+
181
+ /** The URL the forge's mark links to: a report's job URL, unless its own shape names another forge. */
182
+ export function markLink(url, forge) {
183
+ if (!url) return null;
184
+ const on = forgeOfUrl(url);
185
+ return on && on !== forge ? null : url;
186
+ }
187
+
188
+ /** The mark's tooltip: "apply-wave-2 on Forgejo". */
189
+ export function markTitle(job, forge) {
190
+ const f = FORGES[forge];
191
+ if (!f) return "";
192
+ return job ? `${job} on ${f.label}` : `Runs on ${f.label}`;
193
+ }
194
+
195
+ const SVGNS = "http://www.w3.org/2000/svg";
196
+
197
+ /**
198
+ * A mark's icon as an `<svg>` element of `size` x `size`, from a spec of
199
+ * `{ viewBox, transform?, fill?: { dark, light }, attrs?, shapes }` or
200
+ * `{ image }` (a PNG behold ships), for a dark or a `light` ground. It
201
+ * nests in the graph's svg and stands alone in HTML alike. `doc` is the
202
+ * document to make it in.
203
+ */
204
+ export function iconSvg(doc, spec, size, { x = 0, y = 0, light = false } = {}) {
205
+ const svg = doc.createElementNS(SVGNS, "svg");
206
+ const set = (n, k, v) => n.setAttribute(k, String(v));
207
+ set(svg, "width", size);
208
+ set(svg, "height", size);
209
+ set(svg, "x", x);
210
+ set(svg, "y", y);
211
+ set(svg, "aria-hidden", "true");
212
+ set(svg, "focusable", "false");
213
+ if (spec.image) {
214
+ set(svg, "viewBox", "0 0 1 1");
215
+ const img = doc.createElementNS(SVGNS, "image");
216
+ for (const [k, v] of Object.entries({ href: spec.image, x: 0, y: 0, width: 1, height: 1 })) set(img, k, v);
217
+ svg.appendChild(img);
218
+ return svg;
219
+ }
220
+ set(svg, "viewBox", spec.viewBox);
221
+ for (const [k, v] of Object.entries(spec.attrs || {})) set(svg, k, v);
222
+ // A mark drawn in one colour per theme (`fill: { dark, light }`) takes it on a group.
223
+ const host = spec.transform || spec.fill ? doc.createElementNS(SVGNS, "g") : svg;
224
+ if (host !== svg) {
225
+ if (spec.transform) set(host, "transform", spec.transform);
226
+ if (spec.fill) set(host, "fill", light ? spec.fill.light : spec.fill.dark);
227
+ svg.appendChild(host);
228
+ }
229
+ for (const { tag, ...attrs } of spec.shapes) {
230
+ const n = doc.createElementNS(SVGNS, tag);
231
+ for (const [k, v] of Object.entries(attrs)) set(n, k, v);
232
+ host.appendChild(n);
233
+ }
234
+ return svg;
235
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FORGES, answerUrls, markLink, componentsForge, detectForge, forgeOfName, forgeOfPath, forgeOfUrl, markTitle, waveJobUrl } from "./forge-mark.js";
3
+
4
+ // behold#571: which forge runs a wave, from what behold already reads.
5
+ describe("forge detection", () => {
6
+ it("reads a forge named outright", () => {
7
+ expect(forgeOfName("forgejo")).toBe("forgejo");
8
+ expect(forgeOfName("GitHub Actions")).toBe("github");
9
+ expect(forgeOfName("gitlab-ci")).toBe("gitlab");
10
+ expect(forgeOfName("woodpecker")).toBeNull();
11
+ expect(forgeOfName(undefined)).toBeNull();
12
+ });
13
+
14
+ it("reads a pipeline file's path", () => {
15
+ expect(forgeOfPath(".forgejo/workflows/terragucci.yml")).toBe("forgejo");
16
+ expect(forgeOfPath("./.github/workflows/deploy.yml")).toBe("github");
17
+ expect(forgeOfPath(".gitlab-ci.yml")).toBe("gitlab");
18
+ expect(forgeOfPath("ci/pipeline.yml")).toBeNull();
19
+ });
20
+
21
+ it("reads a run URL's host and path shape", () => {
22
+ expect(forgeOfUrl("https://github.com/acme/shop/actions/runs/9/job/31")).toBe("github");
23
+ expect(forgeOfUrl("https://gitlab.com/acme/shop/-/jobs/12")).toBe("gitlab");
24
+ expect(forgeOfUrl("https://git.acme.dev/acme/shop/-/jobs/12")).toBe("gitlab");
25
+ expect(forgeOfUrl("https://codeberg.org/acme/shop/actions/runs/4/jobs/1")).toBe("forgejo");
26
+ expect(forgeOfUrl("http://forgejo:3000/admin/demo-terragrunt/actions/runs/448/jobs/3")).toBe("forgejo");
27
+ expect(forgeOfUrl("https://ghe.acme.dev/acme/shop/actions/runs/9/job/31")).toBe("github");
28
+ expect(forgeOfUrl("https://ci/9131")).toBeNull();
29
+ expect(forgeOfUrl("not a url")).toBeNull();
30
+ expect(forgeOfUrl("javascript:alert(1)")).toBeNull();
31
+ });
32
+
33
+ it("takes a named forge first, then the run URLs, then the path", () => {
34
+ const gh = ["https://github.com/a/b/actions/runs/1/job/2"];
35
+ expect(detectForge({ declared: "forgejo", urls: gh })).toBe("forgejo");
36
+ expect(detectForge({ urls: gh, path: ".forgejo/workflows/x.yml" })).toBe("github");
37
+ expect(detectForge({ path: ".forgejo/workflows/x.yml" })).toBe("forgejo");
38
+ expect(detectForge({ found: "gitlab" })).toBe("gitlab");
39
+ });
40
+
41
+ it("says no forge when nothing says, or the run URLs disagree", () => {
42
+ expect(detectForge({})).toBeNull();
43
+ expect(detectForge({ urls: ["https://ci/1"], path: "ci.yml" })).toBeNull();
44
+ expect(detectForge({ urls: ["https://github.com/a/b/actions/runs/1/job/2", "https://gitlab.com/a/b/-/jobs/3"], path: ".forgejo/workflows/x.yml" })).toBeNull();
45
+ });
46
+
47
+ it("reads a components graph's facets and the reports' URLs", () => {
48
+ const answer = { roots: [{ path: "app", apply: { wave: 2, job_url: "https://codeberg.org/a/b/actions/runs/4/jobs/1" } }], waiting: [] };
49
+ expect(componentsForge({ forge: "forgejo", forgeFrom: "declared" }, null)).toBe("forgejo");
50
+ expect(componentsForge({ forge: "github", forgeFrom: "config" }, answer)).toBe("github");
51
+ // A workflow directory is the weakest word: the reports' URLs outrank it.
52
+ expect(componentsForge({ forge: "github", forgeFrom: "workflow-dir" }, answer)).toBe("forgejo");
53
+ expect(componentsForge({ forge: "github", forgeFrom: "workflow-dir" }, null)).toBe("github");
54
+ expect(componentsForge({ pipeline: ".gitlab-ci.yml" }, null)).toBe("gitlab");
55
+ expect(componentsForge({ jobs: [] }, null)).toBeNull();
56
+ expect(componentsForge(null, answer)).toBeNull();
57
+ expect(answerUrls({ error: "x" })).toEqual([]);
58
+ });
59
+ });
60
+
61
+ describe("a wave's job URL", () => {
62
+ const answer = {
63
+ roots: [
64
+ { path: "network", apply: { wave: 1, commit: "aaa111", finished: "2026-10-15T04:10:00Z", job_url: "https://f/r/actions/runs/7/jobs/2" } },
65
+ { path: "app", apply: { wave: 2, commit: "aaa111", finished: "2026-10-15T04:12:00Z", job_url: "https://f/r/actions/runs/7/jobs/3" } },
66
+ { path: "ledger", apply: { wave: 2, commit: "bbb222", finished: "2026-10-15T04:20:00Z", job_url: "https://f/r/actions/runs/9/jobs/3" } },
67
+ { path: "edge", plan: { job_url: "https://f/r/actions/runs/6/jobs/1" } },
68
+ ],
69
+ waiting: [{ wave: 3, commit: "bbb222", since: "2026-10-15T04:21:00Z", job_url: "https://f/r/actions/runs/9/jobs/4" }],
70
+ };
71
+
72
+ it("is the URL a report carried for that wave, on the drawn commit", () => {
73
+ expect(waveJobUrl(answer, 2, "aaa111")).toBe("https://f/r/actions/runs/7/jobs/3");
74
+ expect(waveJobUrl(answer, 2, "bbb2")).toBe("https://f/r/actions/runs/9/jobs/3");
75
+ expect(waveJobUrl(answer, 3, "bbb222")).toBe("https://f/r/actions/runs/9/jobs/4");
76
+ });
77
+
78
+ it("is the newest with no commit drawn", () => {
79
+ expect(waveJobUrl(answer, 2)).toBe("https://f/r/actions/runs/9/jobs/3");
80
+ });
81
+
82
+ it("is null when no report has one", () => {
83
+ expect(waveJobUrl(answer, 4)).toBeNull();
84
+ expect(waveJobUrl(answer, 1, "bbb222")).toBeNull();
85
+ expect(waveJobUrl(null, 1)).toBeNull();
86
+ });
87
+ });
88
+
89
+ describe("the mark's link", () => {
90
+ it("is the report's URL, unless that URL is another forge's", () => {
91
+ expect(markLink("https://github.com/a/b/actions/runs/9/job/1", "github")).toBe("https://github.com/a/b/actions/runs/9/job/1");
92
+ expect(markLink("https://ci.acme.dev/jobs/9", "forgejo")).toBe("https://ci.acme.dev/jobs/9");
93
+ expect(markLink("https://github.com/a/b/actions/runs/9", "forgejo")).toBeNull();
94
+ expect(markLink(null, "github")).toBeNull();
95
+ });
96
+ });
97
+
98
+ describe("the mark", () => {
99
+ it("says the job and the forge", () => {
100
+ expect(markTitle("apply-wave-2", "forgejo")).toBe("apply-wave-2 on Forgejo");
101
+ expect(markTitle(null, "github")).toBe("Runs on GitHub");
102
+ expect(markTitle("x", "bitbucket")).toBe("");
103
+ });
104
+
105
+ it("has every forge's name and an inline icon, with no external URL", () => {
106
+ for (const f of Object.values(FORGES)) {
107
+ expect(f.label).toMatch(/^(Forgejo|GitHub|GitLab)$/);
108
+ expect(JSON.stringify(f)).not.toMatch(/https?:/);
109
+ }
110
+ });
111
+
112
+ });
package/web/kiosk-runs.js CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { rootColour, rootPaint, rootSegments, rootText, rootsOfWave, waveOfJob } from "./kiosk-roots.js";
14
14
  import { TOUR, chipWords, docsUrl, jobMarks, runFeatures } from "./kiosk-annotations.js";
15
+ import { iconSvg } from "./forge-mark.js";
16
+ import { TOOLS, jobTools } from "./tool-mark.js";
15
17
 
16
18
  /** The run, job and step statuses, as a viewer reads them. */
17
19
  export const STATUS_WORDS = {
@@ -465,7 +467,37 @@ export function renderSidebar(host, index, selectedId, { now = new Date(), scena
465
467
  * null for the defaults. Returns what it drew, so the caller can keep a log's
466
468
  * scroll.
467
469
  */
468
- export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], light = false, hideLine = null } = {}) {
470
+ /**
471
+ * behold#571: a job row's tool marks, `<span class="r-job-tools">` of one
472
+ * `r-tool` per tool (its icon, then its name), or null for none. `carried`
473
+ * are the tools the roots the job applies carry inside them. A tool a vendor
474
+ * page may not name (`hideLine`) is left out.
475
+ */
476
+ export function toolMarks(job, repoTools = [], { light = false, hideLine = null, doc = document, carried = [] } = {}) {
477
+ const ids = jobTools(job, repoTools, carried).filter((t) => !(hideLine && hideLine(TOOLS[t].name)));
478
+ if (!ids.length) return null;
479
+ const box = doc.createElement("span");
480
+ box.className = "r-job-tools";
481
+ for (const t of ids) {
482
+ const tool = TOOLS[t];
483
+ const m = doc.createElement("span");
484
+ m.className = `r-tool r-tool-${t}`;
485
+ m.title = `${job.name} runs ${tool.name}`;
486
+ if (tool.icon) {
487
+ const icon = iconSvg(doc, tool.icon, 11, { light });
488
+ icon.setAttribute("class", "r-tool-icon");
489
+ m.appendChild(icon);
490
+ }
491
+ const name = doc.createElement("span");
492
+ name.className = "r-tool-name";
493
+ name.textContent = tool.name;
494
+ m.appendChild(name);
495
+ box.appendChild(m);
496
+ }
497
+ return box;
498
+ }
499
+
500
+ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () => {}, toggleStep = () => {}, toggleSetup = () => {}, jump = () => {}, toggleFeats = () => {}, featsOpen = false, note = null, flash = null, entry = null, roots = [], waves = [], tools = [], rootTools = {}, light = false, hideLine = null } = {}) {
469
501
  // behold#571: a root's name with its colour's dot, where the line names one.
470
502
  const textOf = (cls, text) => {
471
503
  if (!roots.length || !rootSegments(text, roots).some((x) => x.root)) return el("span", cls, text);
@@ -506,7 +538,16 @@ export function renderRun(host, answer, ui, { now = new Date(), pickJob = () =>
506
538
  const li = el("li", `r-job${job && j.name === job.name ? " r-selected" : ""}`);
507
539
  li.style.setProperty("--depth", String(j.depth));
508
540
  li.appendChild(statusIcon(j.status));
509
- li.appendChild(el("span", "r-job-name", j.name));
541
+ // behold#571: the tools the job runs, each its logo (where its owner allows) and its name, on a
542
+ // line of their own under the job's name, so the row's line keeps its width.
543
+ const jobWave = waveOfJob(j.name, waves);
544
+ const carried = jobWave !== null ? rootsOfWave(jobWave, waves).flatMap((r) => rootTools[r] || []) : [];
545
+ const marks = toolMarks(j, tools, { light, hideLine, carried });
546
+ if (marks) {
547
+ const head = el("span", "r-job-head");
548
+ head.append(el("span", "r-job-name", j.name), marks);
549
+ li.appendChild(head);
550
+ } else li.appendChild(el("span", "r-job-name", j.name));
510
551
  // behold#571: a wave's job row names the roots it applies, each with its dot, and lights its wave box on hover.
511
552
  const wave = waveOfJob(j.name, waves);
512
553
  const applies = wave !== null ? rootsOfWave(wave, waves).filter((r) => rootColour(r, roots, { light })) : [];
@@ -738,6 +779,8 @@ export function startRuns(
738
779
  onLive = () => {},
739
780
  roots = () => [],
740
781
  waves = () => [],
782
+ tools = () => [],
783
+ rootTools = () => ({}),
741
784
  light = () => false,
742
785
  source = () => null,
743
786
  filter = (index) => index,
@@ -798,6 +841,8 @@ export function startRuns(
798
841
  entry,
799
842
  roots: roots(),
800
843
  waves: waves(),
844
+ tools: tools(),
845
+ rootTools: rootTools(),
801
846
  light: light(),
802
847
  hideLine,
803
848
  note: s.note,
@@ -16,7 +16,11 @@
16
16
  * null, the name as text), and the footer's credit. `terms` are the words
17
17
  * that name the tool or its owner. `kind` is what it competes as (see
18
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.
19
+ * engine, and a `pipeline` plans and applies a pull request's change with an
20
+ * engine. `reports: false` marks a tool whose pipeline writes no terragucci
21
+ * reports or run view: its page draws the checkout behold serves for its repo
22
+ * (kiosk.js `frameOk`). Keep the keys in step with VENDOR_TOOLS in
23
+ * src/terragucci-demo.ts.
20
24
  *
21
25
  * - Terraform: HashiCorp's rules allow the name as text, with the mark noted.
22
26
  * - Terragrunt: Gruntwork publishes logo files, but a logo on a page like
@@ -24,6 +28,8 @@
24
28
  * - CDK Terrain: the Open Construct Foundation's continuation of CDKTF; its
25
29
  * site, cdktn.io, publishes no rules for its logo, so the name is text.
26
30
  * - choudoufu: ours, logo and all.
31
+ * - chant: ours too. Its docs ship no logo, so the name is text. Its scenario
32
+ * runs chant's own pull-request pipeline, which installs OpenTofu.
27
33
  */
28
34
  export const VENDORS = {
29
35
  terraform: {
@@ -67,6 +73,17 @@ export const VENDORS = {
67
73
  terms: ["choudoufu"],
68
74
  runsWith: [],
69
75
  },
76
+ chant: {
77
+ name: "chant",
78
+ owner: "Intentius",
79
+ url: "https://intentius.io/chant/",
80
+ logo: null,
81
+ credit: "chant is made by Intentius, who also make this demo.",
82
+ kind: "pipeline",
83
+ terms: ["chant"],
84
+ runsWith: ["opentofu"],
85
+ reports: false,
86
+ },
70
87
  };
71
88
 
72
89
  /** What a tool's scenarios run on, said on its page as the owner's rules allow ("works with OpenTofu"). */
@@ -92,11 +109,13 @@ export const COMPETITORS = {
92
109
  /**
93
110
  * What competes with what. Engines compete with engines (Terraform, OpenTofu,
94
111
  * 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`).
112
+ * Atmos, Terramate); pipelines with pipelines (chant, and none other has a
113
+ * page). An orchestrator runs on an engine, so its page names the engine
114
+ * freely; an engine's page names no other engine, but for the one its
115
+ * scenarios really run with (`runsWith`). chant is a pipeline, so the engine
116
+ * and orchestrator pages keep the `chant approve` lines their runs print.
98
117
  */
99
- export const COMPETES = { engine: "engine", orchestrator: "orchestrator" };
118
+ export const COMPETES = { engine: "engine", orchestrator: "orchestrator", pipeline: "pipeline" };
100
119
 
101
120
  const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
102
121
 
@@ -147,8 +147,8 @@ describe("the branding table (behold#571)", () => {
147
147
  const v = VENDORS[tool];
148
148
  const words = [v.name, ...creditLines(tool).map((l) => l.map((s) => s.text).join("")), ...Object.values(notRunWords(v)), legendWords("components", tool), legendWords("resources", tool)];
149
149
  for (const w of words) expect(namesOther(w, tool), `${tool}: ${w}`).toBe(false);
150
- // choudoufu is ours: its credit says so instead.
151
- if (tool !== "choudoufu") expect(v.credit).toMatch(/(does not|don't) endorse this demo\./);
150
+ // choudoufu and chant are ours: their credits say so instead.
151
+ if (tool !== "choudoufu" && tool !== "chant") expect(v.credit).toMatch(/(does not|don't) endorse this demo\./);
152
152
  }
153
153
  expect(VENDORS.choudoufu.credit).toMatch(/AWS and Kubernetes/);
154
154
  expect(VENDORS.choudoufu.credit).not.toMatch(/experimental/i);
@@ -170,9 +170,18 @@ describe("the branding table (behold#571)", () => {
170
170
  terragrunt: ["CDK Terrain", "CDKTN", "Open Construct", "Atmos", "Cloud Posse", "Terramate"],
171
171
  "cdk-terrain": ["Terragrunt", "Gruntwork", "Atmos", "Cloud Posse", "Terramate"],
172
172
  choudoufu: ["Terraform", "HashiCorp", "OpenTofu"],
173
+ // A pipeline: no other tool with a page is one.
174
+ chant: [],
173
175
  });
174
176
  expect(otherTerms(null)).toEqual([]);
175
177
  });
178
+ it("keeps the chant approve lines on every page, since chant is a pipeline and competes with no engine or orchestrator", () => {
179
+ for (const tool of VENDOR_TOOLS) expect(namesOther("chant approve tf-apply wave-2 --actor demo-reviewer --sign", tool), tool).toBe(false);
180
+ expect(VENDORS.chant.url).toBe("https://intentius.io/chant/");
181
+ expect(VENDORS.chant.owner).toBe("Intentius");
182
+ expect(creditLines("chant").flat().filter((s) => s.url).map((s) => s.text)).toEqual(["chant", "OpenTofu"]);
183
+ expect(toolCheckout("chant")).toBe("demo-chant");
184
+ });
176
185
  it("lets an orchestrator's page name the engines it runs on, and keeps an engine's page from naming another engine", () => {
177
186
  expect(namesOther("Big Terraform setups run on OpenTofu", "terragrunt")).toBe(false);
178
187
  expect(namesOther("Big Terraform setups", "cdk-terrain")).toBe(false);
package/web/kiosk.html CHANGED
@@ -9,16 +9,16 @@
9
9
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='none' stroke='%23a6adc8' stroke-width='1.6'/%3E%3Ccircle cx='8' cy='8' r='2.2' fill='%23a6adc8'/%3E%3C/svg%3E"
10
10
  />
11
11
  <style>
12
- /* #569 step 2. The pre-paint fallback is index.html's: the default
13
- theme's tokens, which initTheme() replaces with the viewer's theme. */
12
+ /* #569 step 2. The pre-paint fallback: the kiosk's default theme's
13
+ tokens (KIOSK_THEME in theme.js), which initTheme() replaces with the viewer's theme. */
14
14
  :root {
15
- --bg: #1e1e2e; --panel: #2e2f40; --line: #3b3c4a; --fg: #cdd6f4; --muted: #777a8c;
16
- --edge: #56596d; --managed: #a6e3a1; --foreign: #f9e2af; --pending: #89b4fa;
17
- --degraded: #f38ba8; --neutral: #6d7187; --runtime: #94e2d5;
18
- --rule: #4e4f64; --focus: #a3b4d0; --active: #3d4660; --well: #11101f; --shadow: #050507;
19
- /* behold#571: the kiosk's surfaces and ink (theme.js kioskSurfaces), the default theme's until initTheme() runs. */
20
- --surface-box: #252536; --surface-pane-a: #2b2b3c; --surface-pane-b: #313142; --zebra: #cdd6f4; --soft: #cdd6f4; --muted-ink: #808395;
21
- --managed-ink: #a6e3a1; --foreign-ink: #f9e2af; --pending-ink: #89b4fa; --degraded-ink: #f38ba8; --runtime-ink: #94e2d5;
15
+ --bg: #09141b; --panel: #1c2325; --line: #2c2e2d; --fg: #deb88d; --muted: #746a5c;
16
+ --edge: #4d483f; --managed: #027c9b; --foreign: #fca02f; --pending: #1e4950;
17
+ --degraded: #d15123; --neutral: #695e4e; --runtime: #50a3b5;
18
+ --rule: #3d4142; --focus: #364548; --active: #243235; --well: #01070d; --shadow: #010304;
19
+ /* behold#571: the kiosk's surfaces and ink (theme.js kioskSurfaces), the kiosk's default theme's (KIOSK_THEME, Sea Shells) until initTheme() runs. */
20
+ --surface-box: #101b22; --surface-pane-a: #152128; --surface-pane-b: #1b272e; --zebra: #deb88d; --soft: #deb88d; --muted-ink: #7c7365;
21
+ --managed-ink: #0e7f9e; --foreign-ink: #fca02f; --pending-ink: #567b82; --degraded-ink: #d15123; --runtime-ink: #50a3b5;
22
22
  --font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
23
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
24
24
  }
@@ -67,6 +67,12 @@
67
67
  .r-job.r-selected { background: var(--active); }
68
68
  .r-job-name { font: 600 0.92em var(--font-mono); }
69
69
  .r-job-needs { color: var(--muted); font-size: 0.78em; }
70
+ /* behold#571: the tools a job runs, each its logo and its name. Short of room, the names stay and the logos go. */
71
+ .r-job-head { display: inline-flex; flex-direction: column; flex: none; line-height: 1.15; }
72
+ .r-job-name { white-space: nowrap; flex: none; }
73
+ .r-job-tools { display: inline-flex; gap: 0.6em; font-size: 0.7em; color: var(--muted); white-space: nowrap; }
74
+ .r-tool { display: inline-flex; align-items: center; gap: 0.25em; }
75
+ .r-tool-icon { flex: none; }
70
76
  /* behold#571: each root's colour, as a dot by its name; never a text or fill colour, which mean a state. */
71
77
  .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; }
72
78
  .r-job-roots { display: inline-flex; gap: 0.75em; font-size: 0.82em; color: var(--fg); }
@@ -292,10 +298,10 @@
292
298
  </div>
293
299
  <footer id="foot"></footer>
294
300
  <script type="module">
295
- import { initTheme } from "/theme.js";
301
+ import { initTheme, KIOSK_THEME } from "/theme.js";
296
302
  import { startKiosk } from "/kiosk.js";
297
303
  import { pageTool } from "/kiosk-vendor.js";
298
- initTheme();
304
+ initTheme(KIOSK_THEME);
299
305
  // behold#571: /<tool> serves this page with its tool in a meta tag; /kiosk has none.
300
306
  startKiosk(
301
307
  {