Public: anyone can read this; the crew can change it.
`bun scripts/fast.ts` prints the test files the fast tier would run for the working tree's diff against main #6580
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6570-bun-scripts-fast-ts-prints-the-test-file → main 3 commits fork
6 files
changed +415
−24
README.md +13
| @@ -416,6 +416,19 @@ map has never seen fall back to the import-hop guess, and a change to | ||
| 416 | 416 | `forge.json`, `package.json`, `scripts/**` or `src/kernel/**` runs |
| 417 | 417 | everything. With no map yet the line says `by imports (no impact map)`. |
| 418 | 418 | |
| 419 | Before you push, ask what the fast tier would run for your change: | |
| 420 | `files=$(bun scripts/fast.ts) && bun test $files`. `bun scripts/fast.ts | |
| 421 | [--base origin/main] [--why | --null | --json]` lists the picked test files | |
| 422 | for the diff from the base's merge-base to the working tree (committed, | |
| 423 | uncommitted, untracked), one per line on stdout and nothing else there; | |
| 424 | `--why` adds a tab and the reason, `--null` ends each path with NUL (for | |
| 425 | `xargs -0 -r bun test`), `--json` is `{full, files:[{file, why}]}`. Exit 0: | |
| 426 | files listed. Exit 3: the change reaches no test (stdout empty). Exit 2: the | |
| 427 | diff cannot be bounded (`forge.json`, `package.json`, `scripts/**`…, said on | |
| 428 | stderr as `full tier: … changed`), or the arguments or git failed. The | |
| 429 | `&&` is what keeps an empty or unbounded answer from becoming a bare | |
| 430 | `bun test`, which would run the whole suite. | |
| 431 | ||
| 419 | 432 | Files go to shards by how long they take, not how big they are: longest |
| 420 | 433 | first onto the least-loaded shard, weighed from `scripts/test-times.json` |
| 421 | 434 | (file → seconds, from one recorded run). A file the table does not name |
docs/OPERATING.md +2
Diff not shown — a line 2,328 characters wide (limit 2,000). +2
View filescripts/fast.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * The test files the fast tier would run for your diff. | |
| 3 | * | |
| 4 | * bun scripts/fast.ts [--base origin/main] [--why | --null | --json] | |
| 5 | * files=$(bun scripts/fast.ts) && bun test $files | |
| 6 | * bun scripts/fast.ts --null | xargs -0 -r bun test | |
| 7 | * | |
| 8 | * The files changed between the base and the working tree (committed and | |
| 9 | * uncommitted, untracked included) go through `fastSet`, with the times | |
| 10 | * table and `scripts/test-impact.json` as the check reads them. Stdout | |
| 11 | * holds only the answer: one path per line (`--why` adds a tab and the | |
| 12 | * reason, `--null` ends each path with NUL, `--json` is one object). | |
| 13 | * Words for people go to stderr. Exit 0: files listed. Exit 2: the diff | |
| 14 | * is unbounded (full tier) or the arguments or git failed. Exit 3: the | |
| 15 | * change reaches no test, stdout empty. A bare `bun test $(…)` with | |
| 16 | * nothing listed would run everything, so guard on the exit code (`&&`). | |
| 17 | * Git is spawned with an argument list, never a shell. | |
| 18 | */ | |
| 19 | ||
| 20 | import { readFileSync } from "node:fs"; | |
| 21 | import { GATE_FILE, gateListAdds, handedImpact, needsFull } from "../src/checks/impact.ts"; | |
| 22 | import { fastSet, duration } from "../src/checks/tiers.ts"; | |
| 23 | import { importsReader, readImpact } from "./tier-input.ts"; | |
| 24 | import { CHECK_CACHE } from "../src/checks/cache.ts"; | |
| 25 | import { gateFiles } from "./gate.ts"; | |
| 26 | import { overlayTimes, parseTimes } from "./shards.ts"; | |
| 27 | ||
| 28 | /** A revision git cannot read as a flag. */ | |
| 29 | const REV = /^[A-Za-z0-9_][A-Za-z0-9_./@^~-]*$/; | |
| 30 | ||
| 31 | export type Args = | |
| 32 | | { readonly ok: true; readonly base: string; readonly format: Format } | |
| 33 | | { readonly ok: false; readonly error: string }; | |
| 34 | ||
| 35 | export type Format = "lines" | "why" | "null" | "json"; | |
| 36 | ||
| 37 | /** `[--base REV] [--why | --null | --json]`; anything else is refused. */ | |
| 38 | export function parseArgs(argv: readonly string[]): Args { | |
| 39 | let base = "origin/main"; | |
| 40 | let format: Format | undefined; | |
| 41 | let seenBase = false; | |
| 42 | for (let i = 0; i < argv.length; i++) { | |
| 43 | const arg = argv[i]!; | |
| 44 | if (arg === "--why" || arg === "--null" || arg === "--json") { | |
| 45 | if (format !== undefined) return { ok: false, error: "one of --why, --null, --json" }; | |
| 46 | format = arg.slice(2) as Format; | |
| 47 | } else if (arg === "--base" || arg.startsWith("--base=")) { | |
| 48 | const value = arg === "--base" ? argv[++i] : arg.slice("--base=".length); | |
| 49 | if (seenBase) return { ok: false, error: "--base named twice" }; | |
| 50 | if (value === undefined || !REV.test(value)) | |
| 51 | return { ok: false, error: "--base needs a revision such as origin/main" }; | |
| 52 | base = value; | |
| 53 | seenBase = true; | |
| 54 | } else return { ok: false, error: `unknown argument ${arg.slice(0, 40)}` }; | |
| 55 | } | |
| 56 | return { ok: true, base, format: format ?? "lines" }; | |
| 57 | } | |
| 58 | ||
| 59 | /** Paths from `git diff --name-only` / `ls-files` output: one per line, no repeats, none outside the repo. */ | |
| 60 | export function parseNames(...outputs: readonly string[]): string[] { | |
| 61 | const seen = new Set<string>(); | |
| 62 | for (const output of outputs) | |
| 63 | for (const line of output.split("\n")) { | |
| 64 | const path = line.trim(); | |
| 65 | if (path === "" || path.startsWith("/") || path.split("/").includes("..")) continue; | |
| 66 | seen.add(path); | |
| 67 | } | |
| 68 | return [...seen]; | |
| 69 | } | |
| 70 | ||
| 71 | export interface Report { | |
| 72 | /** Exactly what goes to stdout. */ | |
| 73 | readonly stdout: string; | |
| 74 | /** Words for a person, to stderr. */ | |
| 75 | readonly notes: string[]; | |
| 76 | readonly code: 0 | 2 | 3; | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * What to print: the files, nothing (3), or the full tier (2). Stdout never | |
| 81 | * carries a sentence, so word-splitting it cannot hand `bun test` a filter; | |
| 82 | * `--json` always carries the whole answer, `full` included. | |
| 83 | */ | |
| 84 | export function report( | |
| 85 | plan: { files: readonly string[]; why: ReadonlyMap<string, string> }, | |
| 86 | unbounded: string | undefined, | |
| 87 | format: Format, | |
| 88 | ): Report { | |
| 89 | const code = unbounded !== undefined ? 2 : plan.files.length === 0 ? 3 : 0; | |
| 90 | const files = unbounded !== undefined ? [] : plan.files; | |
| 91 | const notes = | |
| 92 | unbounded !== undefined | |
| 93 | ? [`fast: full tier: ${unbounded} changed; the diff is not bounded`] | |
| 94 | : files.length === 0 | |
| 95 | ? ["fast: the change reaches no test; nothing to run"] | |
| 96 | : []; | |
| 97 | if (format === "json") | |
| 98 | return { | |
| 99 | stdout: | |
| 100 | JSON.stringify({ | |
| 101 | full: unbounded ?? null, | |
| 102 | files: files.map((file) => ({ file, why: plan.why.get(file) ?? "" })), | |
| 103 | }) + "\n", | |
| 104 | notes, | |
| 105 | code, | |
| 106 | }; | |
| 107 | if (format === "null") return { stdout: files.map((file) => `${file}\0`).join(""), notes, code }; | |
| 108 | return { | |
| 109 | stdout: files | |
| 110 | .map((file) => (format === "why" ? `${file}\t${plan.why.get(file) ?? ""}\n` : `${file}\n`)) | |
| 111 | .join(""), | |
| 112 | notes, | |
| 113 | code, | |
| 114 | }; | |
| 115 | } | |
| 116 | ||
| 117 | function git(args: string[]): string { | |
| 118 | const run = Bun.spawnSync(["git", ...args], { stderr: "pipe" }); | |
| 119 | if (run.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${run.stderr.toString().trim()}`); | |
| 120 | return run.stdout.toString(); | |
| 121 | } | |
| 122 | ||
| 123 | function read(path: string): string | undefined { | |
| 124 | try { | |
| 125 | return readFileSync(path, "utf8"); | |
| 126 | } catch { | |
| 127 | return undefined; | |
| 128 | } | |
| 129 | } | |
| 130 | ||
| 131 | function loadTimes(): Map<string, number> { | |
| 132 | const one = (path: string): Map<string, number> => { | |
| 133 | const text = read(path); | |
| 134 | const parsed = text === undefined ? undefined : parseTimes(text); | |
| 135 | return parsed?.ok ? parsed.value : new Map(); | |
| 136 | }; | |
| 137 | return overlayTimes( | |
| 138 | one(new URL("./test-times.json", import.meta.url).pathname), | |
| 139 | one(`${CHECK_CACHE}/test-times.json`), | |
| 140 | ); | |
| 141 | } | |
| 142 | ||
| 143 | function main(): number { | |
| 144 | const args = parseArgs(process.argv.slice(2)); | |
| 145 | if (!args.ok) { | |
| 146 | console.error(`fast: ${args.error}`); | |
| 147 | return 2; | |
| 148 | } | |
| 149 | let changed: string[]; | |
| 150 | let gateAdds: string[] | undefined; | |
| 151 | try { | |
| 152 | const from = git(["merge-base", args.base, "HEAD"]).trim(); | |
| 153 | changed = parseNames( | |
| 154 | git(["diff", "--name-only", "--no-renames", from]), | |
| 155 | git(["ls-files", "--others", "--exclude-standard"]), | |
| 156 | ); | |
| 157 | if (changed.includes(GATE_FILE)) | |
| 158 | gateAdds = gateListAdds(git(["diff", "-U0", "--no-renames", from, "--", GATE_FILE])); | |
| 159 | } catch (error) { | |
| 160 | console.error(`fast: ${(error as Error).message}`); | |
| 161 | return 2; | |
| 162 | } | |
| 163 | const tests = gateFiles(); | |
| 164 | const handed = handedImpact(process.env["FORGE_CHECK_IMPACT"]); | |
| 165 | const impact = readImpact( | |
| 166 | handed ?? new URL("./test-impact.json", import.meta.url).pathname, | |
| 167 | handed ?? "scripts/test-impact.json", | |
| 168 | (line) => console.error(`fast: ${line}`), | |
| 169 | ); | |
| 170 | const imports = importsReader(); | |
| 171 | const plan = fastSet({ | |
| 172 | ...(impact === undefined ? {} : { impact }), | |
| 173 | changed, | |
| 174 | ...(gateAdds === undefined ? {} : { gateAdds }), | |
| 175 | tests, | |
| 176 | imports, | |
| 177 | times: loadTimes(), | |
| 178 | }); | |
| 179 | const out = report(plan, needsFull(changed, gateAdds), args.format); | |
| 180 | process.stdout.write(out.stdout); | |
| 181 | for (const note of out.notes) console.error(note); | |
| 182 | if (out.code === 0) | |
| 183 | console.error( | |
| 184 | `fast: ${plan.files.length} of ${tests.length} files for ${changed.length} changed, expected ${duration(plan.expected)}${plan.overBudget ? " (over the 5m budget)" : ""}`, | |
| 185 | ); | |
| 186 | return out.code; | |
| 187 | } | |
| 188 | ||
| 189 | if (import.meta.main) process.exit(main()); |
scripts/test-shards.ts +4 −24
| @@ -68,7 +68,8 @@ import { | ||
| 68 | 68 | import { gateFiles, gateRefusal, presentTests } from "./gate.ts"; |
| 69 | 69 | import { cgroupMemory, summaryLines } from "./gate-summary.ts"; |
| 70 | 70 | import { GATE_FILE, executedFiles, formatImpact, gateListAdds, handedImpact, mergeImpact, parseImpact } from "../src/checks/impact.ts"; |
| 71 | import { fastSet, importsOf, duration } from "../src/checks/tiers.ts"; | |
| 71 | import { importsReader, readImpact } from "./tier-input.ts"; | |
| 72 | import { fastSet, duration } from "../src/checks/tiers.ts"; | |
| 72 | 73 | import { CHECK_CACHE } from "../src/checks/cache.ts"; |
| 73 | 74 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 74 | 75 | |
| @@ -259,29 +260,8 @@ if (tier === "fast") { | ||
| 259 | 260 | ); |
| 260 | 261 | if (gateDiff.exitCode === 0) gateAdds = gateListAdds(gateDiff.stdout.toString()); |
| 261 | 262 | } |
| 262 | const cache = new Map<string, string[]>(); | |
| 263 | const imports = (file: string): string[] => { | |
| 264 | let found = cache.get(file); | |
| 265 | if (found === undefined) { | |
| 266 | try { | |
| 267 | found = importsOf(readFileSync(file, "utf8"), file).flatMap((path) => | |
| 268 | /\.[cm]?[jt]sx?$/.test(path) ? [path] : [path, `${path}.ts`], | |
| 269 | ); | |
| 270 | } catch { | |
| 271 | found = []; | |
| 272 | } | |
| 273 | cache.set(file, found); | |
| 274 | } | |
| 275 | return found; | |
| 276 | }; | |
| 277 | let impact: Map<string, string[]> | undefined; | |
| 278 | try { | |
| 279 | const parsed = parseImpact(readFileSync(IMPACT, "utf8")); | |
| 280 | if (parsed.ok) impact = parsed.value; | |
| 281 | else console.log(`test-shards: ${IMPACT_NAME} refused (${parsed.error}); guessing by imports`); | |
| 282 | } catch { | |
| 283 | // no map yet: the import guess stands | |
| 284 | } | |
| 263 | const imports = importsReader(); | |
| 264 | const impact = readImpact(IMPACT, IMPACT_NAME, (line) => console.log(`test-shards: ${line}`)); | |
| 285 | 265 | const plan = fastSet({ |
| 286 | 266 | ...(impact === undefined ? {} : { impact }), |
| 287 | 267 | changed, |
scripts/tier-input.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * What `fastSet` reads from the working tree, for the two scripts that ask | |
| 3 | * it: `test-shards.ts --tier fast` and `fast.ts`. One reading, so the | |
| 4 | * check and the builder's preview cannot drift apart. | |
| 5 | */ | |
| 6 | ||
| 7 | import { readFileSync } from "node:fs"; | |
| 8 | import { importsOf } from "../src/checks/tiers.ts"; | |
| 9 | import { parseImpact } from "../src/checks/impact.ts"; | |
| 10 | ||
| 11 | /** A file's relative imports as candidate paths (an extensionless one also as `.ts`); `[]` when unreadable. Memoised. */ | |
| 12 | export function importsReader(): (file: string) => string[] { | |
| 13 | const cache = new Map<string, string[]>(); | |
| 14 | return (file) => { | |
| 15 | let found = cache.get(file); | |
| 16 | if (found === undefined) { | |
| 17 | try { | |
| 18 | found = importsOf(readFileSync(file, "utf8"), file).flatMap((path) => | |
| 19 | /\.[cm]?[jt]sx?$/.test(path) ? [path] : [path, `${path}.ts`], | |
| 20 | ); | |
| 21 | } catch { | |
| 22 | found = []; | |
| 23 | } | |
| 24 | cache.set(file, found); | |
| 25 | } | |
| 26 | return found; | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * The impact map at `path`, or `undefined` when there is none yet (the | |
| 32 | * import guess stands) or it is refused (said through `say`). | |
| 33 | */ | |
| 34 | export function readImpact( | |
| 35 | path: string, | |
| 36 | name: string, | |
| 37 | say: (line: string) => void, | |
| 38 | ): Map<string, string[]> | undefined { | |
| 39 | try { | |
| 40 | const parsed = parseImpact(readFileSync(path, "utf8")); | |
| 41 | if (parsed.ok) return parsed.value; | |
| 42 | say(`${name} refused (${parsed.error}); guessing by imports`); | |
| 43 | } catch { | |
| 44 | // no map yet | |
| 45 | } | |
| 46 | return undefined; | |
| 47 | } |
test/scripts-fast.test.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { parseArgs, parseNames, report } from "../scripts/fast.ts"; | |
| 6 | ||
| 7 | test("parseArgs defaults to origin/main and takes --base and --why", () => { | |
| 8 | expect(parseArgs([])).toEqual({ ok: true, base: "origin/main", format: "lines" }); | |
| 9 | expect(parseArgs(["--base", "main", "--why"])).toEqual({ ok: true, base: "main", format: "why" }); | |
| 10 | expect(parseArgs(["--base=HEAD~3"])).toEqual({ ok: true, base: "HEAD~3", format: "lines" }); | |
| 11 | }); | |
| 12 | ||
| 13 | test("parseArgs refuses flags-as-revisions, repeats and strangers", () => { | |
| 14 | for (const bad of [["--base"], ["--base", "--output=x"], ["--base", "a b"], ["--base", "a", "--base", "b"], ["--nope"]]) | |
| 15 | expect(parseArgs(bad).ok).toBe(false); | |
| 16 | }); | |
| 17 | ||
| 18 | test("parseNames joins outputs, drops blanks, repeats and paths outside the repo", () => { | |
| 19 | expect(parseNames("a.ts\n\nsrc/b.ts\n", "src/b.ts\n../x\n/etc/passwd\ntest/c.test.ts")).toEqual([ | |
| 20 | "a.ts", | |
| 21 | "src/b.ts", | |
| 22 | "test/c.test.ts", | |
| 23 | ]); | |
| 24 | }); | |
| 25 | ||
| 26 | const plan = { files: ["test/a.test.ts"], why: new Map([["test/a.test.ts", "changed"]]) }; | |
| 27 | ||
| 28 | test("parseArgs takes one output format", () => { | |
| 29 | expect(parseArgs(["--null"])).toEqual({ ok: true, base: "origin/main", format: "null" }); | |
| 30 | expect(parseArgs(["--json", "--why"]).ok).toBe(false); | |
| 31 | }); | |
| 32 | ||
| 33 | test("report lists files in each format, sentences only in notes", () => { | |
| 34 | expect(report(plan, undefined, "lines")).toEqual({ stdout: "test/a.test.ts\n", notes: [], code: 0 }); | |
| 35 | expect(report(plan, undefined, "why").stdout).toBe("test/a.test.ts\tchanged\n"); | |
| 36 | expect(report(plan, undefined, "null").stdout).toBe("test/a.test.ts\0"); | |
| 37 | expect(JSON.parse(report(plan, undefined, "json").stdout)).toEqual({ | |
| 38 | full: null, | |
| 39 | files: [{ file: "test/a.test.ts", why: "changed" }], | |
| 40 | }); | |
| 41 | }); | |
| 42 | ||
| 43 | test("an unbounded diff is exit 2 with nothing on stdout but json", () => { | |
| 44 | for (const format of ["lines", "why", "null"] as const) { | |
| 45 | const out = report(plan, "package.json", format); | |
| 46 | expect(out.code).toBe(2); | |
| 47 | expect(out.stdout).toBe(""); | |
| 48 | expect(out.notes[0]).toContain("full tier"); | |
| 49 | } | |
| 50 | expect(JSON.parse(report(plan, "package.json", "json").stdout)).toEqual({ full: "package.json", files: [] }); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a diff that reaches no test is exit 3 with nothing on stdout", () => { | |
| 54 | const out = report({ files: [], why: new Map() }, undefined, "lines"); | |
| 55 | expect(out).toEqual({ stdout: "", notes: ["fast: the change reaches no test; nothing to run"], code: 3 }); | |
| 56 | }); | |
| 57 | ||
| 58 | // The script itself, against a throwaway repository. | |
| 59 | const SCRIPT = new URL("../scripts/fast.ts", import.meta.url).pathname; | |
| 60 | ||
| 61 | function repo(): string { | |
| 62 | const dir = mkdtempSync(join(process.env["TMPDIR"] ?? tmpdir(), "fast-")); | |
| 63 | const git = (...args: string[]): void => { | |
| 64 | const run = Bun.spawnSync( | |
| 65 | ["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], | |
| 66 | { cwd: dir }, | |
| 67 | ); | |
| 68 | if (run.exitCode !== 0) throw new Error(run.stderr.toString()); | |
| 69 | }; | |
| 70 | mkdirSync(join(dir, "src")); | |
| 71 | mkdirSync(join(dir, "test")); | |
| 72 | writeFileSync(join(dir, "src/widget.ts"), "export const a = 1;\n"); | |
| 73 | writeFileSync(join(dir, "src/other.ts"), "export const b = 1;\n"); | |
| 74 | writeFileSync(join(dir, "test/widget.test.ts"), "export {};\n"); | |
| 75 | writeFileSync(join(dir, "test/other.test.ts"), "export {};\n"); | |
| 76 | writeFileSync(join(dir, "package.json"), "{}\n"); | |
| 77 | git("init", "-q", "-b", "main"); | |
| 78 | git("add", "-A"); | |
| 79 | git("commit", "-qm", "base"); | |
| 80 | git("checkout", "-qb", "work"); | |
| 81 | return dir; | |
| 82 | } | |
| 83 | ||
| 84 | function run(dir: string, ...args: string[]): { out: string; err: string; code: number } { | |
| 85 | const env = { ...process.env }; | |
| 86 | delete env["FORGE_CHECK_IMPACT"]; | |
| 87 | const done = Bun.spawnSync(["bun", SCRIPT, ...args], { cwd: dir, env }); | |
| 88 | return { out: done.stdout.toString(), err: done.stderr.toString(), code: done.exitCode ?? -1 }; | |
| 89 | } | |
| 90 | ||
| 91 | test("a changed source file lists the test it names, committed or not, with --why", () => { | |
| 92 | const dir = repo(); | |
| 93 | try { | |
| 94 | writeFileSync(join(dir, "src/widget.ts"), "export const a = 2;\n"); | |
| 95 | const plain = run(dir, "--base", "main"); | |
| 96 | expect(plain.code).toBe(0); | |
| 97 | expect(plain.out.trim().split("\n")).toEqual(["test/widget.test.ts"]); | |
| 98 | const why = run(dir, "--base", "main", "--why"); | |
| 99 | expect(why.out).toContain("test/widget.test.ts\tnamed for src/widget.ts"); | |
| 100 | // Untracked counts too. | |
| 101 | writeFileSync(join(dir, "src/other.ts"), "export const b = 2;\n"); | |
| 102 | expect(run(dir, "--base", "main").out.trim().split("\n")).toEqual([ | |
| 103 | "test/other.test.ts", | |
| 104 | "test/widget.test.ts", | |
| 105 | ]); | |
| 106 | } finally { | |
| 107 | rmSync(dir, { recursive: true, force: true }); | |
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | test("an unbounded diff exits 2 with the full-tier line and no file list", () => { | |
| 112 | const dir = repo(); | |
| 113 | try { | |
| 114 | writeFileSync(join(dir, "package.json"), '{"a":1}\n'); | |
| 115 | const out = run(dir, "--base", "main"); | |
| 116 | expect(out.code).toBe(2); | |
| 117 | expect(out.out).toBe(""); | |
| 118 | expect(out.err).toContain("full tier: package.json changed"); | |
| 119 | const json = run(dir, "--base", "main", "--json"); | |
| 120 | expect(json.code).toBe(2); | |
| 121 | expect(JSON.parse(json.out)).toEqual({ full: "package.json", files: [] }); | |
| 122 | } finally { | |
| 123 | rmSync(dir, { recursive: true, force: true }); | |
| 124 | } | |
| 125 | }); | |
| 126 | ||
| 127 | test("a base git cannot find exits 2 with a message", () => { | |
| 128 | const dir = repo(); | |
| 129 | try { | |
| 130 | const out = run(dir, "--base", "nope"); | |
| 131 | expect(out.code).toBe(2); | |
| 132 | expect(out.err).toContain("fast:"); | |
| 133 | } finally { | |
| 134 | rmSync(dir, { recursive: true, force: true }); | |
| 135 | } | |
| 136 | }); | |
| 137 | ||
| 138 | test("a clean tree and a docs-only diff exit 3 with empty stdout, so `&&` stops bun test", () => { | |
| 139 | const dir = repo(); | |
| 140 | try { | |
| 141 | const clean = run(dir, "--base", "main"); | |
| 142 | expect([clean.code, clean.out]).toEqual([3, ""]); | |
| 143 | expect(clean.err).toContain("nothing to run"); | |
| 144 | writeFileSync(join(dir, "NOTES.md"), "hi\n"); | |
| 145 | expect(run(dir, "--base", "main", "--null").code).toBe(3); | |
| 146 | } finally { | |
| 147 | rmSync(dir, { recursive: true, force: true }); | |
| 148 | } | |
| 149 | }); | |
| 150 | ||
| 151 | test("--null and --json carry the files without word-splitting", () => { | |
| 152 | const dir = repo(); | |
| 153 | try { | |
| 154 | writeFileSync(join(dir, "src/widget.ts"), "export const a = 2;\n"); | |
| 155 | expect(run(dir, "--base", "main", "--null").out).toBe("test/widget.test.ts\0"); | |
| 156 | expect(JSON.parse(run(dir, "--base", "main", "--json").out).files[0].file).toBe("test/widget.test.ts"); | |
| 157 | } finally { | |
| 158 | rmSync(dir, { recursive: true, force: true }); | |
| 159 | } | |
| 160 | }); |
new above
offline