From 8e2785d9eb4f8ca3181a781fbdfb25437b23cf0f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:28:18 +0800 Subject: [PATCH] fix(locale): cover direct register() dictionaries and assert assembled The parity gate recognized only a `[['zh',{...}],['en',{...}]]` array, so the two separate ctx.locale.register(NS, 'zh'|'en', {...}) calls in ui-permission-presets were unchecked: deleting a key from one side left the gate green. Pair those calls by their namespace argument. Widen the pre-filter to admit zhSettings/accessZh spellings, which a bare \b(zh|en)\b misses and would have skipped before parsing. Assert document.documentElement.lang in the assembled app. The served markup already ships lang="en", so the fr-FR scenario passes whether or not the sync runs; the zh scenario is the discriminating half and now asserts zh-CN before the switch and en after it. Drop the dead vi.unstubAllGlobals() from the document-language spec, which manages navigator with defineProperty and never calls vi.stubGlobal. --- apps/web/tests/settings-chrome.e2e.ts | 11 ++++++ .../tests/document-language.client.spec.ts | 3 +- scripts/locale-dictionary-parity.spec.ts | 38 +++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 216dae4dbb..61f5af88c5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -400,6 +400,11 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) await zhDialog.waitFor({ timeout: 10_000 }) + // The document language follows the active locale in the assembled app, not + // only on a directly-mounted plugin. This is a zh browser, so the served + // markup's `en` must already have been replaced — asserting it here (rather + // than only in an English scenario) is what makes the check discriminating. + expect(await page.evaluate(() => document.documentElement.lang)).toBe('zh-CN') // The Language selector pill shows the active locale's own name. const selector = zhDialog.getByRole('button', { name: '中文' }) expect(await selector.getAttribute('aria-haspopup')).toBe('menu') @@ -410,6 +415,8 @@ describe('web e2e: settings modal and General preferences', () => { // the rest of the app's copy is intentionally out of this row's scope.) const enDialog = page.getByRole('dialog', { name: 'Settings' }) await enDialog.waitFor({ timeout: 10_000 }) + // ...and the attribute follows that switch, in the assembled app. + await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() @@ -498,6 +505,10 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // The markup already ships `en`, so this alone cannot prove the sync ran + // — the zh scenario above is the discriminating half. Asserted here too + // so a future change that resolves en but writes the wrong tag is caught. + expect(await frPage.evaluate(() => document.documentElement.lang)).toBe('en') // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index b10a3e8e69..dc375ec4ce 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -61,7 +61,8 @@ describe('document language', () => { }) afterEach(() => { - vi.unstubAllGlobals() + // navigator properties are installed with defineProperty above, so they + // are removed the same way; nothing here goes through vi.stubGlobal. const own = navigator as unknown as Record delete own.languages delete own.language diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index 5284801ae4..b240c564ae 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -93,9 +93,12 @@ interface Dictionary { */ function dictionariesIn(file: string): Dictionary[] { const text = readFileSync(file, 'utf8') - // Cheap pre-filter: parsing every package source is wasteful, and a file - // with no locale token cannot declare a dictionary under any shape below. - if (!/\b(zh|en)\b/.test(text)) return [] + // Cheap pre-filter: parsing every package source is wasteful. The pattern + // must admit every shape `localeOf` accepts, or a file would be skipped + // before parsing — the silent narrowing this gate exists to prevent. A bare + // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold + // between `h` and an uppercase letter. + if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return [] const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) const found: Dictionary[] = [] const rel = relative(file) @@ -112,10 +115,29 @@ function dictionariesIn(file: string): Dictionary[] { } } - // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a - // registration loop in the plugin body. Both halves key off the enclosing - // array's line so they pair with each other and not across sites. + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair + // handed to a registration loop keys off the enclosing array; separate + // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the + // namespace argument, so the two calls pair with each other. const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression + const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + if (name === 'register' && node.arguments.length >= 3) { + const [ns, tag, dict] = node.arguments + const literal = unwrap(dict) + if ( + ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) + && (tag.text === 'zh' || tag.text === 'en') + && literal !== undefined && ts.isObjectLiteralExpression(literal) + ) { + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + } + } + } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 for (const element of node.elements) { @@ -167,7 +189,9 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin for (const locale of ['zh', 'en'] as const) { const other = locale === 'zh' ? 'Zh' : 'En' if (name === locale) return { locale, pair: '' } - if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + // Synthetic names for inline shapes carry their own pair key after the + // first ':' (the enclosing array's line, or the namespace expression). + if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { return { locale, pair: name.slice(2) } }