Public: anyone can read this; the crew can change it.
Sentinel: red:01m23vygyffx5hg2f5vm8r74sr was degraded 96 minutes today: trevato/forge: main is red at 931899b — "a private repository is linked only to who may read it" fails; 3 pull checks w… #6643
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6614-sentinel-red-01m23vygyffx5hg2f5vm8r74sr → main 11 commits fork 7 threads · 7 unresolved
15 files
changed +590
−10
docs/features/check-flake.md added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | # Flaky tests on main (#6643) | |
| 2 | ||
| 3 | A default-branch check that fails **one or two named tests** re-runs only those | |
| 4 | tests once, alone (`bun test <file> -t <name>`), on the same commit in the same | |
| 5 | cell, before main counts as red (`src/checks/flake.ts`, wired in | |
| 6 | `checks/service.ts`). | |
| 7 | ||
| 8 | - They pass alone: the check is **green**, so no red-main hold (`checks/red_main.ts`) | |
| 9 | starts. Each is a row in `check_flake`; the check's log says | |
| 10 | `flaky: … recorded, main stays green`. | |
| 11 | - One fails alone too, or the red was a build error, a hook, a timeout or more | |
| 12 | than two tests: **red, unchanged**. | |
| 13 | - A test that flakes **twice in 7 days** gets one issue (`Flaky test: <name>`, | |
| 14 | `Files:` its file). An open issue by that title is not filed again. | |
| 15 | - The week's list is `flakes` in the vitals (the health answer for an admin): | |
| 16 | `repo`, `test`, `file`, `count`, `lastCommit`, `last`. A site admin reads it as | |
| 17 | "Flaky this week: …" under the forge notes on the home board and in the terminal's | |
| 18 | dashboard strip. It never degrades the forge. | |
| 19 | ||
| 20 | Left out: pull checks (they keep failing as before, shared with main by name), | |
| 21 | cluster Jobs (the re-run needs the check's own cell), and hook or `(unnamed)` | |
| 22 | failures, which have no name to select by. |
src/actions/health.ts +7
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | * One answer for the API (`GET /api/v1/health`) and the MCP `health` tool. |
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | import { flakeLine, flakeList, flakeNeeds } from "../checks/flake.ts"; | |
| 19 | 20 | import type { App } from "../app.ts"; |
| 20 | 21 | import { appsRuntime } from "../apps/runtime.ts"; |
| 21 | 22 | import { listApps } from "../core/apps.ts"; |
| @@ -95,6 +96,7 @@ export function forgeNeeds(app: App, actor: User | undefined): string[] | undefi | ||
| 95 | 96 | ...(rollout === null ? [] : [rollout]), |
| 96 | 97 | ...h.incidents.map((i) => `${i.key}: ${i.message}`), |
| 97 | 98 | ...h.degraded.map(needLine), |
| 99 | ...flakeNeeds(h.flakes), | |
| 98 | 100 | ]; |
| 99 | 101 | } |
| 100 | 102 | |
| @@ -107,6 +109,11 @@ export function needLine(d: Degraded, now: number = Date.now()): string { | ||
| 107 | 109 | : `${d.sentence} Since ${sinceWords(d.since, now)}.`; |
| 108 | 110 | } |
| 109 | 111 | |
| 112 | /** The week's flaky tests as one line for a site admin; `null` for anyone else, and when none flaked. */ | |
| 113 | export function forgeFlakes(app: App, actor: User | undefined): string | null { | |
| 114 | return actor?.isAdmin === true ? flakeLine(flakeList(app.db, Date.now())) : null; | |
| 115 | } | |
| 116 | ||
| 110 | 117 | /** |
| 111 | 118 | * The check lane as one line for a site admin, as the board's forge strip |
| 112 | 119 | * prints it; `null` for anyone else, and when no check is queued. |
src/checks/flake.ts added +239
| @@ -0,0 +1,239 @@ | ||
| 1 | /** | |
| 2 | * A default-branch check that fails at most two tests re-runs those tests | |
| 3 | * once, alone, before main counts as red (#6643). Passing alone is a flake: | |
| 4 | * the check is green, the flake is recorded, and a test that flakes twice in | |
| 5 | * a week gets one issue for its file. A test that fails alone too, or a red | |
| 6 | * that is not a pair of named tests (a build error, a timeout), stays red. | |
| 7 | * | |
| 8 | * Plain lines in, commands and rows out. The names come from the repository's | |
| 9 | * own output, so each is checked before it reaches a shell, and quoted there. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { createIssue } from "../core/issues.ts"; | |
| 14 | import type { Repo } from "../core/repos.ts"; | |
| 15 | import type { EventBus } from "../core/events.ts"; | |
| 16 | import { id } from "../kernel/id.ts"; | |
| 17 | import type { CheckDetail } from "../core/runs.ts"; | |
| 18 | ||
| 19 | /** The most failures that are re-run; more is a broken main, not a flake. */ | |
| 20 | export const MAX_RERUN = 2; | |
| 21 | ||
| 22 | /** A test that flakes this often in {@link WINDOW_MS} is filed. */ | |
| 23 | export const FILE_AT = 2; | |
| 24 | export const WINDOW_MS = 7 * 24 * 3_600_000; | |
| 25 | ||
| 26 | const HEADER = /^(\S+\.test\.[cm]?[jt]sx?):$/; | |
| 27 | const FAIL = /^\(fail\) (.+?)(?: \[[0-9.]+m?s\])?(?: \[shard [0-9]+\])?$/; | |
| 28 | const PATH = /^[\w@./+-]{1,200}$/; | |
| 29 | // eslint-disable-next-line no-control-regex | |
| 30 | const CONTROL = /[\u0000-\u001f\u007f-\u009f]/; | |
| 31 | const NAME_MAX = 300; | |
| 32 | ||
| 33 | export interface Flaky { | |
| 34 | readonly file: string; | |
| 35 | readonly name: string; | |
| 36 | } | |
| 37 | ||
| 38 | /** | |
| 39 | * The tests a failed check can re-run alone: at most {@link MAX_RERUN} | |
| 40 | * failures, every one named with the file whose report printed it. Anything | |
| 41 | * else (a hook, an unnamed failure, more failures) is undefined — red. | |
| 42 | */ | |
| 43 | export function rerunnable( | |
| 44 | lines: readonly string[], | |
| 45 | detail: CheckDetail | undefined, | |
| 46 | ): Flaky[] | undefined { | |
| 47 | if (detail === undefined || detail.failed < 1 || detail.failed > MAX_RERUN) return undefined; | |
| 48 | if (detail.names.length !== detail.failed) return undefined; | |
| 49 | const found = new Map<string, string>(); | |
| 50 | let file: string | undefined; | |
| 51 | for (const line of lines) { | |
| 52 | const header = HEADER.exec(line); | |
| 53 | if (header) { | |
| 54 | file = header[1]; | |
| 55 | continue; | |
| 56 | } | |
| 57 | const name = FAIL.exec(line)?.[1]; | |
| 58 | if (name !== undefined && detail.names.includes(name) && !found.has(name) && file !== undefined) | |
| 59 | found.set(name, file); | |
| 60 | } | |
| 61 | const out: Flaky[] = []; | |
| 62 | for (const name of detail.names) { | |
| 63 | const at = found.get(name); | |
| 64 | if ( | |
| 65 | at === undefined || | |
| 66 | !PATH.test(at) || | |
| 67 | name.length > NAME_MAX || | |
| 68 | CONTROL.test(name) || | |
| 69 | name.includes("(unnamed)") || | |
| 70 | name.includes("(timeout)") | |
| 71 | ) | |
| 72 | return undefined; | |
| 73 | out.push({ file: at, name: name }); | |
| 74 | } | |
| 75 | return out; | |
| 76 | } | |
| 77 | ||
| 78 | const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 79 | const quote = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`; | |
| 80 | ||
| 81 | /** | |
| 82 | * `bun test` of the one test, anchored so a sibling that contains its name is | |
| 83 | * not run: bun joins a describe's names with a space. | |
| 84 | */ | |
| 85 | export function rerunCommand(test: Flaky): string { | |
| 86 | const pattern = `^${escapeRegex(test.name.split(" > ").join(" "))}$`; | |
| 87 | return `bun test ${quote(test.file)} -t ${quote(pattern)}`; | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Whether the re-run's own output says exactly one test ran and none failed: | |
| 92 | * a pattern that matched nothing exits 0. | |
| 93 | */ | |
| 94 | export function passedAlone(lines: readonly string[]): boolean { | |
| 95 | let pass = 0; | |
| 96 | let fail = 0; | |
| 97 | for (const line of lines) { | |
| 98 | const p = /^\s*(\d+) pass$/.exec(line); | |
| 99 | if (p) pass += Number(p[1]); | |
| 100 | const f = /^\s*(\d+) fail$/.exec(line); | |
| 101 | if (f) fail += Number(f[1]); | |
| 102 | } | |
| 103 | return pass === 1 && fail === 0; | |
| 104 | } | |
| 105 | ||
| 106 | export const flakeTitle = (name: string): string => `Flaky test: ${name.slice(0, 200)}`; | |
| 107 | ||
| 108 | /** | |
| 109 | * Record each flake; a test that has now flaked {@link FILE_AT} times in the | |
| 110 | * week gets an issue for its file unless one by its name is open. Returns the | |
| 111 | * issue numbers opened. | |
| 112 | */ | |
| 113 | export function recordFlakes( | |
| 114 | db: Database, | |
| 115 | bus: EventBus, | |
| 116 | repo: Pick<Repo, "id" | "ownerId">, | |
| 117 | tests: readonly Flaky[], | |
| 118 | ref: { runId: string; commit: string }, | |
| 119 | now = Date.now(), | |
| 120 | ): number[] { | |
| 121 | const opened: number[] = []; | |
| 122 | for (const t of tests) { | |
| 123 | db.query( | |
| 124 | "INSERT INTO check_flake (id, repo_id, run_id, commit_oid, test, file, at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", | |
| 125 | ).run(id(), repo.id, ref.runId, ref.commit, t.name, t.file, new Date(now).toISOString()); | |
| 126 | const since = new Date(now - WINDOW_MS).toISOString(); | |
| 127 | const count = db | |
| 128 | .query<{ n: number }, [string, string, string]>( | |
| 129 | "SELECT COUNT(*) AS n FROM check_flake WHERE repo_id = ?1 AND test = ?2 AND at >= ?3", | |
| 130 | ) | |
| 131 | .get(repo.id, t.name, since)!.n; | |
| 132 | // No issue without a file: the "Files:" line is what routes it to a builder. | |
| 133 | if (count < FILE_AT || t.file === "") continue; | |
| 134 | const title = flakeTitle(t.name); | |
| 135 | const open = db | |
| 136 | .query<{ id: string }, [string, string]>( | |
| 137 | "SELECT id FROM issue WHERE repo_id = ?1 AND title = ?2 AND state = 'open' LIMIT 1", | |
| 138 | ) | |
| 139 | .get(repo.id, title); | |
| 140 | if (open !== null) continue; | |
| 141 | const made = createIssue(db, { | |
| 142 | repoId: repo.id, | |
| 143 | authorId: repo.ownerId, | |
| 144 | title, | |
| 145 | body: | |
| 146 | `_Filed by the check runner from a check log, not written by a person._\n\n` + | |
| 147 | `\`${t.name}\` in \`${t.file}\` failed in main's check and passed when run alone, ${count} times in 7 days ` + | |
| 148 | `(latest: check run ${ref.runId}, ${ref.commit.slice(0, 7)}). Main stayed green each time. ` + | |
| 149 | `Find what it depends on: time, load, order, or shared state.\n\nFiles: ${t.file}`, | |
| 150 | }); | |
| 151 | if (!made.ok) continue; | |
| 152 | bus.append({ | |
| 153 | kind: "issue.opened", | |
| 154 | repoId: repo.id, | |
| 155 | actorId: repo.ownerId, | |
| 156 | payload: { number: made.value.number }, | |
| 157 | }); | |
| 158 | opened.push(made.value.number); | |
| 159 | } | |
| 160 | return opened; | |
| 161 | } | |
| 162 | ||
| 163 | /** What a red queue verdict says of its tests: each failing one with its file, and the files it learned. */ | |
| 164 | export interface QueueRed { | |
| 165 | readonly tests?: { readonly failing: readonly { readonly name: string; readonly file?: string }[] }; | |
| 166 | readonly learn: readonly string[]; | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Record the tests a shorter queue prefix failed that a longer green prefix | |
| 171 | * held. A failing test carries the file the gate saw it run in; without one, | |
| 172 | * the file is known only when the verdicts learned exactly one, and with | |
| 173 | * none the flake is kept without a file and no issue is filed for it. | |
| 174 | */ | |
| 175 | export function recordQueueFlakes( | |
| 176 | db: Database, | |
| 177 | bus: EventBus, | |
| 178 | repo: Pick<Repo, "id" | "ownerId">, | |
| 179 | reds: readonly QueueRed[], | |
| 180 | tip: string, | |
| 181 | now = Date.now(), | |
| 182 | ): number[] { | |
| 183 | const failing = reds.flatMap((r) => r.tests?.failing ?? []); | |
| 184 | if (failing.length === 0) return []; | |
| 185 | const files = [...new Set(reds.flatMap((r) => r.learn))]; | |
| 186 | const only = files.length === 1 && PATH.test(files[0]!) ? files[0]! : ""; | |
| 187 | const seen = new Set<string>(); | |
| 188 | const tests = failing | |
| 189 | .filter((t) => t.name.length <= NAME_MAX && !CONTROL.test(t.name)) | |
| 190 | .map((t) => ({ file: t.file !== undefined && PATH.test(t.file) ? t.file : only, name: t.name })) | |
| 191 | .filter((t) => !seen.has(`${t.file}\0${t.name}`) && seen.add(`${t.file}\0${t.name}`)); | |
| 192 | return recordFlakes(db, bus, repo, tests, { runId: `gate-${tip.slice(0, 12)}`, commit: tip }, now); | |
| 193 | } | |
| 194 | ||
| 195 | export interface FlakeRow { | |
| 196 | readonly repo: string; | |
| 197 | readonly test: string; | |
| 198 | readonly file: string; | |
| 199 | readonly count: number; | |
| 200 | readonly lastCommit: string; | |
| 201 | readonly last: string; | |
| 202 | } | |
| 203 | ||
| 204 | /** A test that flakes this often in the week needs a person, not a note. */ | |
| 205 | export const NEEDS_AT = 3; | |
| 206 | ||
| 207 | /** The tests that flaked {@link NEEDS_AT} times or more, one need each for the attention list. */ | |
| 208 | export function flakeNeeds(rows: readonly FlakeRow[]): string[] { | |
| 209 | return rows | |
| 210 | .filter((r) => r.count >= NEEDS_AT) | |
| 211 | .map((r) => `flaky: ${r.test} failed and passed alone ${r.count} times this week (${r.repo})`); | |
| 212 | } | |
| 213 | ||
| 214 | /** The flake list as one line for the board and the terminal; null when the week had none. */ | |
| 215 | export function flakeLine(rows: readonly FlakeRow[], shown = 3): string | null { | |
| 216 | if (rows.length === 0) return null; | |
| 217 | const top = rows.slice(0, shown).map((r) => `${r.test} ×${r.count} (${r.repo})`); | |
| 218 | const more = rows.length - top.length; | |
| 219 | return `${top.join("; ")}${more > 0 ? ` and ${more} more` : ""}`; | |
| 220 | } | |
| 221 | ||
| 222 | /** The week's flake list, most frequent first: one query, read by the vitals. */ | |
| 223 | export function flakeList(db: Database, now: number, limit = 20): FlakeRow[] { | |
| 224 | return db | |
| 225 | .query<FlakeRow, [string, number]>( | |
| 226 | `SELECT u.username || '/' || r.name AS repo, f.test AS test, f.file AS file, | |
| 227 | COUNT(*) AS count, MAX(f.at) AS last, | |
| 228 | (SELECT commit_oid FROM check_flake g | |
| 229 | WHERE g.repo_id = f.repo_id AND g.test = f.test ORDER BY g.at DESC LIMIT 1) AS lastCommit | |
| 230 | FROM check_flake f | |
| 231 | JOIN repo r ON r.id = f.repo_id | |
| 232 | JOIN user u ON u.id = r.owner_id | |
| 233 | WHERE f.at >= ?1 | |
| 234 | GROUP BY f.repo_id, f.test | |
| 235 | ORDER BY count DESC, last DESC | |
| 236 | LIMIT ?2`, | |
| 237 | ) | |
| 238 | .all(new Date(now - WINDOW_MS).toISOString(), limit); | |
| 239 | } |
src/checks/queue.ts +31 −6
| @@ -180,6 +180,11 @@ export interface QueueOptions { | ||
| 180 | 180 | * kept beyond this process (#6427). |
| 181 | 181 | */ |
| 182 | 182 | readonly onBatch?: (repoId: string, red: boolean) => void; |
| 183 | /** | |
| 184 | * A shorter prefix was red and a longer one holding it green: the red was | |
| 185 | * a flake, and its verdicts are handed here to be recorded. | |
| 186 | */ | |
| 187 | readonly onFlake?: (repoId: string, reds: readonly Red[], tip: string) => void; | |
| 183 | 188 | /** Where the line's gates and red marks outlive this process (#6611). */ |
| 184 | 189 | readonly store?: QueueStore; |
| 185 | 190 | /** |
| @@ -975,7 +980,12 @@ async function drain(line: Line, gate: GateCandidate, options: QueueOptions): Pr | ||
| 975 | 980 | ahead.shift(); |
| 976 | 981 | let left: Waiting[]; |
| 977 | 982 | try { |
| 978 | left = await settleHead({ line, gated: searching, room, onBatch: options.onBatch }, head, verdict, ahead); | |
| 983 | left = await settleHead( | |
| 984 | { line, gated: searching, room, onBatch: options.onBatch, onFlake: options.onFlake }, | |
| 985 | head, | |
| 986 | verdict, | |
| 987 | ahead, | |
| 988 | ); | |
| 979 | 989 | } catch (error) { |
| 980 | 990 | log.warn("merge queue round failed", { error: String(error) }); |
| 981 | 991 | for (const w of head.steps) w.settle(false); |
| @@ -1293,6 +1303,7 @@ interface Search { | ||
| 1293 | 1303 | readonly gated: Gated; |
| 1294 | 1304 | readonly room: Room; |
| 1295 | 1305 | readonly onBatch: ((repoId: string, red: boolean) => void) | undefined; |
| 1306 | readonly onFlake: ((repoId: string, reds: readonly Red[], tip: string) => void) | undefined; | |
| 1296 | 1307 | } |
| 1297 | 1308 | |
| 1298 | 1309 | /** |
| @@ -1344,7 +1355,9 @@ async function round( | ||
| 1344 | 1355 | * top of it. All green, the culprit is in the last run. The runs after the |
| 1345 | 1356 | * red one wait, in line order, for the next candidate. No green verdict is |
| 1346 | 1357 | * thrown away: a disjoint segment gated on the base alone was void once |
| 1347 | * another landed (#6353). | |
| 1358 | * another landed (#6353). A longer prefix green lands whole, even when a | |
| 1359 | * shorter one was red: that red was a flake, reported to `onFlake`, and | |
| 1360 | * every pull in the green prefix is cleared as a suspect. | |
| 1348 | 1361 | * |
| 1349 | 1362 | * However few slots are free, a search gates at least √n − 1 prefixes, so |
| 1350 | 1363 | * the red run holds at most √n steps (#6674): a red batch blocks its line |
| @@ -1393,10 +1406,22 @@ async function judged( | ||
| 1393 | 1406 | for (const w of steps) w.settle(false); |
| 1394 | 1407 | return []; |
| 1395 | 1408 | } |
| 1396 | // The first red prefix, the whole when every gated one was green. | |
| 1397 | const found = verdicts.findIndex((v) => !v.lands); | |
| 1398 | const bad = found < 0 ? prefixes.length : found; | |
| 1399 | const known = bad < prefixes.length ? { treeOid: prefixes[bad]!.treeOid, red: verdicts[bad] as Red } : { treeOid: candidate.treeOid, red }; | |
| 1409 | // The prefix after the longest green one, the whole when every gated one | |
| 1410 | // was green. A red before that green is a flake: the green holds it. | |
| 1411 | const bad = verdicts.findLastIndex((v) => v.lands) + 1; | |
| 1412 | const flaked = verdicts.slice(0, bad).filter((v): v is Red => !v.lands && "reopen" in v); | |
| 1413 | if (flaked.length > 0) { | |
| 1414 | for (const w of steps.slice(0, ends[bad - 1]!)) search.line.suspects.delete(w.entry.pull.id); | |
| 1415 | try { | |
| 1416 | search.onFlake?.(search.line.repoId, flaked, prefixes[bad - 1]!.tipOid); | |
| 1417 | } catch (error) { | |
| 1418 | log.warn("flake not kept", { repo: search.line.repoId, error: String(error) }); | |
| 1419 | } | |
| 1420 | } | |
| 1421 | const known = | |
| 1422 | bad < prefixes.length | |
| 1423 | ? { treeOid: prefixes[bad]!.treeOid, red: verdicts[bad] as Red } | |
| 1424 | : { treeOid: candidate.treeOid, red }; | |
| 1400 | 1425 | const green = bad > 0 ? ends[bad - 1]! : 0; |
| 1401 | 1426 | if (green > 0) await land(prefixes[bad - 1]!, steps.slice(0, green)); |
| 1402 | 1427 | const now = [...landed, ...steps.slice(0, green).map((w) => w.entry)]; |
src/checks/service.ts +46
| @@ -55,6 +55,8 @@ import { giveHistory } from "./history.ts"; | ||
| 55 | 55 | import { readLearned } from "./learned.ts"; |
| 56 | 56 | import { keepCache, restoreCache } from "./cache.ts"; |
| 57 | 57 | import { MAX_FILED, clearedFiles, fileOrderDependent } from "./order_dependent.ts"; |
| 58 | import { parseBunTestFailure } from "./bun-test.ts"; | |
| 59 | import { passedAlone, recordFlakes, rerunCommand, rerunnable } from "./flake.ts"; | |
| 58 | 60 | import { withDisk } from "./disk_red.ts"; |
| 59 | 61 | import { ASKED, redMain, unheld } from "./red_main.ts"; |
| 60 | 62 | import { proposeRevert } from "./revert_red.ts"; |
| @@ -205,6 +207,9 @@ export const PULL_RESERVE = 2; | ||
| 205 | 207 | */ |
| 206 | 208 | export const LAND_SETTLE_MS = 30_000; |
| 207 | 209 | |
| 210 | /** The longest a failed test is given to pass alone, in seconds. */ | |
| 211 | const RERUN_TIMEOUT_S = 300; | |
| 212 | ||
| 208 | 213 | /** How often queued checks another forge left are looked for. */ |
| 209 | 214 | const TICK_MS = 5_000; |
| 210 | 215 | |
| @@ -700,6 +705,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 700 | 705 | keep: run.issueId === null && run.env === repo.defaultBranch, |
| 701 | 706 | keepImpact: pull === undefined && run.env === repo.defaultBranch, |
| 702 | 707 | fileOrderDependent: run.issueId === null && run.env === repo.defaultBranch, |
| 708 | isMain: run.issueId === null && run.env === repo.defaultBranch, | |
| 703 | 709 | write, |
| 704 | 710 | signal, |
| 705 | 711 | }); |
| @@ -722,6 +728,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 722 | 728 | readonly keepImpact?: boolean; |
| 723 | 729 | /** The default branch's own run: only its log may open issues. */ |
| 724 | 730 | readonly fileOrderDependent?: boolean; |
| 731 | /** The default branch's own run: a failure of one or two tests is re-run alone before it counts as red. */ | |
| 732 | readonly isMain?: boolean; | |
| 725 | 733 | readonly write: (line: string) => void; |
| 726 | 734 | readonly signal: AbortSignal; |
| 727 | 735 | } |
| @@ -1038,6 +1046,44 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1038 | 1046 | } catch (error) { |
| 1039 | 1047 | write(`order-dependent tests not filed: ${String(error)}`); |
outdated
The condition reuses |
||
| 1040 | 1048 | } |
| 1049 | // Main's red costs the forge every pull's check (#6643): one or two | |
| 1050 | // failed tests are re-run alone, on this commit in this cell, before | |
| 1051 | // main counts as red. Passing alone is a flake: green, and recorded. | |
| 1052 | if (!ran.ok && job === undefined && x.isMain === true) { | |
| 1053 | try { | |
| 1054 | const tests = rerunnable(checkLines, parseBunTestFailure(checkLines)); | |
| 1055 | if (tests !== undefined) { | |
| 1056 | write(`re-running ${tests.length} failed test(s) alone`); | |
| 1057 | let alone = true; | |
| 1058 | for (const test of tests) { | |
| 1059 | const out: string[] = []; | |
| 1060 | const again = await runStep("flake", rerunCommand(test), { | |
| 1061 | cwd: dir, | |
| 1062 | env, | |
| 1063 | log: (line) => { | |
| 1064 | out.push(line); | |
| 1065 | write(line); | |
| 1066 | }, | |
| 1067 | signal, | |
| 1068 | timeoutMs: Math.min(manifest.timeout, RERUN_TIMEOUT_S) * 1000, | |
| 1069 | cell, | |
| 1070 | }); | |
| 1071 | if (!again.ok || !passedAlone(out)) { | |
| 1072 | alone = false; | |
| 1073 | write(`"${test.name}" fails alone too: main is red`); | |
| 1074 | break; | |
| 1075 | } | |
| 1076 | } | |
| 1077 | if (alone) { | |
| 1078 | recordFlakes(app.db, app.bus, repo, tests, { runId: run.id, commit: run.commitOid }); | |
| 1079 | write(`flaky: ${tests.map((t) => `"${t.name}"`).join(", ")} failed in the full run and passed alone; recorded, main stays green`); | |
| 1080 | ran = { ok: true, value: undefined }; | |
| 1081 | } | |
| 1082 | } | |
| 1083 | } catch (error) { | |
| 1084 | write(`flake re-run failed: ${String(error)}`); | |
| 1085 | } | |
| 1086 | } | |
| 1041 | 1087 | if (!ran.ok) { |
| 1042 | 1088 | const { summary, detail } = summariseCheckFailure( |
| 1043 | 1089 | checkLines, |
src/core/migrations/20261026190000-check-flake.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | // One row per test a default-branch check saw fail and then pass when run | |
| 2 | // alone (#6643, checks/flake.ts): the commit, the run, the test and its file. | |
| 3 | // The flake list is a count of these over a week. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE check_flake ( | |
| 6 | id TEXT PRIMARY KEY, | |
| 7 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 8 | run_id TEXT NOT NULL, | |
| 9 | commit_oid TEXT NOT NULL DEFAULT '', | |
| 10 | test TEXT NOT NULL, | |
| 11 | file TEXT NOT NULL, | |
| 12 | at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 13 | ); | |
| 14 | CREATE INDEX check_flake_test ON check_flake(repo_id, test, at); | |
| 15 | `; |
src/crew/autonomy.ts +5
| @@ -129,6 +129,7 @@ import { crewOffInPreview } from "./preview.ts"; | ||
| 129 | 129 | import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts"; |
| 130 | 130 | import { recheckRedApproved } from "./red_approved.ts"; |
| 131 | 131 | import { keepBatch } from "../flow/red_batches.ts"; |
| 132 | import { recordQueueFlakes } from "../checks/flake.ts"; | |
| 132 | 133 | import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts"; |
| 133 | 134 | import { moveRequested, tallyRequested, tallyWords } from "./requested.ts"; |
| 134 | 135 | import { reviewersOf, sendToReview, sweepReviews } from "./review.ts"; |
| @@ -2100,6 +2101,10 @@ function queueOptions(app: App, repo: Repo): QueueOptions { | ||
| 2100 | 2101 | batch: queueBatch(app.db), |
| 2101 | 2102 | room: () => checksFor(app)?.spareSlots?.() ?? 0, |
| 2102 | 2103 | onBatch: (repoId, red) => keepBatch(app.db, repoId, red), |
| 2104 | onFlake: (repoId, reds, tip) => { | |
| 2105 | const owner = getRepoById(app.db, repoId); | |
| 2106 | if (owner !== undefined) recordQueueFlakes(app.db, app.bus, owner, reds, tip); | |
| 2107 | }, | |
| 2103 | 2108 | store: queueStore(app.db), |
| 2104 | 2109 | adopt: (record, signal) => adoptGate(app, repo, record, signal), |
| 2105 | 2110 | }; |
src/kernel/vitals.ts +4
| @@ -36,6 +36,7 @@ import { DOMAINS } from "./domains.ts"; | ||
| 36 | 36 | import { domainDrillNeeds } from "./domain_drill.ts"; |
| 37 | 37 | import { freeChecks, heldChecks, redHref, redSentence } from "../checks/red_main.ts"; |
| 38 | 38 | import { diskRedSentence, diskReds } from "../checks/disk_red.ts"; |
| 39 | import { flakeList, type FlakeRow } from "../checks/flake.ts"; | |
| 39 | 40 | import { flakeHref, sharedCause, sharedSentence, sharedTests } from "../checks/shared_red.ts"; |
| 40 | 41 | |
| 41 | 42 | /** A red in a repository other than the forge's own is not fixable from here: say so, so it reads as known. */ |
| @@ -106,6 +107,8 @@ export interface Vitals { | ||
| 106 | 107 | }[]; |
| 107 | 108 | readonly apps: { serving: number; asleep: number; previews: number }; |
| 108 | 109 | readonly errors: { lastHour: number; latest: ErrorLine[] }; |
| 110 | /** Tests that failed in main's check and passed alone this week: a list, never a need (#6643). */ | |
| 111 | readonly flakes: FlakeRow[]; | |
| 109 | 112 | readonly at: string; |
| 110 | 113 | } |
| 111 | 114 | |
| @@ -510,6 +513,7 @@ export function vitals(input: VitalsInput): Vitals { | ||
| 510 | 513 | stuck, |
| 511 | 514 | apps, |
| 512 | 515 | errors: { lastHour: seen.lastHour, latest: seen.latest }, |
| 516 | flakes: flakeList(input.db, now), | |
| 513 | 517 | at, |
| 514 | 518 | }; |
| 515 | 519 | } |
src/tui/screens/dashboard.ts +5 −1
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | |
| 17 | 17 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 18 | 18 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 19 | import { forgeLane, forgeNeeds } from "../../actions/health.ts"; | |
| 19 | import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts"; | |
| 20 | 20 | import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts"; |
| 21 | 21 | import { forgeHealth } from "../../kernel/guard.ts"; |
| 22 | 22 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; |
| @@ -196,6 +196,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 196 | 196 | let clientLive = new Map<string, string>(); |
| 197 | 197 | /** The check lane, as the board's strip says it; null when nothing is queued. */ |
| 198 | 198 | let lane: string | null = null; |
| 199 | /** The week's flaky tests, one line; null when none flaked. */ | |
| 200 | let flaky: string | null = null; | |
| 199 | 201 | /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */ |
| 200 | 202 | let decider: string | null = null; |
| 201 | 203 | /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */ |
| @@ -300,6 +302,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 300 | 302 | const facts = f.user === undefined ? new Set<string>() : ownerFactRepoIds(f.db, all, f.user); |
| 301 | 303 | waits = ownerWaits(f.db, all.filter((r) => facts.has(r.id))).map(waitLine); |
| 302 | 304 | lane = forgeLane(f.app, f.user); |
| 305 | flaky = forgeFlakes(f.app, f.user); | |
| 303 | 306 | decider = deciderLine(f.app, f.user); |
| 304 | 307 | const commit = running().commit; |
| 305 | 308 | figures = |
| @@ -964,6 +967,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 964 | 967 | if (forge !== undefined) { |
| 965 | 968 | const lines = forge.length === 0 ? ["All fine"] : withMore(forge, 3); |
| 966 | 969 | if (lane !== null) lines.push(`Lane: ${lane}`); |
| 970 | if (flaky !== null) lines.push(`Flaky this week: ${flaky}`); | |
| 967 | 971 | const [strip, rest] = split(body, "v", [len(lines.length), fill(1)]); |
| 968 | 972 | lines.forEach((line, i) => |
| 969 | 973 | s.text( |
src/web/pages/board.ts +3
| @@ -47,6 +47,7 @@ import { spendView } from "./spend.ts"; | ||
| 47 | 47 | import type { HandContext } from "../../crew/handover.ts"; |
| 48 | 48 | import { forgeHealth, rolloutLine } from "../../kernel/guard.ts"; |
| 49 | 49 | import { coveredByVerdict, forgeVerdict, verdictLine } from "../../kernel/guard_verdict.ts"; |
| 50 | import { flakeLine } from "../../checks/flake.ts"; | |
| 50 | 51 | import { laneLine } from "../../kernel/vitals.ts"; |
| 51 | 52 | import { getIssue, getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 52 | 53 | import { looksForApps } from "../../core/looks.ts"; |
| @@ -393,6 +394,7 @@ function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null { | ||
| 393 | 394 | const others = fh.incidents.filter((i) => !said(`${i.key}: ${i.message}`)); |
| 394 | 395 | const ok = v.ok && fh.ok && verdict === undefined; |
| 395 | 396 | const lane = laneLine(v.lane); |
| 397 | const flaky = flakeLine(v.flakes); | |
| 396 | 398 | const sentinel = sentinelNotice(ctx.app.db); |
| 397 | 399 | const need = (key: string, line: Html): Note => ({ key, need: true, line }); |
| 398 | 400 | const notes: Note[] = [ |
| @@ -412,6 +414,7 @@ function forgeNotes(ctx: Ctx): { notes: Note[]; ok: boolean } | null { | ||
| 412 | 414 | need(`degraded:${d.cause}`, html`<a data-need href="${d.href}">${dot("attn")} ${needLine(d)}</a>`), |
| 413 | 415 | )), |
| 414 | 416 | ...(lane === null ? [] : [{ key: "lane", need: false, line: html`<span>Lane: ${lane}</span>` }]), |
| 417 | ...(flaky === null ? [] : [{ key: "flakes", need: false, line: html`<span data-flakes>Flaky this week: ${flaky}</span>` }]), | |
Plain span with no destination. Link it to the filed Flaky test issue (an <a>) so it is reachable in two taps; keep data-flakes. |
||
| 415 | 418 | ]; |
| 416 | 419 | return { notes, ok }; |
| 417 | 420 | } |
test/board.test.ts +12
| @@ -490,6 +490,18 @@ describe("the board's forge strip", () => { | ||
| 490 | 490 | expect(other).not.toContain("stuck."); |
| 491 | 491 | }); |
| 492 | 492 | |
| 493 | test("the home lists the week's flaky tests, to an admin only", async () => { | |
| 494 | expect(await board("root")).not.toContain("Flaky this week"); | |
| 495 | const made = await createRepo(app, admin, { name: "flaky" }); | |
| 496 | if (!made.ok) throw new Error(String(made.error)); | |
| 497 | app.db | |
| 498 | .query("INSERT INTO check_flake (id, repo_id, run_id, commit_oid, test, file) VALUES ('f1', ?1, 'r1', 'abc1234', 'grp > flaky', 't/a.test.ts')") | |
| 499 | .run(made.value.id); | |
| 500 | expect(await board("root")).toContain("Flaky this week: grp > flaky ×1 (root/flaky)"); | |
| 501 | expect(await board("bob")).not.toContain("Flaky this week"); | |
| 502 | app.db.query("DELETE FROM check_flake").run(); | |
| 503 | }); | |
| 504 | ||
| 493 | 505 | test("the home says the lane when checks are queued, to an admin only", async () => { |
| 494 | 506 | app.db.query("UPDATE run SET state = 'cancelled' WHERE state IN ('queued','running')").run(); |
| 495 | 507 | const idle = await board("root"); |
test/check-flake.test.ts added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { redMain } from "../src/checks/red_main.ts"; | |
| 8 | import { flakeList, flakeNeeds, passedAlone, recordFlakes, recordQueueFlakes, rerunCommand, rerunnable } from "../src/checks/flake.ts"; | |
| 9 | import { startChecks } from "../src/checks/service.ts"; | |
| 10 | import { parseBunTestFailure } from "../src/checks/bun-test.ts"; | |
| 11 | import { listRuns } from "../src/core/runs.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { readRunLog } from "../src/runs/log.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | const report = (names: string[], file = "t/a.test.ts"): string[] => [ | |
| 19 | `${file}:`, | |
| 20 | ...names.map((n) => `(fail) ${n} [1.00ms]`), | |
| 21 | ` 0 pass`, | |
| 22 | ` ${names.length} fail`, | |
| 23 | ]; | |
| 24 | ||
| 25 | test("one or two named failures can be re-run; more, or a hook, cannot", () => { | |
| 26 | const one = report(["grp > flaky"]); | |
| 27 | expect(rerunnable(one, parseBunTestFailure(one))).toEqual([{ file: "t/a.test.ts", name: "grp > flaky" }]); | |
| 28 | const three = report(["a", "b", "c"]); | |
| 29 | expect(rerunnable(three, parseBunTestFailure(three))).toBeUndefined(); | |
| 30 | const hook = ["t/a.test.ts:", "(fail) t/a.test.ts > beforeEach (timeout)", " 1 pass", " 1 fail"]; | |
| 31 | expect(rerunnable(hook, parseBunTestFailure(hook))).toBeUndefined(); | |
| 32 | expect(rerunnable(["tsc failed"], undefined)).toBeUndefined(); | |
| 33 | }); | |
| 34 | ||
| 35 | test("the re-run command quotes the name and anchors it to one test", () => { | |
| 36 | const cmd = rerunCommand({ file: "t/a.test.ts", name: "grp (x) > it's $(rm) a+b" }); | |
| 37 | expect(cmd).toBe(`bun test 't/a.test.ts' -t '^grp \\(x\\) it'\\''s \\$\\(rm\\) a\\+b$'`); | |
| 38 | }); | |
| 39 | ||
| 40 | test("a re-run that matched nothing or failed is not a pass", () => { | |
| 41 | expect(passedAlone([" 1 pass", " 0 fail"])).toBe(true); | |
| 42 | expect(passedAlone(['error: regex "x" matched 0 tests.'])).toBe(false); | |
| 43 | expect(passedAlone([" 0 pass", " 1 fail"])).toBe(false); | |
| 44 | // A pattern that caught a sibling too is not the one test passing. | |
| 45 | expect(passedAlone([" 2 pass", " 0 fail"])).toBe(false); | |
| 46 | }); | |
| 47 | ||
| 48 | let dir: string; | |
| 49 | let app: App; | |
| 50 | let alice: Awaited<ReturnType<typeof makeUser>>; | |
| 51 | async function makeUser() { | |
| 52 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 53 | if (!u.ok) throw new Error(u.error); | |
| 54 | return u.value; | |
| 55 | } | |
| 56 | beforeAll(async () => { | |
| 57 | dir = await mkdtemp(join(tmpdir(), "flake-")); | |
| 58 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 59 | alice = await makeUser(); | |
| 60 | }); | |
| 61 | afterAll(async () => { | |
| 62 | await rm(dir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | test("two flakes in a week file one issue for the test; a third adds none; the list counts them", async () => { | |
| 66 | const made = await createRepo(app, alice, { name: "list", visibility: "public" }); | |
| 67 | if (!made.ok) throw new Error("repo"); | |
| 68 | const repo = made.value; | |
| 69 | const t = [{ file: "t/a.test.ts", name: "grp > flaky" }]; | |
| 70 | const issues = () => app.db.query<{ title: string; body: string }, [string]>("SELECT title, body FROM issue WHERE repo_id = ?1").all(repo.id); | |
| 71 | expect(recordFlakes(app.db, app.bus, repo, t, { runId: "r1", commit: "aaaaaaa1" })).toEqual([]); | |
| 72 | expect(issues().length).toBe(0); | |
| 73 | expect(recordFlakes(app.db, app.bus, repo, t, { runId: "r2", commit: "bbbbbbb2" }).length).toBe(1); | |
| 74 | expect(recordFlakes(app.db, app.bus, repo, t, { runId: "r3", commit: "ccccccc3" })).toEqual([]); | |
| 75 | expect(issues().length).toBe(1); | |
| 76 | expect(issues()[0]!.body).toContain("Files: t/a.test.ts"); | |
| 77 | const list = flakeList(app.db, Date.now()); | |
| 78 | expect(list[0]).toMatchObject({ repo: "alice/list", test: "grp > flaky", count: 3, lastCommit: "ccccccc3" }); | |
| 79 | // A flake outside the week is not counted. | |
| 80 | expect(flakeList(app.db, Date.now() + 8 * 24 * 3_600_000)).toEqual([]); | |
| 81 | }); | |
| 82 | ||
| 83 | const ft = (name: string, file?: string) => ({ failing: [{ name, ...(file === undefined ? {} : { file }) }] }); | |
| 84 | ||
| 85 | test("a queue flake keeps the verdict's one file, the tip and its gate; with no known file it is listed and files no issue", async () => { | |
| 86 | const made = await createRepo(app, alice, { name: "queued", visibility: "public" }); | |
| 87 | if (!made.ok) throw new Error("repo"); | |
| 88 | const repo = made.value; | |
| 89 | const issues = () => app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1").all(repo.id); | |
| 90 | const tip = "abcdef0123456789abcdef0123456789abcdef01"; | |
| 91 | const known = [{ tests: ft("grp > known"), learn: ["t/k.test.ts"] }]; | |
| 92 | recordQueueFlakes(app.db, app.bus, repo, known, tip); | |
| 93 | recordQueueFlakes(app.db, app.bus, repo, known, tip); | |
| 94 | expect(issues()).toHaveLength(1); | |
| 95 | expect(issues()[0]!.body).toContain("Files: t/k.test.ts"); | |
| 96 | const row = app.db | |
| 97 | .query<{ run_id: string; commit_oid: string; file: string }, [string]>("SELECT run_id, commit_oid, file FROM check_flake WHERE repo_id = ?1 AND test = 'grp > known'") | |
| 98 | .get(repo.id)!; | |
| 99 | expect(row).toEqual({ run_id: "gate-abcdef012345", commit_oid: tip, file: "t/k.test.ts" }); | |
| 100 | // Two files named, or none: the test is counted, no issue is filed. | |
| 101 | const vague = [{ tests: ft("grp > vague"), learn: ["t/x.test.ts", "t/y.test.ts"] }]; | |
| 102 | recordQueueFlakes(app.db, app.bus, repo, vague, tip); | |
| 103 | recordQueueFlakes(app.db, app.bus, repo, vague, tip); | |
| 104 | recordQueueFlakes(app.db, app.bus, repo, [{ learn: [] }], tip); | |
| 105 | expect(issues()).toHaveLength(1); | |
| 106 | // A test that carries its own file files an issue though the verdicts learned several. | |
| 107 | recordQueueFlakes(app.db, app.bus, repo, [{ tests: ft("grp > own", "t/own.test.ts"), learn: ["t/x.test.ts", "t/y.test.ts"] }], tip); | |
| 108 | recordQueueFlakes(app.db, app.bus, repo, [{ tests: ft("grp > own", "t/own.test.ts"), learn: [] }], tip); | |
| 109 | expect(issues()).toHaveLength(2); | |
| 110 | expect(issues()[1]!.body).toContain("Files: t/own.test.ts"); | |
| 111 | expect(flakeList(app.db, Date.now()).find((r) => r.test === "grp > vague")?.count).toBe(2); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a test that flaked three times in the week is an attention need; two is only a note", () => { | |
| 115 | const row = (count: number) => ({ repo: "a/b", test: "t", file: "f", count, lastCommit: "c", last: "" }); | |
| 116 | expect(flakeNeeds([row(2)])).toEqual([]); | |
| 117 | expect(flakeNeeds([row(3)])).toEqual(["flaky: t failed and passed alone 3 times this week (a/b)"]); | |
| 118 | }); | |
| 119 | ||
| 120 | /** A check whose full run prints one failing test and exits 1; the re-run is the real test file. */ | |
| 121 | async function mainCheck(name: string, testBody: string) { | |
| 122 | process.env["FORGE_CELLS"] = "off"; | |
| 123 | const made = await createRepo(app, alice, { name, visibility: "public" }); | |
| 124 | if (!made.ok) throw new Error("repo"); | |
| 125 | const repo = made.value; | |
| 126 | const fake = `printf 't/a.test.ts:\\n(fail) grp > flaky\\n 0 pass\\n 1 fail\\n'; exit 1`; | |
| 127 | const checks = startChecks(app, { forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 128 | try { | |
| 129 | const pushed = await commitToBranch(app, alice, repo, { | |
| 130 | message: "a flaky main", | |
| 131 | files: [ | |
| 132 | { path: "forge.json", content: JSON.stringify({ check: { run: fake, install: "" } }) }, | |
| 133 | { path: "t/a.test.ts", content: `import { test, expect, describe } from "bun:test";\ndescribe("grp", () => { test("flaky", () => { ${testBody} }); });\n` }, | |
| 134 | ], | |
| 135 | }); | |
| 136 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 137 | const oid = pushed.value.commitOid; | |
| 138 | const deadline = Date.now() + 40_000; | |
| 139 | const finished = () => listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find((r) => r.commitOid === oid && (r.state === "ok" || r.state === "failed")); | |
| 140 | while (Date.now() < deadline && finished() === undefined) await Bun.sleep(100); | |
| 141 | const run = finished()!; | |
| 142 | return { repo, run, log: readRunLog(app.config.dataDir, run.id).lines.join("\n") }; | |
| 143 | } finally { | |
| 144 | await checks.stop(); | |
| 145 | } | |
| 146 | } | |
| 147 | ||
| 148 | test("a test that fails once and passes alone leaves main green and the flake recorded", async () => { | |
| 149 | const { repo, run, log } = await mainCheck("flaky", "expect(1).toBe(1);"); | |
| 150 | expect(log).toContain("re-running 1 failed test(s) alone"); | |
| 151 | expect(run.state).toBe("ok"); | |
| 152 | expect(redMain(app.db, repo.id)).toBeUndefined(); | |
| 153 | expect(flakeList(app.db, Date.now()).find((f) => f.repo === "alice/flaky")).toMatchObject({ test: "grp > flaky", file: "t/a.test.ts", count: 1 }); | |
| 154 | }, 60_000); | |
| 155 | ||
| 156 | test("a test that fails alone too leaves main red", async () => { | |
| 157 | const { repo, run, log } = await mainCheck("broken", "expect(1).toBe(2);"); | |
| 158 | expect(log).toContain("fails alone too: main is red"); | |
| 159 | expect(run.state).toBe("failed"); | |
| 160 | expect(redMain(app.db, repo.id)?.failing).toEqual(["grp > flaky"]); | |
| 161 | expect(flakeList(app.db, Date.now()).some((f) => f.repo === "alice/broken")).toBe(false); | |
| 162 | }, 60_000); |
test/merge-queue-search.test.ts +27
| @@ -132,4 +132,31 @@ describe("the merge queue searches a red candidate by stacked prefixes", () => { | ||
| 132 | 132 | expect(told).toEqual([14]); |
| 133 | 133 | expect(answers).toEqual([true, true, true, false]); |
| 134 | 134 | }); |
| 135 | ||
| 136 | test("prefixes [a,b] red and [a,b,c] green: a, b and c land in one step with no solo gate, the red is reported as a flake, no suspect is gated alone", async () => { | |
| 137 | told.length = 0; | |
| 138 | const pulls: Queued[] = []; | |
| 139 | for (let i = 31; i <= 34; i++) pulls.push(entry(i, await headAdding(`v${i}`, `f${i}.txt`))); | |
| 140 | const gates: number[][] = []; | |
| 141 | const flaked: string[] = []; | |
| 142 | const red = (name: string): LandVerdict => ({ lands: false, reopen: true, comment: "red", learn: [], tests: { passed: 0, failed: 1, failing: [{ name, file: "test/x.test.ts", errors: [] }] } }); | |
| 143 | const gate = async (candidate: Candidate): Promise<LandVerdict> => { | |
| 144 | const numbers = candidate.steps.map((s) => s.entry.pull.number); | |
| 145 | gates.push(numbers); | |
| 146 | if (numbers.length === 4 || numbers.length === 2) return red(numbers.length === 4 ? "whole" : "flaky one"); | |
| 147 | return { lands: true }; | |
| 148 | }; | |
| 149 | let calls = 0; | |
| 150 | const room = () => (calls++ === 0 ? 0 : 1); | |
| 151 | const answers = await enqueue("search-flake", pulls, gate, { | |
| 152 | depth: 1, | |
| 153 | room, | |
| 154 | onFlake: (_repo, reds) => flaked.push(...reds.flatMap((r) => ("tests" in r ? (r.tests?.failing.map((t) => t.name) ?? []) : []))), | |
| 155 | }); | |
| 156 | expect(gates).toEqual([[31, 32, 33, 34], [31, 32], [31, 32, 33]]); | |
| 157 | // The whole was red and 34 is all that is left of it: the culprit, with no gate of its own. | |
| 158 | expect(answers).toEqual([true, true, true, false]); | |
| 159 | expect(told).toEqual([34]); | |
| 160 | expect(flaked).toEqual(["flaky one"]); | |
| 161 | }); | |
| 135 | 162 | }); |
test/tui-screens.test.ts +9
| @@ -831,10 +831,19 @@ describe("the forge's health in the terminal", () => { | ||
| 831 | 831 | expect(laned.text()).toContain("Lane: 2 checks queued"); |
| 832 | 832 | laned.stop(); |
| 833 | 833 | |
| 834 | forge.db | |
| 835 | .query("INSERT INTO check_flake (id, repo_id, run_id, commit_oid, test, file) VALUES ('f1', ?1, 'r1', 'abc1234', 'grp > flaky', 't/a.test.ts')") | |
| 836 | .run(made.value.id); | |
| 837 | const flaky = boot(forge, as(a.value)); | |
| 838 | await flaky.settle(); | |
| 839 | expect(flaky.text()).toContain("Flaky this week: grp > flaky ×1"); | |
| 840 | flaky.stop(); | |
| 841 | ||
| 834 | 842 | const other = boot(forge, as(b.value)); |
| 835 | 843 | await other.settle(); |
| 836 | 844 | expect(other.text()).not.toContain("quota refused"); |
| 837 | 845 | expect(other.text()).not.toContain("Lane:"); |
| 846 | expect(other.text()).not.toContain("Flaky this week"); | |
| 838 | 847 | expect(other.text()).not.toContain("All fine"); |
| 839 | 848 | other.stop(); |
| 840 | 849 | } finally { |
test/vitals.test.ts +3 −3
| @@ -91,7 +91,7 @@ test("a site admin with a read-only token gets the shape; others get the short o | ||
| 91 | 91 | const v = (await res.json()) as Record<string, any>; |
| 92 | 92 | expect(Object.keys(v).sort()).toEqual( |
| 93 | 93 | [ |
| 94 | "apps", "at", "checkedAt", "commit", "degraded", "disk", "draining", "errors", | |
| 94 | "apps", "at", "checkedAt", "commit", "degraded", "disk", "draining", "errors", "flakes", | |
| 95 | 95 | "incidents", "lane", "memory", "needs", "ok", "queue", "rollout", "stale", "stuck", |
| 96 | 96 | "version", |
| 97 | 97 | ].sort(), |
| @@ -168,8 +168,8 @@ test("the number of queries does not grow with runs", () => { | ||
| 168 | 168 | if (i % 2) app.db.query("UPDATE run SET state='running' WHERE id=?1").run(r.id); |
| 169 | 169 | } |
| 170 | 170 | expect(count()).toBe(before); |
| 171 | // The queue, the running runs, the runs killed twice for their budget, the lane's depth and finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks red on a full disk only while the disk is full), and the newest drill of every domain, and the checks no red main holds. | |
| 172 | expect(before).toBe(9); | |
| 171 | // The queue, the running runs, the runs killed twice for their budget, the lane's depth and finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks red on a full disk only while the disk is full), and the newest drill of every domain, the checks no red main holds, and the week's flake list. | |
| 172 | expect(before).toBe(10); | |
| 173 | 173 | }); |
| 174 | 174 | |
| 175 | 175 | test("errors are counted, bounded, redacted, and a burst is said", async () => { |
new above
offline
forgeVitals(app) runs the full vitals (all queries) just for one line; pass in the vitals the caller already has or call flakeList directly.