@intentius/behold 0.24.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 +100 -0
- package/dist/cli.js +2088 -473
- package/package.json +1 -1
- 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,328 @@
|
|
|
1
|
+
// #509: a terragucci control repo, across its projects. app.js calls
|
|
2
|
+
// initTerragucciEstate once; when /api/project names a control repo, this reads
|
|
3
|
+
// /api/terragucci/estate and draws it over the (empty) graph area:
|
|
4
|
+
//
|
|
5
|
+
// - one box per project, its roots grouped by wave inside it, a solid arrow
|
|
6
|
+
// for each read within a project and a dashed one for each read between
|
|
7
|
+
// projects (an arrow points from a root to the root that reads its state,
|
|
8
|
+
// as on terragucci's estate page);
|
|
9
|
+
// - on each box, terragucci's counts and the run view it was drawn from,
|
|
10
|
+
// each with its age and source;
|
|
11
|
+
// - below, per project, its waiting waves with the approve line to copy (no
|
|
12
|
+
// button approves), its links, and how to serve its own checkout.
|
|
13
|
+
//
|
|
14
|
+
// A project with no run view is drawn as a box that says so, never dropped.
|
|
15
|
+
// Nothing here is live: every figure is dated by estate.json's `generated` or
|
|
16
|
+
// a run view's `updated`. The layout functions are pure and tested.
|
|
17
|
+
|
|
18
|
+
import { ago, clock } from "./terragucci.js";
|
|
19
|
+
|
|
20
|
+
export const COL_W = 176;
|
|
21
|
+
export const ROW_H = 26;
|
|
22
|
+
export const PAD = 12;
|
|
23
|
+
export const HEAD_H = 64;
|
|
24
|
+
export const WAVE_H = 22;
|
|
25
|
+
export const GAP = 72;
|
|
26
|
+
export const NO_RUN_W = 300;
|
|
27
|
+
|
|
28
|
+
/** The address a key opens at: this server's file route. */
|
|
29
|
+
export function keyHref(answer, key) {
|
|
30
|
+
return `${answer.files || ""}${answer.files && answer.files.includes("?") ? encodeURIComponent(key) : key}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Projects in drawing order: a project whose roots another reads comes before
|
|
35
|
+
* the reader, so a dashed edge runs left to right where it can. Ties keep
|
|
36
|
+
* estate.json's order.
|
|
37
|
+
*/
|
|
38
|
+
export function projectOrder(answer) {
|
|
39
|
+
const names = answer.projects.map((p) => p.project);
|
|
40
|
+
const before = new Map(names.map((n) => [n, new Set()]));
|
|
41
|
+
for (const e of answer.edges || []) if (e.cross && before.has(e.to.project) && before.has(e.from.project)) before.get(e.to.project).add(e.from.project);
|
|
42
|
+
const out = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const visit = (n, stack) => {
|
|
45
|
+
if (seen.has(n) || stack.has(n)) return;
|
|
46
|
+
stack.add(n);
|
|
47
|
+
for (const m of names) if (before.get(n).has(m)) visit(m, stack);
|
|
48
|
+
stack.delete(n);
|
|
49
|
+
seen.add(n);
|
|
50
|
+
out.push(n);
|
|
51
|
+
};
|
|
52
|
+
for (const n of names) visit(n, new Set());
|
|
53
|
+
return out.map((n) => answer.projects.find((p) => p.project === n));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The waves a project's box has columns for: its run view's waves and any wave a root names, ascending. */
|
|
57
|
+
export function wavesOf(run) {
|
|
58
|
+
const n = new Set([...(run.waves || []).map((w) => w.number), ...(run.roots || []).map((r) => r.wave)]);
|
|
59
|
+
return [...n].sort((a, b) => a - b);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where everything goes: each project's box, each wave column in it, each
|
|
64
|
+
* root's rect, and each edge's path. Coordinates are SVG user units.
|
|
65
|
+
*/
|
|
66
|
+
export function estateLayout(answer) {
|
|
67
|
+
const boxes = [];
|
|
68
|
+
const at = new Map(); // "project\u0000root" -> rect
|
|
69
|
+
let x = PAD;
|
|
70
|
+
let height = 0;
|
|
71
|
+
for (const p of projectOrder(answer)) {
|
|
72
|
+
const run = p.run;
|
|
73
|
+
const waves = run ? wavesOf(run) : [];
|
|
74
|
+
const rows = run ? Math.max(1, ...waves.map((n) => run.roots.filter((r) => r.wave === n).length)) : 0;
|
|
75
|
+
const w = run && waves.length ? waves.length * COL_W + PAD * 2 : NO_RUN_W;
|
|
76
|
+
const h = run ? HEAD_H + WAVE_H + rows * ROW_H + PAD : HEAD_H + PAD;
|
|
77
|
+
const box = { project: p.project, x, y: PAD, w, h, waves: [] };
|
|
78
|
+
if (run) {
|
|
79
|
+
waves.forEach((n, i) => {
|
|
80
|
+
const wx = x + PAD + i * COL_W;
|
|
81
|
+
const meta = run.waves.find((v) => v.number === n);
|
|
82
|
+
const roots = run.roots
|
|
83
|
+
.filter((r) => r.wave === n)
|
|
84
|
+
.sort((a, b) => (a.root < b.root ? -1 : 1))
|
|
85
|
+
.map((r, j) => {
|
|
86
|
+
const rect = { root: r.root, x: wx, y: PAD + HEAD_H + WAVE_H + j * ROW_H, w: COL_W - 16, h: ROW_H - 6 };
|
|
87
|
+
at.set(`${p.project}\u0000${r.root}`, rect);
|
|
88
|
+
return rect;
|
|
89
|
+
});
|
|
90
|
+
box.waves.push({ number: n, x: wx, y: PAD + HEAD_H, state: meta ? meta.state : "not-started", roots });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
boxes.push(box);
|
|
94
|
+
x += w + GAP;
|
|
95
|
+
height = Math.max(height, box.y + h);
|
|
96
|
+
}
|
|
97
|
+
const edges = [];
|
|
98
|
+
for (const e of answer.edges || []) {
|
|
99
|
+
const a = at.get(`${e.from.project}\u0000${e.from.root}`);
|
|
100
|
+
const b = at.get(`${e.to.project}\u0000${e.to.root}`);
|
|
101
|
+
if (!a || !b) continue;
|
|
102
|
+
const x1 = a.x + a.w;
|
|
103
|
+
const y1 = a.y + a.h / 2;
|
|
104
|
+
const x2 = b.x;
|
|
105
|
+
const y2 = b.y + b.h / 2;
|
|
106
|
+
const bend = Math.max(36, Math.abs(x2 - x1) / 3);
|
|
107
|
+
edges.push({ from: e.from, to: e.to, cross: !!e.cross, d: `M${x1},${y1} C${x1 + bend},${y1} ${x2 - bend},${y2} ${x2},${y2}` });
|
|
108
|
+
}
|
|
109
|
+
return { width: Math.max(x - GAP + PAD, NO_RUN_W + PAD * 2), height: height + PAD, boxes, edges };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
113
|
+
|
|
114
|
+
/** terragucci's counts for a project, with their age and source. */
|
|
115
|
+
export function countsLine(p, now = new Date()) {
|
|
116
|
+
if (!p.counts) return { text: p.status === "not-in-estate" ? "not in estate.json" : "no counts", title: p.missing || "" };
|
|
117
|
+
const c = p.counts;
|
|
118
|
+
return {
|
|
119
|
+
text: `${plural(c.drifted, "root")} drifted · ${c.failed} failed · ${plural(c.waiting, "wave")} waiting · ${ago(c.dated.at, now)}`,
|
|
120
|
+
title: `from ${c.dated.key}, generated ${clock(c.dated.at, now)} (${ago(c.dated.at, now)}); not live`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Which run view the box was drawn from, or why it has none. */
|
|
125
|
+
export function runLine(p, now = new Date()) {
|
|
126
|
+
if (p.run) {
|
|
127
|
+
return {
|
|
128
|
+
text: `run view ${p.run.commit.slice(0, 12)} · updated ${ago(p.run.updated, now)}`,
|
|
129
|
+
title: `${p.run.key}, the run view of ${p.run.commit}, named by estate.json's ${p.run.from === "run_view" ? "run_view" : "newest apply"}; a wave last wrote it ${clock(p.run.updated, now)}`,
|
|
130
|
+
tone: "ok",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (p.refused) return { text: "run view refused", title: `${p.refused.error}\n${p.refused.remedy}`, tone: "bad" };
|
|
134
|
+
return { text: "no run view", title: p.missing || "no run view", tone: "none" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A waiting wave as a line and its approve command. */
|
|
138
|
+
export function waitingRows(p, now = new Date()) {
|
|
139
|
+
return (p.waiting || []).map((w) => ({
|
|
140
|
+
text: `wave ${w.wave} waiting since ${clock(w.since, now)}, ${ago(w.since, now)} · commit ${w.commit.slice(0, 12)}${w.digest ? ` · plan ${String(w.digest).replace(/^jcs1-sha256:/, "").slice(0, 12)}` : ""}`,
|
|
141
|
+
title: `from ${w.dated.key}, generated ${clock(w.dated.at, now)}`,
|
|
142
|
+
command: w.command,
|
|
143
|
+
digestKnown: !!w.digest,
|
|
144
|
+
report: w.report,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The line over the picture: whose estate, how old, how checked. */
|
|
149
|
+
export function estateLegend(answer, now = new Date()) {
|
|
150
|
+
const cross = (answer.edges || []).filter((e) => e.cross).length;
|
|
151
|
+
const drawn = answer.projects.filter((p) => p.run).length;
|
|
152
|
+
const checked = (v) => (v.by === "schema" ? `terragucci ${v.terragucci}'s schema` : "a structural check");
|
|
153
|
+
return {
|
|
154
|
+
text: `terragucci estate of ${plural(answer.projects.length, "project")}, not live · estate.json generated ${ago(answer.estate.generated, now)} · ${drawn} drawn from a run view · ${plural(cross, "read")} between projects`,
|
|
155
|
+
title: [`read from ${answer.source} (${answer.gets} GETs)`, `estate.json checked with ${checked(answer.validation.estate)}`, `run views checked with ${checked(answer.validation.run)}`, `config: ${answer.config}`].join("\n"),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// The DOM. Text only, never innerHTML: every string here came from a bucket.
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
164
|
+
|
|
165
|
+
function el(doc, tag, cls, text) {
|
|
166
|
+
const e = doc.createElement(tag);
|
|
167
|
+
if (cls) e.className = cls;
|
|
168
|
+
if (text !== undefined) e.textContent = text;
|
|
169
|
+
return e;
|
|
170
|
+
}
|
|
171
|
+
function svgEl(doc, tag, attrs = {}, text) {
|
|
172
|
+
const e = doc.createElementNS(SVG, tag);
|
|
173
|
+
for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, String(v));
|
|
174
|
+
if (text !== undefined) e.textContent = text;
|
|
175
|
+
return e;
|
|
176
|
+
}
|
|
177
|
+
function link(doc, text, href) {
|
|
178
|
+
const a = el(doc, "a", "", text);
|
|
179
|
+
a.href = href;
|
|
180
|
+
a.target = "_blank";
|
|
181
|
+
a.rel = "noopener";
|
|
182
|
+
return a;
|
|
183
|
+
}
|
|
184
|
+
function titled(doc, node, title) {
|
|
185
|
+
if (title) node.appendChild(svgEl(doc, "title", {}, title));
|
|
186
|
+
return node;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The picture: boxes, wave columns, roots and edges. */
|
|
190
|
+
export function renderEstateSvg(answer, now = new Date(), doc = document) {
|
|
191
|
+
const L = estateLayout(answer);
|
|
192
|
+
const svg = svgEl(doc, "svg", { width: L.width, height: L.height, viewBox: `0 0 ${L.width} ${L.height}`, class: "tg-estate-svg", role: "img", "aria-label": "terragucci estate: projects, their roots by wave, and the reads between them" });
|
|
193
|
+
const defs = svgEl(doc, "defs");
|
|
194
|
+
const marker = svgEl(doc, "marker", { id: "tg-estate-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
|
|
195
|
+
marker.appendChild(svgEl(doc, "path", { d: "M0,0 L10,5 L0,10 z", fill: "var(--edge)" }));
|
|
196
|
+
defs.appendChild(marker);
|
|
197
|
+
svg.appendChild(defs);
|
|
198
|
+
const byName = new Map(answer.projects.map((p) => [p.project, p]));
|
|
199
|
+
for (const b of L.boxes) {
|
|
200
|
+
const p = byName.get(b.project);
|
|
201
|
+
const g = svgEl(doc, "g", { class: "tg-project", "data-project": b.project });
|
|
202
|
+
g.appendChild(svgEl(doc, "rect", { x: b.x, y: b.y, width: b.w, height: b.h, rx: 6, class: `tg-project-box${p.run ? "" : " tg-no-run"}` }));
|
|
203
|
+
const name = svgEl(doc, "text", { x: b.x + PAD, y: b.y + 20, class: "tg-project-name" }, b.project);
|
|
204
|
+
if (p.index) {
|
|
205
|
+
const a = svgEl(doc, "a", { href: keyHref(answer, p.index), target: "_blank" });
|
|
206
|
+
a.appendChild(name);
|
|
207
|
+
g.appendChild(a);
|
|
208
|
+
} else g.appendChild(name);
|
|
209
|
+
const counts = countsLine(p, now);
|
|
210
|
+
g.appendChild(titled(doc, svgEl(doc, "text", { x: b.x + PAD, y: b.y + 38, class: "tg-project-counts" }, counts.text), counts.title));
|
|
211
|
+
const run = runLine(p, now);
|
|
212
|
+
g.appendChild(titled(doc, svgEl(doc, "text", { x: b.x + PAD, y: b.y + 55, class: `tg-project-run tg-${run.tone}` }, run.text), run.title));
|
|
213
|
+
for (const w of b.waves) {
|
|
214
|
+
g.appendChild(svgEl(doc, "text", { x: w.x, y: w.y + 14, class: `tg-wave-label tg-wave-${w.state}`, "data-wave": w.number }, `wave ${w.number} · ${w.state === "not-started" ? "not started" : w.state}`));
|
|
215
|
+
for (const r of w.roots) {
|
|
216
|
+
const node = svgEl(doc, "g", { class: "tg-root-node", "data-root": r.root });
|
|
217
|
+
node.appendChild(svgEl(doc, "rect", { x: r.x, y: r.y, width: r.w, height: r.h, rx: 4 }));
|
|
218
|
+
node.appendChild(titled(doc, svgEl(doc, "text", { x: r.x + 6, y: r.y + r.h / 2 + 4 }, r.root.length > 24 ? `…${r.root.slice(-23)}` : r.root), `${b.project}: ${r.root}, wave ${w.number}`));
|
|
219
|
+
g.appendChild(node);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
svg.appendChild(g);
|
|
223
|
+
}
|
|
224
|
+
for (const e of L.edges) {
|
|
225
|
+
const path = svgEl(doc, "path", { d: e.d, class: `tg-edge${e.cross ? " tg-edge-cross" : ""}`, "marker-end": "url(#tg-estate-arrow)", "data-from": `${e.from.project} ${e.from.root}`, "data-to": `${e.to.project} ${e.to.root}` });
|
|
226
|
+
svg.appendChild(titled(doc, path, `${e.to.project}: ${e.to.root} reads ${e.from.project}: ${e.from.root}`));
|
|
227
|
+
}
|
|
228
|
+
return svg;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Draw the whole view into `host`. `copy(text, buttonEl)` puts a line on the clipboard. */
|
|
232
|
+
export function renderEstate(host, answer, { copy = () => {}, refresh, now = new Date(), doc = document } = {}) {
|
|
233
|
+
host.replaceChildren();
|
|
234
|
+
if (!answer || answer.error) {
|
|
235
|
+
host.append(el(doc, "div", "tg-refusal", answer ? answer.error : "the estate was not read"), el(doc, "div", "tg-legend", (answer && answer.remedy) || ""));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const lg = estateLegend(answer, now);
|
|
239
|
+
const head = el(doc, "div", "tg-legend", lg.text);
|
|
240
|
+
head.title = lg.title;
|
|
241
|
+
const top = el(doc, "div", "tg-estate-head");
|
|
242
|
+
top.append(head, link(doc, "terragucci's estate page", keyHref(answer, answer.estate.html)));
|
|
243
|
+
if (refresh) {
|
|
244
|
+
const again = el(doc, "button", "", "Read again");
|
|
245
|
+
again.addEventListener("click", refresh);
|
|
246
|
+
top.appendChild(again);
|
|
247
|
+
}
|
|
248
|
+
host.appendChild(top);
|
|
249
|
+
const wrap = el(doc, "div", "tg-estate-graph");
|
|
250
|
+
wrap.appendChild(renderEstateSvg(answer, now, doc));
|
|
251
|
+
host.appendChild(wrap);
|
|
252
|
+
const list = el(doc, "div", "tg-estate-projects");
|
|
253
|
+
const copyRow = (text) => {
|
|
254
|
+
const row = el(doc, "div", "tg-estate-cmd");
|
|
255
|
+
const code = el(doc, "code", "tg-command", text);
|
|
256
|
+
const b = el(doc, "button", "", "Copy");
|
|
257
|
+
b.title = "Copy the line and run it at your shell. behold runs nothing here.";
|
|
258
|
+
b.addEventListener("click", () => copy(text, b));
|
|
259
|
+
row.append(code, b);
|
|
260
|
+
return row;
|
|
261
|
+
};
|
|
262
|
+
for (const p of projectOrder(answer)) {
|
|
263
|
+
const card = el(doc, "section", "tg-estate-project");
|
|
264
|
+
card.dataset.project = p.project;
|
|
265
|
+
const title = el(doc, "div", "tg-estate-title", p.project);
|
|
266
|
+
card.appendChild(title);
|
|
267
|
+
const links = el(doc, "div", "tg-legend");
|
|
268
|
+
const parts = [];
|
|
269
|
+
if (p.index) parts.push(link(doc, "its index", keyHref(answer, p.index)));
|
|
270
|
+
parts.push(link(doc, "estate.html", keyHref(answer, answer.estate.html)));
|
|
271
|
+
if (p.run) parts.push(link(doc, "run view", keyHref(answer, p.run.page)));
|
|
272
|
+
parts.forEach((a, i) => links.append(...(i ? [" · ", a] : [a])));
|
|
273
|
+
card.appendChild(links);
|
|
274
|
+
const counts = countsLine(p, now);
|
|
275
|
+
const c = el(doc, "div", "tg-estate-counts", counts.text);
|
|
276
|
+
c.title = counts.title;
|
|
277
|
+
card.appendChild(c);
|
|
278
|
+
const run = runLine(p, now);
|
|
279
|
+
const r = el(doc, "div", `tg-estate-run tg-${run.tone}`, run.tone === "ok" ? run.text : `${run.text}: ${p.refused ? p.refused.error : p.missing || ""}`);
|
|
280
|
+
r.title = run.title;
|
|
281
|
+
card.appendChild(r);
|
|
282
|
+
for (const w of waitingRows(p, now)) {
|
|
283
|
+
const box = el(doc, "div", "ws-gate tg-wave");
|
|
284
|
+
const t = el(doc, "div", "ws-gate-facts", w.text);
|
|
285
|
+
t.title = w.title;
|
|
286
|
+
box.append(t, copyRow(w.command));
|
|
287
|
+
const note = el(doc, "div", "ws-gate-note", w.digestKnown ? "Run it in the project's own checkout: the approval is a signed git record bound to this plan digest. behold does not approve." : "Run it in the project's own checkout, with the plan digest from the wave's report. behold does not approve.");
|
|
288
|
+
box.appendChild(note);
|
|
289
|
+
if (w.report) {
|
|
290
|
+
const l = el(doc, "div", "ws-gate-note");
|
|
291
|
+
l.appendChild(link(doc, "report", keyHref(answer, w.report)));
|
|
292
|
+
box.appendChild(l);
|
|
293
|
+
}
|
|
294
|
+
card.appendChild(box);
|
|
295
|
+
}
|
|
296
|
+
if (p.checkout) {
|
|
297
|
+
card.appendChild(el(doc, "div", "tg-legend", `checked out at ${p.checkout.path}; serve it to see its roots:`));
|
|
298
|
+
card.appendChild(copyRow(p.checkout.command));
|
|
299
|
+
}
|
|
300
|
+
list.appendChild(card);
|
|
301
|
+
}
|
|
302
|
+
host.appendChild(list);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Once, at boot: when /api/project names a terragucci control repo, read the
|
|
307
|
+
* estate and draw it in a layer over the graph area. `fetchUrl(url)` is
|
|
308
|
+
* app.js's apiFetch, `copy` its copyToClipboard.
|
|
309
|
+
*/
|
|
310
|
+
export async function initTerragucciEstate({ fetchUrl, copy, doc = document } = {}) {
|
|
311
|
+
const project = await fetchUrl("/api/project").then((r) => r.json()).catch(() => ({}));
|
|
312
|
+
if (!project.terragucciControl || !project.terragucciReports) return;
|
|
313
|
+
const graph = doc.getElementById("graph");
|
|
314
|
+
if (!graph || !graph.parentElement) return;
|
|
315
|
+
let host = doc.getElementById("tg-estate");
|
|
316
|
+
if (!host) {
|
|
317
|
+
host = el(doc, "div");
|
|
318
|
+
host.id = "tg-estate";
|
|
319
|
+
graph.parentElement.insertBefore(host, graph.nextSibling);
|
|
320
|
+
}
|
|
321
|
+
const load = async (fresh) => {
|
|
322
|
+
const answer = await fetchUrl(`/api/terragucci/estate${fresh ? "?fresh=1" : ""}`)
|
|
323
|
+
.then((r) => r.json())
|
|
324
|
+
.catch((e) => ({ error: String(e), code: "terragucci-report", remedy: "" }));
|
|
325
|
+
renderEstate(host, answer, { copy, refresh: () => load(true), doc });
|
|
326
|
+
};
|
|
327
|
+
await load(false);
|
|
328
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { COL_W, countsLine, estateLayout, estateLegend, keyHref, NO_RUN_W, PAD, projectOrder, runLine, waitingRows, wavesOf } from "./terragucci-estate.js";
|
|
3
|
+
|
|
4
|
+
// #509: the control repo view's layout and words, over an answer shaped like
|
|
5
|
+
// /api/terragucci/estate's.
|
|
6
|
+
const NOW = new Date("2026-10-08T12:00:00.000Z");
|
|
7
|
+
const dated = (at, key = "estate.json") => ({ key, at });
|
|
8
|
+
const answer = {
|
|
9
|
+
source: "/b",
|
|
10
|
+
kind: "dir",
|
|
11
|
+
config: "/c/terragucci.yml",
|
|
12
|
+
validation: { estate: { by: "structural" }, run: { by: "schema", terragucci: "0.4.7" } },
|
|
13
|
+
read: NOW.toISOString(),
|
|
14
|
+
estate: { key: "estate.json", html: "estate.html", generated: "2026-10-08T10:00:00.000Z" },
|
|
15
|
+
gets: 4,
|
|
16
|
+
files: "/api/terragucci/file?key=",
|
|
17
|
+
projects: [
|
|
18
|
+
{
|
|
19
|
+
project: "github.com/acme/shop",
|
|
20
|
+
status: "ok",
|
|
21
|
+
index: "github.com/acme/shop/index.html",
|
|
22
|
+
counts: { drifted: 2, failed: 0, waiting: 1, dated: dated("2026-10-08T10:00:00.000Z") },
|
|
23
|
+
waiting: [{ wave: 2, commit: "d3e4f5a6b7c8d9e0", since: "2026-10-08T09:31:00.000Z", age_seconds: 1740, digest: "jcs1-sha256:7777777777777777", command: "npx terragucci approve wave-2 --plan jcs1-sha256:7777777777777777", dated: dated("2026-10-08T10:00:00.000Z") }],
|
|
24
|
+
run: {
|
|
25
|
+
commit: "d3e4f5a6b7c8d9e0",
|
|
26
|
+
updated: "2026-10-08T09:31:00.000Z",
|
|
27
|
+
from: "run_view",
|
|
28
|
+
key: "github.com/acme/shop/runs/d3/run.json",
|
|
29
|
+
page: "github.com/acme/shop/runs/d3/run.html",
|
|
30
|
+
roots: [
|
|
31
|
+
{ root: "app", wave: 1, reads: [] },
|
|
32
|
+
{ root: "worker", wave: 2, reads: ["app"] },
|
|
33
|
+
{ root: "cron", wave: 2, reads: ["app"] },
|
|
34
|
+
],
|
|
35
|
+
waves: [
|
|
36
|
+
{ number: 1, roots: ["app"], reads: [], state: "applied", gate: "wave-1" },
|
|
37
|
+
{ number: 2, roots: ["cron", "worker"], reads: [1], state: "waiting", gate: "wave-2" },
|
|
38
|
+
],
|
|
39
|
+
dated: dated("2026-10-08T09:31:00.000Z", "github.com/acme/shop/runs/d3/run.json"),
|
|
40
|
+
},
|
|
41
|
+
configured: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
project: "github.com/acme/network",
|
|
45
|
+
status: "ok",
|
|
46
|
+
counts: { drifted: 0, failed: 0, waiting: 0, dated: dated("2026-10-08T10:00:00.000Z") },
|
|
47
|
+
waiting: [],
|
|
48
|
+
run: { commit: "a1b2", updated: "2026-10-07T12:10:00.000Z", from: "apply", key: "k", page: "p", roots: [{ root: "vpc", wave: 1, reads: [] }], waves: [{ number: 1, roots: ["vpc"], reads: [], state: "applied", gate: "wave-1" }], dated: dated("2026-10-07T12:10:00.000Z", "k") },
|
|
49
|
+
configured: true,
|
|
50
|
+
},
|
|
51
|
+
{ project: "github.com/acme/billing", status: "ok", counts: { drifted: 0, failed: 0, waiting: 0, dated: dated("2026-10-08T10:00:00.000Z") }, waiting: [], missing: "no run view at github.com/acme/billing/runs/f0/run.json", configured: true },
|
|
52
|
+
{ project: "github.com/acme/legacy", status: "ok", counts: { drifted: 0, failed: 1, waiting: 0, dated: dated("2026-10-08T10:00:00.000Z") }, waiting: [], refused: { error: "k is not a terragucci terragucci.run/v1 document", code: "terragucci-report", remedy: "r" }, configured: false },
|
|
53
|
+
],
|
|
54
|
+
edges: [
|
|
55
|
+
{ from: { project: "github.com/acme/shop", root: "app" }, to: { project: "github.com/acme/shop", root: "worker" }, cross: false },
|
|
56
|
+
{ from: { project: "github.com/acme/shop", root: "app" }, to: { project: "github.com/acme/shop", root: "cron" }, cross: false },
|
|
57
|
+
{ from: { project: "github.com/acme/network", root: "vpc" }, to: { project: "github.com/acme/shop", root: "app" }, cross: true },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("the control repo view (#509)", () => {
|
|
62
|
+
it("puts a project before the projects that read its roots", () => {
|
|
63
|
+
expect(projectOrder(answer).map((p) => p.project)).toEqual(["github.com/acme/network", "github.com/acme/shop", "github.com/acme/billing", "github.com/acme/legacy"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("lays out one box per project, roots by wave, and keeps a project with no run view as a box", () => {
|
|
67
|
+
const L = estateLayout(answer);
|
|
68
|
+
expect(L.boxes.map((b) => b.project)).toEqual(["github.com/acme/network", "github.com/acme/shop", "github.com/acme/billing", "github.com/acme/legacy"]);
|
|
69
|
+
const shop = L.boxes[1];
|
|
70
|
+
expect(shop.w).toBe(2 * COL_W + 2 * PAD);
|
|
71
|
+
expect(shop.waves.map((w) => [w.number, w.state, w.roots.map((r) => r.root)])).toEqual([
|
|
72
|
+
[1, "applied", ["app"]],
|
|
73
|
+
[2, "waiting", ["cron", "worker"]],
|
|
74
|
+
]);
|
|
75
|
+
expect(L.boxes[2].waves).toEqual([]);
|
|
76
|
+
expect(L.boxes[2].w).toBe(NO_RUN_W);
|
|
77
|
+
// Boxes do not overlap, and the cross edge runs left to right.
|
|
78
|
+
for (let i = 1; i < L.boxes.length; i++) expect(L.boxes[i].x).toBeGreaterThan(L.boxes[i - 1].x + L.boxes[i - 1].w);
|
|
79
|
+
expect(L.edges).toHaveLength(3);
|
|
80
|
+
const cross = L.edges.find((e) => e.cross);
|
|
81
|
+
const n = cross.d.match(/-?[\d.]+/g).map(Number);
|
|
82
|
+
expect(n[6]).toBeGreaterThan(n[0]); // the end's x is right of the start's
|
|
83
|
+
expect(L.width).toBeGreaterThan(L.boxes[3].x + L.boxes[3].w);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("skips an edge to a root no box draws", () => {
|
|
87
|
+
const L = estateLayout({ ...answer, edges: [{ from: { project: "github.com/acme/billing", root: "x" }, to: { project: "github.com/acme/shop", root: "app" }, cross: true }] });
|
|
88
|
+
expect(L.edges).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("dates every count and run view, and says when a project has none", () => {
|
|
92
|
+
const shop = answer.projects[0];
|
|
93
|
+
expect(countsLine(shop, NOW)).toEqual({ text: "2 roots drifted · 0 failed · 1 wave waiting · 2h ago", title: "from estate.json, generated 10:00 UTC (2h ago); not live" });
|
|
94
|
+
expect(runLine(shop, NOW).text).toBe("run view d3e4f5a6b7c8 · updated 2h ago");
|
|
95
|
+
expect(runLine(answer.projects[2], NOW)).toMatchObject({ text: "no run view", tone: "none" });
|
|
96
|
+
expect(runLine(answer.projects[3], NOW)).toMatchObject({ text: "run view refused", tone: "bad" });
|
|
97
|
+
expect(countsLine({ status: "not-in-estate", missing: "m" }, NOW).text).toBe("not in estate.json");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("offers a waiting wave's approve line to copy, with its age", () => {
|
|
101
|
+
expect(waitingRows(answer.projects[0], NOW)).toEqual([
|
|
102
|
+
{ text: "wave 2 waiting since 09:31 UTC, 2h ago · commit d3e4f5a6b7c8 · plan 777777777777", title: "from estate.json, generated 10:00 UTC", command: "npx terragucci approve wave-2 --plan jcs1-sha256:7777777777777777", digestKnown: true, report: undefined },
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("says whose estate, how old and how checked, and opens keys through the file route", () => {
|
|
107
|
+
const lg = estateLegend(answer, NOW);
|
|
108
|
+
expect(lg.text).toBe("terragucci estate of 4 projects, not live · estate.json generated 2h ago · 2 drawn from a run view · 1 read between projects");
|
|
109
|
+
expect(lg.title).toContain("4 GETs");
|
|
110
|
+
expect(lg.title).toContain("terragucci 0.4.7's schema");
|
|
111
|
+
expect(keyHref(answer, "github.com/acme/shop/index.html")).toBe("/api/terragucci/file?key=github.com%2Facme%2Fshop%2Findex.html");
|
|
112
|
+
expect(wavesOf({ waves: [{ number: 3 }], roots: [{ wave: 1 }] })).toEqual([1, 3]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// #505: the chant/lifecycle lane of a terragucci repo, in words. Pure: app.js
|
|
2
|
+
// fetches /api/terragucci/lifecycle (and hears /api/terragucci/events), then
|
|
3
|
+
// renders what these functions return.
|
|
4
|
+
//
|
|
5
|
+
// Every gate and lock says which commit of chant/lifecycle it came from and
|
|
6
|
+
// how old that commit is, and the lane's line says when behold last read the
|
|
7
|
+
// branch and how (fetched from origin, or the checkout's own copy when the
|
|
8
|
+
// fetch failed). Nothing here is painted as live, and nothing approves,
|
|
9
|
+
// locks or unlocks: a waiting wave offers the line a person runs.
|
|
10
|
+
|
|
11
|
+
import { ago, clock } from "./terragucci.js";
|
|
12
|
+
|
|
13
|
+
const short = (s) => (s ? String(s).replace(/^(jcs1-)?sha256:/, "").slice(0, 12) : "");
|
|
14
|
+
|
|
15
|
+
/** Where an entry came from: "chant/lifecycle 1a2b3c4d5e6f, committed 09:00 UTC, 2h ago". */
|
|
16
|
+
export function sourceWords(source, now = new Date()) {
|
|
17
|
+
return `chant/lifecycle ${short(source.commit)}, committed ${clock(source.committed, now)}, ${ago(source.committed, now)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The lane's line under the tab title: what was read, how, and when. `title` carries the note. */
|
|
21
|
+
export function laneLegend(lane, now = new Date(), polled = null) {
|
|
22
|
+
if (!lane || lane.error) return { text: `chant/lifecycle not read${lane && lane.error ? `: ${lane.error}` : ""}`, title: "", tone: "refusal" };
|
|
23
|
+
const looked = polled && polled.at && polled.at > lane.read.at ? polled.at : lane.read.at;
|
|
24
|
+
const when = `read ${ago(looked, now)}`;
|
|
25
|
+
if (!lane.found) {
|
|
26
|
+
const text = lane.via === "none" ? `chant/lifecycle unknown: behold could not fetch it · ${when}` : `no chant/lifecycle branch yet: no gate asked, no root locked · ${when}`;
|
|
27
|
+
return { text, title: lane.note, tone: lane.via === "none" ? "refusal" : "quiet" };
|
|
28
|
+
}
|
|
29
|
+
const how = lane.via === "fetch" ? "fetched from origin" : "the checkout's origin/chant/lifecycle, as of its last git fetch (behold's fetch failed)";
|
|
30
|
+
return {
|
|
31
|
+
text: `chant/lifecycle at ${short(lane.commit.sha)}, committed ${ago(lane.commit.committed, now)} · ${when}, ${how}`,
|
|
32
|
+
title: [lane.note, lane.fetch && lane.fetch.error ? `fetch: ${lane.fetch.error}` : ""].filter(Boolean).join("\n"),
|
|
33
|
+
tone: lane.via === "fetch" ? "quiet" : "stale",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TITLE = {
|
|
38
|
+
waiting: (g) => `wave ${g.wave} waits for an approval`,
|
|
39
|
+
expired: (g) => `wave ${g.wave}'s request for an approval expired`,
|
|
40
|
+
approved: (g) => `wave ${g.wave} approved by ${g.approval.by}, not applied yet`,
|
|
41
|
+
applied: (g) => `wave ${g.wave} applied`,
|
|
42
|
+
failed: (g) => `wave ${g.wave}'s apply failed`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Each wave's gate as a card: its state, who and when, the digest, the commit it was read from, and for a waiting wave the line to run. No button. */
|
|
46
|
+
export function laneGateCards(lane, now = new Date()) {
|
|
47
|
+
if (!lane || !lane.found) return [];
|
|
48
|
+
return lane.gates.map((g) => {
|
|
49
|
+
const facts = [];
|
|
50
|
+
if (g.pending) facts.push(`asked ${clock(g.pending.timestamp, now)}, ${ago(g.pending.timestamp, now)}`);
|
|
51
|
+
if (g.approval) facts.push(`approved by ${g.approval.by}${g.approval.signed ? " (signed)" : ""}${g.approval.via ? ` via ${g.approval.via}` : ""} ${clock(g.approval.at, now)}, ${ago(g.approval.at, now)}`);
|
|
52
|
+
if (g.applied) facts.push(`${g.applied.result === "failed" ? "apply failed" : "applied"} ${clock(g.applied.finished || g.applied.at, now)}, ${ago(g.applied.finished || g.applied.at, now)}`);
|
|
53
|
+
if (g.digest) facts.push(`plan ${short(g.digest)}`);
|
|
54
|
+
return {
|
|
55
|
+
wave: g.wave,
|
|
56
|
+
state: g.state,
|
|
57
|
+
tone: g.state === "waiting" ? "wave" : g.state === "failed" ? "failed" : g.state === "expired" ? "none" : "ok",
|
|
58
|
+
title: TITLE[g.state] ? TITLE[g.state](g) : `wave ${g.wave}: ${g.state}`,
|
|
59
|
+
facts: facts.join(" · "),
|
|
60
|
+
source: sourceWords(g.source, now),
|
|
61
|
+
...(g.command ? { command: g.command, note: "An approval is a signed git record bound to this plan digest: run the line at your shell. behold does not approve." } : {}),
|
|
62
|
+
...(g.pending && g.pending.url ? { job: g.pending.url } : {}),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Each locked root: who holds it and since when, and the commit it was read from. */
|
|
68
|
+
export function laneLockRows(lane, now = new Date()) {
|
|
69
|
+
if (!lane || !lane.found) return [];
|
|
70
|
+
return lane.locks.map((l) => {
|
|
71
|
+
const how = l.stage === "plan" ? "its plan" : l.via === "lock" ? "/terragucci lock" : "an apply";
|
|
72
|
+
return {
|
|
73
|
+
root: l.root,
|
|
74
|
+
text: `locked by PR #${l.pr} (${l.by}, by ${how}) ${clock(l.at, now)}, ${ago(l.at, now)}`,
|
|
75
|
+
title: `head ${short(l.head)}\nfrom ${sourceWords(l.source, now)}\nUnlocking is a pull request comment (/terragucci unlock), never behold's.`,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The lane in the Terragucci tab: the line, a card per gate, a row per lock.
|
|
82
|
+
* `ui` lends app.js's own `button`, `copy` and `link`, so the cards look like
|
|
83
|
+
* the reports' wave cards. A Copy button is the only control: no approve,
|
|
84
|
+
* lock or unlock.
|
|
85
|
+
*/
|
|
86
|
+
export function renderLane(host, lane, polled, now, ui) {
|
|
87
|
+
const lg = laneLegend(lane, now, polled);
|
|
88
|
+
const head = document.createElement("div");
|
|
89
|
+
head.className = lg.tone === "refusal" ? "tg-refusal" : "tg-legend";
|
|
90
|
+
head.textContent = lg.text;
|
|
91
|
+
head.title = lg.title;
|
|
92
|
+
host.appendChild(head);
|
|
93
|
+
if (polled && polled.reports && polled.reports.error) {
|
|
94
|
+
const e = document.createElement("div");
|
|
95
|
+
e.className = "tg-legend";
|
|
96
|
+
e.textContent = `the reports' index could not be checked: ${polled.reports.error}`;
|
|
97
|
+
host.appendChild(e);
|
|
98
|
+
}
|
|
99
|
+
for (const g of laneGateCards(lane, now)) {
|
|
100
|
+
const box = document.createElement("div");
|
|
101
|
+
box.className = `ws-gate tg-lane-gate tg-${g.tone}`;
|
|
102
|
+
const title = document.createElement("div");
|
|
103
|
+
title.className = "ws-gate-title";
|
|
104
|
+
title.textContent = g.title;
|
|
105
|
+
const facts = document.createElement("div");
|
|
106
|
+
facts.className = "ws-gate-facts";
|
|
107
|
+
facts.textContent = g.facts;
|
|
108
|
+
const src = document.createElement("div");
|
|
109
|
+
src.className = "ws-gate-note";
|
|
110
|
+
src.textContent = `from ${g.source}`;
|
|
111
|
+
box.append(title, facts, src);
|
|
112
|
+
if (g.command) {
|
|
113
|
+
const cmd = document.createElement("code");
|
|
114
|
+
cmd.className = "tg-command";
|
|
115
|
+
cmd.textContent = g.command;
|
|
116
|
+
const copy = ui.button("Copy", "", () => ui.copy(g.command, copy));
|
|
117
|
+
copy.title = "Copy the line. Run it at your shell: the approval is yours, signed, in git.";
|
|
118
|
+
const note = document.createElement("div");
|
|
119
|
+
note.className = "ws-gate-note";
|
|
120
|
+
note.textContent = g.note;
|
|
121
|
+
box.append(cmd, copy, note);
|
|
122
|
+
}
|
|
123
|
+
if (g.job) {
|
|
124
|
+
const links = document.createElement("div");
|
|
125
|
+
links.className = "ws-gate-note";
|
|
126
|
+
links.appendChild(ui.link("job", g.job));
|
|
127
|
+
box.appendChild(links);
|
|
128
|
+
}
|
|
129
|
+
host.appendChild(box);
|
|
130
|
+
}
|
|
131
|
+
for (const l of laneLockRows(lane, now)) {
|
|
132
|
+
const row = document.createElement("div");
|
|
133
|
+
row.className = "tg-root tg-lock";
|
|
134
|
+
const path = document.createElement("div");
|
|
135
|
+
path.className = "tg-path";
|
|
136
|
+
path.textContent = l.root;
|
|
137
|
+
const what = document.createElement("div");
|
|
138
|
+
what.textContent = l.text;
|
|
139
|
+
what.title = l.title;
|
|
140
|
+
row.append(path, what);
|
|
141
|
+
host.appendChild(row);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { laneGateCards, laneLegend, laneLockRows, sourceWords } from "./terragucci-lifecycle.js";
|
|
3
|
+
|
|
4
|
+
const NOW = new Date("2026-10-09T12:00:00Z");
|
|
5
|
+
const source = { branch: "chant/lifecycle", commit: "1a2b3c4d5e6f7a8b", committed: "2026-10-09T10:00:00Z", path: "_gates/tf-apply.jsonl" };
|
|
6
|
+
const D = `jcs1-sha256:${"2".repeat(64)}`;
|
|
7
|
+
const lane = (over = {}) => ({
|
|
8
|
+
branch: "chant/lifecycle",
|
|
9
|
+
found: true,
|
|
10
|
+
via: "fetch",
|
|
11
|
+
fetch: { ok: true },
|
|
12
|
+
commit: { sha: "1a2b3c4d5e6f7a8b", committed: "2026-10-09T10:00:00Z" },
|
|
13
|
+
read: { at: "2026-10-09T11:59:00Z" },
|
|
14
|
+
gates: [
|
|
15
|
+
{ gate: "wave-1", wave: 1, state: "approved", digest: D, approval: { by: "alice", at: "2026-10-09T09:00:00Z", signed: true }, records: [], source },
|
|
16
|
+
{ gate: "wave-2", wave: 2, state: "waiting", digest: D, pending: { timestamp: "2026-10-09T10:00:00Z", expiresAt: "2099-01-01T00:00:00Z" }, records: [], command: `npx terragucci approve wave-2 --plan ${D}`, source },
|
|
17
|
+
],
|
|
18
|
+
locks: [{ root: "envs/prod/orders", pr: 12, by: "carol", at: "2026-10-09T07:00:00Z", head: "abc123", source: { ...source, path: "_locks/tf-apply.json" } }],
|
|
19
|
+
note: "chant/lifecycle as fetched",
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("the chant/lifecycle lane, dated (#505)", () => {
|
|
24
|
+
it("names the commit an entry came from and its age", () => {
|
|
25
|
+
expect(sourceWords(source, NOW)).toBe("chant/lifecycle 1a2b3c4d5e6f, committed 10:00 UTC, 2h ago");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("says when behold last read the branch, and how", () => {
|
|
29
|
+
expect(laneLegend(lane(), NOW).text).toBe("chant/lifecycle at 1a2b3c4d5e6f, committed 2h ago · read 1m ago, fetched from origin");
|
|
30
|
+
const stale = laneLegend(lane({ via: "checkout-ref", fetch: { ok: false, error: "Could not resolve host" } }), NOW);
|
|
31
|
+
expect(stale.text).toContain("the checkout's origin/chant/lifecycle");
|
|
32
|
+
expect(stale.title).toContain("Could not resolve host");
|
|
33
|
+
expect(stale.tone).toBe("stale");
|
|
34
|
+
// A later poll moves "read" forward without a new lane read.
|
|
35
|
+
expect(laneLegend(lane({ read: { at: "2026-10-09T10:00:00Z" } }), NOW, { at: "2026-10-09T11:59:30Z" }).text).toContain("read just now");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("says no branch is no branch, and an unreadable one is unknown", () => {
|
|
39
|
+
expect(laneLegend(lane({ found: false, gates: [], locks: [] }), NOW).text).toMatch(/^no chant\/lifecycle branch yet/);
|
|
40
|
+
expect(laneLegend(lane({ found: false, via: "none" }), NOW).text).toMatch(/^chant\/lifecycle unknown/);
|
|
41
|
+
expect(laneGateCards(lane({ found: false }), NOW)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("words each gate with its state, its source and, when it waits, the line to run", () => {
|
|
45
|
+
const [one, two] = laneGateCards(lane(), NOW);
|
|
46
|
+
expect(one.title).toBe("wave 1 approved by alice, not applied yet");
|
|
47
|
+
expect(one.facts).toContain("approved by alice (signed) 09:00 UTC, 3h ago");
|
|
48
|
+
expect(one.command).toBeUndefined();
|
|
49
|
+
expect(two.title).toBe("wave 2 waits for an approval");
|
|
50
|
+
expect(two.command).toBe(`npx terragucci approve wave-2 --plan ${D}`);
|
|
51
|
+
expect(two.source).toBe("chant/lifecycle 1a2b3c4d5e6f, committed 10:00 UTC, 2h ago");
|
|
52
|
+
expect(two.tone).toBe("wave");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("words a lock with its holder, its age and its source", () => {
|
|
56
|
+
const [l] = laneLockRows(lane(), NOW);
|
|
57
|
+
expect(l.text).toBe("locked by PR #12 (carol, by an apply) 07:00 UTC, 5h ago");
|
|
58
|
+
expect(l.title).toContain("chant/lifecycle 1a2b3c4d5e6f");
|
|
59
|
+
});
|
|
60
|
+
});
|