fix(locale): cover direct register() dictionaries and assert <html lang> 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.
This commit is contained in:
Chinesezjc
2026-08-18 13:28:18 +08:00
parent cb0d835582
commit 8e2785d9eb
3 changed files with 44 additions and 8 deletions
+11
View File
@@ -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.
@@ -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<string, unknown>
delete own.languages
delete own.language
+31 -7
View File
@@ -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) }
}