@intentius/behold 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +123 -5
- package/demos.json +1 -1
- package/dist/cli.js +3572 -3454
- package/example-choudoufu-estate/chant.workspace.json +1 -1
- package/example-terraform-estate/README.md +2 -2
- package/example-terraform-estate/chant.workspace.json +1 -1
- package/package.json +4 -4
- package/web/app.js +101 -2
- package/web/history-picker.js +85 -0
- package/web/history-picker.test.js +77 -0
- package/web/index.html +35 -0
- package/web/terragucci-estate.js +328 -0
- package/web/terragucci-estate.test.js +114 -0
- package/web/terragucci-lifecycle.js +143 -0
- package/web/terragucci-lifecycle.test.js +60 -0
- package/web/terragucci-progress.js +127 -0
- package/web/terragucci-progress.test.js +59 -0
- package/web/terragucci-timeline.js +118 -0
- package/web/terragucci-timeline.test.js +56 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// #511: a choudoufu wave's progress per resource, from terragucci's run view.
|
|
2
|
+
// app.js fetches /api/terragucci/progress (and hears the `progress` event on
|
|
3
|
+
// /api/terragucci/events), then stamps cards and fills the panel from what
|
|
4
|
+
// these functions return.
|
|
5
|
+
//
|
|
6
|
+
// A progress mark is dated by the records read it came from ("in flight,
|
|
7
|
+
// records read 09:43 UTC, 2m ago"). It is a corner tag, never a fill: the
|
|
8
|
+
// fill is the live-status channel, and this is what the wave job's last read
|
|
9
|
+
// of the estates' records found, which may already be behind.
|
|
10
|
+
|
|
11
|
+
import { ago, clock, hrefOf } from "./terragucci.js";
|
|
12
|
+
|
|
13
|
+
export const PROGRESS_WORDS = { done: "done", "in-flight": "in flight", waiting: "waiting", "not-applied": "not applied" };
|
|
14
|
+
const GLYPH = { done: "✓", "in-flight": "▶", waiting: "…", "not-applied": "✕" };
|
|
15
|
+
|
|
16
|
+
/** "records read 09:43 UTC, 2m ago". */
|
|
17
|
+
export function readWords(read, now = new Date()) {
|
|
18
|
+
return `records read ${clock(read, now)}, ${ago(read, now)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The newest wave's mark on a card: a wave applying now outranks an earlier one. */
|
|
22
|
+
export function cardProgress(marks) {
|
|
23
|
+
if (!marks || !marks.length) return null;
|
|
24
|
+
return [...marks].sort((a, b) => b.wave - a.wave)[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The corner tag of a card: "▶ wave 2 in flight · 2m". */
|
|
28
|
+
export function progressTag(mark, now = new Date()) {
|
|
29
|
+
const s = Math.max(0, (now.getTime() - Date.parse(mark.read)) / 1000);
|
|
30
|
+
const age = s < 3600 ? `${Math.max(1, Math.round(s / 60))}m` : s < 172800 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`;
|
|
31
|
+
return { text: `${GLYPH[mark.status]} wave ${mark.wave} ${PROGRESS_WORDS[mark.status]} · ${age}`, tone: mark.status };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One instance as a line: "module.x.aws_y.z[1] (replace): in flight", with when a read first found it done. */
|
|
35
|
+
export function resourceLine(r, now = new Date()) {
|
|
36
|
+
const done = r.status === "done" && r.done_at ? ` ${clock(r.done_at, now)}` : "";
|
|
37
|
+
return `${r.address} (${r.action}): ${PROGRESS_WORDS[r.status]}${done}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A card's mark as sentences, for its tooltip and the inspect pane. */
|
|
41
|
+
export function progressLines(mark, now = new Date()) {
|
|
42
|
+
return [`wave ${mark.wave} ${PROGRESS_WORDS[mark.status]}, ${readWords(mark.read, now)}`, ...mark.resources.map((r) => resourceLine(r, now))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** "2 of 5 done, 1 in flight, 2 waiting", plus "1 not applied" when there is one. */
|
|
46
|
+
export function countsLine(counts) {
|
|
47
|
+
const total = counts.done + counts["in-flight"] + counts.waiting + counts["not-applied"];
|
|
48
|
+
return `${counts.done} of ${total} done, ${counts["in-flight"]} in flight, ${counts.waiting} waiting${counts["not-applied"] ? `, ${counts["not-applied"]} not applied` : ""}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The panel's rows: the run's line, then one per wave with progress. Never "live". */
|
|
52
|
+
export function progressRows(answer, now = new Date()) {
|
|
53
|
+
if (!answer || answer.error) return { head: answer && answer.error ? `apply progress not read: ${answer.error}` : "", title: (answer && answer.remedy) || "", waves: [] };
|
|
54
|
+
if (!answer.run || !answer.run.present) return { head: answer.absent || "", title: "", waves: [] };
|
|
55
|
+
const head = `apply progress of ${answer.run.commit.slice(0, 12)}, from terragucci's run view${answer.watching ? " · asked again while a wave moves" : " · settled"}`;
|
|
56
|
+
const title = [`${answer.run.key}, read ${ago(answer.read, now)}`, answer.unmatched.length ? `no card: ${answer.unmatched.map((u) => `${u.root}: ${u.address}`).join(", ")}` : ""].filter(Boolean).join("\n");
|
|
57
|
+
return {
|
|
58
|
+
head,
|
|
59
|
+
title,
|
|
60
|
+
html: answer.run.html,
|
|
61
|
+
waves: answer.waves.map((w) => ({
|
|
62
|
+
number: w.number,
|
|
63
|
+
text: `wave ${w.number} ${w.state}: ${countsLine(w.counts)}`,
|
|
64
|
+
read: readWords(w.read, now),
|
|
65
|
+
tone: w.counts["not-applied"] ? "not-applied" : w.counts["in-flight"] ? "in-flight" : w.counts.waiting ? "waiting" : "done",
|
|
66
|
+
lines: w.resources.map((r) => `${r.root}: ${resourceLine(r, now)}`),
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const FILL = { done: "var(--muted)", "in-flight": "var(--pending)", waiting: "var(--muted)", "not-applied": "var(--degraded)" };
|
|
72
|
+
|
|
73
|
+
/** Stamp each card's progress tag at its top-right corner. Replaces the previous stamps. */
|
|
74
|
+
export function stampProgress(svg, answer, now = new Date()) {
|
|
75
|
+
if (!svg) return;
|
|
76
|
+
for (const old of svg.querySelectorAll("[data-tg-progress]")) old.remove();
|
|
77
|
+
if (!answer || !answer.cards) return;
|
|
78
|
+
for (const [id, marks] of Object.entries(answer.cards)) {
|
|
79
|
+
const g = svg.querySelector('[data-node-id="' + CSS.escape(id) + '"]');
|
|
80
|
+
const mark = cardProgress(marks);
|
|
81
|
+
if (!g || !mark) continue;
|
|
82
|
+
const rect = g.querySelector("rect");
|
|
83
|
+
const box = rect && rect.getBBox ? rect.getBBox() : null;
|
|
84
|
+
const t = progressTag(mark, now);
|
|
85
|
+
const tag = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
86
|
+
tag.setAttribute("data-tg-progress", t.tone);
|
|
87
|
+
tag.setAttribute("x", String((box ? box.x + box.width : 150) - 10));
|
|
88
|
+
tag.setAttribute("y", String((box ? box.y : 0) + 14));
|
|
89
|
+
tag.setAttribute("text-anchor", "end");
|
|
90
|
+
tag.setAttribute("font-size", "10");
|
|
91
|
+
tag.setAttribute("fill", FILL[t.tone]);
|
|
92
|
+
tag.textContent = t.text;
|
|
93
|
+
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
94
|
+
title.textContent = progressLines(mark, now).join("\n") + "\n(from terragucci's run view, as of that read: not live)";
|
|
95
|
+
tag.appendChild(title);
|
|
96
|
+
g.appendChild(tag);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The panel section: the run's line, then each wave's counts and read time, its resources folded. */
|
|
101
|
+
export function progressSection(answer, now, link) {
|
|
102
|
+
const rows = progressRows(answer, now);
|
|
103
|
+
const box = document.createElement("div");
|
|
104
|
+
box.className = "tg-progress";
|
|
105
|
+
if (!rows.head) return box;
|
|
106
|
+
const head = document.createElement("div");
|
|
107
|
+
head.className = "tg-legend";
|
|
108
|
+
head.textContent = rows.head + " ";
|
|
109
|
+
if (rows.title) head.title = rows.title;
|
|
110
|
+
if (rows.html && link) head.appendChild(link("run", hrefOf(answer, rows.html)));
|
|
111
|
+
box.appendChild(head);
|
|
112
|
+
for (const w of rows.waves) {
|
|
113
|
+
const d = document.createElement("details");
|
|
114
|
+
d.className = `tg-progress-wave tg-progress-${w.tone}`;
|
|
115
|
+
const s = document.createElement("summary");
|
|
116
|
+
s.textContent = `${w.text} · ${w.read}`;
|
|
117
|
+
d.appendChild(s);
|
|
118
|
+
for (const line of w.lines) {
|
|
119
|
+
const row = document.createElement("div");
|
|
120
|
+
row.className = "ws-gate-note";
|
|
121
|
+
row.textContent = line;
|
|
122
|
+
d.appendChild(row);
|
|
123
|
+
}
|
|
124
|
+
box.appendChild(d);
|
|
125
|
+
}
|
|
126
|
+
return box;
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { cardProgress, countsLine, progressLines, progressRows, progressTag, resourceLine } from "./terragucci-progress.js";
|
|
3
|
+
|
|
4
|
+
// #511: the progress words. Every mark is dated by the records read it came
|
|
5
|
+
// from; none says "live".
|
|
6
|
+
const NOW = new Date("2026-10-08T09:45:00Z");
|
|
7
|
+
const mark = (over = {}) => ({
|
|
8
|
+
wave: 2,
|
|
9
|
+
state: "applying",
|
|
10
|
+
read: "2026-10-08T09:43:00.000Z",
|
|
11
|
+
status: "in-flight",
|
|
12
|
+
counts: { done: 1, "in-flight": 1, waiting: 0, "not-applied": 0 },
|
|
13
|
+
resources: [
|
|
14
|
+
{ root: "envs/prod/orders", address: "module.service.aws_dynamodb_table.records[0]", action: "replace", status: "done", done_at: "2026-10-08T09:42:20.000Z" },
|
|
15
|
+
{ root: "envs/prod/orders", address: "module.service.aws_dynamodb_table.records[1]", action: "replace", status: "in-flight" },
|
|
16
|
+
],
|
|
17
|
+
...over,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("progress marks (#511)", () => {
|
|
21
|
+
it("tags a card with its newest wave's status and the read's age", () => {
|
|
22
|
+
const m = cardProgress([mark({ wave: 1, status: "done" }), mark()]);
|
|
23
|
+
expect(m.wave).toBe(2);
|
|
24
|
+
expect(progressTag(m, NOW)).toEqual({ text: "▶ wave 2 in flight · 2m", tone: "in-flight" });
|
|
25
|
+
expect(cardProgress([])).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("dates every line by the records read, and a done instance by when a read found it", () => {
|
|
29
|
+
expect(progressLines(mark(), NOW)).toEqual([
|
|
30
|
+
"wave 2 in flight, records read 09:43 UTC, 2m ago",
|
|
31
|
+
"module.service.aws_dynamodb_table.records[0] (replace): done 09:42 UTC",
|
|
32
|
+
"module.service.aws_dynamodb_table.records[1] (replace): in flight",
|
|
33
|
+
]);
|
|
34
|
+
expect(resourceLine({ address: "a.b", action: "create", status: "not-applied" }, NOW)).toBe("a.b (create): not applied");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("counts a wave the way terragucci's log line does", () => {
|
|
38
|
+
expect(countsLine({ done: 2, "in-flight": 1, waiting: 2, "not-applied": 0 })).toBe("2 of 5 done, 1 in flight, 2 waiting");
|
|
39
|
+
expect(countsLine({ done: 2, "in-flight": 0, waiting: 0, "not-applied": 3 })).toBe("2 of 5 done, 0 in flight, 0 waiting, 3 not applied");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("says when there is no run view, and never paints one as live", () => {
|
|
43
|
+
expect(progressRows({ run: { commit: "c", key: "k", present: false }, absent: "has no k" }, NOW)).toEqual({ head: "has no k", title: "", waves: [] });
|
|
44
|
+
const rows = progressRows(
|
|
45
|
+
{
|
|
46
|
+
read: "2026-10-08T09:44:00Z",
|
|
47
|
+
run: { commit: "d3e4f5a6b7c8d9e0", key: "p/runs/d3e4/run.json", present: true, html: "p/runs/d3e4/run.html" },
|
|
48
|
+
watching: true,
|
|
49
|
+
unmatched: [{ root: "envs/prod/payments", address: "module.service.aws_iam_role.worker" }],
|
|
50
|
+
waves: [{ number: 2, state: "applying", read: "2026-10-08T09:43:00.000Z", counts: { done: 2, "in-flight": 1, waiting: 2, "not-applied": 0 }, resources: [] }],
|
|
51
|
+
},
|
|
52
|
+
NOW,
|
|
53
|
+
);
|
|
54
|
+
expect(rows.head).toBe("apply progress of d3e4f5a6b7c8, from terragucci's run view · asked again while a wave moves");
|
|
55
|
+
expect(rows.head).not.toMatch(/\blive\b/);
|
|
56
|
+
expect(rows.title).toContain("no card: envs/prod/payments: module.service.aws_iam_role.worker");
|
|
57
|
+
expect(rows.waves[0]).toMatchObject({ text: "wave 2 applying: 2 of 5 done, 1 in flight, 2 waiting", read: "records read 09:43 UTC, 2m ago", tone: "in-flight" });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// #506: the timeline lane, from terragucci's audit record. app.js fetches
|
|
2
|
+
// /api/terragucci/timeline once (the whole project, newest first) and hands
|
|
3
|
+
// the answer here: the Terragucci tab shows every entry, a card's inspect pane
|
|
4
|
+
// the entries of its root. The record holds no per-resource entry, so a card's
|
|
5
|
+
// lane is its root's.
|
|
6
|
+
//
|
|
7
|
+
// An entry is dated and attributed as the record has it, and links its run's
|
|
8
|
+
// report through the same file route the marks use. An absent record is said
|
|
9
|
+
// in words: it is not "nothing happened".
|
|
10
|
+
|
|
11
|
+
import { ago, clock, hrefOf } from "./terragucci.js";
|
|
12
|
+
|
|
13
|
+
const KIND_WORDS = {
|
|
14
|
+
"approval-requested": "approval requested",
|
|
15
|
+
approval: "approved",
|
|
16
|
+
"approval-revoked": "approval revoked",
|
|
17
|
+
"override-requested": "override requested",
|
|
18
|
+
override: "policy overridden",
|
|
19
|
+
"override-revoked": "override revoked",
|
|
20
|
+
apply: "apply",
|
|
21
|
+
refused: "refused",
|
|
22
|
+
migration: "state migration",
|
|
23
|
+
unlock: "lock released",
|
|
24
|
+
"state-export": "state exported",
|
|
25
|
+
"ephemeral-apply": "ephemeral copy applied",
|
|
26
|
+
"ephemeral-destroy": "ephemeral copy destroyed",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The tone an entry is drawn in: a refusal or a failure stands out, a wait is pending, the rest is quiet. */
|
|
30
|
+
export function toneOf(entry) {
|
|
31
|
+
if (entry.kind === "refused" || entry.result === "failed" || entry.result === "denied") return "failed";
|
|
32
|
+
if (entry.result === "waiting" || entry.kind === "approval-requested" || entry.kind === "override-requested") return "wave";
|
|
33
|
+
if (entry.kind.endsWith("-revoked")) return "drift";
|
|
34
|
+
return "ok";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One entry as a line: "approved wave-2 by dana: unsigned · plan 4be1…". */
|
|
38
|
+
export function entryLine(entry) {
|
|
39
|
+
const what = KIND_WORDS[entry.kind] || entry.kind;
|
|
40
|
+
const by = entry.who ? ` by ${entry.who}` : "";
|
|
41
|
+
const result = entry.result && entry.result !== entry.kind ? `: ${entry.result}` : "";
|
|
42
|
+
const digest = entry.digest ? ` · plan ${entry.digest.replace(/^(jcs1-)?sha256:/, "").slice(0, 12)}` : "";
|
|
43
|
+
return `${what} ${entry.what}${by}${result}${digest}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The root a card sits in, by terragucci's path, from /api/terragucci's roots: a card id is `<member>/<root name>/…`. */
|
|
47
|
+
export function rootOfCard(marksAnswer, nodeId) {
|
|
48
|
+
if (!marksAnswer || !marksAnswer.roots || !nodeId) return null;
|
|
49
|
+
let best = null;
|
|
50
|
+
for (const r of marksAnswer.roots) {
|
|
51
|
+
const prefix = `${r.member}/${r.name}/`;
|
|
52
|
+
if (nodeId.startsWith(prefix) && (!best || prefix.length > best.prefix.length)) best = { prefix, path: r.path };
|
|
53
|
+
}
|
|
54
|
+
return best ? best.path : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The lane's rows, newest first, of `root` when given. Each row: its clock, its line, its tone, and its links. */
|
|
58
|
+
export function laneRows(answer, { root = null, now = new Date() } = {}) {
|
|
59
|
+
if (!answer || !answer.entries) return [];
|
|
60
|
+
const entries = root ? answer.entries.filter((e) => (e.roots || []).includes(root)) : answer.entries;
|
|
61
|
+
return entries.map((e) => {
|
|
62
|
+
const links = [];
|
|
63
|
+
if (e.run && e.run.report) links.push({ text: "report", href: hrefOf(answer, e.run.report) });
|
|
64
|
+
if (e.run && e.run.job_url) links.push({ text: "job", href: e.run.job_url });
|
|
65
|
+
if (e.evidence && e.evidence.source === "ledger" && /^https?:\/\//.test(e.evidence.url || "")) links.push({ text: "ledger commit", href: e.evidence.url });
|
|
66
|
+
return {
|
|
67
|
+
id: e.id,
|
|
68
|
+
when: `${clock(e.at, now)}, ${ago(e.at, now)}`,
|
|
69
|
+
text: entryLine(e),
|
|
70
|
+
tone: toneOf(e),
|
|
71
|
+
title: [e.at, e.roots && e.roots.length ? `roots: ${e.roots.join(", ")}` : "", e.run && e.run.commit ? `commit ${e.run.commit}` : "", e.run && e.run.pull_request ? `PR #${e.run.pull_request}` : ""].filter(Boolean).join("\n"),
|
|
72
|
+
links,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The line over the lane: whose record, how many entries, or that the source has none. */
|
|
78
|
+
export function laneLegend(answer, { root = null, shown = 0 } = {}) {
|
|
79
|
+
if (!answer) return "audit record not read";
|
|
80
|
+
if (answer.error) return answer.error;
|
|
81
|
+
if (answer.absent) return answer.absent;
|
|
82
|
+
if (!shown) return root ? `the audit record names no entry for ${root}` : `the audit record holds no entry for ${answer.project || "this project"}`;
|
|
83
|
+
const more = !root && answer.total > answer.entries.length ? ` of ${answer.total}` : "";
|
|
84
|
+
return `${shown}${more} entr${shown === 1 ? "y" : "ies"} from terragucci's audit record${root ? ` for ${root}` : ""}, newest first`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The lane as DOM: a legend line and one row per entry. Browser-only (the tests take the pure functions above). */
|
|
88
|
+
export function laneElement(answer, opts = {}) {
|
|
89
|
+
const rows = laneRows(answer, opts);
|
|
90
|
+
const box = document.createElement("div");
|
|
91
|
+
box.className = "tg-timeline";
|
|
92
|
+
const head = document.createElement("div");
|
|
93
|
+
head.className = answer && answer.error ? "tg-refusal" : "tg-legend";
|
|
94
|
+
head.textContent = laneLegend(answer, { root: opts.root || null, shown: rows.length });
|
|
95
|
+
if (answer && answer.remedy) head.title = answer.remedy;
|
|
96
|
+
box.appendChild(head);
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
const row = document.createElement("div");
|
|
99
|
+
row.className = `tg-entry tg-${r.tone}`;
|
|
100
|
+
row.title = r.title;
|
|
101
|
+
const when = document.createElement("span");
|
|
102
|
+
when.className = "tg-when";
|
|
103
|
+
when.textContent = r.when;
|
|
104
|
+
const text = document.createElement("span");
|
|
105
|
+
text.textContent = ` ${r.text}`;
|
|
106
|
+
row.append(when, text);
|
|
107
|
+
for (const l of r.links) {
|
|
108
|
+
const a = document.createElement("a");
|
|
109
|
+
a.textContent = l.text;
|
|
110
|
+
a.href = l.href;
|
|
111
|
+
a.target = "_blank";
|
|
112
|
+
a.rel = "noopener";
|
|
113
|
+
row.append(" · ", a);
|
|
114
|
+
}
|
|
115
|
+
box.appendChild(row);
|
|
116
|
+
}
|
|
117
|
+
return box;
|
|
118
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { entryLine, laneLegend, laneRows, rootOfCard, toneOf } from "./terragucci-timeline.js";
|
|
3
|
+
|
|
4
|
+
// #506: the lane's words, from answers shaped like /api/terragucci/timeline.
|
|
5
|
+
const NOW = new Date("2026-10-08T13:31:00Z");
|
|
6
|
+
const answer = {
|
|
7
|
+
project: "github.com/acme/shop",
|
|
8
|
+
files: "/api/terragucci/file?key=",
|
|
9
|
+
record: { key: "audit.jsonl", present: true },
|
|
10
|
+
total: 3,
|
|
11
|
+
limit: 200,
|
|
12
|
+
entries: [
|
|
13
|
+
{ id: "c", kind: "apply", at: "2026-10-08T09:31:00.000Z", who: null, what: "wave-2", wave: 2, digest: "jcs1-sha256:2e0fba16ce38925adf41", result: "waiting", roots: ["envs/staging/orders"], run: { report: "github.com/acme/shop/r2/report.html", job_url: "https://ci/9131" }, evidence: { source: "report" } },
|
|
14
|
+
{ id: "b", kind: "approval", at: "2026-10-08T09:20:00.000Z", who: "dana", what: "wave-1", digest: null, result: "unsigned", roots: ["envs/dev/orders"], run: {}, evidence: { source: "ledger", url: "https://github.com/acme/shop/commit/7f1e" } },
|
|
15
|
+
{ id: "a", kind: "refused", at: "2026-10-06T09:00:00.000Z", who: "lee", what: "wave-1", digest: null, result: "changed-after-approval", roots: ["envs/dev/orders"], run: {}, evidence: { source: "report" } },
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("the timeline lane (#506)", () => {
|
|
20
|
+
it("words an entry by its kind, who and result", () => {
|
|
21
|
+
expect(entryLine(answer.entries[0])).toBe("apply wave-2: waiting · plan 2e0fba16ce38");
|
|
22
|
+
expect(entryLine(answer.entries[1])).toBe("approved wave-1 by dana: unsigned");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("draws refusals and failures as failed, waits as pending", () => {
|
|
26
|
+
expect(answer.entries.map(toneOf)).toEqual(["wave", "ok", "failed"]);
|
|
27
|
+
expect(toneOf({ kind: "approval-revoked", result: "revoked" })).toBe("drift");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("dates every row and links its report through the file route, its job and its ledger commit", () => {
|
|
31
|
+
const rows = laneRows(answer, { now: NOW });
|
|
32
|
+
expect(rows.map((r) => r.when)).toEqual(["09:31 UTC, 4h ago", "09:20 UTC, 4h ago", "2026-10-06 09:00 UTC, 2d ago"]);
|
|
33
|
+
expect(rows[0].links).toEqual([
|
|
34
|
+
{ text: "report", href: "/api/terragucci/file?key=github.com%2Facme%2Fshop%2Fr2%2Freport.html" },
|
|
35
|
+
{ text: "job", href: "https://ci/9131" },
|
|
36
|
+
]);
|
|
37
|
+
expect(rows[1].links).toEqual([{ text: "ledger commit", href: "https://github.com/acme/shop/commit/7f1e" }]);
|
|
38
|
+
// In an export the key is relative to the bucket.
|
|
39
|
+
expect(laneRows({ ...answer, files: "../../" }, { now: NOW })[0].links[0].href).toBe("../../github.com/acme/shop/r2/report.html");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps a root's entries for a card's inspect pane", () => {
|
|
43
|
+
expect(laneRows(answer, { root: "envs/dev/orders", now: NOW }).map((r) => r.id)).toEqual(["b", "a"]);
|
|
44
|
+
const marks = { roots: [{ member: "tg-example", name: "envs-dev-orders", path: "envs/dev/orders" }, { member: "tg-example", name: "envs-dev", path: "envs/dev" }] };
|
|
45
|
+
expect(rootOfCard(marks, "tg-example/envs-dev-orders/module.service/aws_sqs_queue.jobs")).toBe("envs/dev/orders");
|
|
46
|
+
expect(rootOfCard(marks, "other/x")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("says when the source has no record, rather than that nothing happened", () => {
|
|
50
|
+
expect(laneLegend({ ...answer, entries: [], record: { present: false }, absent: "/r has no audit.jsonl: …" })).toBe("/r has no audit.jsonl: …");
|
|
51
|
+
expect(laneLegend({ ...answer, entries: [] }, { shown: 0 })).toBe("the audit record holds no entry for github.com/acme/shop");
|
|
52
|
+
expect(laneLegend(answer, { root: "envs/prod/search", shown: 0 })).toBe("the audit record names no entry for envs/prod/search");
|
|
53
|
+
expect(laneLegend({ ...answer, total: 250 }, { shown: 3 })).toBe("3 of 250 entries from terragucci's audit record, newest first");
|
|
54
|
+
expect(laneLegend({ error: "audit.jsonl in /r, line 2, is not JSON.", code: "terragucci-report" })).toContain("line 2");
|
|
55
|
+
});
|
|
56
|
+
});
|