- renderCategoryLabel.test.ts passes markedT itself, its assertions in the marked form (`t(balance.category.cash[defaultValue=cash])`): the second fake `t` of the repository is gone. - i18n-mock-guard: a fake `t` is also caught as a binding (`t = (`, `t = vi.fn(`) or as a `t: vi.fn(` property, and a file that mocks react-i18next must call `i18nMock(`, comments dropped before the search. Each new rule has its own case in the guard's tests; the binding rule found renderCategoryLabel.test.ts before its migration. - docs/architecture.md: the "only fake t" sentence is scoped to test files and says the guard only refuses the forms it recognises. Refs #389 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
5.4 KiB
TypeScript
112 lines
5.4 KiB
TypeScript
/**
|
|
* 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([]);
|
|
});
|
|
});
|