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.
111 lines
5.0 KiB
TypeScript
111 lines
5.0 KiB
TypeScript
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([])
|
||
})
|
||
})
|