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:
@@ -1,8 +1 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/EKKOLearnAI/hermes-studio#readme
|
||||
about: Please check the documentation first
|
||||
- name: GitHub Discussions
|
||||
url: https://github.com/EKKOLearnAI/hermes-studio/discussions
|
||||
about: Use GitHub Discussions for questions that don't fit as issues
|
||||
|
||||
@@ -44,5 +44,11 @@ jobs:
|
||||
- name: Test with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: End-to-end tests
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
@@ -56,6 +56,16 @@ Use the smallest relevant check while iterating. Before a broad PR, run
|
||||
`services/hermes/context-engine/` are the chat chain: changes require a
|
||||
`docs/chat-chain-changes/<date>-<slug>.md` fragment.
|
||||
- `packages/desktop` and `packages/website` are removed; do not reintroduce them.
|
||||
- This repo is a reimplementation. A few upstream service addresses are load-bearing
|
||||
and are deliberately kept, each with a whitelist entry in
|
||||
`scripts/harness-check.mjs`: the global-agent relay (`api.hermes-studio.ai`), the
|
||||
OpenRouter referer, and the ESP32 purchase link. Do not "clean them up" as legacy
|
||||
brand text — removing them breaks those features. Do not read the relay address as
|
||||
project identity: this deployment is not affiliated with the upstream operator.
|
||||
- The online-update channel (`runtime-version-manager.ts`) has no default URL. It reads
|
||||
`HERMES_WEB_UI_VERSION_MANIFEST_URL` / `HERMES_WEB_UI_DOWNLOAD_BASE_URL` /
|
||||
`HERMES_WEB_UI_GITHUB_REPO` and degrades with a named error when unset. Do not
|
||||
reintroduce a hardcoded upstream URL.
|
||||
- ArkScope vendor (`packages/client/src/vendor/arkscore/`) is decorative only:
|
||||
login-page title, Journey graph background, first-load / empty states. Never in
|
||||
chat, forms, tables, or click-dense areas. Decorative layers must be
|
||||
|
||||
@@ -44,6 +44,9 @@ should not duplicate server persistence rules.
|
||||
- The project's own SQLite store under `packages/server/src/db` is the only writable
|
||||
store for sessions, messages, and usage. There is no startup import sync from
|
||||
Hermes state.
|
||||
- Every `state.db` handle in Node-side code opens with `readOnly: true`;
|
||||
`tests/server/hermes-db-readonly.test.ts` classifies each `new DatabaseSync` call
|
||||
site and fails if one of them drops the flag or opens it writable.
|
||||
- Uploads default to `config.uploadDir`, which is derived from the Web UI home unless `UPLOAD_DIR` is set.
|
||||
- Runtime data directories must also live under the Web UI home, not beside built `dist` assets.
|
||||
- Profile-scoped Hermes data should use existing profile helpers instead of manually joining paths.
|
||||
|
||||
@@ -7,8 +7,8 @@ untouched.
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/<short-topic> ../worktrees/hermes-web-ui-<short-topic> origin/main
|
||||
cd ../worktrees/hermes-web-ui-<short-topic>
|
||||
git worktree add -b codex/<short-topic> ../worktrees/hermes-ui-<short-topic> origin/main
|
||||
cd ../worktrees/hermes-ui-<short-topic>
|
||||
```
|
||||
|
||||
If the repository uses a fork remote, push to the remote requested by the task.
|
||||
@@ -27,7 +27,7 @@ Use per-worktree state and ports to avoid colliding with a running local app:
|
||||
|
||||
```bash
|
||||
export PORT=18648
|
||||
export HERMES_WEB_UI_HOME="$PWD/.tmp/hermes-web-ui"
|
||||
export HERMES_WEB_UI_HOME="$PWD/.tmp/hermes-ui"
|
||||
export HERMES_WEBUI_STATE_DIR="$HERMES_WEB_UI_HOME"
|
||||
export UPLOAD_DIR="$PWD/.tmp/uploads"
|
||||
npm run dev
|
||||
@@ -52,7 +52,7 @@ requirements only when the behavior cannot be represented with mocks.
|
||||
After a PR is pushed and no more local work is needed:
|
||||
|
||||
```bash
|
||||
git worktree remove ../worktrees/hermes-web-ui-<short-topic>
|
||||
git worktree remove ../worktrees/hermes-ui-<short-topic>
|
||||
```
|
||||
|
||||
Only remove the worktree you created.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# 已知问题与迁移遗留
|
||||
|
||||
记录 UI 库迁移(Phase 006-007,naive-ui → Arco Design Vue)过程中**发现但按规则未修**的问题,以及自动化测试**测不到、只能靠手工验证**的风险点。
|
||||
|
||||
用法:日常使用时遇到异常表现,先来本文档搜关键词。确认命中后按「证据」栏定位,确认是同一个问题再修。
|
||||
|
||||
新增条目请追加到对应分区末尾,不要重排已有条目。
|
||||
|
||||
---
|
||||
|
||||
## 一、已确认的业务逻辑缺陷
|
||||
|
||||
这些是读代码时确认存在的真实缺陷,与迁移无关(迁移前就存在),但迁移过程中被注意到。修它们属于逻辑改动,需单独开分支。
|
||||
|
||||
### 1. ModelSelector 的 computed 里有副作用
|
||||
|
||||
- **现象**:切换模型时 provider 选择偶尔「自己跳」,或 `customProvider` 状态被莫名改写
|
||||
- **位置**:`packages/client/src/components/layout/ModelSelector.vue`,`providerOptions` computed 的 getter
|
||||
- **证据**:computed 的 getter 内部给 `customProvider.value` 赋值。computed 应当是纯读。副作用是:任何其它 computed 或渲染逻辑读到 `providerOptions`,都会顺带改写 `customProvider`
|
||||
- **修法**:把赋值移到 `watch` 或显式的 setter 里
|
||||
|
||||
### 2. ProfileSelector 把 i18n key 当文案用
|
||||
|
||||
- **现象**:某些运行时状态提示里显示原始 key(如 `profiles.runtime.checking`)而不是中文/英文文案
|
||||
- **位置**:`packages/client/src/components/layout/ProfileSelector.vue`,`gatewayStatusText` / `bridgeStatusText`
|
||||
- **证据**:`message.warning(t('profiles.runtime.checking'))` —— `t()` 的结果(一个 key 字符串)被直接当消息正文,缺了外层再套一次 `t()`
|
||||
- **修法**:改成 `message.warning(t('profiles.runtime.checking'))`(让 `t()` 返回字符串函数的结果),即检查是否需要 `t(t(key))` 或改用 `te()` + 手动查表
|
||||
|
||||
### 3. McpManagerView 的 `_autoRetryCount` 赋值可能静默失效
|
||||
|
||||
- **现象**:MCP 失败后点「重试」,自动重试计数似乎没有正确归零
|
||||
- **位置**:`packages/client/src/views/hermes/McpManagerView.vue`,`let _autoRetryCount`(模块级)
|
||||
- **证据**:在 header 按钮的内联表达式 `@click="_autoRetryCount = 0; loadServers()"` 里被重置。`<script setup>` 中的 `let` 绑定在模板内联语句里**不可写**(编译器只对 ref 做解包),这个重置很可能不生效
|
||||
- **修法**:改成 `ref`,或把重置逻辑移进一个具名函数
|
||||
|
||||
### 4. McpManagerView 的 handleModeChange 有冗余重试
|
||||
|
||||
- **现象**:无功能影响,仅代码冗余
|
||||
- **位置**:`packages/client/src/views/hermes/McpManagerView.vue`,`handleModeChange`
|
||||
- **证据**:两段重复的 try/catch 用同一个 `mode` 解析同一份文本,第一次失败后用相同模式重试一次才 return。第一个 catch 分支应直接 return
|
||||
|
||||
### 5. WorkflowView 的批量删除确认 ref 已无读取方
|
||||
|
||||
- **现象**:无功能影响
|
||||
- **位置**:`packages/client/src/views/hermes/WorkflowView.vue`,`showWorkflowBatchDeleteConfirm`
|
||||
- **证据**:naive 的 `NPopconfirm v-model:show` 需要它;Arco 的 `a-popconfirm` 无 `show` prop 后它失去读取方,只剩写。关闭改由 `@ok`/`@cancel` 驱动
|
||||
|
||||
### 6. 两个弹窗共用一个 saving ref
|
||||
|
||||
- **现象**:新增设备弹窗与改名弹窗同时存在时,按钮的 loading/disabled 会互相干扰
|
||||
- **位置**:`packages/client/src/components/layout/SettingsCircuitBadge.vue`
|
||||
- **证据**:两个 modal 共用 `saving`。迁移前就是这样
|
||||
|
||||
### 7. ProfileCreateModal 有两个重复的 style 块
|
||||
|
||||
- **现象**:无功能影响
|
||||
- **位置**:`packages/client/src/components/hermes/profiles/ProfileCreateModal.vue`
|
||||
- **证据**:两个 `<style scoped>` 内容相同(都是 `.modal-footer`)。迁移前存在
|
||||
|
||||
---
|
||||
|
||||
## 二、Arco 与 naive 的行为差异(可能表现为「功能坏了」)
|
||||
|
||||
迁移时按等价语义做了处理,但以下差异在某些边界条件下可能暴露。如遇异常,先查这里。
|
||||
|
||||
### a-input-password 默认是遮蔽的
|
||||
|
||||
- **风险**:任何给 `a-input-password` 传 `:default-visibility="false"` 的地方,密码/API Key 会**明文显示**
|
||||
- **正确做法**:不要传这个 prop。源码 `es/input/input-password.js:77` 是 `type: mergedVisible ? "password" : "text"`,`defaultVisibility` 默认 `true` 即遮蔽
|
||||
- **检查命令**:`grep -rn "default-visibility\|defaultVisibility" packages/client/src`(期望 0 命中)
|
||||
|
||||
### a-upload 默认会发真实 XHR
|
||||
|
||||
- **风险**:没加 `:auto-upload="false"` 的上传组件,选一次文件就向当前页 URL 发一个无意义 multipart 请求
|
||||
- **正确做法**:凡是「只选文件、由 store 走 API」的上传,`a-upload` 必须显式加 `:auto-upload="false"`
|
||||
- **涉及文件**:`ProfileImportModal.vue`、`SkillImportModal.vue`、`FileUploadModal.vue`
|
||||
|
||||
### Arco Trigger 不能嵌套
|
||||
|
||||
- **风险**:`a-tooltip` / `a-dropdown` / `a-popconfirm` / `a-popover` 互相嵌套时,菜单或提示**点不开**
|
||||
- **原因**:它们内部都用 `mergeFirstChild` 接管首个元素节点的事件,多层会互相踩
|
||||
- **正确做法**:同一元素既要 tooltip 又要 dropdown 时,tooltip 改用原生 `:title`
|
||||
- **已处理**:`GroupChatInput.vue` 的设置按钮(`NDropdown > NTooltip > NButton` → `a-dropdown > a-button` + `:title`)、`ChatInput.vue` L1130-1162
|
||||
|
||||
### tooltip/dropdown 默认插槽必须只含一个元素
|
||||
|
||||
- **风险**:尾随的纯文本(`{{ t(...) }}`)会被当可见内容渲染进布局流,变成 **0 宽的 flex item**,把工具栏挤成竖排或让文本被截断
|
||||
- **正确做法**:文案放 `content` prop,默认插槽只留触发元素
|
||||
- **已踩坑位置**:`ChatInput.vue` 三处、`GroupChatInput.vue`、`TerminalPanel.vue`、`KanbanTaskCard.vue`、`KanbanView.vue` 排序按钮
|
||||
- **自查**:`grep -rn "a-tooltip" -A 8 packages/client/src | grep -B2 "{{ t"` 然后肉眼确认
|
||||
|
||||
### NDropdown 的 droplist 没有对应
|
||||
|
||||
- **风险**:给 `a-dropdown` 传 `droplist` 会**静默失效**(Arco 不对未声明的 prop 报错,未声明的 prop 落为普通 attribute)
|
||||
- **正确做法**:菜单写进 `#content` 插槽用 `<a-doption>`
|
||||
- **历史**:Phase 006 的三个下拉全打不开,根因就是这个
|
||||
|
||||
### a-input / a-textarea 的 modelValue 不接受 null
|
||||
|
||||
- **风险**:`null` 初值会导致该组件显示异常或 TS 报错
|
||||
- **正确做法**:`:model-value="x ?? undefined"`,配合 `@change="x = $event ?? null"` 桥接保住 null 语义
|
||||
- **涉及**:`AuxiliaryModelsPanel.vue`、`DisplaySettings.vue`、`VoiceApiFormModal.vue`、`KanbanCreateForm.vue`、`JobFormModal.vue`、`GroupChatPanel.vue`
|
||||
|
||||
### a-spin 的 size 是 Number(px)不是字符串档位
|
||||
|
||||
- **风险**:传 `size="small"` 会被拼成 `fontSize: "smallpx"`
|
||||
- **正确做法**:传 `:size="14"` 这样的数字
|
||||
- **涉及**:`PlatformSettings.vue`、`ProfileCard.vue`、`JobRunHistory.vue`、`VoiceApiFormModal.vue`
|
||||
|
||||
### a-input-password 没有 width prop
|
||||
|
||||
- **风险**:`:width="220"` 被静默丢弃(底层 Trigger 设了 `inheritAttrs: false`)
|
||||
- **正确做法**:改用 `:content-style`
|
||||
|
||||
### a-scrollbar 用 max-height 会高度塌陷
|
||||
|
||||
- **正确做法**:必须显式给 `height`
|
||||
- **涉及**:`McpManagerView.vue`
|
||||
|
||||
### a-modal 的 footer 默认 true
|
||||
|
||||
- **风险**:原本没有 footer 插槽的弹窗,会多出取消/确定按钮
|
||||
- **正确做法**:无自定义 footer 插槽时加 `:footer="false"`;**有**插槽时**绝对不能**加(加了插槽内容不渲染)
|
||||
|
||||
### Arco 按钮在 loading 时吞掉后续 click
|
||||
|
||||
- **风险**:快速连点同一按钮,第二次点击不触发
|
||||
- **影响**:`SkillsUsageView.vue` 的刷新按钮(测试已改写场景绕过,但 UI 上真实存在)
|
||||
- **备注**:不是 bug,是 Arco 的行为
|
||||
|
||||
---
|
||||
|
||||
## 三、自动化测不到、需手工确认的风险点
|
||||
|
||||
单元测试和 e2e 都覆盖不到这些。首次正式使用时请重点走一遍。
|
||||
|
||||
### 右键菜单
|
||||
|
||||
- **文件**:`FileContextMenu.vue`、`WorkflowView.vue`、`HistoryView.vue`、`GroupChatPanel.vue`
|
||||
- **风险**:naive 的 `NDropdown trigger="manual"` + `:x/:y` 迁到了 `a-trigger trigger="contextMenu"` + `:popup-style`
|
||||
- **需确认**:① 右键能弹出 ② **菜单打开时滚动页面,菜单是否跟随滚动**(Arco 在 `autoFitPosition: false` 时不重算位置,菜单会留在原地)
|
||||
- **需确认**:③ 刚右键弹出就立刻点别处,菜单是否被误关(naive 靠 `contextMenuOpenedAt` 时间戳防抖,Arco 靠内置 `clickOutsideToClose`,时序可能微妙不同)
|
||||
|
||||
### 文件树懒加载
|
||||
|
||||
- **文件**:`FileTree.vue`(naive `NTree` → Arco `a-tree`)
|
||||
- **需确认**:① 点目录能加载子目录 ② 只加载目录不加载文件(`filter(e => e.isDir)`)③ 快速切换目录时不会出现子节点串台(深层懒加载的竞态保护弱于根节点,`rootLoadSeq` 只在切 profile/session 时自增)
|
||||
|
||||
### Kanban 折叠与布局
|
||||
|
||||
- **文件**:`KanbanView.vue`、`KanbanColumn.vue`
|
||||
- **备注**:迁移时发现 **kanban 实际没有拖拽逻辑**(排序靠卡片上的 sort 按钮),任务书里的「拖拽落点」风险不存在
|
||||
- **需确认**:① 折叠/展开的列状态正确 ② Arco `a-collapse` 默认全部展开(已显式设 `:default-expand-all="false"`)
|
||||
|
||||
### 表格列的省略与 tooltip
|
||||
|
||||
- **文件**:`SettingsCircuitBadge.vue`、`UserManagementSettings.vue`
|
||||
- **备注**:naive 的 `ellipsis: { tooltip: true }` → Arco 的 `ellipsis: true` + 列顶层 `tooltip: true`
|
||||
- **需确认**:长文本省略后悬浮能显示完整内容
|
||||
|
||||
### 视觉细节
|
||||
|
||||
- **圆角 tag**:naive 的 `NTag round` 无 Arco 对应,改用了 `border-radius: 999px`(`PetdexView.vue`、`DevicesView.vue`)
|
||||
- **按钮尺寸**:naive 的 `size="tiny"` → Arco `size="mini"`,可能与相邻元素不齐
|
||||
- **滚动条**:naive 的 `:native-scrollbar="false"` 无对应,已删除,滚动条样式会变
|
||||
|
||||
### 弹窗的遮罩/ESC 行为
|
||||
|
||||
- **风险**:Arco 默认 `maskClosable` 和 `escToClose` 都是 `true`。用户填了一半的表单按 ESC 或点遮罩会**直接丢弃且不重置**
|
||||
- **涉及**:`VoiceApiFormModal.vue`、`VoiceApiConfigurator.vue`、`SettingsCircuitBadge.vue` 的设备面板
|
||||
- **需确认**:是否应该设 `:mask-closable="false"` / `:esc-to-close="false"`
|
||||
|
||||
### 模型选择弹窗(ProviderFormModal)
|
||||
|
||||
- **备注**:naive 的 `NButton type="primary" secondary`(浅色主按钮)在 Arco 无对应组合,去掉了 `secondary` 保留 `type="primary"`,视觉变成实心主色按钮
|
||||
- **需确认**:视觉是否可接受
|
||||
|
||||
---
|
||||
|
||||
## 四、其它观察
|
||||
|
||||
- `TerminalView.vue` 的终端主题下拉选项 label 是**硬编码英文**(`"Default"`、`"Monokai"`),未走 i18n。迁移前既有问题
|
||||
- `AuxiliaryModelsPanel.vue` 有 5 处手写 `<label>` + `<span>` 而非 `a-form-item`,与其它 Arco 表单的对齐/必填星号规则不一致
|
||||
- `ProviderFormModal.vue` 的 `handleSave` 有 6 个近乎重复的 OAuth 分支,重复度高、容易漏改,建议后续合并成 provider→trigger 映射表
|
||||
- `FileUploadModal.vue` 关闭时只清空了本地 `fileList`,未清 `a-upload` 内部状态。目前靠 modal 默认 `unmount-on-close` 兜底,**若有人关掉该 prop 会出现「列表已清空但 UI 仍显示」**
|
||||
- `McpManagerView.vue` 的 `handleModeChange` 与 `FileContextMenu.vue` 的 `show()` 快速连点时,Arco popup 的 transition 理论上可能动画排队
|
||||
|
||||
---
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `docs/arco-migration-rules.md` —— 迁移规约与完整组件映射表
|
||||
- `docs/arco-mapping.md` —— naive → Arco 映射与结构差异
|
||||
- `docs/chat-chain-changes/2026-10-18-phase007-arco-full-migration.md` —— Phase 007 变更清单
|
||||
@@ -8,9 +8,12 @@ import { config } from '../config'
|
||||
import { getHermesWebUiVersion } from './system-info'
|
||||
|
||||
const ACTIVE_VERSION_FILE = 'active-version.json'
|
||||
const DEFAULT_REMOTE_MANIFEST_URL = 'https://hermes-studio.ai/versions.json'
|
||||
const DEFAULT_DOWNLOAD_BASE_URL = 'https://download.ekkolearnai.com'
|
||||
const DEFAULT_GITHUB_REPO = 'EKKOLearnAI/hermes-studio'
|
||||
// This fork publishes no release channel, so every remote address is
|
||||
// deployment-specific and must be configured explicitly. An empty value
|
||||
// disables the matching capability instead of contacting a third party.
|
||||
const REMOTE_MANIFEST_DISABLED = 'remote version manifest is not configured (set HERMES_WEB_UI_VERSION_MANIFEST_URL)'
|
||||
const DOWNLOAD_BASE_DISABLED = 'download base URL is not configured (set HERMES_WEB_UI_DOWNLOAD_BASE_URL)'
|
||||
const DOWNLOAD_REPO_DISABLED = 'download GitHub repo is not configured (set HERMES_WEB_UI_DOWNLOAD_GITHUB_REPO)'
|
||||
|
||||
export interface ActiveVersionManifest {
|
||||
schema: number
|
||||
@@ -119,15 +122,18 @@ function activeVersionPath(): string {
|
||||
}
|
||||
|
||||
function downloadBaseUrl(): string {
|
||||
return (process.env.HERMES_WEB_UI_DOWNLOAD_BASE_URL || DEFAULT_DOWNLOAD_BASE_URL).trim().replace(/\/$/, '')
|
||||
return (process.env.HERMES_WEB_UI_DOWNLOAD_BASE_URL?.trim() || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function downloadAssetUrl(assetName: string, tag: string, source: VersionDownloadSource): string {
|
||||
if (source === 'github') {
|
||||
const repo = process.env.HERMES_WEB_UI_DOWNLOAD_GITHUB_REPO?.trim() || DEFAULT_GITHUB_REPO
|
||||
const repo = process.env.HERMES_WEB_UI_DOWNLOAD_GITHUB_REPO?.trim() || ''
|
||||
if (!repo) throw new Error(DOWNLOAD_REPO_DISABLED)
|
||||
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||
}
|
||||
return `${downloadBaseUrl()}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||
const base = downloadBaseUrl()
|
||||
if (!base) throw new Error(DOWNLOAD_BASE_DISABLED)
|
||||
return `${base}/${encodeURIComponent(tag)}/${encodeURIComponent(assetName)}`
|
||||
}
|
||||
|
||||
function readJsonFile<T>(file: string): T | null {
|
||||
@@ -235,8 +241,13 @@ export function listInstalledWebUiVersions(active = readActiveVersionManifest())
|
||||
return installed.sort((left, right) => right.version.localeCompare(left.version, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
function remoteManifestUrl(): string {
|
||||
return process.env.HERMES_WEB_UI_VERSION_MANIFEST_URL?.trim() || ''
|
||||
}
|
||||
|
||||
async function fetchRemoteVersions(): Promise<{ manifest: RemoteVersionManifest | null; error: string }> {
|
||||
const url = process.env.HERMES_WEB_UI_VERSION_MANIFEST_URL?.trim() || DEFAULT_REMOTE_MANIFEST_URL
|
||||
const url = remoteManifestUrl()
|
||||
if (!url) return { manifest: null, error: REMOTE_MANIFEST_DISABLED }
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(5000) })
|
||||
if (!response.ok) return { manifest: null, error: `GET ${url} returned ${response.status}` }
|
||||
@@ -255,7 +266,7 @@ export async function getRuntimeVersionStatus(): Promise<RuntimeVersionStatus> {
|
||||
active,
|
||||
platform: runtimePlatformKey(),
|
||||
activeVersionPath: activeVersionPath(),
|
||||
remoteManifestUrl: process.env.HERMES_WEB_UI_VERSION_MANIFEST_URL?.trim() || DEFAULT_REMOTE_MANIFEST_URL,
|
||||
remoteManifestUrl: remoteManifestUrl(),
|
||||
remoteError: error,
|
||||
hermes: {
|
||||
activeVersion: active?.hermesRuntimeVersion || '',
|
||||
|
||||
+129
-19
@@ -107,6 +107,14 @@ for (const file of [
|
||||
'docs/harness/worktree-runbook.md',
|
||||
'docs/harness/pr-review.md',
|
||||
'docs/chat-chain-changes/README.md',
|
||||
// 构建 / 测试 / 类型检查的入口配置。缺任何一个都会让 npm run build 或
|
||||
// npm run test 的行为与 CI 不一致,属于 harness 自身失效,必须先报出来。
|
||||
'README.md',
|
||||
'package.json',
|
||||
'vite.config.ts',
|
||||
'vitest.config.ts',
|
||||
'playwright.config.ts',
|
||||
'tsconfig.json',
|
||||
]) {
|
||||
requireFile(file)
|
||||
}
|
||||
@@ -124,6 +132,24 @@ for (const dir of [
|
||||
requireDir(dir)
|
||||
}
|
||||
|
||||
// 禁止目录:AGENTS.md 的 Red Lines 明确写明 packages/desktop 与 packages/website
|
||||
// 已删除、不得重新引入;ekko-agent 与 esp32-c3 同样是已砍掉的旧运行时/固件包。
|
||||
// 这里只检查目录是否存在,不检查字符串引用——个别源码里还留着指向这些路径的
|
||||
// 字符串(例如 scripts/build-server.mjs 与 mcu-firmware.ts),那些引用有
|
||||
// existsSync 保护,不会导致构建失败,清理由各自负责。
|
||||
//
|
||||
// 注意:reference_code 暂不在此列,按任务书要求留到 Phase 009 再加,避免中途误红。
|
||||
for (const dir of [
|
||||
'packages/desktop',
|
||||
'packages/website',
|
||||
'packages/ekko-agent',
|
||||
'packages/esp32-c3',
|
||||
]) {
|
||||
if (existsSync(path.join(root, dir))) {
|
||||
fail(`Retired package directory must not come back: ${dir}`)
|
||||
}
|
||||
}
|
||||
|
||||
const agents = await readText('AGENTS.md')
|
||||
const agentLines = agents.trimEnd().split(/\r?\n/)
|
||||
if (agentLines.length > 120) {
|
||||
@@ -156,6 +182,48 @@ for (const scriptName of [
|
||||
}
|
||||
}
|
||||
|
||||
// 禁止脚本:desktop / website 两个包已删除(见上面的禁止目录检查),
|
||||
// 它们的构建脚本必须一起消失,否则会留下一个必然失败的 npm script,
|
||||
// 让人以为「跑一下 npm run build:desktop 还能用」。
|
||||
const scriptNames = Object.keys(packageJson.scripts || {})
|
||||
for (const forbidden of [
|
||||
/^desktop:/,
|
||||
/^build:desktop/,
|
||||
/^dev:website$/,
|
||||
/^build:website$/,
|
||||
]) {
|
||||
for (const scriptName of scriptNames) {
|
||||
if (forbidden.test(scriptName)) {
|
||||
fail(`package.json must not keep a script for a retired package: ${scriptName} (matched ${forbidden})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第二 UI 库红线:Phase 007 已把 naive-ui 全量迁移到 Arco Design Vue。
|
||||
// 同时装两套 UI 库会让 CSS 体积、主题变量与组件解析产生难以定位的冲突,
|
||||
// 因此 package.json 里只允许出现 @arco-design/web-vue 一套。
|
||||
const UI_LIBRARIES = [
|
||||
'@arco-design/web-vue',
|
||||
'naive-ui',
|
||||
'element-plus',
|
||||
'ant-design-vue',
|
||||
'primevue',
|
||||
'vuetify',
|
||||
'quasar',
|
||||
]
|
||||
const declaredDependencies = Object.keys({
|
||||
...packageJson.dependencies,
|
||||
...packageJson.devDependencies,
|
||||
})
|
||||
for (const dependency of declaredDependencies) {
|
||||
if (!UI_LIBRARIES.includes(dependency)) continue
|
||||
if (dependency === '@arco-design/web-vue') continue
|
||||
fail(`Only one UI library is allowed (@arco-design/web-vue); remove dependency: ${dependency}`)
|
||||
}
|
||||
if (!declaredDependencies.includes('@arco-design/web-vue')) {
|
||||
fail('package.json must depend on @arco-design/web-vue (the only allowed UI library)')
|
||||
}
|
||||
|
||||
const architecture = await readText('ARCHITECTURE.md')
|
||||
for (const phrase of [
|
||||
'packages/client/src',
|
||||
@@ -228,10 +296,11 @@ for (const file of changedChatChainFragments) {
|
||||
}
|
||||
|
||||
// --- Legacy brand scan -------------------------------------------------
|
||||
// The project was renamed from ekko / hermes-web-ui to hermes-ui. A few
|
||||
// legacy identifiers are deliberately preserved so existing deployments keep
|
||||
// working; see ALLOWED_LEGACY_BRAND below for the reason each one survives.
|
||||
const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i]
|
||||
// The project was renamed from ekko / hermes-web-ui / hermes-studio to
|
||||
// hermes-ui. A few legacy identifiers are deliberately preserved so existing
|
||||
// deployments keep working; see ALLOWED_LEGACY_BRAND and
|
||||
// GLOBAL_ALLOWED_PATTERNS below for the reason each one survives.
|
||||
const LEGACY_BRAND_PATTERNS = [/\bekko\b/i, /hermes-web-ui/i, /hermes-studio/i]
|
||||
|
||||
const SCAN_ROOTS = [
|
||||
'packages/client/src',
|
||||
@@ -254,11 +323,25 @@ const SCAN_FILES = [
|
||||
]
|
||||
const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.mjs', '.cjs', '.vue', '.json', '.yml', '.yaml', '.md', '.py'])
|
||||
const SCAN_SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'reference_code', '__pycache__'])
|
||||
// This file necessarily spells out the patterns and allow-list entries it checks.
|
||||
const SCAN_SKIP_FILES = new Set(['scripts/harness-check.mjs'])
|
||||
// These files necessarily spell out the patterns and allow-list entries they check.
|
||||
// tests/server/no-legacy-brand.test.ts is the vitest mirror of this scan: it
|
||||
// re-declares the same patterns and the same allow-list, so it would otherwise
|
||||
// report itself on every line.
|
||||
const SCAN_SKIP_FILES = new Set([
|
||||
'scripts/harness-check.mjs',
|
||||
'tests/server/no-legacy-brand.test.ts',
|
||||
])
|
||||
|
||||
// Every entry must state why the legacy string is still allowed.
|
||||
// Every entry must state why the legacy string is still allowed. An entry with
|
||||
// a `pattern` exempts only the lines matching it, so a new violation elsewhere
|
||||
// in the same file is still reported; omit `pattern` only when every legacy
|
||||
// string in the file is intentional.
|
||||
const ALLOWED_LEGACY_BRAND = [
|
||||
{
|
||||
file: 'AGENTS.md',
|
||||
pattern: /hermes-studio\.ai|global-agent relay|not affiliated/,
|
||||
reason: 'Red Lines warn agents not to strip the load-bearing upstream relay address, so the doc has to name it',
|
||||
},
|
||||
{
|
||||
file: 'packages/server/src/db/index.ts',
|
||||
reason: 'DB filenames predate the rename; renamed files would orphan data in ~/.hermes-web-ui',
|
||||
@@ -269,7 +352,8 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
},
|
||||
{
|
||||
file: 'packages/server/src/config.ts',
|
||||
reason: 'HERMES_WEB_UI_HOME / ~/.hermes-web-ui state directory is a preserved public contract',
|
||||
pattern: /url: 'https:\/\/api\.hermes-studio\.ai'/,
|
||||
reason: 'remoteRelay is the live external global-agent relay service, not the product brand',
|
||||
},
|
||||
{
|
||||
file: 'packages/server/src/middleware/user-auth.ts',
|
||||
@@ -283,13 +367,39 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts',
|
||||
reason: 'legacy MCP server/command names are detected to clean up old on-disk config',
|
||||
},
|
||||
{
|
||||
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, not the product brand',
|
||||
},
|
||||
{
|
||||
file: 'packages/client/src/components/layout/AppSidebar.vue',
|
||||
pattern: /href="https:\/\/hermes-studio\.ai\/"/,
|
||||
reason: 'sidebar footer links to the upstream product website, not the product brand',
|
||||
},
|
||||
{
|
||||
file: 'packages/client/src/components/layout/SettingsCircuitBadge.vue',
|
||||
pattern: /const purchaseUrl = 'https:\/\/hermes-studio\.ai\/docs\//,
|
||||
reason: 'purchase badge links to the upstream docs site that hosts the ESP32 offer',
|
||||
},
|
||||
{
|
||||
file: 'tests/server/studio-mcp-autoinject.test.ts',
|
||||
reason: 'asserts legacy MCP entries are cleaned from existing on-disk config',
|
||||
},
|
||||
{
|
||||
file: 'tests/client/markdown-rendering.test.ts',
|
||||
reason: 'fixture URL is arbitrary sample data, not project branding',
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
file: 'tests/server/cli-port-detection.test.ts',
|
||||
@@ -299,10 +409,6 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
file: 'tests/server/agent-bridge-python-concurrency.test.ts',
|
||||
reason: 'asserts preview socket path derived from the preview endpoint env var',
|
||||
},
|
||||
{
|
||||
file: 'packages/server/src/services/runtime-version-manager.ts',
|
||||
reason: 'release download host and repo slug are the runtime-version lookup contract; replaced in Phase 008',
|
||||
},
|
||||
{
|
||||
file: 'packages/server/src/controllers/hermes/sessions.ts',
|
||||
reason: 'the visible-session-source check still lists coding_agent so sessions stored before Phase 003 stay listed',
|
||||
@@ -314,7 +420,7 @@ const ALLOWED_LEGACY_BRAND = [
|
||||
]
|
||||
|
||||
// Legacy identifiers that stay valid everywhere, because they are public
|
||||
// deployment contracts rather than project branding.
|
||||
// deployment contracts or wire-protocol values rather than project branding.
|
||||
const GLOBAL_ALLOWED_PATTERNS = [
|
||||
/HERMES_WEB_UI[A-Z0-9_]*/,
|
||||
/HERMES_WEBUI_STATE_DIR/,
|
||||
@@ -322,6 +428,9 @@ const GLOBAL_ALLOWED_PATTERNS = [
|
||||
/hermes_brightness/,
|
||||
/hermes_style/,
|
||||
/hermes_locale/,
|
||||
// Global-agent relay socket role identifier. It is part of the wire contract
|
||||
// with the relay service, so renaming it would break cross-version peers.
|
||||
/role\s*(?::|===)\s*'hermes-studio'/,
|
||||
]
|
||||
|
||||
async function collectScanFiles(target, out) {
|
||||
@@ -343,9 +452,10 @@ function isAllowedLegacyBrand(relativePath, line) {
|
||||
if (GLOBAL_ALLOWED_PATTERNS.some(pattern => pattern.test(line))) return true
|
||||
const normalized = relativePath.split(path.sep).join('/')
|
||||
// An allow-list entry may name a directory, in which case it covers its files.
|
||||
return ALLOWED_LEGACY_BRAND.some(
|
||||
entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`),
|
||||
)
|
||||
return ALLOWED_LEGACY_BRAND.some(entry => {
|
||||
if (normalized !== entry.file && !normalized.startsWith(`${entry.file}/`)) return false
|
||||
return !entry.pattern || entry.pattern.test(line)
|
||||
})
|
||||
}
|
||||
|
||||
const scanFiles = []
|
||||
@@ -367,7 +477,7 @@ for (const relativePath of scanFiles) {
|
||||
}
|
||||
}
|
||||
for (const hit of brandHits) {
|
||||
fail(`Legacy brand string must be removed (ekko / hermes-web-ui): ${hit}`)
|
||||
fail(`Legacy brand string must be removed (ekko / hermes-web-ui / hermes-studio): ${hit}`)
|
||||
}
|
||||
|
||||
// --- Retired runtime scan ----------------------------------------------
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const app = {
|
||||
isPackaged: false,
|
||||
getAppPath: () => process.cwd(),
|
||||
getVersion: () => '0.0.0',
|
||||
getLocale: () => 'en',
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests that handle-bridge-run properly writes ended_at / end_reason
|
||||
* to the session DB when a run terminates (normal or error).
|
||||
*
|
||||
* Relates to: https://github.com/EKKOLearnAI/hermes-studio/issues/1998
|
||||
* Relates to: upstream issue #1998 (session ended_at / end_reason not persisted)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* 红线:Hermes Agent 的 `state.db` 对 Node 侧是**只读**的(AGENTS.md Red Lines)。
|
||||
*
|
||||
* 现状(Phase 008 §3.2 调研结论):
|
||||
* - `packages/server/src/db/hermes/sessions-db.ts` —— 7 处 `new DatabaseSync(...)`,
|
||||
* 路径来自同文件的 `sessionDbPath()` / `sessionDbPathForProfile()`;
|
||||
* - `packages/server/src/db/hermes/conversations-db.ts` —— 1 处,路径来自
|
||||
* 同文件的 `conversationDbPath()`;
|
||||
* - 两者全部带 `readOnly: true`。
|
||||
*
|
||||
* 判断「这个 DatabaseSync 打开的是不是 state.db」的做法(不使用「state.db 字符串
|
||||
* 出现次数」这种脆判定):
|
||||
* 1. 先在**同一个文件**里找出所有「路径构造函数」——函数体里出现 `'state.db'`
|
||||
* 字面量的函数(当前是 sessionDbPath / sessionDbPathForProfile / conversationDbPath);
|
||||
* 2. 对每个 `new DatabaseSync(` 调用点取第一个实参,判定它的来源:
|
||||
* 实参里出现 `'state.db'` 字面量 → state.db;
|
||||
* 实参里调用了上述某个路径构造函数 → state.db(覆盖
|
||||
* `profile ? sessionDbPathForProfile(profile) : sessionDbPath()` 这类三元表达式);
|
||||
* 实参是裸标识符 → 在同文件里找到它的 `const/let/var` 声明并**递归**解析
|
||||
* (sessions-db.ts 的 `const dbPath = ...` 就是这种形态),用 visited 集合防环;
|
||||
* 3. 解析不出来 → 归为 `unknown` 并**直接判失败**,要求显式分类。
|
||||
*
|
||||
* 第 3 步是关键:宁可误报也不漏报。这样将来有人把路径来源改成一个新形态
|
||||
* (比如从配置读路径),测试会立刻失败并要求写明该调用点的归类,而不是
|
||||
* 静悄悄地跳过检查、让红线形同虚设。
|
||||
*/
|
||||
|
||||
const SERVER_SRC = 'packages/server/src'
|
||||
|
||||
/**
|
||||
* 唯一允许可写的 SQLite 打开点:Web UI 自己的库。
|
||||
*
|
||||
* 豁免原因:`packages/server/src/db/index.ts` 打开的是 Web UI 自有的 SQLite 库
|
||||
* (文件名早于 hermes-ui 改名,见该文件顶部注释),保存的是本项目自己的会话、
|
||||
* 设置、设备等数据,**必须**可写;它与 Hermes Agent 的 state.db 无关。
|
||||
* 这也是全仓唯一被允许写开的库——新增豁免前请先确认新库不是 Hermes 侧的数据。
|
||||
*/
|
||||
const WRITABLE_EXEMPT_FILES = new Set(['packages/server/src/db/index.ts'])
|
||||
|
||||
type Classification = 'state-db' | 'own-db' | 'unknown'
|
||||
|
||||
type CallSite = {
|
||||
file: string
|
||||
line: number
|
||||
/** 第一个实参原文,用于报错定位 */
|
||||
pathExpr: string
|
||||
/** 第二个实参(options)原文 */
|
||||
optionsExpr: string
|
||||
classification: Classification
|
||||
}
|
||||
|
||||
const toRelative = (file: string) => file.split('\\').join('/')
|
||||
|
||||
function listSourceFiles(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry)
|
||||
if (statSync(full).isDirectory()) {
|
||||
// Python 桥接层是 vendored 只读代码,且不直接用 sqlite
|
||||
if (entry.name === 'python' || entry.name === '__pycache__') continue
|
||||
listSourceFiles(full, out)
|
||||
} else if (full.endsWith('.ts')) {
|
||||
out.push(toRelative(full))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const OPENERS: Record<string, string> = { '(': ')', '[': ']', '{': '}' }
|
||||
|
||||
/** 从 openIndex 处的括号开始做配对,返回配对括号之后的下标 */
|
||||
function matchBracket(source: string, openIndex: number): number {
|
||||
const close = OPENERS[source[openIndex]]
|
||||
let depth = 0
|
||||
for (let i = openIndex; i < source.length; i += 1) {
|
||||
if (source[i] === source[openIndex]) depth += 1
|
||||
else if (source[i] === close) {
|
||||
depth -= 1
|
||||
if (depth === 0) return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 start 处读出一个「实参 / 初始化表达式」:在括号深度为 0 的位置遇到
|
||||
* 逗号、分号或换行即停止,因此既能吃下 `dbPath, { ... }` 里的 `dbPath`,
|
||||
* 也能吃下跨行的三元表达式。
|
||||
*/
|
||||
function readExpression(source: string, start: number): string {
|
||||
let i = start
|
||||
for (; i < source.length; i += 1) {
|
||||
const ch = source[i]
|
||||
if (OPENERS[ch]) {
|
||||
const close = matchBracket(source, i)
|
||||
// 括号不配对(理论上不会发生)时保守退出,避免整文件扫描被拖死
|
||||
if (close === -1) return source.slice(start, i)
|
||||
i = close
|
||||
continue
|
||||
}
|
||||
// 括号外的逗号 / 分号 / 换行即表达式结束
|
||||
if (ch === ',' || ch === ';' || ch === '\n') break
|
||||
}
|
||||
return source.slice(start, i).trim()
|
||||
}
|
||||
|
||||
/** 按顶层逗号切分实参列表(忽略括号内的逗号) */
|
||||
function splitArgs(source: string): string[] {
|
||||
const args: string[] = []
|
||||
let current = ''
|
||||
let i = 0
|
||||
while (i < source.length) {
|
||||
const ch = source[i]
|
||||
if (OPENERS[ch]) {
|
||||
const close = matchBracket(source, i)
|
||||
if (close === -1) break
|
||||
current += source.slice(i, close + 1)
|
||||
i = close + 1
|
||||
continue
|
||||
}
|
||||
if (ch === ',') {
|
||||
args.push(current)
|
||||
current = ''
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
current += ch
|
||||
i += 1
|
||||
}
|
||||
if (current.trim()) args.push(current)
|
||||
return args.map(arg => arg.trim())
|
||||
}
|
||||
|
||||
/** 收集同文件里所有「路径构造函数」:函数体中出现 'state.db' 字面量的函数名 */
|
||||
function collectStateDbPathFunctions(source: string): Set<string> {
|
||||
const names = new Set<string>()
|
||||
const declRe = /(?:^|\n)\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g
|
||||
for (const match of source.matchAll(declRe)) {
|
||||
const braceIndex = source.indexOf('{', match.index + match[0].length)
|
||||
if (braceIndex === -1) continue
|
||||
const end = matchBracket(source, braceIndex)
|
||||
if (end === -1) continue
|
||||
if (/'state\.db'|"state\.db"/.test(source.slice(braceIndex, end))) names.add(match[1])
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** 收集同文件里所有 const/let/var 声明 -> 初始化表达式原文 */
|
||||
function collectBindings(source: string): Map<string, string> {
|
||||
const bindings = new Map<string, string>()
|
||||
const declRe = /(?:^|[\s;{(])(?:const|let|var)\s+(\w+)\s*(?::[^=\n]+)?=\s*/g
|
||||
for (const match of source.matchAll(declRe)) {
|
||||
const expr = readExpression(source, match.index + match[0].length)
|
||||
if (expr) bindings.set(match[1], expr)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
function classify(
|
||||
pathExpr: string,
|
||||
stateDbFunctions: Set<string>,
|
||||
bindings: Map<string, string>,
|
||||
visited: Set<string>,
|
||||
): Classification {
|
||||
// 1. 实参直接写了 state.db 路径
|
||||
if (/'state\.db'|"state\.db"/.test(pathExpr)) return 'state-db'
|
||||
|
||||
// 2. 实参调用了同文件的某个 state.db 路径构造函数(含三元表达式等组合形态)
|
||||
for (const name of stateDbFunctions) {
|
||||
if (new RegExp(`\\b${name}\\s*\\(`).test(pathExpr)) return 'state-db'
|
||||
}
|
||||
|
||||
// 3. 实参是裸标识符:回到同文件的声明处递归解析
|
||||
if (/^\w+$/.test(pathExpr)) {
|
||||
if (visited.has(pathExpr)) return 'unknown'
|
||||
visited.add(pathExpr)
|
||||
const initializer = bindings.get(pathExpr)
|
||||
if (initializer === undefined) return 'unknown'
|
||||
return classify(initializer, stateDbFunctions, bindings, visited)
|
||||
}
|
||||
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function findCallSites(): CallSite[] {
|
||||
const sites: CallSite[] = []
|
||||
|
||||
for (const file of listSourceFiles(SERVER_SRC)) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const stateDbFunctions = collectStateDbPathFunctions(source)
|
||||
const bindings = collectBindings(source)
|
||||
|
||||
for (const match of source.matchAll(/new\s+DatabaseSync\s*\(/g)) {
|
||||
const openIndex = match.index + match[0].length - 1
|
||||
const closeIndex = matchBracket(source, openIndex)
|
||||
const args = closeIndex === -1 ? [] : splitArgs(source.slice(openIndex + 1, closeIndex))
|
||||
|
||||
sites.push({
|
||||
file,
|
||||
line: source.slice(0, match.index).split('\n').length,
|
||||
pathExpr: args[0] ?? '',
|
||||
optionsExpr: args[1] ?? '',
|
||||
// 豁免文件不参与 state.db 归类:它打开的是自有库,恒为可写
|
||||
classification: WRITABLE_EXEMPT_FILES.has(file)
|
||||
? 'own-db'
|
||||
: classify(args[0] ?? '', stateDbFunctions, bindings, new Set()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sites
|
||||
}
|
||||
|
||||
function describeSite(site: CallSite): string {
|
||||
return `${site.file}:${site.line} new DatabaseSync(${site.pathExpr}${
|
||||
site.optionsExpr ? `, ${site.optionsExpr}` : ''
|
||||
})`
|
||||
}
|
||||
|
||||
describe('Hermes state.db stays read-only from Node', () => {
|
||||
const callSites = findCallSites()
|
||||
const stateDbSites = callSites.filter(site => site.classification === 'state-db')
|
||||
const ownDbSites = callSites.filter(site => site.classification === 'own-db')
|
||||
const unknownSites = callSites.filter(site => site.classification === 'unknown')
|
||||
|
||||
it('finds every DatabaseSync call site it is meant to guard', () => {
|
||||
// 防止扫描规则本身失效(比如 collectBindings / matchBracket 改坏)导致整文件空转
|
||||
expect(callSites.length).toBeGreaterThanOrEqual(9)
|
||||
expect(stateDbSites.length).toBeGreaterThanOrEqual(8)
|
||||
expect(
|
||||
unknownSites.map(describeSite),
|
||||
'无法判定数据库路径来源,因此无法确认是否只读。请把该调用点的路径来源改成'
|
||||
+ '本文件里的 state.db 路径构造函数,或在写明原因后加入 WRITABLE_EXEMPT_FILES。',
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('opens every state.db handle with readOnly: true', () => {
|
||||
const writable = stateDbSites
|
||||
.filter(site => !/readOnly\s*:\s*true/.test(site.optionsExpr))
|
||||
.map(describeSite)
|
||||
|
||||
expect(
|
||||
writable,
|
||||
writable.length === 0 ? '' : [
|
||||
'以下调用打开了 Hermes 的 state.db 但没有 readOnly: true。',
|
||||
'state.db 归 Hermes Agent 所有,Node 侧只能读;',
|
||||
'打开方式应与 sessions-db.ts 现有写法一致:',
|
||||
' new DatabaseSync(<path>, { open: true, readOnly: true })',
|
||||
'需要写入的数据请落到 Web UI 自有库,不要写进 Hermes 的库。',
|
||||
].join('\n'),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('exempts only the Web UI own database from read-only', () => {
|
||||
// 自有库必须仍然是可写的:把它改成 readOnly 会让 Web UI 自己的数据写不进去,
|
||||
// 那说明豁免的前提(自有库可写)已经不成立,需要重新审视这条豁免。
|
||||
expect(ownDbSites.map(site => site.file)).toEqual([...WRITABLE_EXEMPT_FILES])
|
||||
for (const site of ownDbSites) {
|
||||
expect(
|
||||
site.optionsExpr,
|
||||
`${site.file} 是自有库豁免点,必须保持可写(不要加 readOnly: true)`,
|
||||
).not.toMatch(/readOnly\s*:\s*true/)
|
||||
}
|
||||
})
|
||||
|
||||
it('never reaches for the retired better-sqlite3 API', () => {
|
||||
// 全仓已统一到 node:sqlite 的 DatabaseSync。这几条是防回退:
|
||||
// better-sqlite3 的 `new Database(...)` 默认就是可写打开,且需要再调 .open(),
|
||||
// 一旦回退,state.db 就会被无声地以读写模式打开。
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const file of listSourceFiles(SERVER_SRC)) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
source.split(/\r?\n/).forEach((line, index) => {
|
||||
if (/\bnew\s+Database\s*\(/.test(line)) {
|
||||
offenders.push(`${file}:${index + 1} 使用了 better-sqlite3 的 new Database(...): ${line.trim()}`)
|
||||
}
|
||||
if (/better-sqlite3/.test(line)) {
|
||||
offenders.push(`${file}:${index + 1} 引用了 better-sqlite3: ${line.trim()}`)
|
||||
}
|
||||
if (/\.open\s*\(/.test(line)) {
|
||||
offenders.push(`${file}:${index + 1} 使用了 better-sqlite3 的 .open(): ${line.trim()}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expect(
|
||||
offenders,
|
||||
offenders.length === 0 ? '' : [
|
||||
'数据库访问必须统一走 node:sqlite 的 DatabaseSync:',
|
||||
" import { DatabaseSync } from 'node:sqlite'",
|
||||
'打开 Hermes 的 state.db 时必须带 { open: true, readOnly: true }。',
|
||||
].join('\n'),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the state.db path constructors in sync with the red line', () => {
|
||||
// 只读红线的前提是「路径构造函数仍然指向 state.db」。如果有人把 state.db 的
|
||||
// 路径改成可写来源或改名,这里会先一步报出来,避免红线测试被悄悄架空。
|
||||
const found = new Set<string>()
|
||||
for (const file of listSourceFiles(SERVER_SRC)) {
|
||||
for (const name of collectStateDbPathFunctions(readFileSync(file, 'utf8'))) {
|
||||
found.add(`${file}#${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
[...found].sort(),
|
||||
'state.db 路径构造函数集合变了:如果新增了构造函数,请确认它同样只被以 '
|
||||
+ 'readOnly: true 打开;如果是被删掉/改名了,请同步更新本测试。',
|
||||
).toEqual([
|
||||
'packages/server/src/db/hermes/conversations-db.ts#conversationDbPath',
|
||||
'packages/server/src/db/hermes/sessions-db.ts#sessionDbPath',
|
||||
'packages/server/src/db/hermes/sessions-db.ts#sessionDbPathForProfile',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
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',
|
||||
},
|
||||
{
|
||||
// 原因:购买徽章指向托管 ESP32 活动的上游文档站
|
||||
file: 'packages/client/src/components/layout/SettingsCircuitBadge.vue',
|
||||
pattern: /const purchaseUrl = 'https:\/\/hermes-studio\.ai\/docs\//,
|
||||
reason: 'purchase badge links to the upstream docs site hosting the ESP32 offer',
|
||||
},
|
||||
{
|
||||
// 原因:断言旧 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)
|
||||
const end = harness.indexOf('\n]\n', start)
|
||||
expect(end, `${HARNESS_SCRIPT} 里的 ${name} 数组没有正确闭合`).toBeGreaterThan(start)
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,6 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'packages/client/src'),
|
||||
electron: resolve(__dirname, 'tests/mocks/electron.ts'),
|
||||
'/logo.png': resolve(__dirname, 'packages/client/public/logo.png'),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user