Public: anyone can read this; the crew can change it.
The gate is every test/*.test.ts minus EXCLUDED: GATE is deleted and a pull adding a test touches no shared file #2084
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2071-the-gate-is-every-test-test-ts-minus-exc → main 8 commits fork
15 files
changed +331
−563
.forgejo/workflows/flake-hunt.yml +2 −2
| @@ -3,7 +3,7 @@ name: flake-hunt | ||
| 3 | 3 | # run, on a loaded box (scripts/flake-hunt.ts). A file red in some runs and |
| 4 | 4 | # green in others is flaky: the hunt files an issue on the forge carrying |
| 5 | 5 | # the failure text, the seed and this run; the quarantine job then moves |
| 6 | # every file an open `Flaky: …` issue names from GATE to EXCLUDED and | |
| 6 | # every file an open `Flaky: …` issue names into EXCLUDED and | |
| 7 | 7 | # proposes that as a pull. Nobody hand-edits the list. |
| 8 | 8 | # |
| 9 | 9 | # Flakiness is judged per file across runs, so the gate splits into slices |
| @@ -83,4 +83,4 @@ jobs: | ||
| 83 | 83 | push -q "${FORGE_URL}/${FORGE_REPO}.git" "HEAD:refs/heads/$branch" |
| 84 | 84 | curl -fsS -X POST "$FORGE_API/repos/$FORGE_REPO/pulls" \ |
| 85 | 85 | -H "Authorization: Bearer $FORGE_TOKEN" -H "Content-Type: application/json" \ |
| 86 | --data "$(BRANCH="$branch" bun -e 'const b = process.env.BRANCH; console.log(JSON.stringify({title: `Quarantine flaky tests (${b})`, head: b, base: "main", body: "Moved by `bun scripts/flake-hunt.ts quarantine`, not by hand: each file leaves GATE under the open `Flaky:` issue that carries its failure, seed and run."}))')" | |
| 86 | --data "$(BRANCH="$branch" bun -e 'const b = process.env.BRANCH; console.log(JSON.stringify({title: `Quarantine flaky tests (${b})`, head: b, base: "main", body: "Moved by `bun scripts/flake-hunt.ts quarantine`, not by hand: each file leaves the gate under the open `Flaky:` issue that carries its failure, seed and run."}))')" |
CLAUDE.md +6 −5
| @@ -85,11 +85,12 @@ bun run src/main.ts # boots on :3000, data in ./data | ||
| 85 | 85 | (the gate list), `test/check-list.test.ts`, `test/hollow.test.ts`. Never |
| 86 | 86 | `bun scripts/test-shards.ts --gate` inside a run: the check on your push is |
| 87 | 87 | that same gate, and running both doubles the load on a small machine. |
| 88 | It runs the files in `GATE` (`scripts/gate.ts`), not the whole suite; every | |
| 89 | other test file is in `EXCLUDED` with its reason and owning issue. Add a new | |
| 90 | test file to `GATE`, or the check goes red. | |
| 91 | - **A flaky test is a red that teaches nothing.** A file joins `GATE` only | |
| 92 | with ten green random-order runs under load in the pull | |
| 88 | It runs every `test/*.test.ts` except the few in `EXCLUDED` | |
| 89 | (`scripts/gate.ts`), each with its reason and owning issue. A new test file | |
| 90 | is in the gate by existing: edit no list for it. | |
| 91 | - **A flaky test is a red that teaches nothing.** An excluded file rejoins the | |
| 92 | gate (its `EXCLUDED` entry deleted) only with ten green random-order runs | |
| 93 | under load in the pull | |
| 93 | 94 | (`bun scripts/flake-hunt.ts admit test/x.test.ts`); the nightly hunt files |
| 94 | 95 | a `Flaky:` issue and `flake-hunt.ts quarantine` moves the file to |
| 95 | 96 | `EXCLUDED`, so never hand-edit that move. Never add `retry` — fix the |
README.md +20 −11
| @@ -802,7 +802,8 @@ bun run check:record # the same, and rewrites scripts/test-times.json | ||
| 802 | 802 | |
| 803 | 803 | `bun run check` is the gate: the same command `forge.json` declares, so what |
| 804 | 804 | it says here is what the deploy will say. It type-checks once, then runs the |
| 805 | test files `scripts/gate.ts` lists in `GATE` as parallel `bun test` shards | |
| 805 | test files `scripts/gate.ts` admits — every `test/*.test.ts` not in its | |
| 806 | `EXCLUDED` — as parallel `bun test` shards | |
| 806 | 807 | (`scripts/test-shards.ts`): one per CPU the process may use, no more than |
| 807 | 808 | one per 512 MiB of the memory its cgroup allows (16 on the 16-CPU, 8 GiB |
| 808 | 809 | check cell; the old ceiling of eight left half of it idle, #2038), and |
| @@ -823,8 +824,9 @@ enough to set the gate's floor alone: the studio's and the apps | ||
| 823 | 824 | adversarial suites are split by subject into `test/studio*.test.ts` and |
| 824 | 825 | `test/apps-adversarial*.test.ts`. |
| 825 | 826 | |
| 826 | The check's first step is the gate-list audit (`bun scripts/gate.ts`), so a | |
| 827 | test file listed in neither `GATE` nor `EXCLUDED` fails in under a second, | |
| 827 | The check's first step is the gate-list audit (`bun scripts/gate.ts`), so an | |
| 828 | `EXCLUDED` entry for a file that is gone, or one with no reason or issue, | |
| 829 | fails in under a second, | |
| 828 | 830 | before install-heavy work. `tsc` then runs `--incremental` with a 2 GB heap, |
| 829 | 831 | its build info in `.forge-cache/` (made first with `mkdir -p`: tsc will not |
| 830 | 832 | create the directory, and a checkout with no restored cache failed on it, |
| @@ -887,16 +889,22 @@ in that test. Two things it will not do: | ||
| 887 | 889 | and names the tool (`test/helpers/tools.ts`). Install OpenSSH and they run |
| 888 | 890 | again — the skip is conditional on the binary, never on anything else. |
| 889 | 891 | |
| 890 | Every `test/*.test.ts` is either in `GATE` or in `EXCLUDED` with a one-line | |
| 891 | reason and the issue that owns admitting it; `test/check-list.test.ts` goes | |
| 892 | red on a file in neither, in both, or named without existing, so a new test | |
| 893 | file never sits outside the gate silently. A file joins the gate by moving | |
| 894 | from `EXCLUDED` to `GATE` in a pull whose check is green and whose description | |
| 895 | carries ten green runs in random order under load: | |
| 892 | Every `test/*.test.ts` is in the gate unless `EXCLUDED` names it with a | |
| 893 | one-line reason and the issue that owns admitting it, so a new test file is | |
| 894 | gated the moment it exists and no pull edits a shared list for it (#2037). | |
| 895 | `test/check-list.test.ts` goes red on an exclusion named twice, without a | |
| 896 | reason or issue, or for a file that does not exist. An excluded file rejoins | |
| 897 | the gate by deleting its `EXCLUDED` entry in a pull whose check is green and | |
| 898 | whose description carries ten green runs in random order under load: | |
| 896 | 899 | `bun scripts/flake-hunt.ts admit test/x.test.ts` runs the file ten times, each |
| 897 | 900 | its own `bun test --randomize` with a fresh seed beside busy loops, prints the |
| 898 | 901 | record and exits non-zero unless all ten are green. |
| 899 | 902 | |
| 903 | A brand-new test file does not need that record: it joins the gate on its | |
| 904 | own green check, not ten runs. That is accepted on purpose — a flaky new | |
| 905 | test can turn the gate red once before anyone sees it — and the nightly | |
| 906 | flake hunt below is the backstop that files it and quarantines it. | |
| 907 | ||
| 900 | 908 | **No flaky tests.** Nightly, `.forgejo/workflows/flake-hunt.yml` runs every |
| 901 | 909 | gate file five times on a loaded box, each run a fresh seed that orders both |
| 902 | 910 | the files and (through `--randomize`) the tests inside them, nothing stopped |
| @@ -907,8 +915,9 @@ dependence, a leaked server, a timeout set near the real duration, a tight | ||
| 907 | 915 | wall-clock bound); then |
| 908 | 916 | `bun scripts/flake-hunt.ts quarantine` moves every file an open `Flaky:` |
| 909 | 917 | issue the hunt itself filed (its author is the token's user and the forge |
| 910 | has not marked it untrusted) names from `GATE` to `EXCLUDED` under that issue and proposes the move | |
| 911 | as a pull. Nobody hand-edits the list. A file red in every run is broken, | |
| 918 | has not marked it untrusted) into `EXCLUDED` under that issue and proposes | |
| 919 | the move as a pull; a file the issue names that no longer exists is skipped, | |
| 920 | since its exclusion would turn the audit red. Nobody hand-edits the list. A file red in every run is broken, | |
| 912 | 921 | not flaky, and the check already says so. The fix is the test's shape, never |
| 913 | 922 | a retry: `test/flake.test.ts` fails on `--retry`, `--rerun-each`, a bunfig |
| 914 | 923 | `retry` or a `{ retry: N }` anywhere the gate runs, and on any test that |
scripts/compare-changed.ts +2 −2
| @@ -18,6 +18,7 @@ import { tmpdir } from "node:os"; | ||
| 18 | 18 | import { junitFiles, redFiles, runsAll, verdict, missed, type Row } from "../src/checks/changed_compare.ts"; |
| 19 | 19 | import { GATE_FILE, gateListAdds, parseImpact } from "../src/checks/impact.ts"; |
| 20 | 20 | import { fastSet, importsOf } from "../src/checks/tiers.ts"; |
| 21 | import { treeGate } from "./gate.ts"; | |
| 21 | 22 | |
| 22 | 23 | const argv = process.argv.slice(2); |
| 23 | 24 | const flag = (name: string, fallback: string): string => { |
| @@ -106,8 +107,7 @@ for (const pull of pulls) { | ||
| 106 | 107 | if (!existsSync(tree)) git("worktree", "add", "--detach", "-f", tree, oid); |
| 107 | 108 | else Bun.spawnSync(["git", "-C", tree, "checkout", "--detach", "--force", oid]); |
| 108 | 109 | if (!existsSync(join(tree, "node_modules"))) symlinkSync(join(root, "node_modules"), join(tree, "node_modules")); |
| 109 | const gateModule = (await import(`${join(tree, "scripts/gate.ts")}?${oid}`)) as { GATE: string[] }; | |
| 110 | const gate = new Set<string>(gateModule.GATE); | |
| 110 | const gate = new Set<string>(await treeGate(tree, oid)); | |
| 111 | 111 | const cache = new Map<string, string[]>(); |
| 112 | 112 | const imports = (file: string): string[] => { |
| 113 | 113 | let found = cache.get(file); |
scripts/flake-hunt.ts +10 −13
| @@ -10,8 +10,8 @@ | ||
| 10 | 10 | * seed and the run (FORGE_API, FORGE_REPO, FORGE_TOKEN; FORGE_RUN_URL |
| 11 | 11 | * names the run). Exits 1 when it found one. |
| 12 | 12 | * bun scripts/flake-hunt.ts quarantine |
| 13 | * Moves every file an open `Flaky: test/….test.ts` issue the hunt filed | |
| 14 | * (author is the token's user, not untrusted) names from GATE to | |
| 13 | * Puts every file an open `Flaky: test/….test.ts` issue the hunt filed | |
| 14 | * (author is the token's user, not untrusted) names into | |
| 15 | 15 | * EXCLUDED under that issue, in scripts/gate.ts. The issue is |
| 16 | 16 | * the record; this is the only hand that edits the list for a flake. |
| 17 | 17 | * bun scripts/flake-hunt.ts admit test/x.test.ts [--runs 10] |
| @@ -30,12 +30,12 @@ import { | ||
| 30 | 30 | huntedFile, |
| 31 | 31 | HUNT_RUNS, |
| 32 | 32 | issueFor, |
| 33 | quarantine, | |
| 33 | quarantineFlaky, | |
| 34 | 34 | readRun, |
| 35 | 35 | shuffled, |
| 36 | 36 | type FileRun, |
| 37 | 37 | } from "./flake.ts"; |
| 38 | import { GATE } from "./gate.ts"; | |
| 38 | import { gateFiles, presentTests } from "./gate.ts"; | |
| 39 | 39 | import { parseShardSpec, sliceFiles } from "./shards.ts"; |
| 40 | 40 | |
| 41 | 41 | const GATE_FILE = new URL("./gate.ts", import.meta.url).pathname; |
| @@ -175,7 +175,7 @@ async function hunt(): Promise<number> { | ||
| 175 | 175 | const runs = runsOf(HUNT_RUNS); |
| 176 | 176 | const spec = parseShardSpec(valueOf("--shard")); |
| 177 | 177 | if (!spec.ok) fail(spec.error); |
| 178 | let files = [...GATE]; | |
| 178 | let files = gateFiles(); | |
| 179 | 179 | if (!("none" in spec)) { |
| 180 | 180 | const sliced = sliceFiles(files, spec.index, spec.total); |
| 181 | 181 | if (!sliced.ok) fail(sliced.error); |
| @@ -233,14 +233,11 @@ async function hunt(): Promise<number> { | ||
| 233 | 233 | } |
| 234 | 234 | |
| 235 | 235 | async function quarantineAll(): Promise<number> { |
| 236 | let source = readFileSync(GATE_FILE, "utf8"); | |
| 237 | const moved: string[] = []; | |
| 238 | for (const [file, issue] of await openFlakyIssues()) { | |
| 239 | const next = quarantine(source, file, issue); | |
| 240 | if (next === source) continue; | |
| 241 | source = next; | |
| 242 | moved.push(`${file} (#${issue})`); | |
| 243 | } | |
| 236 | const { source, moved } = quarantineFlaky( | |
| 237 | readFileSync(GATE_FILE, "utf8"), | |
| 238 | await openFlakyIssues(), | |
| 239 | new Set(presentTests()), | |
| 240 | ); | |
| 244 | 241 | if (moved.length === 0) { |
| 245 | 242 | console.log("nothing to quarantine"); |
| 246 | 243 | return 0; |
scripts/flake.ts +35 −18
| @@ -4,8 +4,8 @@ | ||
| 4 | 4 | * A test file that is red in one run and green in another, over the same |
| 5 | 5 | * code, is flaky. `scripts/flake-hunt.ts` runs each gate file several times |
| 6 | 6 | * in random order and hands every run here; this module names the flaky |
| 7 | * files, writes the issue that carries the evidence, and moves a file from | |
| 8 | * `GATE` to `EXCLUDED` in `scripts/gate.ts` — so nobody hand-edits the list. | |
| 7 | * files, writes the issue that carries the evidence, and adds a file to | |
| 8 | * `EXCLUDED` in `scripts/gate.ts` — so nobody hand-edits a quarantine. | |
| 9 | 9 | * |
| 10 | 10 | * The unit is the file, judged by `bun test`'s exit code: that is what the |
| 11 | 11 | * gate judges, what quarantine moves, and the only verdict a crash between |
| @@ -173,7 +173,7 @@ export function issueFor( | ||
| 173 | 173 | "", |
| 174 | 174 | ...ROOT_CAUSES.map((cause) => `- [ ] ${cause.name} — ${cause.fix}`), |
| 175 | 175 | "", |
| 176 | `To rejoin the gate: fix it, run \`bun scripts/flake-hunt.ts admit ${flake.file}\`, and put its ${ADMISSION_RUNS}-run record in the pull that moves the file back to \`GATE\`.`, | |
| 176 | `To rejoin the gate: fix it, run \`bun scripts/flake-hunt.ts admit ${flake.file}\`, and put its ${ADMISSION_RUNS}-run record in the pull that deletes its \`EXCLUDED\` entry.`, | |
| 177 | 177 | ].join("\n"), |
| 178 | 178 | }; |
| 179 | 179 | } |
| @@ -190,8 +190,8 @@ export function flakyFile(title: string): string | undefined { | ||
| 190 | 190 | /** |
| 191 | 191 | * The file an issue from the forge's list quarantines, or undefined. Only |
| 192 | 192 | * an issue the hunt filed counts: its author is the hunt's own user and the |
| 193 | * forge has not marked it `untrusted`. Anyone else's `Flaky:` title moves | |
| 194 | * nothing out of GATE. | |
| 193 | * forge has not marked it `untrusted`. Anyone else's `Flaky:` title takes | |
| 194 | * nothing out of the gate. | |
| 195 | 195 | */ |
| 196 | 196 | export function huntedFile( |
| 197 | 197 | issue: { title?: unknown; authorId?: unknown; untrusted?: unknown }, |
| @@ -213,25 +213,42 @@ const entryFor = (file: string, reason: string, issue: number): string => | ||
| 213 | 213 | "", |
| 214 | 214 | ].join("\n"); |
| 215 | 215 | |
| 216 | /** `scripts/gate.ts` with `file` moved from GATE to EXCLUDED under `issue`. */ | |
| 216 | /** | |
| 217 | * `scripts/gate.ts` with `file` excluded under `issue`: one entry at the end | |
| 218 | * of `EXCLUDED`. Unchanged when the file is excluded already. | |
| 219 | */ | |
| 217 | 220 | export function quarantine( |
| 218 | 221 | source: string, |
| 219 | 222 | file: string, |
| 220 | 223 | issue: number, |
| 221 | 224 | reason = "flaky: red in one hunt run and green in another", |
| 222 | 225 | ): string { |
| 223 | const gateStart = source.indexOf("export const GATE"); | |
| 224 | const gateEnd = source.indexOf("\n];", gateStart); | |
| 225 | const line = ` ${JSON.stringify(file)},\n`; | |
| 226 | const at = source.indexOf(line, gateStart); | |
| 227 | if (gateStart === -1 || at === -1 || at > gateEnd) return source; | |
| 228 | const without = source.slice(0, at) + source.slice(at + line.length); | |
| 229 | const end = without.indexOf("\n];", without.indexOf("export const EXCLUDED")); | |
| 230 | return ( | |
| 231 | without.slice(0, end + 1) + | |
| 232 | entryFor(file, reason, issue) + | |
| 233 | without.slice(end + 1) | |
| 234 | ); | |
| 226 | const start = source.indexOf("export const EXCLUDED"); | |
| 227 | const end = source.indexOf("\n];", start); | |
| 228 | if (start === -1 || end === -1) return source; | |
| 229 | if (source.slice(start, end).includes(`file: ${JSON.stringify(file)}`)) return source; | |
| 230 | return source.slice(0, end + 1) + entryFor(file, reason, issue) + source.slice(end + 1); | |
| 231 | } | |
| 232 | ||
| 233 | /** | |
| 234 | * `scripts/gate.ts` with every open hunt issue's file quarantined, and the | |
| 235 | * `file (#issue)` lines that moved. A file no longer `present` is skipped: | |
| 236 | * an exclusion for a file that is gone would turn the gate's audit red. | |
| 237 | */ | |
| 238 | export function quarantineFlaky( | |
| 239 | source: string, | |
| 240 | flaky: Iterable<readonly [string, number]>, | |
| 241 | present: ReadonlySet<string>, | |
| 242 | ): { source: string; moved: string[] } { | |
| 243 | const moved: string[] = []; | |
| 244 | for (const [file, issue] of flaky) { | |
| 245 | if (!present.has(file)) continue; | |
| 246 | const next = quarantine(source, file, issue); | |
| 247 | if (next === source) continue; | |
| 248 | source = next; | |
| 249 | moved.push(`${file} (#${issue})`); | |
| 250 | } | |
| 251 | return { source, moved }; | |
| 235 | 252 | } |
| 236 | 253 | |
| 237 | 254 | /** A retry anywhere in what the gate runs: the lines that carry one. */ |
scripts/gate.ts +44 −403
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | * Which test files the gate runs, and why every other one does not. | |
| 2 | * Which test files the gate runs, and why the few others do not. | |
| 3 | 3 | * |
| 4 | * `GATE` is the list `bun scripts/test-shards.ts --gate` runs, so | |
| 5 | * `forge.json`'s `check.run` names no test file itself. `EXCLUDED` is every | |
| 6 | * other `test/*.test.ts`, each with the reason it is out and the issue that | |
| 7 | * owns bringing it in. `test/check-list.test.ts` goes red when a file is in | |
| 8 | * neither list, in both, or named here without existing — so a new test file | |
| 9 | * cannot sit outside the gate silently. | |
| 4 | * The gate is every `test/*.test.ts` minus `EXCLUDED` (`gateFiles`): the | |
| 5 | * list `bun scripts/test-shards.ts --gate` runs, so `forge.json`'s | |
| 6 | * `check.run` names no test file itself, and a new test file is gated the | |
| 7 | * moment it exists — no shared list to edit, nothing to conflict on (#2037). | |
| 8 | * `EXCLUDED` is the quarantine: each file with the reason it is out and the | |
| 9 | * issue that owns bringing it back. `test/check-list.test.ts` goes red when | |
| 10 | * an exclusion names a file that does not exist, twice, or without a reason | |
| 11 | * or an issue. | |
| 10 | 12 | * |
| 11 | * A file joins the gate by moving from `EXCLUDED` to `GATE` in a pull whose | |
| 13 | * A file rejoins the gate by deleting its `EXCLUDED` entry in a pull whose | |
| 12 | 14 | * check is green and whose description carries ten green runs in random |
| 13 | 15 | * order under load (`bun scripts/flake-hunt.ts admit <file>`). A flaky file |
| 14 | 16 | * leaves it only through `bun scripts/flake-hunt.ts quarantine`, under the |
| @@ -16,6 +18,7 @@ | ||
| 16 | 18 | */ |
| 17 | 19 | |
| 18 | 20 | import { readdirSync } from "node:fs"; |
| 21 | import { join } from "node:path"; | |
| 19 | 22 | |
| 20 | 23 | export type Exclusion = { |
| 21 | 24 | /** Repository-relative path, `test/….test.ts`. */ |
| @@ -26,382 +29,6 @@ export type Exclusion = { | ||
| 26 | 29 | issue: number; |
| 27 | 30 | }; |
| 28 | 31 | |
| 29 | export const GATE: readonly string[] = [ | |
| 30 | "test/archive.test.ts", | |
| 31 | "test/tui-archive.test.ts", | |
| 32 | "test/archive-agent.test.ts", | |
| 33 | "test/waves-page.test.ts", | |
| 34 | "test/waves-order.test.ts", | |
| 35 | "test/domain-drill.test.ts", | |
| 36 | "test/conflicts.test.ts", | |
| 37 | "test/lands-once.test.ts", | |
| 38 | "test/merge-queue.test.ts", | |
| 39 | "test/conflicts-tui.test.ts", | |
| 40 | "test/accounts-retire.test.ts", | |
| 41 | "test/releases-page.test.ts", | |
| 42 | "test/client-home.test.ts", | |
| 43 | "test/cloud-use.test.ts", | |
| 44 | "test/accounts.test.ts", | |
| 45 | "test/asks-mute.test.ts", | |
| 46 | "test/feed-chips.test.ts", | |
| 47 | "test/agents.test.ts", | |
| 48 | "test/app-domains.test.ts", | |
| 49 | "test/app.test.ts", | |
| 50 | "test/apps-idle.test.ts", | |
| 51 | "test/body-limits.test.ts", | |
| 52 | "test/boards-query.test.ts", | |
| 53 | "test/boards-page.test.ts", | |
| 54 | "test/boards-store.test.ts", | |
| 55 | "test/boards-sampler.test.ts", | |
| 56 | "test/turnstile.test.ts", | |
| 57 | "test/variants-deck.test.ts", | |
| 58 | "test/variants-surfaces.test.ts", | |
| 59 | "test/thoughts.test.ts", | |
| 60 | "test/triage.test.ts", | |
| 61 | "test/passkeys.test.ts", | |
| 62 | "test/passkey-recovery.test.ts", | |
| 63 | "test/signup-passkey.test.ts", | |
| 64 | "test/webauthn.test.ts", | |
| 65 | "test/chaos.test.ts", | |
| 66 | "test/flow-stage.test.ts", | |
| 67 | "test/resilience.test.ts", | |
| 68 | "test/flow-card.test.ts", | |
| 69 | "test/cluster-jobs.test.ts", | |
| 70 | "test/checks-placement.test.ts", | |
| 71 | "test/checks-job-vanish.test.ts", | |
| 72 | "test/fuzz-found.test.ts", | |
| 73 | "test/fuzz-parsers.test.ts", | |
| 74 | "test/attach-look.test.ts", | |
| 75 | "test/protection.test.ts", | |
| 76 | "test/tui-limbs.test.ts", | |
| 77 | "test/tui-onyou.test.ts", | |
| 78 | "test/tui-waves.test.ts", | |
| 79 | "test/tui-deploys.test.ts", | |
| 80 | "test/tui-access.test.ts", | |
| 81 | "test/tui-newforge.test.ts", | |
| 82 | "test/tui-decider.test.ts", | |
| 83 | "test/claim-link.test.ts", | |
| 84 | "test/og-card.test.ts", | |
| 85 | "test/og-route.test.ts", | |
| 86 | "test/og-png.test.ts", | |
| 87 | "test/og-designs.test.ts", | |
| 88 | "test/router.test.ts", | |
| 89 | "test/health.test.ts", | |
| 90 | "test/client-address.test.ts", | |
| 91 | "test/client-list.test.ts", | |
| 92 | "test/family-heartbeat.test.ts", | |
| 93 | "test/contents-api.test.ts", | |
| 94 | "test/crew-agents-only.test.ts", | |
| 95 | "test/crew-brief-audience.test.ts", | |
| 96 | "test/run-log-audience.test.ts", | |
| 97 | "test/crew.test.ts", | |
| 98 | "test/crew-already-done.test.ts", | |
| 99 | "test/crew-check-failed.test.ts", | |
| 100 | "test/crew-continues.test.ts", | |
| 101 | "test/crew-declines.test.ts", | |
| 102 | "test/crew-network.test.ts", | |
| 103 | "test/crew-pause.test.ts", | |
| 104 | "test/crew-review.test.ts", | |
| 105 | "test/crew-review-budget.test.ts", | |
| 106 | "test/commit-identity.test.ts", | |
| 107 | "test/enterable-apps.test.ts", | |
| 108 | "test/event-order.test.ts", | |
| 109 | "test/refresh-meta.test.ts", | |
| 110 | "test/unfurl.test.ts", | |
| 111 | "test/restore-card.test.ts", | |
| 112 | "test/recovery-doc.test.ts", | |
| 113 | "test/release-checkout.test.ts", | |
| 114 | "test/release-preflight.test.ts", | |
| 115 | "test/release-rollback.test.ts", | |
| 116 | "test/release-swap.test.ts", | |
| 117 | "test/release-handover.test.ts", | |
| 118 | "test/threats-doc.test.ts", | |
| 119 | "test/returns.test.ts", | |
| 120 | "test/review-route.test.ts", | |
| 121 | "test/review-lines.test.ts", | |
| 122 | "test/review-gate.test.ts", | |
| 123 | "test/review-from-check.test.ts", | |
| 124 | "test/review-from-green.test.ts", | |
| 125 | "test/themes.test.ts", | |
| 126 | "test/waves.test.ts", | |
| 127 | "test/cut-wave.test.ts", | |
| 128 | "test/crew-wrapup.test.ts", | |
| 129 | "test/crew-limits.test.ts", | |
| 130 | "test/crew-parks.test.ts", | |
| 131 | "test/crew-red-main.test.ts", | |
| 132 | "test/crew-sandbox.test.ts", | |
| 133 | "test/crew-remotes.test.ts", | |
| 134 | "test/crew-brief-gate.test.ts", | |
| 135 | "test/passport.test.ts", | |
| 136 | "test/passport-edge.test.ts", | |
| 137 | "test/crew-direction.test.ts", | |
| 138 | "test/crew-direction-autonomy.test.ts", | |
| 139 | "test/crew-guard.test.ts", | |
| 140 | "test/review-waits.test.ts", | |
| 141 | "test/scale.test.ts", | |
| 142 | "test/sealed-private-work.test.ts", | |
| 143 | "test/settings.test.ts", | |
| 144 | "test/signin-throttle.test.ts", | |
| 145 | "test/signins.test.ts", | |
| 146 | "test/sole-owner-suspend.test.ts", | |
| 147 | "test/standing-every-process.test.ts", | |
| 148 | "test/steer.test.ts", | |
| 149 | "test/sealed-git.test.ts", | |
| 150 | "test/sealed-issues.test.ts", | |
| 151 | "test/sealed-release.test.ts", | |
| 152 | "test/sealed-preview.test.ts", | |
| 153 | "test/session-settle.test.ts", | |
| 154 | "test/landing.test.ts", | |
| 155 | "test/decide.test.ts", | |
| 156 | "test/ledger.test.ts", | |
| 157 | "test/capacity.test.ts", | |
| 158 | "test/access-log.test.ts", | |
| 159 | "test/flow-pipeline.test.ts", | |
| 160 | "test/flow-throughput.test.ts", | |
| 161 | "test/limits.test.ts", | |
| 162 | "test/guard-new-accounts.test.ts", | |
| 163 | "test/checks.test.ts", | |
| 164 | "test/dependencies.test.ts", | |
| 165 | "test/map-closing.test.ts", | |
| 166 | "test/parked-close.test.ts", | |
| 167 | "test/autonomy.test.ts", | |
| 168 | "test/autonomy_sweep.test.ts", | |
| 169 | "test/conflict_sweep.test.ts", | |
| 170 | "test/studio.test.ts", | |
| 171 | "test/studio-changes.test.ts", | |
| 172 | "test/studio-door.test.ts", | |
| 173 | "test/studio-runs.test.ts", | |
| 174 | "test/studio-shipping.test.ts", | |
| 175 | "test/studio-search.test.ts", | |
| 176 | "test/studio-words.test.ts", | |
| 177 | "test/converge-settled.test.ts", | |
| 178 | "test/following.test.ts", | |
| 179 | "test/forks.test.ts", | |
| 180 | "test/front.test.ts", | |
| 181 | "test/fork-counts.test.ts", | |
| 182 | "test/fork-flow.test.ts", | |
| 183 | "test/approve-here.test.ts", | |
| 184 | "test/fork-pull-approval.test.ts", | |
| 185 | "test/cross-repo-pull.test.ts", | |
| 186 | "test/pull-mergeable.test.ts", | |
| 187 | "test/pull-variants.test.ts", | |
| 188 | "test/pull-checks-line.test.ts", | |
| 189 | "test/pulls.test.ts", | |
| 190 | "test/api-reader-marks.test.ts", | |
| 191 | "test/app-members.test.ts", | |
| 192 | "test/apps.test.ts", | |
| 193 | "test/apps-crew.test.ts", | |
| 194 | "test/apps-identity.test.ts", | |
| 195 | "test/apps-new.test.ts", | |
| 196 | "test/apps-slow-test.test.ts", | |
| 197 | "test/apps-tests-clause.test.ts", | |
| 198 | "test/apps-adversarial.test.ts", | |
| 199 | "test/apps-adversarial-teardown.test.ts", | |
| 200 | "test/apps-adversarial-test-step.test.ts", | |
| 201 | "test/apps-adversarial-grants.test.ts", | |
| 202 | "test/apps-adversarial-rules.test.ts", | |
| 203 | "test/apps-busy.test.ts", | |
| 204 | "test/apps-token.test.ts", | |
| 205 | "test/contain.test.ts", | |
| 206 | "test/contract.test.ts", | |
| 207 | "test/subscription-budget.test.ts", | |
| 208 | "test/surfaces-adversarial.test.ts", | |
| 209 | "test/orgs.test.ts", | |
| 210 | "test/suspended-hidden.test.ts", | |
| 211 | "test/verify-email.test.ts", | |
| 212 | "test/web.test.ts", | |
| 213 | "test/git-trust.test.ts", | |
| 214 | "test/helpers.test.ts", | |
| 215 | "test/shards.test.ts", | |
| 216 | "test/gate-summary.test.ts", | |
| 217 | "test/snapshot-drill.test.ts", | |
| 218 | "test/drill-verdict.test.ts", | |
| 219 | "test/drill-board-line.test.ts", | |
| 220 | "test/snapshot-drill-page.test.ts", | |
| 221 | "test/snapshot-store.test.ts", | |
| 222 | "test/snapshot-mirror.test.ts", | |
| 223 | "test/snapshot-mirror-page.test.ts", | |
| 224 | "test/migrate.test.ts", | |
| 225 | "test/schema-additive.test.ts", | |
| 226 | "test/schema-migrations.test.ts", | |
| 227 | "test/domains.test.ts", | |
| 228 | "test/vitals.test.ts", | |
| 229 | "test/vitals-degraded.test.ts", | |
| 230 | "test/red-main.test.ts", | |
| 231 | "test/sentinel.test.ts", | |
| 232 | "test/tui-sentinel.test.ts", | |
| 233 | "test/version.test.ts", | |
| 234 | "test/watches.test.ts", | |
| 235 | "test/guard.test.ts", | |
| 236 | "test/asked.test.ts", | |
| 237 | "test/thought-stream.test.ts", | |
| 238 | "test/asks.test.ts", | |
| 239 | "test/review-wait.test.ts", | |
| 240 | "test/asks-bulk.test.ts", | |
| 241 | "test/proposal-card.test.ts", | |
| 242 | "test/asks-waiting-for.test.ts", | |
| 243 | "test/asks-one-list.test.ts", | |
| 244 | "test/hand-decider.test.ts", | |
| 245 | "test/decisions-by-doors.test.ts", | |
| 246 | "test/standing-rule.test.ts", | |
| 247 | "test/asks-expiry.test.ts", | |
| 248 | "test/asks-rank.test.ts", | |
| 249 | "test/plan-pillars.test.ts", | |
| 250 | "test/plan-route.test.ts", | |
| 251 | "test/planner-declined.test.ts", | |
| 252 | "test/feed.test.ts", | |
| 253 | "test/feed-choose.test.ts", | |
| 254 | "test/feed-page.test.ts", | |
| 255 | "test/card-shell.test.ts", | |
| 256 | "test/offline.test.ts", | |
| 257 | "test/share.test.ts", | |
| 258 | "test/cloud.test.ts", | |
| 259 | "test/run-secrets.test.ts", | |
| 260 | "test/reserved-names.test.ts", | |
| 261 | "test/forge-cloudflare.test.ts", | |
| 262 | "test/forge-cloudflare-celld.test.ts", | |
| 263 | "test/branch_tell.test.ts", | |
| 264 | "test/tell_from.test.ts", | |
| 265 | "test/usage.test.ts", | |
| 266 | "test/tokens.test.ts", | |
| 267 | "test/credentials.test.ts", | |
| 268 | "test/standing.test.ts", | |
| 269 | "test/board.test.ts", | |
| 270 | "test/decider-day.test.ts", | |
| 271 | "test/spawn-plan.test.ts", | |
| 272 | "test/spawn-anon.test.ts", | |
| 273 | "test/spawn-host.test.ts", | |
| 274 | "test/spawn.test.ts", | |
| 275 | "test/spawn-api.test.ts", | |
| 276 | "test/spawn-child-apps.test.ts", | |
| 277 | "test/spawn-child-runs-apps.test.ts", | |
| 278 | "test/spawn-first-boot.test.ts", | |
| 279 | "test/spawn-fresh-forks.test.ts", | |
| 280 | "test/spawn-keys.test.ts", | |
| 281 | "test/web-apps.test.ts", | |
| 282 | "test/web-ui.test.ts", | |
| 283 | "test/fleet-ops.test.ts", | |
| 284 | "test/meter.test.ts", | |
| 285 | "test/told-by.test.ts", | |
| 286 | "test/highlight.test.ts", | |
| 287 | "test/diff-edge-cases.test.ts", | |
| 288 | "test/diff.test.ts", | |
| 289 | "test/edge-auth.test.ts", | |
| 290 | "test/own-session.test.ts", | |
| 291 | "test/edit-words.test.ts", | |
| 292 | "test/edits-security.test.ts", | |
| 293 | "test/edits.test.ts", | |
| 294 | "test/event-audience.test.ts", | |
| 295 | "test/everyday.test.ts", | |
| 296 | "test/explore.test.ts", | |
| 297 | "test/feeds.test.ts", | |
| 298 | "test/first-login.test.ts", | |
| 299 | "test/fleet-graph.test.ts", | |
| 300 | "test/git-local.test.ts", | |
| 301 | "test/git-ssh.test.ts", | |
| 302 | "test/git.test.ts", | |
| 303 | "test/hardening.test.ts", | |
| 304 | "test/here-manifest.test.ts", | |
| 305 | "test/image-gif.test.ts", | |
| 306 | "test/image-graphics.test.ts", | |
| 307 | "test/image-jpeg.test.ts", | |
| 308 | "test/image-png.test.ts", | |
| 309 | "test/issues.test.ts", | |
| 310 | "test/kernel.test.ts", | |
| 311 | "test/keys.test.ts", | |
| 312 | "test/limb_order.test.ts", | |
| 313 | "test/limbs.test.ts", | |
| 314 | "test/live-pages.test.ts", | |
| 315 | "test/live.test.ts", | |
| 316 | "test/looks.test.ts", | |
| 317 | "test/mail-caps.test.ts", | |
| 318 | "test/make-it-yours.test.ts", | |
| 319 | "test/map.test.ts", | |
| 320 | "test/pillars.test.ts", | |
| 321 | "test/carried.test.ts", | |
| 322 | "test/carried-deploy.test.ts", | |
| 323 | "test/milestones.test.ts", | |
| 324 | "test/mcp.test.ts", | |
| 325 | "test/name-marks.test.ts", | |
| 326 | "test/new-forge.test.ts", | |
| 327 | "test/notify.test.ts", | |
| 328 | "test/onboarding.test.ts", | |
| 329 | "test/org-owners.test.ts", | |
| 330 | "test/origins-oauth.test.ts", | |
| 331 | "test/origins-operator.test.ts", | |
| 332 | "test/origins-reconnect.test.ts", | |
| 333 | "test/origins-security-auth.test.ts", | |
| 334 | "test/origins-security-provider.test.ts", | |
| 335 | "test/origins-security-push.test.ts", | |
| 336 | "test/origins-sync.test.ts", | |
| 337 | "test/origins.test.ts", | |
| 338 | "test/overlap.test.ts", | |
| 339 | "test/outsiders-switch.test.ts", | |
| 340 | "test/check-budget.test.ts", | |
| 341 | "test/check-proven.test.ts", | |
| 342 | "test/check-list.test.ts", | |
| 343 | "test/check-land-main.test.ts", | |
| 344 | "test/check-tiers.test.ts", | |
| 345 | "test/changed-compare.test.ts", | |
| 346 | "test/pull-check-again.test.ts", | |
| 347 | "test/holds.test.ts", | |
| 348 | "test/hollow.test.ts", | |
| 349 | "test/flags.test.ts", | |
| 350 | "test/home-feed.test.ts", | |
| 351 | "test/home-walk.test.ts", | |
| 352 | "test/oauth-discovery.test.ts", | |
| 353 | "test/oauth-flow.test.ts", | |
| 354 | "test/oidc.test.ts", | |
| 355 | "test/data-home.test.ts", | |
| 356 | "test/tree.test.ts", | |
| 357 | "test/reports.test.ts", | |
| 358 | "test/report-discovery.test.ts", | |
| 359 | "test/apps-report.test.ts", | |
| 360 | "test/apps-toolbar.test.ts", | |
| 361 | "test/private-app.test.ts", | |
| 362 | "test/view-source.test.ts", | |
| 363 | "test/web-browse.test.ts", | |
| 364 | "test/web-editor.test.ts", | |
| 365 | "test/web-phone.test.ts", | |
| 366 | "test/web-workflows.test.ts", | |
| 367 | "test/webhooks.test.ts", | |
| 368 | "test/actions.test.ts", | |
| 369 | "test/app-env.test.ts", | |
| 370 | "test/apps-ssh.test.ts", | |
| 371 | "test/pacing.test.ts", | |
| 372 | "test/openapi.test.ts", | |
| 373 | "test/palette.test.ts", | |
| 374 | "test/qr.test.ts", | |
| 375 | "test/branches.test.ts", | |
| 376 | "test/cli-sync.test.ts", | |
| 377 | "test/code.test.ts", | |
| 378 | "test/counts.test.ts", | |
| 379 | "test/crew-loop.test.ts", | |
| 380 | "test/crew-totals.test.ts", | |
| 381 | "test/flake.test.ts", | |
| 382 | "test/crew-locks.test.ts", | |
| 383 | "test/ssh-site.test.ts", | |
| 384 | "test/web-code.test.ts", | |
| 385 | "test/web-origins.test.ts", | |
| 386 | "test/roll.test.ts", | |
| 387 | "test/rehearsal.test.ts", | |
| 388 | "test/tui-tell.test.ts", | |
| 389 | "test/tui-app.test.ts", | |
| 390 | "test/tui-apps.test.ts", | |
| 391 | "test/tui-editor.test.ts", | |
| 392 | "test/tui-icons.test.ts", | |
| 393 | "test/tui-input.test.ts", | |
| 394 | "test/tui-local.test.ts", | |
| 395 | "test/tui-mouse.test.ts", | |
| 396 | "test/tui-render.test.ts", | |
| 397 | "test/tui-screens.test.ts", | |
| 398 | "test/tui-size.test.ts", | |
| 399 | "test/tui-widgets.test.ts", | |
| 400 | "test/tui-width.test.ts", | |
| 401 | "test/tui-workflows.test.ts", | |
| 402 | "test/tui-workspace.test.ts", | |
| 403 | ]; | |
| 404 | ||
| 405 | 32 | export const EXCLUDED: readonly Exclusion[] = [ |
| 406 | 33 | { file: "test/crew-sandbox-real.test.ts", reason: "landed unlisted and red locally; the builder admits it", issue: 1238 }, |
| 407 | 34 | { file: "test/api-errors.test.ts", reason: "not yet admitted", issue: 261 }, |
| @@ -487,38 +114,26 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 487 | 114 | ]; |
| 488 | 115 | |
| 489 | 116 | /** |
| 490 | * Everything wrong with a gate list, against the test files that exist: | |
| 491 | * one line per problem, none when every file is accounted for exactly once. | |
| 117 | * Everything wrong with the quarantine, against the test files that exist: | |
| 118 | * one line per problem, none when every exclusion is sound. | |
| 492 | 119 | */ |
| 493 | 120 | export function auditGate( |
| 494 | 121 | present: readonly string[], |
| 495 | gate: readonly string[], | |
| 496 | 122 | excluded: readonly Exclusion[], |
| 497 | 123 | ): string[] { |
| 498 | 124 | const problems: string[] = []; |
| 499 | 125 | const exists = new Set(present); |
| 500 | const gated = new Set<string>(); | |
| 501 | for (const file of gate) { | |
| 502 | if (gated.has(file)) problems.push(`${file} is in GATE twice`); | |
| 503 | gated.add(file); | |
| 504 | if (!exists.has(file)) | |
| 505 | problems.push(`${file} is in GATE but does not exist`); | |
| 506 | } | |
| 507 | 126 | const out = new Set<string>(); |
| 508 | 127 | for (const { file, reason, issue } of excluded) { |
| 509 | 128 | if (out.has(file)) problems.push(`${file} is in EXCLUDED twice`); |
| 510 | 129 | out.add(file); |
| 511 | 130 | if (!exists.has(file)) |
| 512 | 131 | problems.push(`${file} is in EXCLUDED but does not exist`); |
| 513 | if (gated.has(file)) problems.push(`${file} is in both GATE and EXCLUDED`); | |
| 514 | 132 | if (typeof reason !== "string" || reason.trim() === "") |
| 515 | 133 | problems.push(`${file} is excluded with no reason`); |
| 516 | 134 | if (!Number.isInteger(issue) || issue <= 0) |
| 517 | 135 | problems.push(`${file} is excluded with no issue number`); |
| 518 | 136 | } |
| 519 | for (const file of present) | |
| 520 | if (!gated.has(file) && !out.has(file)) | |
| 521 | problems.push(`${file} is in neither GATE nor EXCLUDED`); | |
| 522 | 137 | return problems; |
| 523 | 138 | } |
| 524 | 139 | |
| @@ -534,24 +149,50 @@ export function presentTests(root = "."): string[] { | ||
| 534 | 149 | } |
| 535 | 150 | } |
| 536 | 151 | |
| 152 | /** What the gate runs: every present test file not excluded, in name order. */ | |
| 153 | export function gateFiles( | |
| 154 | present: readonly string[] = presentTests(), | |
| 155 | excluded: readonly Exclusion[] = EXCLUDED, | |
| 156 | ): string[] { | |
| 157 | const out = new Set(excluded.map((e) => e.file)); | |
| 158 | return present.filter((file) => !out.has(file)); | |
| 159 | } | |
| 160 | ||
| 537 | 161 | /** |
| 538 | * What the check prints when the list is wrong: every problem, then one | |
| 539 | * last line naming the files to list. Empty when the list is right. | |
| 162 | * The gate a checked-out tree's own `scripts/gate.ts` names, whichever shape | |
| 163 | * it has: a tree from before #2071 lists it in `GATE`; a later one derives | |
| 164 | * it with `gateFiles` from the tree's test files. `key` makes each tree's | |
| 165 | * import its own module, so one process can read many commits. | |
| 166 | */ | |
| 167 | export async function treeGate(tree: string, key: string): Promise<string[]> { | |
| 168 | const module = (await import(`${join(tree, "scripts/gate.ts")}?${key}`)) as { | |
| 169 | GATE?: readonly string[]; | |
| 170 | gateFiles?: (present: readonly string[]) => string[]; | |
| 171 | presentTests?: (root: string) => string[]; | |
| 172 | }; | |
| 173 | if (module.gateFiles && module.presentTests) | |
| 174 | return module.gateFiles(module.presentTests(tree)); | |
| 175 | return [...(module.GATE ?? [])]; | |
| 176 | } | |
| 177 | ||
| 178 | /** | |
| 179 | * What the check prints when the quarantine is wrong: every problem, then | |
| 180 | * one last line naming the files to fix. Empty when it is right. | |
| 540 | 181 | */ |
| 541 | 182 | export function gateRefusal(present: readonly string[]): string[] { |
| 542 | const problems = auditGate(present, GATE, EXCLUDED); | |
| 183 | const problems = auditGate(present, EXCLUDED); | |
| 543 | 184 | if (problems.length === 0) return []; |
| 544 | 185 | const named = [ |
| 545 | 186 | ...new Set(problems.map((p) => p.match(/^(test\/\S+\.test\.ts) /)?.[1])), |
| 546 | 187 | ].filter((f): f is string => f !== undefined); |
| 547 | 188 | return [ |
| 548 | 189 | ...problems, |
| 549 | `${named.join(", ")}: list it in GATE or EXCLUDED in scripts/gate.ts`, | |
| 190 | `${named.join(", ")}: fix its EXCLUDED entry in scripts/gate.ts`, | |
| 550 | 191 | ]; |
| 551 | 192 | } |
| 552 | 193 | |
| 553 | 194 | // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so |
| 554 | // an unlisted file fails in under a second — not after install and tsc. | |
| 195 | // a stale exclusion fails in under a second — not after install and tsc. | |
| 555 | 196 | if (import.meta.main) { |
| 556 | 197 | const refusal = gateRefusal(presentTests()); |
| 557 | 198 | for (const line of refusal) console.error(`gate: ${line}`); |
scripts/test-shards.ts +6 −4
| @@ -8,7 +8,8 @@ | ||
| 8 | 8 | * bun scripts/test-shards.ts --record [bun test flags…] test/a.test.ts … |
| 9 | 9 | * bun scripts/test-shards.ts [bun test flags…] -- a b … (test/a.test.ts …) |
| 10 | 10 | * |
| 11 | * `--gate` runs the files `scripts/gate.ts` admits, which is how | |
| 11 | * `--gate` runs the files `scripts/gate.ts` admits (every test file not | |
| 12 | * excluded), which is how | |
| 12 | 13 | * `forge.json`'s `check.run` names them; files on the command line are for |
| 13 | 14 | * running a slice by hand. `--shard N/M` (or `FORGE_CHECK_SHARD_INDEX` + |
| 14 | 15 | * `FORGE_CHECK_SHARD_TOTAL`) keeps only the Nth contiguous slice of that |
| @@ -59,7 +60,7 @@ import { | ||
| 59 | 60 | weigher, |
| 60 | 61 | type ShardResult, |
| 61 | 62 | } from "./shards.ts"; |
| 62 | import { GATE, gateRefusal, presentTests } from "./gate.ts"; | |
| 63 | import { gateFiles, gateRefusal, presentTests } from "./gate.ts"; | |
| 63 | 64 | import { cgroupMemory, summaryLines } from "./gate-summary.ts"; |
| 64 | 65 | import { GATE_FILE, executedFiles, formatImpact, gateListAdds, handedImpact, mergeImpact, parseImpact } from "../src/checks/impact.ts"; |
| 65 | 66 | import { fastSet, importsOf, duration } from "../src/checks/tiers.ts"; |
| @@ -223,8 +224,8 @@ if (!split.ok) { | ||
| 223 | 224 | process.exit(2); |
| 224 | 225 | } |
| 225 | 226 | const flags = split.flags; |
| 226 | // A test file in neither GATE nor EXCLUDED costs a whole gate run to find | |
| 227 | // out; say so before anything spawns. Explicit files alone are not audited. | |
| 227 | // A stale exclusion costs a whole gate run to find out; say so before | |
| 228 | // anything spawns. Explicit files alone are not audited. | |
| 228 | 229 | if (gate || tier === "fast") { |
| 229 | 230 | const refusal = gateRefusal(presentTests()); |
| 230 | 231 | if (refusal.length > 0) { |
| @@ -232,6 +233,7 @@ if (gate || tier === "fast") { | ||
| 232 | 233 | process.exit(2); |
| 233 | 234 | } |
| 234 | 235 | } |
| 236 | const GATE = gateFiles(); | |
| 235 | 237 | let files = [...(gate || tier === "fast" ? GATE : []), ...split.files]; |
| 236 | 238 | if (tier === "fast") { |
| 237 | 239 | const diff = Bun.spawnSync( |
src/checks/impact.ts +11 −11
| @@ -91,15 +91,16 @@ export function formatImpact(map: ReadonlyMap<string, readonly string[]>): strin | ||
| 91 | 91 | export const GATE_FILE = "scripts/gate.ts"; |
| 92 | 92 | |
| 93 | 93 | /** |
| 94 | * The test files a `git diff -U0 scripts/gate.ts` adds to GATE or EXCLUDED | |
| 95 | * (a move counts as an add). `undefined` when any changed line is anything | |
| 96 | * but a list entry — that is logic, and the map cannot bound it. Removed | |
| 97 | * entries select nothing and are not logic. | |
| 94 | * The test files a `git diff -U0 scripts/gate.ts` brings into the gate: the | |
| 95 | * entries it takes out of EXCLUDED and does not put back (a move within the | |
| 96 | * list admits nothing). `undefined` when any changed line is anything but an | |
| 97 | * exclusion — that is logic, and the map cannot bound it. Added entries | |
| 98 | * take a file out of the gate, which selects nothing and is not logic. | |
| 98 | 99 | */ |
| 99 | 100 | export function gateListAdds(diff: string): string[] | undefined { |
| 100 | const entry = /^"(test\/[A-Za-z0-9_.-]+\.test\.[cm]?[jt]sx?)",?(?:\s*\/\/.*)?$/; | |
| 101 | 101 | const exclusion = /^\{?\s*file:\s*"(test\/[A-Za-z0-9_.-]+\.test\.[cm]?[jt]sx?)",?/; |
| 102 | 102 | const data = /^(\{|\},?|reason:\s*.*|issue:\s*\d+,?)$/; |
| 103 | const removed = new Set<string>(); | |
| 103 | 104 | const added = new Set<string>(); |
| 104 | 105 | let inHunk = false; |
| 105 | 106 | for (const line of diff.split("\n")) { |
| @@ -109,18 +110,17 @@ export function gateListAdds(diff: string): string[] | undefined { | ||
| 109 | 110 | const sign = line[0]; |
| 110 | 111 | if (sign !== "+" && sign !== "-") continue; |
| 111 | 112 | const text = line.slice(1).trim(); |
| 112 | const match = entry.exec(text) ?? exclusion.exec(text); | |
| 113 | if (match) { | |
| 114 | if (sign === "+") added.add(match[1]!); | |
| 115 | } else if (text !== "" && !data.test(text) && !/^\{\s*file:.*\},?$/.test(text)) return undefined; | |
| 113 | const match = exclusion.exec(text); | |
| 114 | if (match) (sign === "-" ? removed : added).add(match[1]!); | |
| 115 | else if (text !== "" && !data.test(text)) return undefined; | |
| 116 | 116 | } |
| 117 | return [...added]; | |
| 117 | return [...removed].filter((file) => !added.has(file)); | |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | 120 | /** |
| 121 | 121 | * What the map cannot bound: manifests, scripts, the kernel. `gateAdds` is |
| 122 | 122 | * the result of `gateListAdds` for this pull; with it, a change to |
| 123 | * scripts/gate.ts alone is bounded by the tests it lists. | |
| 123 | * scripts/gate.ts alone is bounded by the tests it admits. | |
| 124 | 124 | */ |
| 125 | 125 | export function needsFull( |
| 126 | 126 | changed: readonly string[], |
src/checks/tiers.ts +2 −2
| @@ -73,7 +73,7 @@ export interface FastInput { | ||
| 73 | 73 | readonly tests: readonly string[]; |
| 74 | 74 | /** A file's relative imports; `[]` for one that cannot be read. */ |
| 75 | 75 | readonly imports: (file: string) => readonly string[]; |
| 76 | /** The tests a diff of scripts/gate.ts adds (`gateListAdds`); `undefined` when it is logic or unread. */ | |
| 76 | /** The tests a diff of scripts/gate.ts admits (`gateListAdds`); `undefined` when it is logic or unread. */ | |
| 77 | 77 | readonly gateAdds?: readonly string[]; |
| 78 | 78 | /** Seconds each test took last time (`scripts/test-times.json`). */ |
| 79 | 79 | readonly times: ReadonlyMap<string, number>; |
| @@ -118,7 +118,7 @@ export function fastSet(input: FastInput): FastPlan { | ||
| 118 | 118 | for (const test of input.tests) why.set(test, `full: ${unbounded} changed`); |
| 119 | 119 | return finish(`full tier, ${unbounded} changed`); |
| 120 | 120 | } |
| 121 | // A gate.ts that only lists tests is those tests, nothing more. | |
| 121 | // A gate.ts that only edits EXCLUDED is the tests it admits, nothing more. | |
| 122 | 122 | const diffed = |
| 123 | 123 | input.gateAdds === undefined |
| 124 | 124 | ? input.changed |
test/check-list.test.ts +80 −41
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | /** |
| 2 | * Every test file is accounted for (`scripts/gate.ts`): in the gate, or | |
| 3 | * excluded by name with a reason and the issue that owns admitting it. A | |
| 4 | * new test file that is in neither list turns the check red, so it cannot | |
| 5 | * sit outside the gate silently. | |
| 2 | * The gate is every test file minus a short quarantine (`scripts/gate.ts`, | |
| 3 | * #2037): a new test file is gated by existing, with no list to edit, and | |
| 4 | * every exclusion names a file that exists, a reason, and the issue that | |
| 5 | * owns admitting it. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { describe, expect, test } from "bun:test"; |
| 9 | import { readdirSync } from "node:fs"; | |
| 10 | import { auditGate, EXCLUDED, GATE } from "../scripts/gate.ts"; | |
| 9 | import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { auditGate, EXCLUDED, gateFiles, gateRefusal, treeGate } from "../scripts/gate.ts"; | |
| 11 | 13 | |
| 12 | 14 | const root = new URL("..", import.meta.url).pathname; |
| 13 | 15 | const present = readdirSync(`${root}test`) |
| @@ -16,12 +18,19 @@ const present = readdirSync(`${root}test`) | ||
| 16 | 18 | .sort(); |
| 17 | 19 | |
| 18 | 20 | describe("the gate's list", () => { |
| 19 | test("every test/*.test.ts is in exactly one list, and every entry exists", () => { | |
| 20 | expect(auditGate(present, GATE, EXCLUDED)).toEqual([]); | |
| 21 | test("every exclusion is sound against the files that exist", () => { | |
| 22 | expect(auditGate(present, EXCLUDED)).toEqual([]); | |
| 23 | expect(gateRefusal(present)).toEqual([]); | |
| 21 | 24 | }); |
| 22 | 25 | |
| 23 | test("this guard is itself in the gate", () => { | |
| 24 | expect(GATE).toContain("test/check-list.test.ts"); | |
| 26 | test("the gate is every present test file not excluded", () => { | |
| 27 | const out = new Set(EXCLUDED.map((e) => e.file)); | |
| 28 | expect(gateFiles()).toEqual(present.filter((f) => !out.has(f))); | |
| 29 | expect(gateFiles().length + EXCLUDED.length).toBe(present.length); | |
| 30 | }); | |
| 31 | ||
| 32 | test("this guard is itself in the gate: no exclusion names it", () => { | |
| 33 | expect(EXCLUDED.map((e) => e.file)).not.toContain("test/check-list.test.ts"); | |
| 25 | 34 | }); |
| 26 | 35 | |
| 27 | 36 | test("forge.json's check names no test file and runs the gate's list", async () => { |
| @@ -34,55 +43,85 @@ describe("the gate's list", () => { | ||
| 34 | 43 | }); |
| 35 | 44 | }); |
| 36 | 45 | |
| 37 | describe("auditGate", () => { | |
| 38 | const files = ["test/a.test.ts", "test/b.test.ts"]; | |
| 46 | describe("gateFiles", () => { | |
| 47 | const files = ["test/a.test.ts", "test/b.test.ts", "test/c.test.ts"]; | |
| 39 | 48 | |
| 40 | test("a file in neither list is named", () => { | |
| 41 | expect(auditGate(files, ["test/a.test.ts"], [])).toEqual([ | |
| 42 | "test/b.test.ts is in neither GATE nor EXCLUDED", | |
| 49 | test("a new file is in the gate with no list naming it", () => { | |
| 50 | expect(gateFiles([...files, "test/x.test.ts"], [])).toEqual([ | |
| 51 | ...files, | |
| 52 | "test/x.test.ts", | |
| 43 | 53 | ]); |
| 44 | 54 | }); |
| 45 | 55 | |
| 46 | test("a file in both lists is named", () => { | |
| 56 | test("an excluded file is not", () => { | |
| 47 | 57 | expect( |
| 48 | auditGate(files, files, [ | |
| 49 | { file: "test/b.test.ts", reason: "slow", issue: 1 }, | |
| 50 | ]), | |
| 51 | ).toEqual(["test/b.test.ts is in both GATE and EXCLUDED"]); | |
| 58 | gateFiles(files, [{ file: "test/b.test.ts", reason: "slow", issue: 1 }]), | |
| 59 | ).toEqual(["test/a.test.ts", "test/c.test.ts"]); | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("auditGate", () => { | |
| 64 | const files = ["test/a.test.ts", "test/b.test.ts"]; | |
| 65 | ||
| 66 | test("a file in no list is no problem", () => { | |
| 67 | expect(auditGate(files, [])).toEqual([]); | |
| 52 | 68 | }); |
| 53 | 69 | |
| 54 | 70 | test("an exclusion with no reason or no issue is named", () => { |
| 55 | 71 | expect( |
| 56 | auditGate( | |
| 57 | files, | |
| 58 | [], | |
| 59 | [ | |
| 60 | { file: "test/a.test.ts", reason: " ", issue: 7 }, | |
| 61 | { file: "test/b.test.ts", reason: "slow", issue: 0 }, | |
| 62 | ], | |
| 63 | ), | |
| 72 | auditGate(files, [ | |
| 73 | { file: "test/a.test.ts", reason: " ", issue: 7 }, | |
| 74 | { file: "test/b.test.ts", reason: "slow", issue: 0 }, | |
| 75 | ]), | |
| 64 | 76 | ).toEqual([ |
| 65 | 77 | "test/a.test.ts is excluded with no reason", |
| 66 | 78 | "test/b.test.ts is excluded with no issue number", |
| 67 | 79 | ]); |
| 68 | 80 | }); |
| 69 | 81 | |
| 70 | test("an entry naming a file that does not exist is named", () => { | |
| 82 | test("an exclusion naming a file that does not exist is named", () => { | |
| 71 | 83 | expect( |
| 72 | auditGate( | |
| 73 | files, | |
| 74 | [...files, "test/gone.test.ts"], | |
| 75 | [{ file: "test/old.test.ts", reason: "slow", issue: 3 }], | |
| 76 | ), | |
| 77 | ).toEqual([ | |
| 78 | "test/gone.test.ts is in GATE but does not exist", | |
| 79 | "test/old.test.ts is in EXCLUDED but does not exist", | |
| 80 | ]); | |
| 84 | auditGate(files, [{ file: "test/old.test.ts", reason: "slow", issue: 3 }]), | |
| 85 | ).toEqual(["test/old.test.ts is in EXCLUDED but does not exist"]); | |
| 81 | 86 | }); |
| 82 | 87 | |
| 83 | test("a file named twice in one list is named", () => { | |
| 84 | expect(auditGate(files, [...files, "test/a.test.ts"], [])).toEqual([ | |
| 85 | "test/a.test.ts is in GATE twice", | |
| 88 | test("a file excluded twice is named", () => { | |
| 89 | const entry = { file: "test/a.test.ts", reason: "slow", issue: 3 }; | |
| 90 | expect(auditGate(files, [entry, entry])).toEqual([ | |
| 91 | "test/a.test.ts is in EXCLUDED twice", | |
| 86 | 92 | ]); |
| 87 | 93 | }); |
| 88 | 94 | }); |
| 95 | ||
| 96 | describe("treeGate: the gate an older commit's tree names", () => { | |
| 97 | const tree = (gate: string, tests: string[]): string => { | |
| 98 | const dir = mkdtempSync(join(tmpdir(), "forge-tree-gate-")); | |
| 99 | mkdirSync(join(dir, "scripts")); | |
| 100 | mkdirSync(join(dir, "test")); | |
| 101 | writeFileSync(join(dir, "scripts/gate.ts"), gate); | |
| 102 | for (const file of tests) writeFileSync(join(dir, file), ""); | |
| 103 | return dir; | |
| 104 | }; | |
| 105 | ||
| 106 | test("a tree from before #2071 is read from its GATE list", async () => { | |
| 107 | const dir = tree( | |
| 108 | 'export const GATE = ["test/a.test.ts"];\nexport const EXCLUDED = [];\n', | |
| 109 | ["test/a.test.ts", "test/b.test.ts"], | |
| 110 | ); | |
| 111 | try { | |
| 112 | expect(await treeGate(dir, "old")).toEqual(["test/a.test.ts"]); | |
| 113 | } finally { | |
| 114 | rmSync(dir, { recursive: true, force: true }); | |
| 115 | } | |
| 116 | }); | |
| 117 | ||
| 118 | test("a later tree derives it from its own test files minus EXCLUDED", async () => { | |
| 119 | const dir = tree("", ["test/a.test.ts", "test/zz-new.test.ts", EXCLUDED[0]!.file]); | |
| 120 | copyFileSync(`${root}scripts/gate.ts`, join(dir, "scripts/gate.ts")); | |
| 121 | try { | |
| 122 | expect(await treeGate(dir, "new")).toEqual(["test/a.test.ts", "test/zz-new.test.ts"]); | |
| 123 | } finally { | |
| 124 | rmSync(dir, { recursive: true, force: true }); | |
| 125 | } | |
| 126 | }); | |
| 127 | }); |
test/check-tiers.test.ts +22 −13
| @@ -198,16 +198,22 @@ describe("the fast set, by measured coverage", () => { | ||
| 198 | 198 | expect(fastSet({ ...base, changed: ["scripts/test-times.json"] }).files).toEqual([]); |
| 199 | 199 | }); |
| 200 | 200 | |
| 201 | test("a gate.ts diff that only lists tests selects those tests; logic runs full", () => { | |
| 201 | test("a gate.ts diff that only edits EXCLUDED selects the tests it admits; logic runs full", () => { | |
| 202 | 202 | const listed = [ |
| 203 | "@@ -10,0 +11,2 @@", | |
| 204 | '+ "test/new.test.ts",', | |
| 205 | '+ { file: "test/other.test.ts", reason: "slow", issue: 5 },', | |
| 206 | "@@ -20 +22,0 @@", | |
| 207 | '- "test/old.test.ts",', | |
| 203 | "@@ -10,2 +10,0 @@", | |
| 204 | '- { file: "test/new.test.ts", reason: "slow", issue: 5 },', | |
| 205 | "- {", | |
| 206 | '- file: "test/other.test.ts",', | |
| 207 | '- reason: "flaky",', | |
| 208 | "- issue: 6,", | |
| 209 | "- },", | |
| 210 | "@@ -20,0 +18,2 @@", | |
| 211 | '+ { file: "test/old.test.ts", reason: "flaky", issue: 7 },', | |
| 212 | '+ { file: "test/other.test.ts", reason: "moved", issue: 6 },', | |
| 208 | 213 | ].join("\n"); |
| 209 | 214 | const adds = gateListAdds(listed); |
| 210 | expect(adds).toEqual(["test/new.test.ts", "test/other.test.ts"]); | |
| 215 | // Quarantining old selects nothing; other only moved, so it stays out. | |
| 216 | expect(adds).toEqual(["test/new.test.ts"]); | |
| 211 | 217 | const plan = fastSet({ ...base, changed: ["scripts/gate.ts"], gateAdds: adds! }); |
| 212 | 218 | expect(plan.line).not.toContain("full tier"); |
| 213 | 219 | expect(plan.files).not.toContain("test/web-phone.test.ts"); |
| @@ -219,12 +225,15 @@ describe("the fast set, by measured coverage", () => { | ||
| 219 | 225 | gateAdds: adds!, |
| 220 | 226 | }); |
| 221 | 227 | expect(more.why.get("test/new.test.ts")).toBe("changed"); |
| 222 | expect(gateListAdds('@@ -1 +1 @@\n+ "test/a.test.ts",\n+export const X = 1;')).toBeUndefined(); | |
| 223 | expect(gateListAdds('@@ -1 +1 @@\n- if (x) return;')).toBeUndefined(); | |
| 224 | const hunk = "@@ -1,0 +2,2 @@\n"; | |
| 225 | expect(gateListAdds(`${hunk}+ "test/a.test.ts",\n+++i;`)).toBeUndefined(); | |
| 226 | expect(gateListAdds(`${hunk}+ "test/a.test.ts",\n---n;`)).toBeUndefined(); | |
| 227 | expect(gateListAdds(`--- a/scripts/gate.ts\n+++ b/scripts/gate.ts\n${hunk}+ "test/a.test.ts",`)).toEqual(["test/a.test.ts"]); | |
| 228 | const hunk = "@@ -1,2 +1,0 @@\n"; | |
| 229 | const entry = '- { file: "test/a.test.ts", reason: "slow", issue: 1 },'; | |
| 230 | expect(gateListAdds(`${hunk}${entry}\n+export const X = 1;`)).toBeUndefined(); | |
| 231 | expect(gateListAdds("@@ -1 +1 @@\n- if (x) return;")).toBeUndefined(); | |
| 232 | // A bare name is no list entry now: there is no GATE to put it in. | |
| 233 | expect(gateListAdds('@@ -1 +1 @@\n+ "test/a.test.ts",')).toBeUndefined(); | |
| 234 | expect(gateListAdds(`${hunk}${entry}\n+++i;`)).toBeUndefined(); | |
| 235 | expect(gateListAdds(`${hunk}${entry}\n---n;`)).toBeUndefined(); | |
| 236 | expect(gateListAdds(`--- a/scripts/gate.ts\n+++ b/scripts/gate.ts\n${hunk}${entry}`)).toEqual(["test/a.test.ts"]); | |
| 228 | 237 | const logic = fastSet({ ...base, changed: ["scripts/gate.ts"] }); |
| 229 | 238 | expect(logic.files).toEqual(base.tests); |
| 230 | 239 | }); |
test/flake.test.ts +40 −11
| @@ -9,7 +9,7 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; | ||
| 9 | 9 | import { readdirSync, readFileSync } from "node:fs"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | import { auditGate, GATE, type Exclusion } from "../scripts/gate.ts"; | |
| 12 | import { auditGate, gateFiles, type Exclusion } from "../scripts/gate.ts"; | |
| 13 | 13 | import { |
| 14 | 14 | admissionRecord, |
| 15 | 15 | admitted, |
| @@ -21,6 +21,7 @@ import { | ||
| 21 | 21 | HUNT_RUNS, |
| 22 | 22 | issueFor, |
| 23 | 23 | quarantine, |
| 24 | quarantineFlaky, | |
| 24 | 25 | readRun, |
| 25 | 26 | retries, |
| 26 | 27 | ROOT_CAUSES, |
| @@ -164,30 +165,58 @@ describe("the issue", () => { | ||
| 164 | 165 | |
| 165 | 166 | describe("quarantine", () => { |
| 166 | 167 | const source = read("scripts/gate.ts"); |
| 167 | const file = GATE[0]!; | |
| 168 | const file = gateFiles()[0]!; | |
| 168 | 169 | |
| 169 | test("moves the file from GATE to EXCLUDED under the issue", async () => { | |
| 170 | test("takes the file out of the gate into EXCLUDED under the issue", async () => { | |
| 170 | 171 | const moved = quarantine(source, file, 4242); |
| 171 | 172 | expect(moved).not.toBe(source); |
| 172 | 173 | const dir = await scratch(); |
| 173 | 174 | await writeFile(join(dir, "gate.ts"), moved); |
| 174 | 175 | const mod = (await import(join(dir, "gate.ts"))) as { |
| 175 | GATE: readonly string[]; | |
| 176 | 176 | EXCLUDED: readonly Exclusion[]; |
| 177 | gateFiles: (present?: readonly string[]) => string[]; | |
| 177 | 178 | }; |
| 178 | 179 | const present = readdirSync(`${root}test`) |
| 179 | 180 | .filter((name) => name.endsWith(".test.ts")) |
| 180 | .map((name) => `test/${name}`); | |
| 181 | expect(auditGate(present, mod.GATE, mod.EXCLUDED)).toEqual([]); | |
| 182 | expect(mod.GATE).not.toContain(file); | |
| 183 | expect(mod.GATE).toHaveLength(GATE.length - 1); | |
| 181 | .map((name) => `test/${name}`) | |
| 182 | .sort(); | |
| 183 | expect(auditGate(present, mod.EXCLUDED)).toEqual([]); | |
| 184 | const gate = mod.gateFiles(present); | |
| 185 | expect(gate).not.toContain(file); | |
| 186 | expect(gate).toHaveLength(gateFiles().length - 1); | |
| 184 | 187 | expect(mod.EXCLUDED.at(-1)).toMatchObject({ file, issue: 4242 }); |
| 185 | 188 | }); |
| 186 | 189 | |
| 187 | test("a file not in GATE is left alone", async () => { | |
| 190 | test("a file excluded already is left alone", async () => { | |
| 188 | 191 | const { EXCLUDED } = await import("../scripts/gate.ts"); |
| 189 | 192 | expect(quarantine(source, EXCLUDED[0]!.file, 1)).toBe(source); |
| 190 | expect(quarantine(source, "test/nope.test.ts", 1)).toBe(source); | |
| 193 | expect(quarantine("no list here", file, 1)).toBe("no list here"); | |
| 194 | }); | |
| 195 | }); | |
| 196 | ||
| 197 | describe("quarantineFlaky", () => { | |
| 198 | const source = read("scripts/gate.ts"); | |
| 199 | const file = gateFiles()[0]!; | |
| 200 | const present = new Set(gateFiles()); | |
| 201 | ||
| 202 | test("an issue naming a missing file adds no EXCLUDED entry", () => { | |
| 203 | const got = quarantineFlaky(source, [["test/gone.test.ts", 77]], present); | |
| 204 | expect(got.source).toBe(source); | |
| 205 | expect(got.moved).toEqual([]); | |
| 206 | }); | |
| 207 | ||
| 208 | test("a present file is quarantined and named; one excluded already is not", () => { | |
| 209 | const got = quarantineFlaky( | |
| 210 | source, | |
| 211 | [ | |
| 212 | ["test/gone.test.ts", 77], | |
| 213 | [file, 78], | |
| 214 | [file, 79], | |
| 215 | ], | |
| 216 | present, | |
| 217 | ); | |
| 218 | expect(got.source).toBe(quarantine(source, file, 78)); | |
| 219 | expect(got.moved).toEqual([`${file} (#78)`]); | |
| 191 | 220 | }); |
| 192 | 221 | }); |
| 193 | 222 | |
| @@ -268,7 +297,7 @@ describe("no retry in the gate", () => { | ||
| 268 | 297 | (name) => `.forgejo/workflows/${name}`, |
| 269 | 298 | ), |
| 270 | 299 | // This file holds the detector's own examples. |
| 271 | ...GATE.filter((file) => file !== "test/flake.test.ts"), | |
| 300 | ...gateFiles().filter((file) => file !== "test/flake.test.ts"), | |
| 272 | 301 | ]; |
| 273 | 302 | expect(named.flatMap((path) => retries(path, read(path)))).toEqual([]); |
| 274 | 303 | }); |
test/hollow.test.ts +3 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { describe, expect, test } from "bun:test"; |
| 9 | import { GATE } from "../scripts/gate.ts"; | |
| 9 | import { EXCLUDED } from "../scripts/gate.ts"; | |
| 10 | 10 | import { audit, hollow, readSources } from "../scripts/hollow.ts"; |
| 11 | 11 | |
| 12 | 12 | const root = new URL("..", import.meta.url).pathname; |
| @@ -54,8 +54,8 @@ describe("the repository", () => { | ||
| 54 | 54 | WHOLE_TREE, |
| 55 | 55 | ); |
| 56 | 56 | |
| 57 | test("this guard is itself in the gate", () => { | |
| 58 | expect(GATE).toContain("test/hollow.test.ts"); | |
| 57 | test("this guard is itself in the gate: no exclusion names it", () => { | |
| 58 | expect(EXCLUDED.map((e) => e.file)).not.toContain("test/hollow.test.ts"); | |
| 59 | 59 | }); |
| 60 | 60 | |
| 61 | 61 | test( |
test/shards.test.ts +48 −24
| @@ -27,7 +27,7 @@ import { | ||
| 27 | 27 | type ShardResult, |
| 28 | 28 | } from "../scripts/shards.ts"; |
| 29 | 29 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 30 | import { EXCLUDED, GATE } from "../scripts/gate.ts"; | |
| 30 | import { EXCLUDED, gateFiles, presentTests } from "../scripts/gate.ts"; | |
| 31 | 31 | import { CHECK_CACHE } from "../src/checks/cache.ts"; |
| 32 | 32 | |
| 33 | 33 | const files = Array.from({ length: 23 }, (_, i) => `test/f${i}.test.ts`); |
| @@ -353,7 +353,7 @@ describe("splitArgs", () => { | ||
| 353 | 353 | expect(split.ok).toBe(true); |
| 354 | 354 | if (!split.ok) return; |
| 355 | 355 | const files = [ |
| 356 | ...(split.flags.includes("--gate") ? GATE : []), | |
| 356 | ...(split.flags.includes("--gate") ? gateFiles() : []), | |
| 357 | 357 | ...split.files, |
| 358 | 358 | ]; |
| 359 | 359 | expect(files.length).toBeGreaterThan(0); |
| @@ -464,11 +464,27 @@ test("says two things, a pause apart", async () => { | ||
| 464 | 464 | |
| 465 | 465 | describe("the gate audit before any shard", () => { |
| 466 | 466 | const script = new URL("../scripts/test-shards.ts", import.meta.url).pathname; |
| 467 | const listed = [...GATE, ...EXCLUDED.map((e) => e.file)]; | |
| 468 | const run = async (extra: string[], args: string[]) => { | |
| 467 | const listed = presentTests(); | |
| 468 | const git = (dir: string, ...args: string[]) => | |
| 469 | Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { | |
| 470 | cwd: dir, | |
| 471 | stdout: "ignore", | |
| 472 | stderr: "ignore", | |
| 473 | }).exitCode; | |
| 474 | const run = async (extra: string[], args: string[], drop: string[] = [], commit = false) => { | |
| 469 | 475 | const dir = mkdtempSync(join(tmpdir(), "forge-audit-")); |
| 470 | 476 | mkdirSync(join(dir, "test")); |
| 471 | for (const file of [...listed, ...extra]) writeFileSync(join(dir, file), ""); | |
| 477 | for (const file of listed) if (!drop.includes(file)) writeFileSync(join(dir, file), ""); | |
| 478 | if (commit) { | |
| 479 | expect(git(dir, "init", "-q")).toBe(0); | |
| 480 | expect(git(dir, "add", "-A")).toBe(0); | |
| 481 | expect(git(dir, "commit", "-qm", "base")).toBe(0); | |
| 482 | } | |
| 483 | for (const file of extra) writeFileSync(join(dir, file), ""); | |
| 484 | if (commit) { | |
| 485 | expect(git(dir, "add", "-A")).toBe(0); | |
| 486 | expect(git(dir, "commit", "-qm", "add a test")).toBe(0); | |
| 487 | } | |
| 472 | 488 | const started = performance.now(); |
| 473 | 489 | const child = Bun.spawn(["bun", script, ...args], { |
| 474 | 490 | cwd: dir, |
| @@ -485,36 +501,43 @@ describe("the gate audit before any shard", () => { | ||
| 485 | 501 | return { code, out, err, ms: performance.now() - started }; |
| 486 | 502 | }; |
| 487 | 503 | |
| 488 | test("an unlisted test file is refused by name, last line, nothing spawned", async () => { | |
| 504 | test("a stale exclusion is refused by name, last line, nothing spawned", async () => { | |
| 505 | const gone = EXCLUDED[0]!.file; | |
| 489 | 506 | for (const args of [["--gate"], ["--tier", "fast", "--diff", "a..b"]]) { |
| 490 | const got = await run(["test/zz-new.test.ts"], args); | |
| 507 | const got = await run([], args, [gone]); | |
| 491 | 508 | expect(got.code).toBe(2); |
| 492 | 509 | const last = got.err.trim().split("\n").pop()!; |
| 493 | expect(last).toContain("test/zz-new.test.ts"); | |
| 494 | expect(last).toContain("list it in GATE or EXCLUDED in scripts/gate.ts"); | |
| 510 | expect(last).toBe(`test-shards: ${gone}: fix its EXCLUDED entry in scripts/gate.ts`); | |
| 495 | 511 | expect(got.out + got.err).not.toContain("start "); |
| 496 | 512 | expect(got.out).not.toContain("── shard"); |
| 497 | 513 | expect(got.ms).toBeLessThan(1000); |
| 498 | 514 | } |
| 499 | 515 | }); |
| 500 | 516 | |
| 501 | test("a listed set proceeds, and explicit files are not audited", async () => { | |
| 502 | const last = String(GATE.length); | |
| 503 | const got = await run([], ["--gate", "--shard", `${last}/${last}`]); | |
| 504 | expect(got.err).not.toContain("list it in GATE"); | |
| 505 | expect(got.out).toContain(`matrix slice ${last}/${last}`); | |
| 506 | const explicit = await run(["test/zz-new.test.ts"], ["test/zz-new.test.ts"]); | |
| 507 | expect(explicit.err).not.toContain("list it in GATE"); | |
| 517 | test("a new file is in the gate with no list naming it, and explicit files are not audited", async () => { | |
| 518 | const last = String(gateFiles().length + 1); | |
| 519 | const got = await run(["test/zz-new.test.ts"], ["--gate", "--shard", `${last}/${last}`]); | |
| 520 | expect(got.err).not.toContain("EXCLUDED"); | |
| 521 | expect(got.out).toContain(`matrix slice ${last}/${last} (1 of ${last} files)`); | |
| 522 | const explicit = await run(["test/zz-new.test.ts"], ["test/zz-new.test.ts"], [listed[0]!]); | |
| 523 | expect(explicit.err).not.toContain("EXCLUDED"); | |
| 524 | }, 30_000); | |
| 525 | ||
| 526 | test("a pull that only adds test/x.test.ts is gated on it (#2037)", async () => { | |
| 527 | const got = await run(["test/zz-new.test.ts"], ["--tier", "fast", "--diff", "HEAD~1..HEAD"], [], true); | |
| 528 | expect(got.err).not.toContain("EXCLUDED"); | |
| 529 | expect(got.out).toContain("for 1 changed"); | |
| 530 | expect(got.out).toContain(" test/zz-new.test.ts: changed\n"); | |
| 508 | 531 | }, 30_000); |
| 509 | 532 | }); |
| 510 | 533 | |
| 511 | 534 | describe("the gate audit alone, first in the check", () => { |
| 512 | 535 | const script = new URL("../scripts/gate.ts", import.meta.url).pathname; |
| 513 | const listed = [...GATE, ...EXCLUDED.map((e) => e.file)]; | |
| 514 | const run = async (extra: string[]) => { | |
| 536 | const listed = presentTests(); | |
| 537 | const run = async (extra: string[], drop: string[] = []) => { | |
| 515 | 538 | const dir = mkdtempSync(join(tmpdir(), "forge-gate-")); |
| 516 | 539 | mkdirSync(join(dir, "test")); |
| 517 | for (const file of [...listed, ...extra]) writeFileSync(join(dir, file), ""); | |
| 540 | for (const file of [...listed, ...extra]) if (!drop.includes(file)) writeFileSync(join(dir, file), ""); | |
| 518 | 541 | const child = Bun.spawn(["bun", script], { cwd: dir, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); |
| 519 | 542 | const err = await new Response(child.stderr).text(); |
| 520 | 543 | const code = await child.exited; |
| @@ -522,13 +545,14 @@ describe("the gate audit alone, first in the check", () => { | ||
| 522 | 545 | return { code, err }; |
| 523 | 546 | }; |
| 524 | 547 | |
| 525 | test("an unlisted file fails it by name; a listed set passes", async () => { | |
| 526 | const red = await run(["test/zz-new.test.ts"]); | |
| 548 | test("a stale exclusion fails it by name; a new unlisted file passes", async () => { | |
| 549 | const gone = EXCLUDED[0]!.file; | |
| 550 | const red = await run([], [gone]); | |
| 527 | 551 | expect(red.code).toBe(2); |
| 528 | expect(red.err.trim().split("\n").pop()).toContain( | |
| 529 | "test/zz-new.test.ts: list it in GATE or EXCLUDED in scripts/gate.ts", | |
| 552 | expect(red.err.trim().split("\n").pop()).toBe( | |
| 553 | `gate: ${gone}: fix its EXCLUDED entry in scripts/gate.ts`, | |
| 530 | 554 | ); |
| 531 | const green = await run([]); | |
| 555 | const green = await run(["test/zz-new.test.ts"]); | |
| 532 | 556 | expect(green.code).toBe(0); |
| 533 | 557 | expect(green.err).toBe(""); |
| 534 | 558 | }, 30_000); |
new above
offline