| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { |
| 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 | |
| 17 | let next = 0; |
| 18 | function 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 | |
| 32 | test("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 | |
| 38 | test("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 | |
| 43 | test("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 | |
| 53 | test("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 | |
| 70 | test("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 | |
| 78 | test("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 | |
| 94 | test("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 | |
| 106 | test("a called workflow's jobs sit in a box under the job calling it", () => { |
| 107 | const jobs = [ |
| 108 | job("build", [], { name: "Build", uses: "./.g1t/workflows/build.yml" }), |
| 109 | job("build/compile", [], { name: "Build / compile" }), |
| 110 | job("build/package", ["build/compile"], { name: "Build / package" }), |
| 111 | job("deploy", ["build"]), |
| 112 | ]; |
| 113 | const units = groupJobs(jobs); |
| 114 | assert.deepEqual( |
| 115 | units.map((unit) => [unit.kind, unit.key]), |
| 116 | [ |
| 117 | ["call", "build"], |
| 118 | ["job", "deploy"], |
| 119 | ], |
| 120 | ); |
| 121 | const call = units[0]!; |
| 122 | assert.ok(call.kind === "call"); |
| 123 | assert.equal(call.uses, "./.g1t/workflows/build.yml"); |
| 124 | assert.deepEqual( |
| 125 | call.units.map((unit) => unit.label), |
| 126 | ["compile", "package"], |
| 127 | ); |
| 128 | |
| 129 | const layout = layoutRun(jobs); |
| 130 | const box = layout.groups[0]!; |
| 131 | const compile = layout.nodes.find((n) => n.unit.key === "build/compile")!; |
| 132 | const pack = layout.nodes.find((n) => n.unit.key === "build/package")!; |
| 133 | const deploy = layout.nodes.find((n) => n.unit.key === "deploy")!; |
| 134 | // Inside the box, one after the other; the job needing the caller after the box. |
| 135 | assert.ok(compile.x >= box.x && compile.x + compile.w <= box.x + box.w); |
| 136 | assert.ok(pack.x > compile.x && pack.x + pack.w <= box.x + box.w); |
| 137 | assert.ok(compile.y >= box.y + GRAPH.groupHead); |
| 138 | assert.ok(deploy.x >= box.x + box.w + GRAPH.columnGap); |
| 139 | assert.deepEqual( |
| 140 | layout.edges.map((edge) => [edge.from, edge.to]).sort(), |
| 141 | [ |
| 142 | ["build", "deploy"], |
| 143 | ["build/compile", "build/package"], |
| 144 | ], |
| 145 | ); |
| 146 | assert.equal(layout.width, deploy.x + deploy.w + GRAPH.margin); |
| 147 | }); |
| 148 | |
| 149 | test("a connector's colour follows the run: red from a failure, moving into a running job", () => { |
| 150 | const layout = layoutRun([ |
| 151 | job("a", [], { conclusion: "failure" }), |
| 152 | job("b", ["a"], { conclusion: "skipped" }), |
| 153 | job("c"), |
| 154 | job("d", ["c"], { status: "in_progress", conclusion: null }), |
| 155 | job("e", ["c"], { status: "waiting", conclusion: null }), |
| 156 | ]); |
| 157 | const state = Object.fromEntries(layout.edges.map((edge) => [`${edge.from}>${edge.to}`, edge.state])); |
| 158 | assert.deepEqual(state, { "a>b": "failed", "c>d": "active", "c>e": "idle" }); |
| 159 | }); |
| 160 | |
| 161 | test("a deployment's address makes its node taller", () => { |
| 162 | const layout = layoutRun([job("deploy", [], { environment: "production", environmentUrl: "https://g1t.page" })]); |
| 163 | assert.equal(layout.nodes[0]!.h, GRAPH.nodeHeight + GRAPH.urlHeight); |
| 164 | }); |
| 165 | |
| 166 | test("several jobs stand together as running, then as the worst ending", () => { |
| 167 | assert.deepEqual(standingOf([job("a"), job("b", [], { status: "calling", conclusion: null })]), { status: "in_progress", conclusion: null }); |
| 168 | assert.deepEqual(standingOf([job("a"), job("b", [], { conclusion: "failure" }), job("c", [], { conclusion: "cancelled" })]), { |
| 169 | status: "completed", |
| 170 | conclusion: "failure", |
| 171 | }); |
| 172 | assert.deepEqual(standingOf([job("a", [], { conclusion: "skipped" })]), { status: "completed", conclusion: "skipped" }); |
| 173 | }); |
| 174 | |
| 175 | test("connectors run straight on one row, and turn with rounded corners between rows", () => { |
| 176 | assert.equal(connector(0, 10, 50, 10), "M0 10H50"); |
| 177 | const turn = connector(0, 10, 100, 70); |
| 178 | assert.match(turn, /^M0 10H\d+Q/); |
| 179 | assert.ok(turn.endsWith("H100")); |
| 180 | }); |
| 181 | |
| 182 | test("an empty run lays out as nothing", () => { |
| 183 | const layout = layoutRun([]); |
| 184 | assert.equal(layout.nodes.length, 0); |
| 185 | assert.equal(layout.edges.length, 0); |
| 186 | }); |
| 187 | |
| 188 | test("a matrix whose jobs deploy to one address shows it once", () => { |
| 189 | const url = "https://g1t.sh"; |
| 190 | const shared = layoutRun([ |
| 191 | job("core", [], { name: "core (a)", environmentUrl: url }), |
| 192 | job("core", [], { name: "core (b)", environmentUrl: url }), |
| 193 | ]); |
| 194 | assert.equal(shared.nodes[0]!.h, GRAPH.nodeHeight + GRAPH.urlHeight); |
| 195 | const mixed = layoutRun([ |
| 196 | job("core", [], { name: "core (a)", environmentUrl: url }), |
| 197 | job("core", [], { name: "core (b)", environmentUrl: null }), |
| 198 | ]); |
| 199 | assert.equal(mixed.nodes[0]!.h, GRAPH.nodeHeight); |
| 200 | }); |