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.
300 lines
9.7 KiB
TypeScript
300 lines
9.7 KiB
TypeScript
import { describe, expect, it, beforeAll } from 'vitest'
|
|
import { readdirSync, readFileSync } from 'fs'
|
|
import { join, relative } from 'path'
|
|
|
|
import { changelog } from '@/data/changelog'
|
|
import { messages, supportedLocales } from '@/i18n/messages'
|
|
import en from '@/i18n/locales/en'
|
|
import zh from '@/i18n/locales/zh'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
const SOURCE_ROOT = join(process.cwd(), 'packages/client/src')
|
|
|
|
const allMessages: Record<string, Record<string, unknown>> = { en }
|
|
|
|
const rawMessages: Record<string, Record<string, unknown>> = {
|
|
en,
|
|
zh,
|
|
}
|
|
|
|
function walkFiles(dir: string, files: string[] = []): string[] {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
walkFiles(path, files)
|
|
} else if (/\.(ts|vue)$/.test(entry.name) && !path.replace(/\\/g, '/').includes('/i18n/locales/')) {
|
|
files.push(path)
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
function collectLiteralTranslationKeys(): string[] {
|
|
const keys = new Set<string>()
|
|
const translationCall = /(?:\b|\$)t\(\s*['"]([^'"]+)['"]/g
|
|
|
|
for (const file of walkFiles(SOURCE_ROOT)) {
|
|
const source = readFileSync(file, 'utf8')
|
|
for (const match of source.matchAll(translationCall)) {
|
|
keys.add(match[1])
|
|
}
|
|
}
|
|
|
|
for (const entry of changelog) {
|
|
for (const change of entry.changes) {
|
|
keys.add(change)
|
|
}
|
|
}
|
|
|
|
return [...keys].sort()
|
|
}
|
|
|
|
function getPath(messages: Record<string, unknown>, key: string): unknown {
|
|
let current: unknown = messages
|
|
for (const part of key.split('.')) {
|
|
if (!current || typeof current !== 'object' || !(part in current)) return undefined
|
|
current = (current as Record<string, unknown>)[part]
|
|
}
|
|
return current
|
|
}
|
|
|
|
function hasPath(messages: Record<string, unknown>, key: string): boolean {
|
|
return typeof getPath(messages, key) !== 'undefined'
|
|
}
|
|
|
|
const SKILLS_USAGE_LOCALIZED_KEYS = [
|
|
'sidebar.skillsUsage',
|
|
'skillsUsage.title',
|
|
'skillsUsage.subtitle',
|
|
'skillsUsage.refresh',
|
|
'skillsUsage.periodSelector',
|
|
'skillsUsage.periodLabel',
|
|
'skillsUsage.summary',
|
|
'skillsUsage.totalActions',
|
|
'skillsUsage.loads',
|
|
'skillsUsage.edits',
|
|
'skillsUsage.distinctSkills',
|
|
'skillsUsage.topSkills',
|
|
'skillsUsage.dailyTrend',
|
|
'skillsUsage.periodSummary',
|
|
'skillsUsage.skill',
|
|
'skillsUsage.share',
|
|
'skillsUsage.lastUsed',
|
|
'skillsUsage.noData',
|
|
'skillsUsage.loadFailed',
|
|
'skillsUsage.otherSkills',
|
|
]
|
|
|
|
const SKILLS_USAGE_COMPACT_LABEL_LIMITS: Record<string, number> = {
|
|
'skillsUsage.totalActions': 12,
|
|
'skillsUsage.loads': 10,
|
|
'skillsUsage.edits': 10,
|
|
'skillsUsage.distinctSkills': 12,
|
|
'skillsUsage.topSkills': 16,
|
|
'skillsUsage.dailyTrend': 16,
|
|
'skillsUsage.skill': 10,
|
|
'skillsUsage.share': 10,
|
|
'skillsUsage.lastUsed': 12,
|
|
'skillsUsage.otherSkills': 16,
|
|
}
|
|
|
|
const APPROVAL_AND_WRITE_GATE_LOCALIZED_KEYS = [
|
|
'chat.approvalAgree',
|
|
'skills.writeApprovalTitle',
|
|
'skills.writeApprovalDescription',
|
|
'skills.writeApprovalEmpty',
|
|
'skills.writeApprovalViewDiff',
|
|
'skills.writeApprovalNoDiff',
|
|
'skills.writeApprovalApprove',
|
|
'skills.writeApprovalReject',
|
|
'skills.writeApprovalPendingMemoryWrite',
|
|
'skills.writeApprovalCurrentFile',
|
|
'skills.writeApprovalPatchOldStringMissing',
|
|
'settings.session.requireAuth',
|
|
'settings.session.memoryWriteApproval',
|
|
'settings.session.skillsWriteApproval',
|
|
]
|
|
|
|
const PLATFORM_SETTINGS_LOCALE_SPECIFIC_LOCALIZED_KEYS: Record<string, string[]> = {
|
|
de: ['platform.qqAppId', 'platform.qqAppSecret'],
|
|
ja: ['platform.homeserver', 'platform.accountId'],
|
|
ko: ['platform.botToken', 'platform.accessToken', 'platform.homeserver', 'platform.weixinToken', 'platform.accountId'],
|
|
ru: ['platform.weixinToken'],
|
|
}
|
|
|
|
const PLATFORM_SETTINGS_LOCALIZED_KEYS = [
|
|
'platform.requireMention',
|
|
'platform.requireMentionGroup',
|
|
'platform.requireMentionChannel',
|
|
'platform.requireMentionRoom',
|
|
'platform.reactions',
|
|
'platform.reactionsHint',
|
|
'platform.freeResponseChats',
|
|
'platform.freeResponseChatsHint',
|
|
'platform.freeResponseChannels',
|
|
'platform.freeResponseChannelsHint',
|
|
'platform.freeResponseRooms',
|
|
'platform.freeResponseRoomsHint',
|
|
'platform.mentionPatterns',
|
|
'platform.mentionPatternsHint',
|
|
'platform.autoThread',
|
|
'platform.autoThreadHint',
|
|
'platform.autoThreadHintRoom',
|
|
'platform.dmMentionThreads',
|
|
'platform.dmMentionThreadsHint',
|
|
'platform.allowBots',
|
|
'platform.allowBotsHint',
|
|
'platform.allowedChannels',
|
|
'platform.allowedChannelsHint',
|
|
'platform.ignoredChannels',
|
|
'platform.ignoredChannelsHint',
|
|
'platform.noThreadChannels',
|
|
'platform.noThreadChannelsHint',
|
|
'platform.exclusiveTokenWarning',
|
|
'platform.botTokenHint',
|
|
'platform.accessTokenHint',
|
|
'platform.homeserverHint',
|
|
'platform.matrixUserId',
|
|
'platform.matrixUserIdHint',
|
|
'platform.matrixPassword',
|
|
'platform.matrixPasswordHint',
|
|
'platform.appIdHint',
|
|
'platform.appSecretHint',
|
|
'platform.encryptKey',
|
|
'platform.encryptKeyHint',
|
|
'platform.verificationToken',
|
|
'platform.verificationTokenHint',
|
|
'platform.clientIdHint',
|
|
'platform.clientSecretHint',
|
|
'platform.cardTemplateId',
|
|
'platform.cardTemplateIdHint',
|
|
'platform.botIdHint',
|
|
'platform.wecomSecretHint',
|
|
'platform.waEnabled',
|
|
'platform.waEnabledHint',
|
|
'platform.weixinTokenHint',
|
|
'platform.accountIdHint',
|
|
'platform.qrLogin',
|
|
'platform.qrRelogin',
|
|
'platform.qrFetching',
|
|
'platform.qrScanHint',
|
|
'platform.qrScanedHint',
|
|
'platform.qqAppIdHint',
|
|
'platform.qqAppSecretHint',
|
|
'platform.qqMarkdown',
|
|
'platform.qqMarkdownHint',
|
|
'platform.qqSandbox',
|
|
'platform.qqSandboxHint',
|
|
'platform.qqQrScanHint',
|
|
'platform.allowedUsers',
|
|
'platform.allowedUsersHint',
|
|
'platform.allowAllUsers',
|
|
'platform.allowAllUsersHint',
|
|
]
|
|
|
|
function labelLength(value: unknown): number {
|
|
return typeof value === 'string' ? Array.from(value.replace(/\{[^}]+\}/g, '')).length : Infinity
|
|
}
|
|
|
|
describe('i18n locale coverage', () => {
|
|
// 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) {
|
|
if (l !== 'en' && messages[l]) {
|
|
allMessages[l] = messages[l]
|
|
}
|
|
}
|
|
})
|
|
|
|
it('defines every statically referenced translation key in the English source locale', () => {
|
|
const missing = collectLiteralTranslationKeys()
|
|
.filter((key) => !hasPath(en, key))
|
|
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
|
|
|
expect(missing).toEqual([])
|
|
})
|
|
|
|
it('defines every statically referenced translation key in effective runtime messages', () => {
|
|
const requiredKeys = collectLiteralTranslationKeys()
|
|
const missing = Object.entries(allMessages).flatMap(([locale, localeMessages]) =>
|
|
requiredKeys
|
|
.filter((key) => !hasPath(localeMessages, key))
|
|
.filter((key) => !ALLOWED_MISSING_KEYS.has(key))
|
|
.map((key) => `${locale}: ${key}`),
|
|
)
|
|
|
|
expect(missing).toEqual([])
|
|
})
|
|
|
|
it('localizes Skills Usage page copy in every non-English locale instead of falling back to English', () => {
|
|
const englishMessages = messages.en
|
|
const untranslated = Object.entries(messages).flatMap(([locale, localeMessages]) => {
|
|
if (locale === 'en') return []
|
|
|
|
return SKILLS_USAGE_LOCALIZED_KEYS.flatMap((key) => {
|
|
const localeValue = getPath(localeMessages, key)
|
|
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
|
|
return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : []
|
|
})
|
|
})
|
|
|
|
expect(untranslated).toEqual([])
|
|
})
|
|
|
|
it('localizes approval and write-gate copy in every non-English locale instead of falling back to English', () => {
|
|
const englishMessages = messages.en
|
|
const untranslated = Object.entries(messages).flatMap(([locale, localeMessages]) => {
|
|
if (locale === 'en') return []
|
|
|
|
return APPROVAL_AND_WRITE_GATE_LOCALIZED_KEYS.flatMap((key) => {
|
|
const localeValue = getPath(localeMessages, key)
|
|
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
|
|
return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : []
|
|
})
|
|
})
|
|
|
|
expect(untranslated).toEqual([])
|
|
})
|
|
|
|
it('localizes platform settings copy in every raw non-English locale instead of falling back to English', () => {
|
|
const untranslated = Object.entries(rawMessages).flatMap(([locale, localeMessages]) => {
|
|
if (locale === 'en') return []
|
|
|
|
const localeKeys = [
|
|
...PLATFORM_SETTINGS_LOCALIZED_KEYS,
|
|
...(PLATFORM_SETTINGS_LOCALE_SPECIFIC_LOCALIZED_KEYS[locale] || []),
|
|
]
|
|
|
|
return localeKeys.flatMap((key) => {
|
|
const localeValue = getPath(localeMessages, key)
|
|
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
|
|
return localeValue === getPath(en, key) ? [`${locale}: ${key}`] : []
|
|
})
|
|
})
|
|
|
|
expect(untranslated).toEqual([])
|
|
})
|
|
|
|
it('keeps Skills Usage summary and table labels compact across locales', () => {
|
|
const oversized = Object.entries(messages).flatMap(([locale, localeMessages]) =>
|
|
Object.entries(SKILLS_USAGE_COMPACT_LABEL_LIMITS).flatMap(([key, maxLength]) => {
|
|
const localeValue = getPath(localeMessages, key)
|
|
return labelLength(localeValue) > maxLength
|
|
? [`${locale}: ${key} (${labelLength(localeValue)} > ${maxLength})`]
|
|
: []
|
|
}),
|
|
)
|
|
|
|
expect(oversized).toEqual([])
|
|
})
|
|
|
|
it('keeps the coverage scanner rooted in client source files', () => {
|
|
expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src'))
|
|
})
|
|
})
|