Public: anyone can read this; the crew can change it.
A file that is red in the full suite and green when run alone is filed as flaky under load, not as red #12302
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12296-a-file-that-is-red-in-the-full-suite-and → main 5 commits fork
5 files
changed +387
−1
src/checks/rerun.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * A red full-suite verification runs each failing file once more on its own | |
| 3 | * (#12296). A file green alone is flaky under load: a builder cannot fix it, | |
| 4 | * so the report carries it as `flakyUnderLoad`, not as a plain red. A file red | |
| 5 | * alone stays red. The re-runs share one time budget; a file the budget did | |
| 6 | * not reach is not classified and stays a plain red, and a pass that ended | |
| 7 | * past it is "over": not trusted as green, not called red either. | |
| 8 | * | |
| 9 | * Plain data in (the files, a runner, the budget), plain data out (a verdict | |
| 10 | * per file and the lines for the verification's log). | |
| 11 | */ | |
| 12 | ||
| 13 | /** | |
| 14 | * How long all of one verification's re-runs may take together. They sit on | |
| 15 | * the gate's path (#1242): one file alone takes about 1 s (a unit file) to | |
| 16 | * 6 s (one that boots the checks service), so a minute re-runs the few files | |
| 17 | * a flake under load leaves red; a suite that needs more is not load. | |
| 18 | */ | |
| 19 | export const RERUN_BUDGET_MS = 60_000; | |
| 20 | /** The most files re-run; more is a broken suite, not load. */ | |
| 21 | export const MAX_RERUN_FILES = 10; | |
| 22 | ||
| 23 | const PATH = /^[\w@./+-]{1,200}\.test\.[cm]?[jt]sx?$/; | |
| 24 | ||
| 25 | export type Alone = "green" | "red" | "over" | "skipped"; | |
| 26 | ||
| 27 | export interface Rerun { | |
| 28 | readonly file: string; | |
| 29 | readonly alone: Alone; | |
| 30 | /** How long its re-run took; 0 when skipped. */ | |
| 31 | readonly ms: number; | |
| 32 | } | |
| 33 | ||
| 34 | /** Runs one test file alone within `timeoutMs`; true when it passed. */ | |
| 35 | export type FileRunner = (file: string, timeoutMs: number) => Promise<boolean>; | |
| 36 | ||
| 37 | /** Whether a name from the suite's own output is a test file path safe to hand a shell. */ | |
| 38 | export const rerunnableFile = (file: string): boolean => | |
| 39 | PATH.test(file) && !file.split("/").includes(".."); | |
| 40 | ||
| 41 | /** `bun test` of the one file, as a path so no other file matches it. */ | |
| 42 | export const rerunFileCommand = (file: string): string => | |
| 43 | `bun test '${file.startsWith("./") ? file : `./${file}`}'`; | |
| 44 | ||
| 45 | /** Each failing file once more, alone, in order, while the budget lasts. */ | |
| 46 | export async function rerunAlone( | |
| 47 | files: readonly string[], | |
| 48 | run: FileRunner, | |
| 49 | options: { budgetMs?: number; now?: () => number } = {}, | |
| 50 | ): Promise<Rerun[]> { | |
| 51 | const budget = options.budgetMs ?? RERUN_BUDGET_MS; | |
| 52 | const now = options.now ?? Date.now; | |
| 53 | const start = now(); | |
| 54 | const out: Rerun[] = []; | |
| 55 | for (const [i, file] of files.entries()) { | |
| 56 | const left = budget - (now() - start); | |
| 57 | if (i >= MAX_RERUN_FILES || left <= 0 || !rerunnableFile(file)) { | |
| 58 | out.push({ file, alone: "skipped", ms: 0 }); | |
| 59 | continue; | |
| 60 | } | |
| 61 | const began = now(); | |
| 62 | let green = false; | |
| 63 | try { | |
| 64 | green = await run(file, left); | |
| 65 | } catch { | |
| 66 | green = false; | |
| 67 | } | |
| 68 | const ms = now() - began; | |
| 69 | // A pass that overran the budget is not trusted as green, nor is it red. | |
| 70 | const alone: Alone = !green ? "red" : now() - start <= budget ? "green" : "over"; | |
| 71 | out.push({ file, alone, ms }); | |
| 72 | } | |
| 73 | return out; | |
| 74 | } | |
| 75 | ||
| 76 | /** The files green alone: the report's `flakyUnderLoad`. */ | |
| 77 | export const flakyUnderLoad = (results: readonly Rerun[]): string[] => | |
| 78 | results.filter((r) => r.alone === "green").map((r) => r.file); | |
| 79 | ||
| 80 | /** A red detail with the files green alone named on it; unchanged when there are none. */ | |
| 81 | export function withFlaky<T extends { readonly files?: readonly string[] }>( | |
| 82 | detail: T | undefined, | |
| 83 | flaky: readonly string[], | |
| 84 | ): (T & { readonly flakyUnderLoad?: readonly string[] }) | undefined { | |
| 85 | if (detail === undefined || flaky.length === 0) return detail; | |
| 86 | const files = detail.files ?? []; | |
| 87 | const named = flaky.filter((f) => files.includes(f)); | |
| 88 | return named.length === 0 ? detail : { ...detail, flakyUnderLoad: named }; | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * One line per file for the verification's log, | |
| 93 | * e.g. `rerun: test/x.test.ts green alone in 3.1 s`. | |
| 94 | */ | |
| 95 | export const rerunLines = (results: readonly Rerun[]): string[] => | |
| 96 | results.map((r) => { | |
| 97 | const took = `${(r.ms / 1000).toFixed(1)} s`; | |
| 98 | if (r.alone === "skipped") return `rerun: ${r.file} not re-run (budget or limit reached, or not a test path)`; | |
| 99 | if (r.alone === "over") return `rerun: ${r.file} passed alone in ${took}, past the budget: not counted`; | |
| 100 | return `rerun: ${r.file} ${r.alone} alone in ${took}`; | |
| 101 | }); | |
| 102 | ||
| 103 | /** What the red verification knows when it decides whether to re-run. */ | |
| 104 | export interface RedRun { | |
| 105 | /** The failing files the suite's output named. */ | |
| 106 | readonly files: readonly string[]; | |
| 107 | /** Ran as a cluster Job: there is no checkout here to re-run in. */ | |
| 108 | readonly inJob: boolean; | |
| 109 | /** The step was killed by a signal it did not send: no verdict to refine. */ | |
| 110 | readonly killed: boolean; | |
| 111 | /** The run was cancelled. */ | |
| 112 | readonly aborted: boolean; | |
| 113 | } | |
| 114 | ||
| 115 | /** | |
| 116 | * The red verification's re-runs end to end: nothing for a Job, a killed step | |
| 117 | * or a cancelled run; otherwise each file alone, its line in the log, and the | |
| 118 | * files green alone returned. A runner that throws is logged, never raised. | |
| 119 | */ | |
| 120 | export async function rerunRed( | |
| 121 | red: RedRun, | |
| 122 | run: FileRunner, | |
| 123 | write: (line: string) => void, | |
| 124 | options: { budgetMs?: number; now?: () => number } = {}, | |
| 125 | ): Promise<string[]> { | |
| 126 | if (red.files.length === 0 || red.inJob || red.killed || red.aborted) return []; | |
| 127 | try { | |
| 128 | const results = await rerunAlone(red.files, run, options); | |
| 129 | rerunLines(results).forEach(write); | |
| 130 | return flakyUnderLoad(results); | |
| 131 | } catch (error) { | |
| 132 | write(`rerun failed: ${String(error)}`); | |
| 133 | return []; | |
| 134 | } | |
| 135 | } |
src/checks/service.ts +19 −1
| @@ -79,6 +79,7 @@ import { IMPACT_FILE, keepImpact, seedImpact } from "./impact-store.ts"; | ||
| 79 | 79 | import { KEPT_FILE, keepKept, seedKept } from "./kept-store.ts"; |
| 80 | 80 | import { afterVerify, closeOnGreen } from "./culprit.ts"; |
| 81 | 81 | import { redDetail, reportGreen, reportRed } from "./report.ts"; |
| 82 | import { rerunFileCommand, rerunRed, withFlaky } from "./rerun.ts"; | |
| 82 | 83 | import { |
| 83 | 84 | bisectOrigin, |
| 84 | 85 | failedFiles, |
| @@ -1596,6 +1597,23 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1596 | 1597 | // A red verification keeps the map it measured all the same, and |
| 1597 | 1598 | // names the files it failed in for the bisection (#7786). |
| 1598 | 1599 | const files = x.tier === "verify" ? failedFiles(checkLines) : []; |
| 1600 | // Each failing file once more on its own (#12296): green alone is | |
| 1601 | // flaky under load, which a builder cannot fix; red alone stays red. | |
| 1602 | const flaky = await rerunRed( | |
| 1603 | { files, inJob: job !== undefined, killed, aborted: signal.aborted }, | |
| 1604 | async (file, timeoutMs) => { | |
| 1605 | const again = await runStep("rerun", rerunFileCommand(file), { | |
| 1606 | cwd: dir, | |
| 1607 | env, | |
| 1608 | log: write, | |
| 1609 | signal, | |
| 1610 | timeoutMs: Math.min(timeoutMs, manifest.timeout * 1000), | |
| 1611 | cell, | |
| 1612 | }); | |
| 1613 | return again.ok; | |
| 1614 | }, | |
| 1615 | write, | |
| 1616 | ); | |
| 1599 | 1617 | if (x.tier === "verify" && x.keepImpact === true) { |
| 1600 | 1618 | try { |
| 1601 | 1619 | const kept = keepImpact(app.config.dataDir, repo.id, dir); |
| @@ -1609,7 +1627,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1609 | 1627 | return { |
| 1610 | 1628 | ok: false, |
| 1611 | 1629 | error: killed ? ran.error : summary, |
| 1612 | detail: redDetail(detail, files, checkLines), | |
| 1630 | detail: withFlaky(redDetail(detail, files, checkLines), flaky), | |
| 1613 | 1631 | ...(wholeVerify ? { passed: passedFiles(checkLines) } : {}), |
| 1614 | 1632 | stage: checkStage(ran.error), |
| 1615 | 1633 | ...(killed ? { killed } : {}), |
src/core/runs.ts +2
| @@ -63,6 +63,8 @@ export interface CheckDetail { | ||
| 63 | 63 | readonly shards?: readonly { readonly file: string; readonly shard: string; readonly slice: readonly string[] }[]; |
| 64 | 64 | /** How many files or shards the full suite ran side by side, as its banner said (#12295). */ |
| 65 | 65 | readonly concurrency?: string; |
| 66 | /** Of `files`, those that ran green when re-run alone: flaky under load, not red (#12296). */ | |
| 67 | readonly flakyUnderLoad?: readonly string[]; | |
| 66 | 68 | /** The check machine's free disk share (0..1) when a red finished (#5453). */ |
| 67 | 69 | readonly diskFree?: number; |
| 68 | 70 | /** `temp_full`: a shard ran out of temp space, the environment's fault, not the change's (#8679). */ |
test/check-verify-rerun.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * A red verification runs each failing file once more alone (#12296), | |
| 3 | * through the checks service: green alone is `flakyUnderLoad` on the run's | |
| 4 | * detail, red alone stays red, and each re-run has its `rerun:` line in the | |
| 5 | * log. A step killed by a signal is not re-run. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; | |
| 14 | import { VERIFY_ENV } from "../src/checks/verify.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import { getRun, listRuns, type Run } from "../src/core/runs.ts"; | |
| 17 | import { createUser, type User } from "../src/core/users.ts"; | |
| 18 | import { readRunLog } from "../src/runs/log.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let alice: User; | |
| 26 | let repo: Repo; | |
| 27 | let checks: ChecksHandle; | |
| 28 | ||
| 29 | /** The full suite's output: both files failed beside each other. */ | |
| 30 | const bothRed = `printf 't/a.test.ts:\\n(fail) a > works\\nt/b.test.ts:\\n(fail) b > works\\n 0 pass\\n 2 fail\\n'`; | |
| 31 | ||
| 32 | const testFile = (name: string, pass: boolean): string => | |
| 33 | `import { test, expect, describe } from "bun:test";\ndescribe("${name}", () => { test("works", () => { expect(${pass ? 1 : 2}).toBe(1); }); });\n`; | |
| 34 | ||
| 35 | async function push(verify: string, note: string): Promise<string> { | |
| 36 | const pushed = await commitToBranch(app, alice, repo, { | |
| 37 | message: note, | |
| 38 | files: [ | |
| 39 | { path: "forge.json", content: JSON.stringify({ check: { run: "true", verify, install: "" } }) }, | |
| 40 | { path: "t/a.test.ts", content: `// ${note}\n${testFile("a", true)}` }, | |
| 41 | { path: "t/b.test.ts", content: testFile("b", false) }, | |
| 42 | ], | |
| 43 | }); | |
| 44 | if (!pushed.ok) throw new Error(JSON.stringify(pushed.error)); | |
| 45 | return pushed.value.commitOid; | |
| 46 | } | |
| 47 | ||
| 48 | async function until<T>(read: () => T | undefined, ms = 40_000): Promise<T> { | |
| 49 | const deadline = Date.now() + ms; | |
| 50 | for (;;) { | |
| 51 | const got = read(); | |
| 52 | if (got !== undefined) return got; | |
| 53 | if (Date.now() > deadline) throw new Error("timed out"); | |
| 54 | await Bun.sleep(100); // real-timer: polls a check that runs real bun test children | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | const finished = (env: string, oid: string): Run | undefined => | |
| 59 | listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 60 | (r) => r.env === env && r.commitOid === oid && (r.state === "ok" || r.state === "failed"), | |
| 61 | ); | |
| 62 | ||
| 63 | async function verify(oid: string, at: number): Promise<{ run: Run; log: string }> { | |
| 64 | await until(() => finished(repo.defaultBranch, oid)); | |
| 65 | expect(await checks.verifyDue(at)).toHaveLength(1); | |
| 66 | const run = await until(() => finished(VERIFY_ENV, oid)); | |
| 67 | return { run, log: readRunLog(app.config.dataDir, run.id).lines.join("\n") }; | |
| 68 | } | |
| 69 | ||
| 70 | beforeAll(async () => { | |
| 71 | process.env["FORGE_CELLS"] = "off"; | |
| 72 | dir = await mkdtemp(join(tmpdir(), "check-verify-rerun-")); | |
| 73 | app = createApp({ dataDir: dir, port: 0, host: "forge.test" }); | |
| 74 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 75 | if (!u.ok) throw new Error(u.error); | |
| 76 | alice = u.value; | |
| 77 | const made = await createRepo(app, alice, { name: "reran", visibility: "public" }); | |
| 78 | if (!made.ok) throw new Error("repo"); | |
| 79 | repo = made.value; | |
| 80 | checks = startChecks(app, { forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 81 | }); | |
| 82 | afterAll(async () => { | |
| 83 | await checks.stop(); | |
| 84 | await rm(dir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | test("a file green alone is flaky under load on the run; a file red alone is not", async () => { | |
| 88 | const oid = await push(`${bothRed}; exit 1`, "red together"); | |
| 89 | const { run, log } = await verify(oid, Date.now() + 31 * 60_000); | |
| 90 | expect(run.state).toBe("failed"); | |
| 91 | const detail = getRun(app.db, run.id)?.checkDetail; | |
| 92 | expect(detail?.files).toEqual(["t/a.test.ts", "t/b.test.ts"]); | |
| 93 | expect(detail?.flakyUnderLoad).toEqual(["t/a.test.ts"]); | |
| 94 | expect(log).toMatch(/rerun: t\/a\.test\.ts green alone in \d+\.\d s/); | |
| 95 | expect(log).toMatch(/rerun: t\/b\.test\.ts red alone in \d+\.\d s/); | |
| 96 | }, 60_000); | |
| 97 | ||
| 98 | test("a verification killed by a signal re-runs nothing", async () => { | |
| 99 | const oid = await push(`${bothRed}; kill -KILL $$`, "killed"); | |
| 100 | const { run, log } = await verify(oid, Date.now() + 62 * 60_000); | |
| 101 | expect(run.state).toBe("failed"); | |
| 102 | expect(log).toContain("was killed by SIGKILL"); | |
| 103 | expect(log).not.toContain("rerun:"); | |
| 104 | expect(getRun(app.db, run.id)?.checkDetail?.flakyUnderLoad).toBeUndefined(); | |
| 105 | }, 60_000); |
test/checks-rerun.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { | |
| 3 | flakyUnderLoad, | |
| 4 | MAX_RERUN_FILES, | |
| 5 | rerunAlone, | |
| 6 | rerunFileCommand, | |
| 7 | rerunLines, | |
| 8 | RERUN_BUDGET_MS, | |
| 9 | rerunnableFile, | |
| 10 | rerunRed, | |
| 11 | withFlaky, | |
| 12 | } from "../src/checks/rerun.ts"; | |
| 13 | ||
| 14 | /** A clock the runner moves: each run takes `ms[file]`. */ | |
| 15 | function clocked(ms: Record<string, number>, green: Set<string>) { | |
| 16 | let t = 0; | |
| 17 | const ran: string[] = []; | |
| 18 | return { | |
| 19 | now: () => t, | |
| 20 | ran, | |
| 21 | run: async (file: string) => { | |
| 22 | ran.push(file); | |
| 23 | t += ms[file] ?? 0; | |
| 24 | return green.has(file); | |
| 25 | }, | |
| 26 | }; | |
| 27 | } | |
| 28 | ||
| 29 | describe("rerunAlone (#12296)", () => { | |
| 30 | test("a file green alone is flaky under load; a file red alone stays red", async () => { | |
| 31 | const c = clocked({ "test/a.test.ts": 3100, "test/b.test.ts": 200 }, new Set(["test/a.test.ts"])); | |
| 32 | const results = await rerunAlone(["test/a.test.ts", "test/b.test.ts"], c.run, { now: c.now }); | |
| 33 | expect(results).toEqual([ | |
| 34 | { file: "test/a.test.ts", alone: "green", ms: 3100 }, | |
| 35 | { file: "test/b.test.ts", alone: "red", ms: 200 }, | |
| 36 | ]); | |
| 37 | expect(flakyUnderLoad(results)).toEqual(["test/a.test.ts"]); | |
| 38 | expect(rerunLines(results)).toEqual([ | |
| 39 | "rerun: test/a.test.ts green alone in 3.1 s", | |
| 40 | "rerun: test/b.test.ts red alone in 0.2 s", | |
| 41 | ]); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the budget bounds the re-runs: files past it are not run and not flaky", async () => { | |
| 45 | const files = ["test/a.test.ts", "test/b.test.ts", "test/c.test.ts"]; | |
| 46 | const c = clocked({ "test/a.test.ts": 600, "test/b.test.ts": 600 }, new Set(files)); | |
| 47 | const results = await rerunAlone(files, c.run, { now: c.now, budgetMs: 1000 }); | |
| 48 | expect(c.ran).toEqual(["test/a.test.ts", "test/b.test.ts"]); | |
| 49 | // b passed but overran the budget: not trusted as green, and not red. | |
| 50 | expect(results.map((r) => r.alone)).toEqual(["green", "over", "skipped"]); | |
| 51 | expect(flakyUnderLoad(results)).toEqual(["test/a.test.ts"]); | |
| 52 | expect(rerunLines(results)).toEqual([ | |
| 53 | "rerun: test/a.test.ts green alone in 0.6 s", | |
| 54 | "rerun: test/b.test.ts passed alone in 0.6 s, past the budget: not counted", | |
| 55 | "rerun: test/c.test.ts not re-run (budget or limit reached, or not a test path)", | |
| 56 | ]); | |
| 57 | }); | |
| 58 | ||
| 59 | test("the runner is given what is left of the budget", async () => { | |
| 60 | const given: number[] = []; | |
| 61 | let t = 0; | |
| 62 | await rerunAlone( | |
| 63 | ["test/a.test.ts", "test/b.test.ts"], | |
| 64 | async (_f, left) => { | |
| 65 | given.push(left); | |
| 66 | t += 400; | |
| 67 | return true; | |
| 68 | }, | |
| 69 | { now: () => t, budgetMs: 1000 }, | |
| 70 | ); | |
| 71 | expect(given).toEqual([1000, 600]); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a runner that throws is red; unsafe paths and files past the limit are skipped", async () => { | |
| 75 | const many = Array.from({ length: MAX_RERUN_FILES + 1 }, (_, i) => `test/f${i}.test.ts`); | |
| 76 | const results = await rerunAlone(["test/x.test.ts; rm -rf /", "../up.test.ts", ...many], async (f) => { | |
| 77 | if (f === "test/f0.test.ts") throw new Error("boom"); | |
| 78 | return true; | |
| 79 | }, { now: () => 0 }); | |
| 80 | expect(results[0]!.alone).toBe("skipped"); | |
| 81 | expect(results[1]!.alone).toBe("skipped"); | |
| 82 | expect(results[2]!.alone).toBe("red"); | |
| 83 | expect(results.at(-1)!.alone).toBe("skipped"); | |
| 84 | }); | |
| 85 | }); | |
| 86 | ||
| 87 | describe("rerunRed (#12296)", () => { | |
| 88 | const files = ["test/a.test.ts", "test/b.test.ts"]; | |
| 89 | const red = { files, inJob: false, killed: false, aborted: false }; | |
| 90 | ||
| 91 | test("re-runs each file, writes a line per file and returns those green alone", async () => { | |
| 92 | const lines: string[] = []; | |
| 93 | const flaky = await rerunRed(red, async (f) => f === "test/a.test.ts", (l) => lines.push(l), { now: () => 0 }); | |
| 94 | expect(flaky).toEqual(["test/a.test.ts"]); | |
| 95 | expect(lines).toEqual(["rerun: test/a.test.ts green alone in 0.0 s", "rerun: test/b.test.ts red alone in 0.0 s"]); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a cluster Job, a killed step or a cancelled run re-runs nothing and writes nothing", async () => { | |
| 99 | for (const not of [{ inJob: true }, { killed: true }, { aborted: true }, { files: [] }]) { | |
| 100 | const lines: string[] = []; | |
| 101 | let ran = 0; | |
| 102 | const flaky = await rerunRed({ ...red, ...not }, async () => (ran++, true), (l) => lines.push(l)); | |
| 103 | expect([flaky, lines, ran]).toEqual([[], [], 0]); | |
| 104 | } | |
| 105 | }); | |
| 106 | ||
| 107 | test("the budget is a minute: it bounds what the re-runs add to the gate", () => { | |
| 108 | expect(RERUN_BUDGET_MS).toBe(60_000); | |
| 109 | }); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("helpers", () => { | |
| 113 | test("only plain test paths are rerunnable, and the command quotes the path", () => { | |
| 114 | expect(rerunnableFile("test/app-home.test.ts")).toBe(true); | |
| 115 | expect(rerunnableFile("test/a'b.test.ts")).toBe(false); | |
| 116 | expect(rerunnableFile("src/x.ts")).toBe(false); | |
| 117 | expect(rerunFileCommand("test/a.test.ts")).toBe("bun test './test/a.test.ts'"); | |
| 118 | }); | |
| 119 | ||
| 120 | test("withFlaky names only failing files, and leaves a detail with none untouched", () => { | |
| 121 | const detail = { passed: 1, failed: 2, names: [], files: ["test/a.test.ts", "test/b.test.ts"] }; | |
| 122 | expect(withFlaky(detail, [])).toBe(detail); | |
| 123 | expect(withFlaky(detail, ["test/a.test.ts", "test/z.test.ts"])?.flakyUnderLoad).toEqual(["test/a.test.ts"]); | |
| 124 | expect(withFlaky(undefined, ["test/a.test.ts"])).toBeUndefined(); | |
| 125 | }); | |
| 126 | }); |
new above
offline