mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user