From 863d32af5a49065b6abf96d006845b6b507ca8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 10:52:04 +0200 Subject: [PATCH] test(ai): refuse feature-flags mocks that leave the loader's reader out A structural check over src/ and tests/: every `vi.mock` / `vi.doMock` of `@/lib/feature-flags` either uses the shared switches factory or names `loadAssistantSwitches`. The matcher is whitespace-tolerant, asserts a non-zero match count, and checks itself against planted one-line, split, automock and `import()` forms. --- .../feature-flags-mock-guard.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/__tests__/feature-flags-mock-guard.test.ts diff --git a/src/__tests__/feature-flags-mock-guard.test.ts b/src/__tests__/feature-flags-mock-guard.test.ts new file mode 100644 index 000000000..15d83e52d --- /dev/null +++ b/src/__tests__/feature-flags-mock-guard.test.ts @@ -0,0 +1,188 @@ +/** + * Every mock of `@/lib/feature-flags` answers the capability loader too. + * + * The module has two readers of the operator's assistant switches. The AI + * capability loader (`src/lib/ai/capabilities/load.ts`) reads + * `loadAssistantSwitches()`; the crons and a few routes read + * `getAssistantFlags()`. A mock that stubs only the second leaves the first + * undefined, the loader throws, catches its own throw, and resolves every + * capability to `check_failed`. Nothing goes red. A positive control then + * quietly exercises the "AI unavailable" branch, and a negative case passes + * for reason `check_failed` rather than the reason it names. That shipped in + * an integration test whose owner-side control enqueued nothing and whose + * delegate-side refusal proved nothing, and eight unit files carried the same + * shape. + * + * So a mock of the module must say what the loader reads: either through + * `mockAssistantSwitches` (`./helpers/assistant-switches-mock.ts`), which + * points both readers at one source, or by naming `loadAssistantSwitches` in + * its factory. A spread of the real module does not count: the real reader + * then runs against whatever the file did to `@/lib/db`, which is the same + * failure one layer down. An automock (no factory) does not count either; its + * `loadAssistantSwitches` returns `undefined`. + * + * Its limit: it reads the factory's text, not its behaviour. A factory that + * names `loadAssistantSwitches` and returns nonsense from it passes here; the + * assertion that it returns what the test means belongs in the test. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { stripComments, walkSourceFiles } from "./helpers/source-files"; + +const SELF = "src/__tests__/feature-flags-mock-guard.test.ts"; + +const ROOTS = [ + { root: join(process.cwd(), "src"), label: "src", floor: 3000 }, + { root: join(process.cwd(), "tests"), label: "tests", floor: 100 }, +]; + +/** + * The start of a mock of the module: `vi.mock(` or `vi.doMock(`, the path in + * any quote style or as `import("…")`, with any whitespace and line breaks + * between the tokens. Prettier splits long calls across lines, so a matcher + * that assumed one line would find nothing and pass. + */ +const MOCK_START = + /\bvi\s*\.\s*(?:doMock|mock)\s*\(\s*(?:import\s*\(\s*)?(["'`])@\/lib\/feature-flags(?:\/index(?:\.ts)?)?\1/g; + +/** What a factory has to contain for the loader's reader to be answered. */ +const ANSWERS_THE_LOADER = + /\bmockAssistantSwitches\s*\(|\bloadAssistantSwitches\b/; + +interface MockSite { + file: string; + line: number; + /** The call's full argument text, from its `(` to the matching `)`. */ + call: string; +} + +/** + * The argument text of the call that opens at `open` (the index of its `(`), + * by bracket balance. Good enough for test factories; it skips over string + * literals so a `)` inside one does not close the call early. + */ +function callText(source: string, open: number): string { + let depth = 0; + let quote: string | null = null; + for (let i = open; i < source.length; i += 1) { + const ch = source[i]; + if (quote) { + if (ch === "\\") i += 1; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") quote = ch; + else if (ch === "(") depth += 1; + else if (ch === ")") { + depth -= 1; + if (depth === 0) return source.slice(open + 1, i); + } + } + throw new Error("unbalanced vi.mock call"); +} + +function findFeatureFlagMocks( + files: ReadonlyArray<{ file: string; source: string }>, +): MockSite[] { + const sites: MockSite[] = []; + for (const { file, source } of files) { + const code = stripComments(source); + for (const match of code.matchAll(MOCK_START)) { + const open = code.indexOf("(", match.index); + sites.push({ + file, + line: code.slice(0, match.index).split("\n").length, + call: callText(code, open), + }); + } + } + return sites; +} + +function repoFiles(): Array<{ file: string; source: string }> { + return ROOTS.flatMap(({ root, label, floor }) => + walkSourceFiles(root, { floor }) + .filter((rel) => !rel.startsWith("generated/")) + // This file plants offending mocks as string literals to test its own + // matcher; the sweep reads everything else. + .filter((rel) => `${label}/${rel}` !== SELF) + .map((rel) => ({ + file: `${label}/${rel}`, + source: readFileSync(join(root, rel), "utf8"), + })), + ); +} + +describe("mocks of @/lib/feature-flags", () => { + const sites = findFeatureFlagMocks(repoFiles()); + + it("finds the mocks it is meant to police", () => { + // Pinned at the count on the day this was written. An empty set agrees + // with every rule below, so a matcher that stopped matching would pass + // them all; this is what fails instead. + expect(sites.length).toBeGreaterThanOrEqual(10); + }); + + it("every one answers the capability loader's reader", () => { + const offenders = sites + .filter((site) => !ANSWERS_THE_LOADER.test(site.call)) + .map((site) => `${site.file}:${site.line}`); + expect( + offenders, + "These mocks leave `loadAssistantSwitches` unanswered, so the AI " + + "capability loader throws and every capability reads `check_failed`. " + + "Use `mockAssistantSwitches` from src/__tests__/helpers/" + + "assistant-switches-mock.ts, or name `loadAssistantSwitches` in the " + + "factory.", + ).toEqual([]); + }); + + it("the matcher catches the shapes it claims to", () => { + // The rule, run against planted sources rather than the tree, so a + // matcher that went blind shows here and not only as a clean sweep. + const planted = findFeatureFlagMocks([ + { + file: "one-line.test.ts", + source: `vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: vi.fn() }));`, + }, + { + file: "split.test.ts", + source: `vi\n .mock(\n '@/lib/feature-flags',\n async (orig) => ({ ...(await orig()), getAssistantFlags: vi.fn() }),\n);`, + }, + { file: "automock.test.ts", source: `vi.mock("@/lib/feature-flags");` }, + { + file: "do-mock.test.ts", + source: `vi.doMock(import("@/lib/feature-flags"), () => ({}));`, + }, + { + file: "commented.test.ts", + source: `// vi.mock("@/lib/feature-flags", () => ({}));`, + }, + { + file: "helper.test.ts", + source: `vi.mock("@/lib/feature-flags", async () =>\n (await import("x")).mockAssistantSwitches(() => f()),\n);`, + }, + { + file: "named.test.ts", + source: `vi.mock("@/lib/feature-flags", () => ({ loadAssistantSwitches: vi.fn(async () => null) }));`, + }, + { + file: "other-module.test.ts", + source: `vi.mock("@/lib/feature-flags-extra", () => ({}));`, + }, + ]); + const verdict = Object.fromEntries( + planted.map((site) => [site.file, ANSWERS_THE_LOADER.test(site.call)]), + ); + expect(verdict).toEqual({ + "one-line.test.ts": false, + "split.test.ts": false, + "automock.test.ts": false, + "do-mock.test.ts": false, + "helper.test.ts": true, + "named.test.ts": true, + }); + }); +});