Files
hermes-ui/tests/client/i18n-parity.test.ts
T
toor cc644c59f6 Phase 008: close the test and CI loop around the migration
Adds the checks that make the constraints from Phase 001-007 mechanical
instead of tribal knowledge, and clears the legacy brand text that was still
reachable at runtime.

New red-line tests:
  - no-legacy-brand mirrors the harness scan under vitest, and asserts its
    allow-list has not drifted from harness-check.mjs
  - hermes-db-readonly classifies every new DatabaseSync call site by
    following the path it opens, and fails if a state.db handle drops
    readOnly. It also pins the one legitimately writable database.
  - i18n-parity compares the raw zh/en locale objects both ways. The
    existing coverage test merged en into zh at import time, so a key
    missing from zh could never fail there.

harness:check gains the four missing gates from the phase brief - retired
package directories, retired package scripts, a single UI library, and the
required root files. Each was verified to fail by mutating the repo.

The online-update channel had hardcoded upstream URLs. It now reads
HERMES_WEB_UI_VERSION_MANIFEST_URL and friends and degrades with a named
error, because a reimplementation has nowhere to pull releases from.

The remaining legacy strings are deliberate and each carries a reason in the
allow-list: the global-agent relay address and the OpenRouter referer are
load-bearing, the socket role identifier is a wire contract, and the pre-rename
DB filenames would orphan existing deployments if changed. AGENTS.md now says
so explicitly, so a future agent does not "clean them up".

Also removes a dead electron mock alias, an i18n exemption that had stopped
exempting anything, and a teardown race in plugins-view.test.ts where a-spin
animation frames outlived the jsdom environment.

Verified self-contained: with reference_code/ moved aside, harness:check,
build, test and test:e2e all pass, so the directory can now be deleted.
2026-10-19 00:23:19 +08:00

111 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
// 直接 import 未 merge 的裸 locale 对象。
//
// 刻意**不**用 `@/i18n/messages` 的 messages:那里 zh 已经和 en 做过
// mergeMessagesWithFallback(见 packages/client/src/i18n/messages.ts),
// zh 缺的 key 会自动从 en 补上,于是「zh 缺 key」这件事在运行时永远看不出来。
// 本文件要卡的是**源文件层面**的对等,所以必须用裸对象。
import en from '@/i18n/locales/en'
import zh from '@/i18n/locales/zh'
type Messages = Record<string, unknown>
/**
* 关于 tests/client/i18n-coverage.test.ts 里的
* `ALLOWED_MISSING_KEYS = { 'changelog.new_0_5_4_7', 'chat.sessionNotFound' }`:
*
* 实测结论(Phase 008 §3.1 逐 key 核对 en.ts / zh.ts 原文):
* - `chat.sessionNotFound` —— **en 与 zh 都有**(en.ts:665 / zh.ts:665,
* 分别在各自 `chat` 命名空间下)。它出现在 i18n-coverage 的豁免表里属于
* 历史遗留/过宽豁免,对本测试**不适用**,因此这里不做任何豁免。
* - `changelog.new_0_5_4_7` —— **en 与 zh 都没有**。它是 changelog 的版本条目名
* (packages/client/src/data/changelog.ts 的数据),从来不是 locale key;
* i18n-coverage 把 changelog 的 change 文案也当成 key 去查 en,才会需要豁免。
* 两个 locale 同时缺失,不构成「单边缺 key」,因此这里同样不需要豁免。
*
* 换句话说:这两个 key 在本测试里**都不是**单边差异,豁免它们只会把真实缺陷
* 藏起来。若将来真的出现单边差异,正确做法是补齐缺失一侧的 locale 文件,
* 而不是往这里加豁免。
*/
type LeafKind = 'string' | 'object' | 'other'
/** 递归 flatten:返回 完整 key 路径 -> 叶子类型(命名空间本身也记为 'object') */
function flatten(source: Messages, prefix = '', out = new Map<string, LeafKind>()) {
for (const [key, value] of Object.entries(source)) {
const fullKey = prefix ? `${prefix}.${key}` : key
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
out.set(fullKey, 'object')
flatten(value as Messages, fullKey, out)
} else {
out.set(fullKey, typeof value === 'string' ? 'string' : 'other')
}
}
return out
}
const enKeys = flatten(en as Messages)
const zhKeys = flatten(zh as Messages)
const missingInZh = [...enKeys.keys()].filter(key => !zhKeys.has(key)).sort()
const missingInEn = [...zhKeys.keys()].filter(key => !enKeys.has(key)).sort()
const typeMismatches = [...enKeys.keys()]
.filter(key => zhKeys.has(key) && zhKeys.get(key) !== enKeys.get(key))
.sort()
.map(key => `${key}: en=${enKeys.get(key)} / zh=${zhKeys.get(key)}`)
describe('i18n locale parity between zh and en', () => {
it('flattens a non-trivial key space from both locales', () => {
// 防止 import 路径写错、或 flatten 逻辑改坏导致两边都变成空集合、永远绿灯
expect(enKeys.size).toBeGreaterThan(1000)
expect(zhKeys.size).toBeGreaterThan(1000)
// 抽查几个嵌套命名空间,确认递归真的展开了层级
for (const key of ['chat.sessionNotFound', 'chat.renameSession']) {
expect(enKeys.get(key), `en 缺少嵌套 key ${key}`).toBe('string')
expect(zhKeys.get(key), `zh 缺少嵌套 key ${key}`).toBe('string')
}
})
it('defines every English key in Chinese', () => {
expect(
missingInZh,
missingInZh.length === 0 ? '' : [
`${missingInZh.length} 个 key 在 en.ts 里有、zh.ts 里没有:`,
...missingInZh.map(key => ` ${key}`),
'',
'修复方式:在 packages/client/src/i18n/locales/zh.ts 对应的命名空间下补上中文。',
'注意不要改本文件来豁免它们——单边缺失会让线上退化成英文文案。',
].join('\n'),
).toEqual([])
})
it('defines every Chinese key in English', () => {
expect(
missingInEn,
missingInEn.length === 0 ? '' : [
`${missingInEn.length} 个 key 在 zh.ts 里有、en.ts 里没有:`,
...missingInEn.map(key => ` ${key}`),
'',
'修复方式:在 packages/client/src/i18n/locales/en.ts 对应的命名空间下补上英文。',
'英文是 fallback 语言,缺失会让中文用户在切换语言时看到空白。',
].join('\n'),
).toEqual([])
})
it('keeps the leaf type of every shared key consistent', () => {
// 例如 en 里某个 key 是命名空间对象、zh 里却是字符串:运行时会在插值时炸掉,
// 或者某一侧多出一层嵌套导致 t('a.b') 查不到值。
expect(
typeMismatches,
typeMismatches.length === 0 ? '' : [
'以下 key 在 en 与 zh 的叶子类型不一致(string / object / other):',
...typeMismatches.map(entry => ` ${entry}`),
'',
'修复方式:把两侧的层级结构改成一致(要么都是字符串,要么都是同层命名空间)。',
].join('\n'),
).toEqual([])
})
})