Kaynağa Gözat

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.
Chinesezjc 1 ay önce
ebeveyn
işleme
8e2785d9eb

+ 11 - 0
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.

+ 2 - 1
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<string, unknown>
     delete own.languages
     delete own.language

+ 31 - 7
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) }
     }