Skip to content
229 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import {
5 GRAPH,
6 type GraphJob,
7 baseName,
8 columns,
9 connector,
10 crossings,
11 groupJobs,
12 layoutRun,
13 orderColumns,
14 standingOf,
15} from "./run-graph.ts";
16
17let next = 0;
18function job(key: string, needs: string[] = [], more: Partial<GraphJob> = {}): GraphJob {
19 return {
20 id: `job_${next++}`,
21 key,
22 name: key,
23 needs,
24 status: "completed",
25 conclusion: "success",
26 startedAt: null,
27 finishedAt: null,
28 ...more,
29 };
30}
31
32test("columns go by the longest path of needs", () => {
33 const units = groupJobs([job("lint"), job("build"), job("test", ["build"]), job("deploy", ["build", "test"])]);
34 const column = columns(units);
35 assert.deepEqual(Object.fromEntries(column), { lint: 0, build: 0, test: 1, deploy: 2 });
36});
37
38test("a need of a job not in the run is left out", () => {
39 const column = columns(groupJobs([job("a", ["gone"]), job("b", ["a"])]));
40 assert.deepEqual(Object.fromEntries(column), { a: 0, b: 1 });
41});
42
43test("a cycle cannot loop the layout", () => {
44 const jobs = [job("a", ["b"]), job("b", ["a"]), job("c", ["c"])];
45 const column = columns(groupJobs(jobs));
46 for (const value of column.values()) assert.ok(Number.isFinite(value) && value >= 0);
47 const layout = layoutRun(jobs);
48 // Never a connector going back a column.
49 assert.ok(layout.edges.length <= 1);
50 for (const edge of layout.edges) assert.ok(column.get(edge.from)! < column.get(edge.to)!);
51});
52
53test("ordering a column by its needs takes out a crossing", () => {
54 // As listed: a, b over x, y, with x needing b and y needing a: they cross.
55 const units = groupJobs([job("a"), job("b"), job("x", ["b"]), job("y", ["a"])]);
56 const column = columns(units);
57 const edges: [string, string][] = [
58 ["b", "x"],
59 ["a", "y"],
60 ];
61 assert.equal(crossings([["a", "b"], ["x", "y"]], edges), 1);
62 const order = orderColumns(units, column);
63 assert.deepEqual(order, [
64 ["a", "b"],
65 ["y", "x"],
66 ]);
67 assert.equal(crossings(order, edges), 0);
68});
69
70test("ordering keeps the order as listed when nothing crosses", () => {
71 const units = groupJobs([job("a"), job("b"), job("x", ["a"]), job("y", ["b"])]);
72 assert.deepEqual(orderColumns(units, columns(units)), [
73 ["a", "b"],
74 ["x", "y"],
75 ]);
76});
77
78test("a matrix's jobs are one node, named without their combination", () => {
79 const units = groupJobs([
80 job("build"),
81 job("test", ["build"], { name: "test (ubuntu-latest, 20)" }),
82 job("test", ["build"], { name: "test (ubuntu-latest, 22)" }),
83 job("test", ["build"], { name: "test (macos-latest, 22)" }),
84 ]);
85 assert.equal(units.length, 2);
86 const matrix = units[1]!;
87 assert.equal(matrix.kind, "matrix");
88 assert.equal(matrix.label, "test");
89 assert.equal(matrix.kind === "matrix" && matrix.jobs.length, 3);
90 assert.equal(baseName("test (a, b)"), "test");
91 assert.equal(baseName("plain"), "plain");
92});
93
94test("an expanded matrix is taller, and its connectors still meet its first line", () => {
95 const jobs = [job("build"), job("test", ["build"], { name: "test (1)" }), job("test", ["build"], { name: "test (2)" })];
96 const closed = layoutRun(jobs);
97 const open = layoutRun(jobs, new Set(["test"]));
98 const node = (layout: typeof closed) => layout.nodes.find((n) => n.unit.key === "test")!;
99 assert.equal(node(closed).h, GRAPH.nodeHeight);
100 assert.equal(node(open).h, GRAPH.nodeHeight + 2 * GRAPH.rowHeight + 6);
101 assert.ok(node(open).expanded);
102 assert.equal(open.edges.length, 1);
103 assert.equal(open.edges[0]!.path, closed.edges[0]!.path);
104});
105
A job that calls a reusable workflow behaves as on GitHub. Its `strategy.matrix` calls the workflow once for each combination, with `matrix` read in `with:` and `secrets:`, the called jobs named `test (20) / unit`, `max-parallel` limiting how many calls run at once and `fail-fast` cancelling the rest when one fails; the run's graph keeps a matrix's calls in the caller's box. Its `concurrency` holds the group while the called workflow runs, and waits, replaces or cancels as a job's own does. `with:` values are checked against each input's `type`: a boolean or a number given as text fails the calling job with a hint to use `fromJSON()`, a string input takes a number or a boolean as its text, an input left out is `false`, `0` or `''` by its type, and a name, or a secret passed by name, that the called workflow does not declare fails it too. A run connects up to ten levels of workflows, not four, and calls at most 50 different reusable workflows, each limit failing the calling job with a message saying so. A called workflow's job with `fail-fast: false` on its own matrix is no longer cancelled by a sibling's failure. The workflow page no longer warns that a matrix call runs once, and the Actions guide says how inputs, matrix calls and the limits work.106test("a matrix's calls share the caller's box, each call's jobs named by its combination", () => {
107 const uses = "./.g1t/workflows/build.yml";
108 const jobs = [
109 job("build", [], { name: "Build (linux)", uses }),
110 job("build", [], { name: "Build (mac)", uses }),
111 job("build[0]/test", [], { name: "Build (linux) / test" }),
112 job("build[1]/test", [], { name: "Build (mac) / test" }),
113 job("deploy", ["build"]),
114 ];
115 const units = groupJobs(jobs);
116 assert.deepEqual(
117 units.map((unit) => [unit.kind, unit.key]),
118 [
119 ["call", "build"],
120 ["job", "deploy"],
121 ],
122 );
123 const call = units[0]!;
124 assert.ok(call.kind === "call");
125 assert.equal(call.callers.length, 2);
126 assert.deepEqual(
127 call.units.map((unit) => [unit.key, unit.label]),
128 [
129 ["build[0]/test", "Build (linux) / test"],
130 ["build[1]/test", "Build (mac) / test"],
131 ],
132 );
133});
134
A run opens on its summary: what triggered it, its status, duration and artifacts, a graph of its jobs (what needs what, matrices folded, called workflows boxed, deploys with their address), annotations, and each job's summary; the job list groups the same way135test("a called workflow's jobs sit in a box under the job calling it", () => {
136 const jobs = [
137 job("build", [], { name: "Build", uses: "./.g1t/workflows/build.yml" }),
138 job("build/compile", [], { name: "Build / compile" }),
139 job("build/package", ["build/compile"], { name: "Build / package" }),
140 job("deploy", ["build"]),
141 ];
142 const units = groupJobs(jobs);
143 assert.deepEqual(
144 units.map((unit) => [unit.kind, unit.key]),
145 [
146 ["call", "build"],
147 ["job", "deploy"],
148 ],
149 );
150 const call = units[0]!;
151 assert.ok(call.kind === "call");
152 assert.equal(call.uses, "./.g1t/workflows/build.yml");
153 assert.deepEqual(
154 call.units.map((unit) => unit.label),
155 ["compile", "package"],
156 );
157
158 const layout = layoutRun(jobs);
159 const box = layout.groups[0]!;
160 const compile = layout.nodes.find((n) => n.unit.key === "build/compile")!;
161 const pack = layout.nodes.find((n) => n.unit.key === "build/package")!;
162 const deploy = layout.nodes.find((n) => n.unit.key === "deploy")!;
163 // Inside the box, one after the other; the job needing the caller after the box.
164 assert.ok(compile.x >= box.x && compile.x + compile.w <= box.x + box.w);
165 assert.ok(pack.x > compile.x && pack.x + pack.w <= box.x + box.w);
166 assert.ok(compile.y >= box.y + GRAPH.groupHead);
167 assert.ok(deploy.x >= box.x + box.w + GRAPH.columnGap);
168 assert.deepEqual(
169 layout.edges.map((edge) => [edge.from, edge.to]).sort(),
170 [
171 ["build", "deploy"],
172 ["build/compile", "build/package"],
173 ],
174 );
175 assert.equal(layout.width, deploy.x + deploy.w + GRAPH.margin);
176});
177
178test("a connector's colour follows the run: red from a failure, moving into a running job", () => {
179 const layout = layoutRun([
180 job("a", [], { conclusion: "failure" }),
181 job("b", ["a"], { conclusion: "skipped" }),
182 job("c"),
183 job("d", ["c"], { status: "in_progress", conclusion: null }),
184 job("e", ["c"], { status: "waiting", conclusion: null }),
185 ]);
186 const state = Object.fromEntries(layout.edges.map((edge) => [`${edge.from}>${edge.to}`, edge.state]));
187 assert.deepEqual(state, { "a>b": "failed", "c>d": "active", "c>e": "idle" });
188});
189
190test("a deployment's address makes its node taller", () => {
191 const layout = layoutRun([job("deploy", [], { environment: "production", environmentUrl: "https://g1t.page" })]);
192 assert.equal(layout.nodes[0]!.h, GRAPH.nodeHeight + GRAPH.urlHeight);
193});
194
195test("several jobs stand together as running, then as the worst ending", () => {
196 assert.deepEqual(standingOf([job("a"), job("b", [], { status: "calling", conclusion: null })]), { status: "in_progress", conclusion: null });
197 assert.deepEqual(standingOf([job("a"), job("b", [], { conclusion: "failure" }), job("c", [], { conclusion: "cancelled" })]), {
198 status: "completed",
199 conclusion: "failure",
200 });
201 assert.deepEqual(standingOf([job("a", [], { conclusion: "skipped" })]), { status: "completed", conclusion: "skipped" });
202});
203
204test("connectors run straight on one row, and turn with rounded corners between rows", () => {
205 assert.equal(connector(0, 10, 50, 10), "M0 10H50");
206 const turn = connector(0, 10, 100, 70);
207 assert.match(turn, /^M0 10H\d+Q/);
208 assert.ok(turn.endsWith("H100"));
209});
210
211test("an empty run lays out as nothing", () => {
212 const layout = layoutRun([]);
213 assert.equal(layout.nodes.length, 0);
214 assert.equal(layout.edges.length, 0);
215});
216
217test("a matrix whose jobs deploy to one address shows it once", () => {
218 const url = "https://g1t.sh";
219 const shared = layoutRun([
220 job("core", [], { name: "core (a)", environmentUrl: url }),
221 job("core", [], { name: "core (b)", environmentUrl: url }),
222 ]);
223 assert.equal(shared.nodes[0]!.h, GRAPH.nodeHeight + GRAPH.urlHeight);
224 const mixed = layoutRun([
225 job("core", [], { name: "core (a)", environmentUrl: url }),
226 job("core", [], { name: "core (b)", environmentUrl: null }),
227 ]);
228 assert.equal(mixed.nodes[0]!.h, GRAPH.nodeHeight);
229});