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.
This commit is contained in:
@@ -197,10 +197,11 @@ function labelLength(value: unknown): number {
|
||||
}
|
||||
|
||||
describe('i18n locale coverage', () => {
|
||||
const ALLOWED_MISSING_KEYS = new Set([
|
||||
'changelog.new_0_5_4_7',
|
||||
'chat.sessionNotFound',
|
||||
])
|
||||
// The changelog treats each release's change blurb as a key, but those live
|
||||
// in packages/client/src/data/changelog.ts, not in the locale files.
|
||||
// chat.sessionNotFound used to be exempt here too, but it is defined in both
|
||||
// locales, so the exemption only hid real gaps.
|
||||
const ALLOWED_MISSING_KEYS = new Set(['changelog.new_0_5_4_7'])
|
||||
|
||||
beforeAll(() => {
|
||||
for (const l of supportedLocales) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -269,7 +269,7 @@ describe('MarkdownRenderer', () => {
|
||||
it('renders local mov links as inline video players', () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '[录屏2026-05-08 15.19.46.mov](/Users/ekko/Desktop/录屏2026-05-08%2015.19.46.mov)',
|
||||
content: '[录屏2026-05-08 15.19.46.mov](/Users/tester/Desktop/录屏2026-05-08%2015.19.46.mov)',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('MarkdownRenderer', () => {
|
||||
expect(video.exists()).toBe(true)
|
||||
expect(video.attributes('src')).toContain('/api/hermes/download?path=')
|
||||
const src = new URL(video.attributes('src'))
|
||||
expect(decodeURIComponent(src.searchParams.get('path') || '')).toBe('/Users/ekko/Desktop/录屏2026-05-08 15.19.46.mov')
|
||||
expect(decodeURIComponent(src.searchParams.get('path') || '')).toBe('/Users/tester/Desktop/录屏2026-05-08 15.19.46.mov')
|
||||
expect(wrapper.find('.markdown-video-footer .att-name').text()).toBe('录屏2026-05-08 15.19.46.mov')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import ArcoVue from '@arco-design/web-vue'
|
||||
|
||||
const messageMock = vi.hoisted(() => ({
|
||||
@@ -46,6 +46,22 @@ function buttonByText(wrapper: any, text: string) {
|
||||
}
|
||||
|
||||
describe('PluginsView management actions', () => {
|
||||
// a-spin drives its indicator with requestAnimationFrame. Without unmounting,
|
||||
// those callbacks land after jsdom tears down and surface as an unhandled
|
||||
// "Cannot read properties of null (reading '_location')" roughly a third of
|
||||
// the time. Unmount before the environment goes away.
|
||||
const wrappers: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (wrappers.length) wrappers.pop()?.unmount()
|
||||
})
|
||||
|
||||
const mountView = () => {
|
||||
const wrapper = mount(PluginsView, { global: arcoGlobal })
|
||||
wrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
profilesStoreMock.activeProfileName = 'default'
|
||||
@@ -73,7 +89,7 @@ describe('PluginsView management actions', () => {
|
||||
})
|
||||
|
||||
it('enables manageable standalone plugins and refreshes the inventory', async () => {
|
||||
const wrapper = mount(PluginsView, { global: arcoGlobal })
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
await buttonByText(wrapper, 'common.enable').trigger('click')
|
||||
@@ -105,7 +121,7 @@ describe('PluginsView management actions', () => {
|
||||
metadata: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PluginsView, { global: arcoGlobal })
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('plugins.managedElsewhere')
|
||||
|
||||
Reference in New Issue
Block a user