Public: anyone can read this; the crew can change it.
A red check's summary names each shard's first failure, never the (unnamed) fallout behind it #8750
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8730-a-red-check-s-summary-names-each-shard-s → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +88
−12
src/checks/bun-test.ts +40 −6
| @@ -13,7 +13,11 @@ import type { CheckDetail } from "../core/runs.ts"; | ||
| 13 | 13 | |
| 14 | 14 | // The duration is bun's to print or not: under CI it leaves it off a test |
| 15 | 15 | // that took next to no time, so a name is a name with or without one. |
| 16 | const FAIL_LINE = /^\(fail\) (.+?)(?: \[[0-9.]+m?s\])?(?: \[shard [0-9]+\])?$/; | |
| 16 | const FAIL_LINE = /^\(fail\) (.+?)(?: \[[0-9.]+m?s\])?(?: \[shard ([0-9]+)\])?$/; | |
| 17 | /** The runner's header over one shard's output, live (`── shard 2/4: …`) or held (`── shard 2/4 (exit 1, 3s): …`). */ | |
| 18 | const SHARD_HEADER = /^── shard ([0-9]+)\/[0-9]+(?::| \(exit)/; | |
| 19 | /** A name that belongs to no test: bun's `(unnamed)`, however {@link nameUnnamed} renamed it. */ | |
| 20 | const FALLOUT = /(?:^|> )\(unnamed\)(?::|$)/; | |
| 17 | 21 | const PASS_COUNT = /^\s*(\d+) pass$/; |
| 18 | 22 | const FAIL_COUNT = /^\s*(\d+) fail$/; |
| 19 | 23 | |
| @@ -85,7 +89,32 @@ export function nameUnnamed(lines: readonly string[]): string[] { | ||
| 85 | 89 | export const MAX_FAILING_NAMES = 3; |
| 86 | 90 | |
| 87 | 91 | /** |
| 88 | * The counts and the first few failing names, in the order printed, or | |
| 92 | * The first few failing names: each red shard's lead first, in shard order | |
| 93 | * (the log holds shards in the order they finished, the tally in shard | |
| 94 | * order), then the rest of each shard's in the order printed. A shard's | |
| 95 | * named failures are its names; its `(unnamed)` hook fallout counts only | |
| 96 | * when it named nothing else, so a summary never leads with what fell over | |
| 97 | * behind a real failure (#8730). Output with no shards is one shard. | |
| 98 | */ | |
| 99 | function failingNames(failures: readonly (readonly [shard: number, name: string])[]): string[] { | |
| 100 | const byShard = new Map<number, string[]>(); | |
| 101 | for (const [shard, name] of failures) { | |
| 102 | const names = byShard.get(shard) ?? []; | |
| 103 | if (!names.includes(name)) names.push(name); | |
| 104 | byShard.set(shard, names); | |
| 105 | } | |
| 106 | const groups = [...byShard.entries()] | |
| 107 | .sort(([a], [b]) => a - b) | |
| 108 | .map(([, names]) => { | |
| 109 | const named = names.filter((name) => !FALLOUT.test(name)); | |
| 110 | return named.length > 0 ? named : names; | |
| 111 | }); | |
| 112 | const ordered = [...groups.map((names) => names[0]!), ...groups.flatMap((names) => names.slice(1))]; | |
| 113 | return [...new Set(ordered)].slice(0, MAX_FAILING_NAMES); | |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * The counts and the first few failing names ({@link failingNames}), or | |
| 89 | 118 | * undefined when the lines never carried `bun test`'s own summary (no |
| 90 | 119 | * `N pass` / `M fail`, or every `M` came to zero — a failure from something |
| 91 | 120 | * other than a test). A green shard's `0 fail` printed after a red one's |
| @@ -94,14 +123,19 @@ export const MAX_FAILING_NAMES = 3; | ||
| 94 | 123 | export function parseBunTestFailure( |
| 95 | 124 | lines: readonly string[], |
| 96 | 125 | ): CheckDetail | undefined { |
| 97 | const names: string[] = []; | |
| 126 | const failures: (readonly [number, string])[] = []; | |
| 127 | let shard = 0; | |
| 98 | 128 | let passed: number | undefined; |
| 99 | 129 | let failed: number | undefined; |
| 100 | 130 | for (const line of nameUnnamed(lines)) { |
| 131 | const header = SHARD_HEADER.exec(line); | |
| 132 | if (header) { | |
| 133 | shard = Number(header[1]); | |
| 134 | continue; | |
| 135 | } | |
| 101 | 136 | const fail = FAIL_LINE.exec(line); |
| 102 | 137 | if (fail) { |
| 103 | if (names.length < MAX_FAILING_NAMES && !names.includes(fail[1]!)) | |
| 104 | names.push(fail[1]!); | |
| 138 | failures.push([fail[2] === undefined ? shard : Number(fail[2]), fail[1]!]); | |
| 105 | 139 | continue; |
| 106 | 140 | } |
| 107 | 141 | const pass = PASS_COUNT.exec(line); |
| @@ -114,5 +148,5 @@ export function parseBunTestFailure( | ||
| 114 | 148 | } |
| 115 | 149 | if (passed === undefined || failed === undefined || failed === 0) |
| 116 | 150 | return undefined; |
| 117 | return { passed, failed, names }; | |
| 151 | return { passed, failed, names: failingNames(failures) }; | |
| 118 | 152 | } |
test/checks.test.ts +45
| @@ -172,6 +172,51 @@ describe("parseBunTestFailure: a convenience over one known runner, not a genera | ||
| 172 | 172 | }); |
| 173 | 173 | }); |
| 174 | 174 | |
| 175 | test("a shard's named failure leads; the (unnamed) fallout behind it is not named", () => { | |
| 176 | const lines = [ | |
| 177 | "── shard 1/1 (exit 1, 3s): test/a.test.ts", | |
| 178 | "test/a.test.ts:", | |
| 179 | "(fail) a > writes the row [2ms]", | |
| 180 | "error: database is closed", | |
| 181 | "(fail) (unnamed) [1ms]", | |
| 182 | " 4 pass", | |
| 183 | " 2 fail", | |
| 184 | ]; | |
| 185 | expect(parseBunTestFailure(lines)?.names).toEqual(["a > writes the row"]); | |
| 186 | // Only a shard that named nothing else is named by its fallout. | |
| 187 | const only = ["test/b.test.ts:", "error: boom", "(fail) (unnamed)", " 0 pass", " 1 fail"]; | |
| 188 | expect(parseBunTestFailure(only)?.names).toEqual(["test/b.test.ts > (unnamed): error: boom"]); | |
| 189 | }); | |
| 190 | ||
| 191 | test("shards that finished out of order are named in shard order, each shard's first failure first", () => { | |
| 192 | const lines = [ | |
| 193 | "── shard 3/3 (exit 1, 2s): test/c.test.ts", | |
| 194 | "test/c.test.ts:", | |
| 195 | "(fail) c one [1ms]", | |
| 196 | "(fail) c two [1ms]", | |
| 197 | " 1 pass", | |
| 198 | " 2 fail", | |
| 199 | "── shard 1/3 (exit 1, 9s): test/a.test.ts", | |
| 200 | "test/a.test.ts:", | |
| 201 | "error: boom", | |
| 202 | "(fail) (unnamed) [1ms]", | |
| 203 | " 3 pass", | |
| 204 | " 1 fail", | |
| 205 | "── shard 2/3 (exit 0, 5s): test/b.test.ts", | |
| 206 | " 7 pass", | |
| 207 | " 0 fail", | |
| 208 | "2 of 3 shards failed:", | |
| 209 | "(fail) test/a.test.ts > (unnamed): error: boom [1ms] [shard 1]", | |
| 210 | "(fail) c one [1ms] [shard 3]", | |
| 211 | "(fail) c two [1ms] [shard 3]", | |
| 212 | ]; | |
| 213 | expect(parseBunTestFailure(lines)).toEqual({ | |
| 214 | passed: 11, | |
| 215 | failed: 3, | |
| 216 | names: ["test/a.test.ts > (unnamed): error: boom", "c one", "c two"], | |
| 217 | }); | |
| 218 | }); | |
| 219 | ||
| 175 | 220 | test("output that is not bun test's own shape is not guessed at", () => { |
| 176 | 221 | expect( |
| 177 | 222 | parseBunTestFailure([ |
new above
offline
Fallout is dropped when the shard has any named failure, including a real afterAll throw that nameUnnamed gave a file name. The summary then hides a genuine cause. Consider keeping renamed entries that carry a file prefix.