import { describe, expect, it } from "vitest"; import ts from "typescript"; import { readSources } from "./rawSources.testkit"; // A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says // it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is // unknown, and i18next's options take any value — so the slips are caught here, on the syntax: // - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]"; // - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`) // stays in the language it was raised in. const isT = (e: ts.Expression) => (ts.isIdentifier(e) && e.text === "t") || (ts.isPropertyAccessExpression(e) && e.name.text === "t"); const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression => ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text); const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression); function mixedNotifText(name: string, src: string): string[] { const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const out: string[] = []; const at = (n: ts.Node, what: string) => out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`); const visit = (n: ts.Node) => { if (isTCall(n)) { for (const arg of n.arguments.slice(1)) { if (!ts.isObjectLiteralExpression(arg)) continue; for (const p of arg.properties) { if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"])) at(p, "a notice value handed to t()"); } } } if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0])) at(n, "a said text kept in a notice"); if ( ts.isPropertyAssignment(n) && ts.isIdentifier(n.name) && n.name.text === "detail" && isTCall(n.initializer) ) at(n, "a said text kept as a notice's detail"); ts.forEachChild(n, visit); }; visit(file); return out; } describe("a notice's text and a plain t() do not mix", () => { it("holds across src", () => { const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]); expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]); }); it.each([ ['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"], ['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"], ['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"], ['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"], ['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"], ])("sees %s", (src, what) => { expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]); }); it.each([ 'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })', 't("channels.discovery.confirmSubscribe", { name: nameOf(c) })', "sayNotif(msg(key), t)", ])("lets %s through", (src) => { expect(mixedNotifText("x.tsx", src)).toEqual([]); }); });