Files
hermes-ui/tests/server/no-legacy-brand.test.ts
T
toor a1fff482bf make the test suite and openapi generator platform-independent
Windows checkouts turned LF into CRLF (core.autocrlf=true), and several
tests plus the openapi generator hardcoded LF or POSIX path separators.
The result was a suite that was green on Linux CI but red on Windows, and
an openapi.json whose /api/hermes/* routes silently disappeared when
generated on Windows.

Pin the repository to LF via .gitattributes, strip shebangs before
Vite's SSR transform, and key route files by their posix path. Add a
manually triggered macOS verification workflow so the same checks run
on a self-hosted macOS runner.
2026-10-20 11:17:34 +08:00

313 lines
14 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 { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
/**
* 旧品牌清理的测试层红线(Phase 008 §3)。
*
* `scripts/harness-check.mjs` 里已经有一份静态版扫描(CI 的 `npm run
* harness:check` 会跑)。这里再写一份 vitest 版不是为了重复,而是为了在
* `npm run test` 里给出**逐行、可定位**的失败信息:harness 只会打印
* `path:line: 内容`,而这里会把命中的具体 pattern、文件、以及修复建议
* 一起列出来,方便维护者直接定位。
*
* 扫描规则(pattern / 目录 / 扩展名 / 白名单粒度)必须与
* `scripts/harness-check.mjs` 的 LEGACY_BRAND_PATTERNS + SCAN_* +
* ALLOWED_LEGACY_BRAND + GLOBAL_ALLOWED_PATTERNS 保持一致,漂移由本文件
* 末尾的 "uses the same allow-list as scripts/harness-check.mjs" 用例机械拦住。
*/
const HARNESS_SCRIPT = 'scripts/harness-check.mjs'
// 与 harness-check.mjs 的 LEGACY_BRAND_PATTERNS 一致:ekko / hermes-web-ui / hermes-studio
const LEGACY_BRAND_PATTERNS = [
{ name: 'ekko', pattern: /\bekko\b/i },
{ name: 'hermes-web-ui', pattern: /hermes-web-ui/i },
{ name: 'hermes-studio', pattern: /hermes-studio/i },
]
// 与 harness-check.mjs 的 SCAN_ROOTS 覆盖范围一致(harness 按包拆开,
// 这里用 packages 根目录 + 其余相同取值;packages 下当前只有 client/server/skills)
const SCAN_ROOTS = ['packages', 'tests', 'scripts', 'bin', '.github']
// 与 harness-check.mjs 的 SCAN_EXTENSIONS 一致
const SCAN_EXTENSIONS = new Set([
'.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py',
])
// 与 harness-check.mjs 的 SCAN_SKIP_DIRS 一致
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__'])
// 与 harness-check.mjs 的 SCAN_SKIP_FILES 一致。
// 扫描器自己必然要写出被禁字符串,所以它和本文件都必须跳过自己。
const SCAN_SKIP_FILES = new Set([
'scripts/harness-check.mjs',
'tests/server/no-legacy-brand.test.ts',
])
/**
* 与 harness-check.mjs 的 ALLOWED_LEGACY_BRAND 逐条对应。
*
* 每条豁免必须写清原因(AGENTS.md 要求):改动这份列表前先确认那个旧字符串
* 仍然是「部署兼容 / 上游身份 / 历史数据」而不是漏网的品牌残留。
*
* 粒度与 harness 一致:**两级**。
* - 带 `pattern`:只豁免匹配该 pattern 的行,同文件内新增的违规仍会被报出来;
* - 不带 `pattern`(pattern 为 undefined):整文件豁免,仅在文件里每一处旧
* 字符串都是有意保留时才允许。
*/
type AllowEntry = { file: string; pattern?: RegExp; reason: string }
const ALLOWED_LEGACY_BRAND: AllowEntry[] = [
{
// 原因:AGENTS.md 的 Red Lines 要提醒 agent 别把承载功能的上游中继地址当残留清掉,
// 所以文档必须写出该地址
file: 'AGENTS.md',
pattern: /hermes-studio\.ai|global-agent relay|not affiliated/,
reason: 'The Red Lines warn agents not to strip the load-bearing relay address, so the doc names it',
},
{
// 原因:DB 文件名早于改名;改名会让 ~/.hermes-web-ui 下已有的部署数据变成孤儿
file: 'packages/server/src/db/index.ts',
reason: 'DB file names predate the rename; renaming them would orphan existing deployments',
},
{
// 原因:CLI 必须读同一个改名前的 DB 文件名,否则和服务端对不上
file: 'bin/hermes-ui.mjs',
reason: 'CLI resolves the same pre-rename DB filename as packages/server/src/db/index.ts',
},
{
// 原因:remoteRelay 是外部 global-agent 中继服务的线上地址,不是产品品牌
file: 'packages/server/src/config.ts',
pattern: /url: 'https:\/\/api\.hermes-studio\.ai'/,
reason: 'remoteRelay is the live external relay endpoint, not the product brand',
},
{
// 原因:旧 JWT audience 必须继续被接受,否则升级会强制所有用户重新登录
file: 'packages/server/src/middleware/user-auth.ts',
reason: 'legacy JWT audience must stay accepted so upgrades do not force re-login',
},
{
// 原因:改名前写下的 manifest owner 必须继续被识别,否则托管 skills 会被重复注入
file: 'packages/server/src/services/hermes/skill-injector.ts',
reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected',
},
{
// 原因:这些旧 MCP server / command 名是被**用来清理**存量磁盘配置的识别标记
file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts',
reason: 'legacy MCP names are the markers used to clean up old on-disk config',
},
{
// 原因:OpenRouter 的 attribution referer 是上游 app 身份,不是产品品牌
file: 'packages/server/src/services/hermes/agent-bridge/manager.ts',
pattern: /HERMES_OPENROUTER_APP_REFERER: 'https:\/\/hermes-studio\.ai'/,
reason: 'OpenRouter attribution referer is the upstream app identity',
},
{
// 原因:侧边栏页脚指向上游产品官网,不是产品品牌
file: 'packages/client/src/components/layout/AppSidebar.vue',
pattern: /href="https:\/\/hermes-studio\.ai\/"/,
reason: 'sidebar footer links to the upstream product website',
},
{
// 原因:断言旧 MCP 条目会从存量磁盘配置里被清掉
file: 'tests/server/studio-mcp-autoinject.test.ts',
reason: 'asserts legacy MCP entries are cleaned from existing on-disk config',
},
{
// 原因:断言 packages/server/src/config.ts 里配置的 remoteRelay base URL
file: 'tests/server/mcu-login-controller.test.ts',
pattern: /const remoteRelayUrl = 'https:\/\/api\.hermes-studio\.ai'/,
reason: 'asserts the remoteRelay base URL configured in packages/server/src/config.ts',
},
{
// 原因:断言 agent-bridge/manager.ts 里配置的 OpenRouter referer
file: 'tests/server/agent-bridge-manager.test.ts',
pattern: /HERMES_OPENROUTER_APP_REFERER\)\.toBe\('https:\/\/hermes-studio\.ai'\)/,
reason: 'asserts the OpenRouter referer configured in agent-bridge/manager.ts',
},
{
// 原因:断言旧 MCP 工具前缀不会泄漏进 bridge instructions
file: 'tests/server/run-chat-bridge-final-context.test.ts',
pattern: /not\.toContain\('mcp__hermes-studio__'\)/,
reason: 'asserts the legacy MCP tool prefix does not leak into bridge instructions',
},
{
// 原因:临时目录名 + 改名前保留的 DB 文件名
file: 'tests/server/cli-port-detection.test.ts',
reason: 'temp dir names and the preserved pre-rename DB filename',
},
{
// 原因:断言由 preview endpoint 环境变量推导出的 preview socket 路径
file: 'tests/server/agent-bridge-python-concurrency.test.ts',
reason: 'asserts preview socket path derived from the preview endpoint env var',
},
{
// 原因:可见来源检查仍需列出改名前的来源,Phase 003 之前存的会话才不会被隐藏
file: 'packages/server/src/controllers/hermes/sessions.ts',
reason: 'visible-session-source check still lists the pre-rename source so old sessions stay listed',
},
{
// 原因:SOURCE_LABELS 仍映射改名前的来源,历史会话才有标签可渲染
file: 'packages/client/src/shared/session-display.ts',
reason: 'SOURCE_LABELS still maps the pre-rename source so stored sessions render a label',
},
]
/**
* 与 harness-check.mjs 的 GLOBAL_ALLOWED_PATTERNS 一致:这些标识符是公开的部署
* 契约或线上协议取值,而不是项目品牌,任何文件里出现都合法。
*/
const GLOBAL_ALLOWED_PATTERNS: RegExp[] = [
/HERMES_WEB_UI[A-Z0-9_]*/,
/HERMES_WEBUI_STATE_DIR/,
/\.hermes-web-ui/,
/hermes_brightness/,
/hermes_style/,
/hermes_locale/,
// global-agent 中继的 socket role 标识符,属于与中继服务之间的线上契约,改名会打断跨版本互通
/role\s*(?::|===)\s*'hermes-studio'/,
]
const toRelative = (file: string) => file.split('\\').join('/')
function collectFiles(target: string, out: string[] = []): string[] {
if (!existsSync(target)) return out
for (const entry of readdirSync(target)) {
const full = join(target, entry)
if (statSync(full).isDirectory()) {
if (SCAN_SKIP_DIRS.has(entry.name)) continue
collectFiles(full, out)
} else {
const dot = full.lastIndexOf('.')
if (dot !== -1 && SCAN_EXTENSIONS.has(full.slice(dot))) out.push(toRelative(full))
}
}
return out
}
function isAllowed(relativePath: string, line: string): boolean {
if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true
return ALLOWED_LEGACY_BRAND.some((entry) => {
// 与 harness 一致:条目可以写目录名,此时覆盖其下所有文件
if (relativePath !== entry.file && !relativePath.startsWith(`${entry.file}/`)) return false
return !entry.pattern || entry.pattern.test(line)
})
}
type Violation = { file: string; line: number; brand: string; text: string }
function scanLegacyBrand(files: string[]): Violation[] {
const violations: Violation[] = []
for (const file of files) {
if (SCAN_SKIP_FILES.has(file)) continue
const lines = readFileSync(file, 'utf8').split(/\r?\n/)
for (const [index, line] of lines.entries()) {
const brand = LEGACY_BRAND_PATTERNS.find(entry => entry.pattern.test(line))
if (!brand || isAllowed(file, line)) continue
violations.push({ file, line: index + 1, brand: brand.name, text: line.trim() })
}
}
return violations
}
function describeViolations(violations: Violation[]): string {
const detail = violations
.map(v => ` ${v.file}:${v.line} 命中旧品牌 "${v.brand}"\n ${v.text}`)
.join('\n')
return `${violations.length} 处旧品牌字符串未清理:\n${detail}\n\n`
+ '修复方式:把该处改成 hermes-ui 的写法;如果这处旧字符串是部署兼容 / 上游身份 /'
+ '历史数据所必需,就把它加进 scripts/harness-check.mjs 的 ALLOWED_LEGACY_BRAND'
+ '(带 pattern 的条目只豁免匹配行,不带 pattern 的条目豁免整文件),'
+ '并同步本文件的 ALLOWED_LEGACY_BRAND,否则 drift 用例会失败。'
}
describe('legacy brand removal (ekko / hermes-web-ui / hermes-studio)', () => {
const scannedFiles = SCAN_ROOTS.flatMap(root => collectFiles(root))
it('has a non-empty scan surface', () => {
// 防止扫描目录写错导致本文件整体空转、永远绿灯
expect(scannedFiles.length).toBeGreaterThan(200)
for (const root of SCAN_ROOTS) {
expect(existsSync(root), `${root} 必须存在,否则品牌扫描覆盖面被悄悄缩小`).toBe(true)
}
})
it('contains no legacy brand string outside the allow-list', () => {
const violations = scanLegacyBrand(scannedFiles)
expect(violations, describeViolations(violations)).toEqual([])
})
it('keeps every allow-list entry justified and free of stale paths', () => {
for (const entry of ALLOWED_LEGACY_BRAND) {
expect(entry.reason.trim(), `${entry.file} 的豁免必须写明原因`).not.toBe('')
expect(
existsSync(entry.file),
`白名单条目 ${entry.file} 指向的文件已不存在,请删除该豁免`
+ '(否则将来新建同名文件时会白白掩盖违规)',
).toBe(true)
}
})
it('uses the same allow-list as scripts/harness-check.mjs', () => {
// 防漂移守卫:本文件的豁免是 harness-check.mjs 的**副本**。
// 不从 harness import 那份清单,是因为 harness-check.mjs 是一支顶层即执行的
// 脚本(import 它会连带跑完整检查、读 git diff、甚至 process.exit),
// 为了拿一份常量把它重构成模块代价过大;改成从源码里把两份清单抽出来做等价性
// 比对,任何一侧增删条目都会在这里失败。
const harness = readFileSync(HARNESS_SCRIPT, 'utf8')
const readArray = (name: string): string => {
const marker = `const ${name} = [`
const start = harness.indexOf(marker)
expect(start, `${HARNESS_SCRIPT} 里找不到 ${name},本测试需要同步更新`).toBeGreaterThan(-1)
// 行尾不能写死 '\n]':Windows 上 checkout 会把 LF 转成 CRLF
// (core.autocrlf=true),写死 LF 的话这个测试只会在 Windows 上红,
// Linux CI 上却是绿的。仓库的 .gitattributes 已把行尾统一成 LF,但
// 在它被加进来之前 checkout 过的工作区仍留着 CRLF,所以这里两种都要认。
const rest = harness.slice(start)
const closeMatch = /\r?\n\]/.exec(rest)
expect(closeMatch, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).not.toBeNull()
const end = start + closeMatch!.index
return harness.slice(start + marker.length - 1, end + 2)
}
// harness 里不带 pattern 的条目 = 整文件豁免,这里统一记成 '<file>'
const WHOLE_FILE = '<file>'
const entryRe = /\{\s*file:\s*'([^']+)'(?:,\s*pattern:\s*(\/(?:[^/\\\n]|\\.)*\/[a-z]*))?,\s*reason:\s*'([^']*)'/g
const harnessEntries: { file: string; pattern: string }[] = []
for (const match of readArray('ALLOWED_LEGACY_BRAND').matchAll(entryRe)) {
// 去掉字面量两侧的斜杠与 flag,只留 source,才能和 RegExp.prototype.source 比较
const pattern = match[2] ? match[2].replace(/^\/(.*)\/[a-z]*$/, '$1') : WHOLE_FILE
harnessEntries.push({ file: match[1], pattern })
expect(match[3].trim(), `harness 的 ${match[1]} 豁免缺少 reason`).not.toBe('')
}
const globalRe = /^[ \t]*(\/(?:[^/\\\n]|\\.)*\/[a-z]*),[ \t]*$/gm
const harnessGlobals = [...readArray('GLOBAL_ALLOWED_PATTERNS').matchAll(globalRe)]
.map(m => m[1].replace(/^\/(.*)\/[a-z]*$/, '$1'))
expect(harnessEntries.length, `未能从 ${HARNESS_SCRIPT} 解析出 ALLOWED_LEGACY_BRAND`).toBeGreaterThan(0)
expect(harnessGlobals.length, `未能从 ${HARNESS_SCRIPT} 解析出 GLOBAL_ALLOWED_PATTERNS`).toBeGreaterThan(0)
const sortKey = (entry: { file: string; pattern: string }) => `${entry.file}\u0000${entry.pattern}`
const localEntries = ALLOWED_LEGACY_BRAND.map(e => ({
file: e.file,
pattern: e.pattern ? e.pattern.source : WHOLE_FILE,
}))
expect(
localEntries.map(sortKey).sort(),
`ALLOWED_LEGACY_BRAND 已与 ${HARNESS_SCRIPT} 漂移,请把两边改成一致`,
).toEqual(harnessEntries.map(sortKey).sort())
expect(
GLOBAL_ALLOWED_PATTERNS.map(p => p.source).sort(),
`GLOBAL_ALLOWED_PATTERNS 已与 ${HARNESS_SCRIPT} 漂移,请把两边改成一致`,
).toEqual(harnessGlobals.sort())
})
})