Files
TREK/client/tests/unit/i18n/i18nKeysScript.test.ts
T
Maurice c0516dd9bf fix(client): check each literal branch of a template key
A template key was read as a pattern in which every interpolation is a
wildcard, so t(`roadtrip.window.${a ? 'onRoute' : 'onRoutTypo'}`) passed
as long as some other roadtrip.window key existed, and the typo rendered
as raw text.

An interpolation that can only produce string literals (a ternary chain
of them, in parentheses or not) is now expanded, and each resulting key is
checked like a literal one. Thirteen call sites become literal keys that
way. Where data is involved, a literal fallback (`${role ?? 'editor'}`)
is still checked as a key, with the template kept as a pattern under
its own allow-list site. Literals in a condition (`phase === 'start'`)
are never taken for results.

Narrow templates over data still pass without an allow-list entry. The
script header now says plainly what that leaves unchecked: a runtime
value with no key under a narrow prefix, which needs the value's type.
2026-10-08 13:04:42 +02:00

167 lines
8.9 KiB
TypeScript

import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
DYNAMIC_ALLOWED,
MAX_IMPLICIT_MATCHES,
countMatches,
evaluate,
firstArgument,
interpolationResults,
readEnKeys,
scanSource,
scanTree,
templatePattern,
} from '../../../scripts/i18n-keys.mjs'
// FE-I18N-KEYS-001 to FE-I18N-KEYS-015: the client key check (scripts/i18n-keys.mjs).
const EN = new Set(['budget.title', 'places.count', 'places.count.one', 'trips.total.other', 'trips.total.one', 'costs.filter.all'])
describe('i18n key check', () => {
it('FE-I18N-KEYS-001: finds literal keys in t, tHtml, tr, TransHtml, translateApiError and *Key props', () => {
const { literal } = scanSource([
"t('budget.title')",
'props.t("places.count", { count })',
"tHtml('a.html')",
"tr('pdf.mapTitle')",
'<TransHtml html="journey.text" />',
"translateApiError(t, err, 'files.uploadError')",
"const tabs = [{ labelKey: 'tabs.one' }]",
'<Hint titleKey="hint.title" />',
].join('\n'))
expect(literal.map((l) => l.key)).toEqual([
'budget.title', 'places.count', 'a.html', 'pdf.mapTitle', 'journey.text', 'files.uploadError', 'tabs.one', 'hint.title',
])
})
it('FE-I18N-KEYS-002: checks both sides of a conditional key and ignores later arguments', () => {
const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })")
expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed'])
expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'")
})
it('FE-I18N-KEYS-003: reads templates, appended prefixes and key builders as patterns', () => {
const { literal, dynamic } = scanSource([
't(`costs.filter.${f}`)',
"t('costs.filter.' + f)",
't(`plain.key`)',
'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`',
'const cacheKey = (id: number): string => `${id} x`',
"const other = notATranslation('x.y')",
].join('\n'))
expect(literal.map((l) => l.key)).toEqual(['plain.key'])
expect(dynamic.map((d) => d.template)).toEqual(['costs.filter.${f}', 'costs.filter.${…}', 'help.guide.${id}.title'])
expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true)
expect(templatePattern('${opt.label}Hint')).toBeNull()
// An interpolation is one key segment: costs.filter.${f} does not reach a nested costs.filter.x.y.
expect(templatePattern('costs.filter.${f}')!.test('costs.filter.x.y')).toBe(false)
})
it('FE-I18N-KEYS-004: fails a literal key en lacks and accepts a plural group by its general form', () => {
const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })")
const { missing } = evaluate(scan, EN, [])
expect(missing.map((m) => m.key)).toEqual(['budget.addCategory'])
})
it('FE-I18N-KEYS-005: fails a template no en key matches unless it is allowed with the keys it resolves to', () => {
const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)')
expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint'])
const allowed = [{ file: '<source>', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }]
const result = evaluate(scan, EN, allowed)
expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}'])
expect(result.stale).toEqual([])
})
it('FE-I18N-KEYS-006: refuses an allow-list entry nothing uses or whose keys en lacks', () => {
const scan = scanSource('t(`${opt.label}Hint`)')
const unused = [{ file: '<source>', template: '${gone}', because: 'test', resolves: ['budget.title'] }]
expect(evaluate(scan, EN, unused).stale).toEqual(unused)
const wrong = [{ file: '<source>', template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }]
expect(evaluate(scan, EN, wrong).stale).toEqual(wrong)
const elsewhere = [{ file: 'other.tsx', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }]
expect(evaluate(scan, EN, elsewhere).stale).toEqual(elsewhere)
expect(evaluate(scan, EN, elsewhere).unmatched.map((u) => u.template)).toEqual(['${opt.label}Hint'])
})
it('FE-I18N-KEYS-007: reports en keys neither a literal nor a pattern reaches', () => {
const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)")
expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one'])
})
it('FE-I18N-KEYS-008: every allow-list entry names its file and a reason, and a prefix-less one its keys', () => {
for (const entry of DYNAMIC_ALLOWED) {
expect(entry.file).toMatch(/\.tsx?$/)
expect(entry.because.length).toBeGreaterThan(20)
if (templatePattern(entry.template) === null) expect('resolves' in entry && entry.resolves.length).toBeGreaterThan(0)
}
})
it('FE-I18N-KEYS-009: fails closed on a missing source tree or one without any key', () => {
expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/)
const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-'))
try {
expect(() => scanTree(empty)).toThrow(/scanner is broken/)
} finally {
rmSync(empty, { recursive: true, force: true })
}
})
it('FE-I18N-KEYS-010: the client source names no key en lacks today', async () => {
const enKeys = await readEnKeys()
const { missing, unmatched, broad, stale } = evaluate(scanTree(), enKeys)
expect(missing).toEqual([])
expect(unmatched).toEqual([])
expect(broad).toEqual([])
expect(stale).toEqual([])
})
// A table of en keys wider than the implicit bound: wide.k0 to wide.k<MAX>.
const WIDE = new Set([...EN, ...Array.from({ length: MAX_IMPLICIT_MATCHES + 1 }, (_, i) => `wide.k${i}`)])
it('FE-I18N-KEYS-011: counts a plural group once when measuring what a template reaches', () => {
expect(countMatches(templatePattern('trips.${x}')!, EN)).toBe(1)
expect(countMatches(templatePattern('wide.${x}')!, WIDE)).toBe(MAX_IMPLICIT_MATCHES + 1)
})
it('FE-I18N-KEYS-012: fails a template wider than the bound unless its file has an entry for it', () => {
const scan = scanSource('t(`wide.${x}`); t(`costs.filter.${f}`)', 'a.tsx')
const { broad, unmatched } = evaluate(scan, WIDE, [])
expect(broad.map((b) => [b.template, b.matches])).toEqual([['wide.${x}', MAX_IMPLICIT_MATCHES + 1]])
expect(unmatched).toEqual([])
const otherFile = [{ file: 'b.tsx', template: 'wide.${x}', because: 'test' }]
expect(evaluate(scan, WIDE, otherFile).broad).toHaveLength(1)
const ownFile = [{ file: 'a.tsx', template: 'wide.${x}', because: 'test' }]
expect(evaluate(scan, WIDE, ownFile)).toMatchObject({ broad: [], stale: [] })
})
it('FE-I18N-KEYS-013: refuses an entry for a narrow template, and an allowed template that matches nothing', () => {
const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`)', 'a.tsx')
const narrow = [{ file: 'a.tsx', template: 'costs.filter.${f}', because: 'test' }]
expect(evaluate(scan, EN, narrow).stale).toEqual(narrow)
const gone = [{ file: 'a.tsx', template: 'gone.prefix.${x}', because: 'test' }]
expect(evaluate(scan, EN, gone).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}'])
})
it('FE-I18N-KEYS-014: checks each literal branch of a template as a key, so a typo in one fails', () => {
const scan = scanSource("t(`costs.filter.${on ? 'all' : 'alll'}`)", 'a.tsx')
expect(scan.dynamic).toEqual([])
expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.all', 'costs.filter.alll'])
expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.alll'])
// Nested ternaries, parentheses and optional chaining in the condition; a literal in a comparison is no result.
expect(interpolationResults("(a?.b === 'x') ? 'p' : c ? ('q') : 'r'")).toEqual({ literals: ['p', 'q', 'r'], complete: true })
expect(interpolationResults("fn('a.b')")).toEqual({ literals: [], complete: false })
})
it('FE-I18N-KEYS-015: keeps a template with data in it a pattern and still checks its literal fallback', () => {
const scan = scanSource("t(`costs.filter.${role ?? 'nope'}`); t(`budget.${p === 'x' ? 'title' : p}`)", 'a.tsx')
expect(scan.dynamic.map((d) => d.template)).toEqual(["costs.filter.${role ?? 'nope'}", "budget.${p === 'x' ? 'title' : p}"])
expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.nope', 'budget.title'])
expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.nope'])
// A derived pattern is judged under its source template's allow-list entry.
const two = scanSource("t(`wide.${a ?? 'k0'}.${b}`)", 'a.tsx')
expect(two.dynamic.map((d) => d.site)).toEqual([undefined, "wide.${a ?? 'k0'}.${b}"])
})
})