/** * Guard: one fake `t` in the test suite, the marked one of `src/test/i18nMock.ts` (#389). * * A local fake `t` that returns the key as is hides a forgotten call to `t` * (#387 review). The migration moved every test file to `i18nMock`; this guard * keeps it that way. It reads the sources, as `transactions-transfer-icon.test.ts` * does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the * module itself that: * 1. declares a `useTranslation:` property (a hand-written `react-i18next` module); * 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a * `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a * function that takes `t`); * 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name * alone, in a comment for instance, is not enough: comments are dropped * before the search). * * Keywords rather than an analysis of the factory: less fragile. The matches * are anchored on a word boundary, so `useLicenseContext: () =>`, * `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This * file names the patterns only inside regular expressions and excludes itself. */ import { describe, it, expect } from "vitest"; import { readdirSync, readFileSync } from "fs"; import { relative, resolve, sep } from "path"; const SRC = resolve(import.meta.dirname, ".."); const MODULE = resolve(SRC, "test", "i18nMock.ts"); const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts"); const LOCAL_MODULE = /\buseTranslation\s*:/; const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/; const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/; const CALLS_I18NMOCK = /\bi18nMock\s*\(/; /** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */ function withoutComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); } /** The offending lines of one source, as `path:line: text`. */ function offences(path: string, source: string): string[] { const name = relative(SRC, path).split(sep).join("/"); const found = source .split("\n") .flatMap((line, i) => LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [], ); if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) { found.push(`${name}: mocks react-i18next without i18nMock`); } return found; } const TEST_FILES = (readdirSync(SRC, { recursive: true }) as string[]) .filter((entry) => /\.test\.tsx?$/.test(entry)) .map((entry) => resolve(SRC, entry)) .filter((path) => path !== MODULE && path !== SELF); describe("i18n mock guard (#389)", () => { it("scans the test files of src, recursively", () => { expect(TEST_FILES.length).toBeGreaterThan(100); expect(TEST_FILES).toContain(resolve(SRC, "components", "settings", "LicenseCard.render.test.tsx")); expect(TEST_FILES).toContain(resolve(SRC, "pages", "settings", "UsersSettingsPage.test.tsx")); }); it("recognises a local fake, and only that", () => { const local = [ 'vi.mock("react-i18next", () => ({', " useTranslation: () => ({ t: (k: string) => k }),", "}));", ].join("\n"); expect(offences(resolve(SRC, "x.test.tsx"), local)).toHaveLength(2); expect(offences(resolve(SRC, "x.test.tsx"), 'vi.mock("react-i18next", () => fake);')).toHaveLength(1); const clean = [ 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());', "const context = { useLicenseContext: () => ctx, get: (target: object) => target };", "await view.removeKey({ confirm, t: markedT });", "const at = (n: number) => n; const format = (v: string) => v;", "if (t === (other as unknown)) return; const run = (t) => t;", "const t = markedT; const set = { t: markedT };", ].join("\n"); expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]); }); it("recognises a fake t bound to a name or built with vi.fn", () => { const x = resolve(SRC, "x.test.ts"); expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1); expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1); expect(offences(x, " t = (key: string) => key;")).toHaveLength(1); expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1); expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1); }); it("requires an actual call to i18nMock(, not its name in a comment", () => { const x = resolve(SRC, "x.test.tsx"); const inComment = [ "// Not i18nMock(): a hand-written module.", "/* i18nMock( would be the pattern */", 'vi.mock("react-i18next", () => fake);', ].join("\n"); expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]); expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1); const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));'; expect(offences(x, called)).toEqual([]); }); it("finds no other fake t than the one of src/test/i18nMock.ts", () => { const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8"))); expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]); }); });