From bc2bf3c8b5523b99c24e70144311e1abb4bbaa69 Mon Sep 17 00:00:00 2001 From: chenyida7-prog Date: Thu, 3 Sep 2026 00:40:53 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test+fix(claude-code-config):=20=E9=AA=8C?= =?UTF-8?q?=E6=94=B6=20Claude=20Code=20MCP/Hooks/Agent=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=86=99=E5=85=A5=E4=B8=8E=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=BC=80=E5=85=B3=E5=8F=8C=E5=90=91=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产物一:docs/test-plan-claude-code-config.md(测试方案,待 mentor 审阅) 前置调研(三类配置落盘位置/格式、设置页开关读写链路、关=删除非停用)、 三次核心检验、自动化 vs 手动分工、回归说明、发现的问题记录。 产物二:自动化测试 - tests/test_claude_code_config_verify.py(后端/API 层,27 例,fake_home + TestClient) 检验一:init 写入三份文件内容正确 + detect 三开关全开 + 开关↔文件一致 检验二:关某开关 → 配置项被物理移除(非停用标记)+ 保留用户其他配置 + 幂等 检验三:再开 → 恢复写入,内容与初次逐字段等价 + 多轮循环稳定 独立性:单开关开/关不扰动另外两个 + 8 种开关组合矩阵逐一核验 引导契约护栏:拒绝非法 kind、重复初始化保持开启 - tests/frontend/test_claude_code_config.py(前端,静态 9 + 浏览器 5) 静态:开关绑定 clientStatus、点击调 configureClient(plat.key,kind.key)、 configureClient 双向取反 + POST/DELETE 分支 + 回读、单写锁、 guideExecute 用 .key 且只「确保开启」 浏览器(全量 mock /api/**,真跑 Alpine,零副作用不碰真实 ~/.claude): 开关 on/off ↔ mock 配置态一致、点击发对方向 HTTP 动词并回读翻转、 三开关独立、guideExecute 不误删已配置项 / 未配置时写全三种 测→修→再测:修复 frontend/js/store.js::guideExecute 两个缺陷(详见方案文档「发现的问题」) #1 kind 传成 {key,label,desc} 对象而非字符串 → URL /api/client-config/X/[object Object] → 后端 400 被 catch 吞掉 → 初始化引导三份配置一个都写不进去(结论页却显示「初始化完成」) #2 (修完 #1 才显形)guideExecute 拿双向开关 configureClient 当「确保开启」用, 对已配置项会走 DELETE → 「重新运行初始化引导」把已有配置误删 修复:取 kindMeta.key 字符串 + 已开启的 kind 直接 continue(不改 configureClient 双向语义) frontend/index.html 的 store.js?v= 内容哈希随 store.js 改动更新(build.py 版本化, CI check_build.py ④ 一致性检查要求匹配)。index.standalone.html 为 gitignore 构建产物,不入库。 不改动任何既有测试。既有测试基线(改动前后一致):后端全量 758 passed; tests/frontend/ 78 passed / 115 skipped / 3 pre-existing failed (test_smoke.py::TestRouteRendering 3 例缺 backend_running skip 守卫,与本改动无关)。 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SSMDrsmrXR5kr68SUS5D5P --- docs/test-plan-claude-code-config.md | 297 +++++++++++++++++ frontend/index.html | 2 +- frontend/js/store.js | 10 +- tests/frontend/test_claude_code_config.py | 341 +++++++++++++++++++ tests/test_claude_code_config_verify.py | 389 ++++++++++++++++++++++ 5 files changed, 1037 insertions(+), 2 deletions(-) create mode 100644 docs/test-plan-claude-code-config.md create mode 100644 tests/frontend/test_claude_code_config.py create mode 100644 tests/test_claude_code_config_verify.py diff --git a/docs/test-plan-claude-code-config.md b/docs/test-plan-claude-code-config.md new file mode 100644 index 0000000..5127409 --- /dev/null +++ b/docs/test-plan-claude-code-config.md @@ -0,0 +1,297 @@ +# 测试方案:Claude Code 平台 MCP / Hooks / Agent 配置写入 + 设置页开关双向联动 + +> 状态:待 mentor 审阅 +> 分支:`test/claude-code-config-verify` +> 自动化测试: +> - `tests/test_claude_code_config_verify.py`(后端 / API 层,27 例) +> - `tests/frontend/test_claude_code_config.py`(前端静态 9 例 + 浏览器 5 例) + +--- + +## 1. 验证目标 + +验证并保证「Claude Code」这个 AI 平台在**初始化配置后**: + +1. **配置写入正确**:MCP / Hooks / Agent 三种配置分别正确写入 Claude Code 对应的配置文件。 +2. **设置页开关双向控制**:设置页里这三个开关能正确双向控制 + - 开 → 写入配置 + - 关 → 移除配置 + - 再开 → 恢复写入 +3. **三个开关互相独立**,互不串扰。 + +> **重要前提**:Claude Code 本身在国内网络下无法联网运行,但这**不影响本任务**—— +> 要验的是「配置文件写入」和「设置页开关读写逻辑」,**全部纯本地验证** +> (检查配置文件内容 + 前端逻辑),不需要真的把 Claude Code 跑起来联网。 + +--- + +## 2. 前置调研(读代码得出的现状) + +### 2.1 Claude Code 的三种配置分别写到哪、什么格式 + +单一来源:`backend/AiClientConfig/platforms.json` → `platforms.ClaudeCode`(macOS): + +| kind | 配置文件 | 键 / 形式 | 生成函数 | +|------|----------|-----------|----------| +| **mcp** | `~/.claude.json` | `mcpServers.MyKnowledge` = 一个 stdio server 对象 | `client_config.mcp_entry("ClaudeCode")` | +| **hooks** | `~/.claude/settings.json` | `hooks.PreToolUse[]` 里追加一个 matcher | `client_config.hooks_matcher("ClaudeCode")` | +| **agent** | `~/.claude/agents/MyKnowledge-agent.md` | 一个 Markdown 文件(YAML frontmatter + 正文) | `client_config.agent_content("ClaudeCode")` | + +三类落在**三个不同文件**(`~/.claude.json` / `~/.claude/settings.json` / `~/.claude/agents/…`), +天然隔离——这是「三个开关互相独立」的结构性基础。 + +**MCP 条目**(`~/.claude.json` → `mcpServers.MyKnowledge`): + +```json +{ + "type": "stdio", + "command": "", // 开发/PyPI:sys.executable;桌面 App(frozen):打包二进制 + "args": ["-m", "backend.cli", "mcp"], // frozen 时为 ["--mcp"] + "env": { + "MYKNOWLEDGE_ROOT": "<知识库根目录>", + "MYKNOWLEDGE_CLIENT": "ClaudeCode" // MCP 进程心跳上报时标识调用方 + } +} +``` + +**Hooks 条目**(`~/.claude/settings.json` → `hooks.PreToolUse[]`): + +```json +{ + "matcher": "Bash|Write|Edit", + "hooks": [ + { "type": "command", + "command": "curl -s -X POST http://127.0.0.1:8080/hooks/pre-tool-use -H 'Content-Type: application/json' -d @-" } + ] +} +``` + +- Claude Code 把 PreToolUse 的 JSON payload 写到 hook 命令的 **stdin**,`-d @-` 让 curl 从 stdin 读 body。 +- matcher 用 `Bash|Write|Edit`(Claude PreToolUse 按工具名匹配,光 `Bash` 会漏掉 Write/Edit)。 + +**Agent 文件**(`~/.claude/agents/MyKnowledge-agent.md`):正文取自 +`backend/AiClientConfig/agents/MyKnowledge-agent.md`,frontmatter 取自 +`agents/frontmatter.json` 里 ClaudeCode 对应 variant(`name` / `description` / `tools` / `model: inherit`)。 + +### 2.2 设置页三个开关如何读写这些配置 + +**读(开关的「开 / 关」视觉态)**: + +``` +index.html toggle 的 class + └─ $store.app.clientStatus(plat.key, kind.key) // store.js + └─ return this.clientConfig[platform][kind] // bool + └─ this.clientConfig = await api.getClientConfig() // loadClientConfig() + └─ GET /api/client-config + └─ client_config.detect_all() → detect_platform("ClaudeCode") + ├─ mcp : "MyKnowledge" in (~/.claude.json).mcpServers + ├─ hooks : ~/.claude/settings.json 里有 command 含 /hooks/pre-tool-use 的 matcher + └─ agent : ~/.claude/agents/MyKnowledge-agent.md 存在 +``` + +即:**开关状态 = 后端对真实配置文件的检测结果**。不存在「前端自己记状态」。 + +**写(点击开关)**: + +``` +index.html @click="$store.app.configureClient(plat.key, kind.key)" + └─ store.js configureClient(platform, kind): + prev = !!this.clientConfig[platform][kind] // 当前检测态 + target = !prev // 取反 + this.clientConfig[platform][kind] = target // optimistic 先翻 + target ? api.setClientConfig(platform, kind) // 开:POST /api/client-config/

/ + : api.deleteClientConfig(platform, kind) // 关:DELETE /api/client-config/

/ + await this.loadClientConfig() // 回读真实态(权威) + // 失败:loadClientConfig 回弹真实态 + 行内 fallback 文本(5s) +``` + +- 单写锁 `clientConfiguring`:`if (this.clientConfiguring) return;`——同一时刻只允许一个写入, + 防止并发点击导致的串扰 / 竞态。 +- 后端路由: + - `POST /api/client-config/{platform}/{kind}` → `client_config.write_kind()` + - `DELETE /api/client-config/{platform}/{kind}` → `client_config.remove_kind()` + +### 2.3 「关掉开关」的实际行为:**删除**,不是「保留但停用」 + +`client_config.remove_kind("ClaudeCode", kind)`: + +| kind | 关掉时的动作 | 结果 | +|------|--------------|------| +| mcp | `mcpServers.pop("MyKnowledge")` | 键被**物理删除**(`{"mcpServers": {}}`),无残留停用标记 | +| hooks | 过滤掉 command 命中我们签名的 matcher | matcher 从 `PreToolUse[]` 中**移除** | +| agent | `path.unlink()` | agent 文件**被删除** | + +- 只动 MyKnowledge 相关条目——用户自己的其他 `mcpServers` / 其他 hooks matcher / 无关设置项**全部保留**。 +- 幂等:对已不存在的条目再执行 remove 也成功(不报错)。 +- 没有「保留但停用」这种中间态。 + +「再次打开」= `write_kind()` 重新增量合并写回,内容与初次写入**等价**。 + +--- + +## 3. 三次核心检验 + +每次都对比「设置页开关状态」(`GET /api/client-config` 的结果,= 开关视觉态数据源) +↔「Claude Code 实际配置文件」,两者**必须一致**。 + +### 检验一:初始化后 + +| 项 | 预期 | +|----|------| +| `~/.claude.json` | `mcpServers.MyKnowledge` 存在,`type=stdio`,`args=["-m","backend.cli","mcp"]`,`env.MYKNOWLEDGE_CLIENT="ClaudeCode"` | +| `~/.claude/settings.json` | `hooks.PreToolUse[]` 含 1 个 matcher,`matcher="Bash\|Write\|Edit"`,command 为 `curl … -d @-` | +| `~/.claude/agents/MyKnowledge-agent.md` | 文件存在,以 `---`(frontmatter) 开头,正文含 `# MyKnowledge Agent` | +| `GET /api/client-config` → `ClaudeCode` | `{mcp: true, hooks: true, agent: true}` | +| 设置页三个开关 | 均显示为**开** | + +自动化:`test_claude_code_config_verify.py::TestCheck1_InitializedState`(6 例)、 +`test_claude_code_config.py::TestClaudeCodeToggleBrowser::test_switch_reflects_backend_state`。 + +### 检验二:关掉某开关 + +对 mcp / hooks / agent 分别:`DELETE /api/client-config/ClaudeCode/` + +| 项 | 预期 | +|----|------| +| 对应配置文件 | 该 kind 的 MyKnowledge 条目**被移除**(键删除 / matcher 删除 / 文件删除),非停用标记 | +| 用户其他配置 | 保留(其他 mcpServers、其他 hooks、PostToolUse、无关设置项都在) | +| `GET /api/client-config` → `ClaudeCode[kind]` | `false` | +| 该开关 | 显示为**关**;另外两个开关**不变** | + +自动化:`TestCheck2_ToggleOff`(5 例,含「关=删除非停用」「保留用户其他配置」「幂等」)、 +`test_claude_code_config.py::…::test_toggle_off_sends_delete_then_on_sends_post`。 + +### 检验三:再次打开 + +`POST /api/client-config/ClaudeCode/` + +| 项 | 预期 | +|----|------| +| 对应配置文件 | 条目**恢复写入**,内容与初次写入**逐字段等价** | +| `GET /api/client-config` → `ClaudeCode[kind]` | `true` | +| 该开关 | 恢复为**开** | +| 多轮 关→开→关→开 | 状态稳定,无漂移 | + +自动化:`TestCheck3_ToggleBackOn`(3 例,含「恢复内容等价」「多轮循环稳定」)。 + +### 补充:三个开关各自独立、互不影响 + +| 场景 | 预期 | +|------|------| +| 关 mcp | hooks / agent 的检测态与文件不变 | +| 关 hooks | mcp / agent 不变 | +| 关 agent | mcp / hooks 不变 | +| 开某个关着的 kind | 不会顺带打开另外两个 | +| 8 种开/关组合矩阵 | 设置页开关矩阵 ↔ 配置文件矩阵**逐一完全一致** | + +自动化:`TestCheck4_SwitchIndependence`(4 例,含 `test_independent_state_matrix` 遍历 8 组合)、 +`test_claude_code_config.py::…::test_three_switches_independent`(浏览器点关 hooks,断言只对 hooks 发一次 DELETE)。 + +--- + +## 4. 自动化测试 vs 手动验证 + +### 自动化覆盖 + +| 层 | 文件 | 覆盖 | +|----|------|------| +| **后端 / API** | `tests/test_claude_code_config_verify.py`(`fake_home` 隔离 + `TestClient` 打真实路由) | 三次核心检验的**配置文件内容 + 检测态 + 开关联动**全链路;关=删除非停用;保留用户配置;独立性 8 组合矩阵;引导契约护栏(拒绝非法 kind、重复初始化保持开启) | +| **前端 · 静态** | `tests/frontend/test_claude_code_config.py::TestClaudeCodeConfigStatic`(源码断言,总是运行) | 开关视觉态绑定 `clientStatus`;点击调 `configureClient(plat.key, kind.key)`;`configureClient` 双向取反 + POST/DELETE 分支 + 回读;单写锁;`guideExecute` 用 `.key` 且只「确保开启」 | +| **前端 · 浏览器** | `tests/frontend/test_claude_code_config.py::TestClaudeCodeToggleBrowser`(Playwright,全量 mock `/api/**`,真跑 Alpine,不碰真实 `~/.claude`、不需 8080 后端) | 开关 on/off class ↔ mock 配置态一致;点击发对方向 HTTP 动词并回读翻转;三开关独立;`guideExecute` 不误删已配置项 / 未配置时写全三种 | + +**为什么前端写路径用 mock 而不用真后端**:仓库既有前端浏览器测试(`test_stage3.py`)明确 +「只 GET 检测 + 渲染,不实际 POST 写入用户全局配置」——真后端会写到跑测试这台机器的真实 +`~/.claude`。本方案用 `page.route` 拦截全部 `/api/**`,内存态模拟后端,既真实驱动 Alpine +开关逻辑,又零副作用。 + +### 需手动验证(自动化未覆盖 / 不适合自动化) + +| 项 | 手动步骤 | +|----|----------| +| 桌面 App 内引导页真机走查 | `myknowledge serve` → 浏览器打开 → 初始化引导选 Claude Code → 走完 → 去 `~/.claude.json` / `~/.claude/settings.json` / `~/.claude/agents/` 核对三份文件 | +| 设置页开关视觉/交互细节 | 设置 → MCP/Hooks/Agents 三页,逐个点开关,观察 knob 滑动动画、toast 文案(「已就绪 / 已关闭」)、失败时行内 fallback | +| Claude Code 真机联调(**国内网络不可行,出于完整性列出**) | 有外网环境时:配置写入后启动 Claude Code,确认它能加载 MyKnowledge MCP server(`/mcp` 列出工具)、PreToolUse hook 生效、`@MyKnowledge-agent` 可用 | +| 「重新运行初始化引导」回归 | 已配置 Claude Code → 设置→通用→重新运行初始化引导→选 Claude Code→走完 → 确认三份配置**仍在**(不被清掉) | + +--- + +## 5. 回归说明 + +- **不改动任何既有测试文件**。两个新测试文件独立新增。 +- `frontend/js/store.js` 的功能修复(见下「发现的问题」)触发 `frontend/build.py` 的 + `?v=` 内容哈希更新 → `frontend/index.html` 里 `store.js?v=` 一行随之更新(CI 的 + `frontend/check_build.py` ④ 版本化一致性检查要求二者匹配)。`index.standalone.html` + 为 `.gitignore` 忽略的构建产物,不入库。 +- 基线(改动前,clean `main`)既有测试状态: + - `tests/`(后端全量):**全绿**(`758 passed`,含无关的 `.myknowledge_test` 集)。 + - `tests/frontend/test_stage3.py`:`17 passed, 8 skipped`(skip = 需 8080 后端)。 + - `tests/frontend/test_client_config.py` 对应的后端 `tests/test_client_config.py`:**全绿**。 + - `tests/frontend/test_smoke.py::TestRouteRendering` 的 3 例 + (`test_no_modal_visible_on_load` / `test_dashboard_shows_title` / `test_trash_view_renders`) + **改动前即失败**(缺少 `backend_running` skip 守卫,无 8080 后端时 dashboard 无数据 → + 标题区 `x-show` 隐藏)。**与本次改动无关**,改动后仍是同样这 3 例失败,无新增失败。 +- 改动后: + - `tests/test_claude_code_config_verify.py`:`27 passed` + - `tests/frontend/test_claude_code_config.py`:`14 passed`(静态 9 + 浏览器 5) + - `tests/frontend/test_stage3.py`:`17 passed, 8 skipped`(不变) + - `tests/test_client_config.py`:不变全绿 + - 后端全量 `758 passed` + - `frontend/check_build.py`:`28/29`(唯一失败 `⑤ 编辑保存往返测试` 缺 `frontend/node_modules` + 的 turndown,**改动前即如此**,CI 会装依赖);`④ ?v= 版本化一致性` ✓ + +--- + +## 发现的问题(测 → 修 → 再测) + +### 问题 #1:初始化引导一个配置都写不进去(kind 传成对象 → 后端 400) + +- **现象**:走「初始化引导 / 重新运行初始化引导」,Step 2.2「正在为 N 个平台初始化 AI 协作…」 + 执行完、结论页显示「初始化完成,已为所选平台开启协作能力」,但 `~/.claude.json` / + `~/.claude/settings.json` / `~/.claude/agents/` **三份文件一个都没被写**。设置页三个开关仍是「关」。 +- **根因**:`frontend/js/store.js` `guideExecute()`: + ```js + for (const kind of this.platformKinds(platform)) { // ← platformKinds() 返回 {key,label,desc} 对象数组 + await this.configureClient(platform, kind); // ← 把整个对象当 kind 传下去 + } + ``` + `configureClient` → `api.setClientConfig(platform, {对象})` → + URL `/api/client-config/ClaudeCode/${encodeURIComponent({对象})}` = + `/api/client-config/ClaudeCode/%5Bobject%20Object%5D` → + 后端 `write_kind("ClaudeCode", "[object Object]")` → `kind not in KINDS` → **HTTP 400** → + 被 `guideExecute` 里的 `catch (e) {}` 静默吞掉。同文件其他调用点(`guideConfigItems`) + 都正确取了 `kind.key`,唯独 `guideExecute` 漏了。 +- **修复**(`frontend/js/store.js`,最小改动): + ```js + for (const kindMeta of this.platformKinds(platform)) { + const kind = kindMeta.key; // ← 取字符串 key + if (this.clientStatus(platform, kind) === true) continue; // 见问题 #2 + try { await this.configureClient(platform, kind); } catch (e) { /* 不中断后续 */ } + } + ``` +- **验证**:`test_claude_code_config.py::…::test_guide_execute_writes_all_three_when_unconfigured` + (改前红:`guideExecute` 对 ClaudeCode 发出的是非法 kind、0 个有效 POST;改后绿:mcp/hooks/agent 各一个 POST)。 + 后端护栏 `test_claude_code_config_verify.py::TestGuideExecuteContract::test_object_like_kind_is_rejected`。 + +### 问题 #2:重新运行初始化引导会把已配置的项**误关掉** + +- **现象**(修完 #1 后才会显形;#1 存在时所有调用都 400,掩盖了它):已经配置好 Claude Code + 的用户,点「设置 → 通用 → 重新运行初始化引导」,重新勾选 Claude Code 走一遍—— + 期望是「确认 / 保持开启」,实际会把 mcp/hooks/agent **三个都关掉**(配置被删)。 +- **根因**:`configureClient(platform, kind)` 是**双向开关**(`target = !prev`)。 + `guideExecute` 拿它当「确保开启」用:当 `prev` 已是 `true`,`target` 就变成 `false` → 走 DELETE。 + 引导的语义自始至终是「初始化 / 开启」(UI 文案无一处是「切换」),对已开启项执行关闭是明确的逻辑错误。 +- **修复**:同上一处,加一行 `if (this.clientStatus(platform, kind) === true) continue;`—— + 引导只对**尚未开启**的 kind 调 `configureClient`(写操作幂等,POST 已开启项本也安全, + 这里直接跳过更省一次往返)。**不改 `configureClient` 本身**(设置页开关仍需要它的双向语义)。 +- **验证**:`test_claude_code_config.py::…::test_guide_reexecute_does_not_remove_configured` + (改前红:对已配置 ClaudeCode 发 DELETE;改后绿:零 DELETE,配置保持)。 + 静态断言 `test_guide_execute_only_ensures_on`。 + 后端护栏 `test_claude_code_config_verify.py::TestGuideExecuteContract::test_reinit_keeps_config_on`。 + +### 未发现问题的部分 + +- **设置页三个开关本身**(`configureClient` + `index.html` toggle 绑定)逻辑**正确**: + 开→POST、关→DELETE、再开→POST,回读 `loadClientConfig` 权威刷新,单写锁防串扰。 + 三次核心检验在开关这条路径上全部通过,无需修改。 +- **后端 `write_kind` / `remove_kind` / `detect_platform`** 逻辑**正确**:增量合并、只删自己的条目、 + 幂等、恢复内容等价、三文件天然隔离。既有 `tests/test_client_config.py` + 本次新增均通过。 diff --git a/frontend/index.html b/frontend/index.html index 758057d..a78648a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -69,7 +69,7 @@ - + diff --git a/frontend/js/store.js b/frontend/js/store.js index 7ab95ea..aea95bf 100644 --- a/frontend/js/store.js +++ b/frontend/js/store.js @@ -1570,7 +1570,15 @@ let _tocCollapsedSet = {}; this.guideExecPercent = 100; for (const platform of selected) { if (platform === "Enchante") continue; // deeplink 手动安装,结论页处理 - for (const kind of this.platformKinds(platform)) { + for (const kindMeta of this.platformKinds(platform)) { + // platformKinds() 返回 {key,label,desc} 元信息对象;configureClient / 后端 + // 需要的是 kind 字符串(mcp/hooks/agent)。漏取 .key 会让 URL 变成 + // /api/client-config//[object Object] → 后端 400 → 引导一个配置都写不进去。 + const kind = kindMeta.key; + // 引导只做「确保开启」:configureClient 是双向开关,对已配置的 kind 再调用会把它 + // 关掉——「重新运行初始化引导」时会误删已有配置。已开启则跳过(写操作幂等, + // 未开启才走 configureClient 写入)。 + if (this.clientStatus(platform, kind) === true) continue; try { await this.configureClient(platform, kind); } catch (e) { diff --git a/tests/frontend/test_claude_code_config.py b/tests/frontend/test_claude_code_config.py new file mode 100644 index 0000000..a791cb3 --- /dev/null +++ b/tests/frontend/test_claude_code_config.py @@ -0,0 +1,341 @@ +"""前端验收:Claude Code 平台 MCP / Hooks / Agent 设置页开关的读写逻辑。 + +配套测试方案:``docs/test-plan-claude-code-config.md``。 + +分两层: + +1. ``TestClaudeCodeConfigStatic`` —— 静态源码断言(不依赖后端 / 浏览器,总是运行)。 + 钉死 store.js / api.js / index.html 里开关的双向逻辑: + - 开关视觉态绑定 ``clientStatus(platform, kind)``(= 后端 detect 结果); + - 点击调 ``configureClient(plat.key, kind.key)``; + - ``configureClient`` 按当前状态取反:开→``setClientConfig`` (POST), + 关→``deleteClientConfig`` (DELETE),之后 ``loadClientConfig`` 回读真实态; + - 单写锁 ``clientConfiguring`` 防并发串扰; + - ``guideExecute``(重新运行初始化引导)用 ``kindMeta.key`` 字符串、且只 + 「确保开启」不误关已配置项 —— 见测试方案「发现的问题」#1 #2。 + +2. ``TestClaudeCodeToggleBrowser`` —— Playwright,全量 mock ``/api/**``(不碰真实 + ``~/.claude``、不需要 8080 后端),真跑 Alpine:验证开关 UI ↔ mock 配置态一致、 + 点击发对方向的 HTTP 动词、``guideExecute`` 不误删已配置项。 +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +STORE = ROOT / "frontend" / "js" / "store.js" +API = ROOT / "frontend" / "js" / "api.js" +INDEX = ROOT / "frontend" / "index.html" + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. 静态源码断言 +# ═══════════════════════════════════════════════════════════════════════════ + + +class TestClaudeCodeConfigStatic: + def test_api_has_write_and_remove_methods(self) -> None: + js = API.read_text(encoding="utf-8") + assert "async setClientConfig(platform, kind)" in js + assert "async deleteClientConfig(platform, kind)" in js + assert 'method: "POST"' in js and 'method: "DELETE"' in js + assert "/api/client-config/${encodeURIComponent(platform)}/${encodeURIComponent(kind)}" in js + + def test_configure_client_is_bidirectional_toggle(self) -> None: + """configureClient 按当前检测态取反:开=POST 写,关=DELETE 移除。""" + js = STORE.read_text(encoding="utf-8") + body = js[js.index("async configureClient("):js.index("async configureClient(") + 1600] + assert "const prev = !!this.clientConfig[platform][kind]" in body + assert "target = !prev" in body + assert "api.setClientConfig(platform, kind)" in body + assert "api.deleteClientConfig(platform, kind)" in body + # 写/移除后回读真实态(开关状态以后端 detect 为准) + assert "await this.loadClientConfig()" in body + + def test_configure_client_single_flight_lock(self) -> None: + js = STORE.read_text(encoding="utf-8") + body = js[js.index("async configureClient("):js.index("async configureClient(") + 1600] + assert "if (this.clientConfiguring) return" in body + assert "this.clientConfiguring = { platform, kind }" in body + assert "this.clientConfiguring = null" in body # finally 释放 + + def test_switch_visual_state_bound_to_client_status(self) -> None: + """设置页 toggle 的开/关 class 由 clientStatus(plat.key, kind.key) 决定, + 点击调 configureClient(plat.key, kind.key)。""" + html = INDEX.read_text(encoding="utf-8") + assert "$store.app.clientStatus(plat.key, kind.key)" in html + assert "$store.app.configureClient(plat.key, kind.key)" in html + # clientStatus 直接取 detect 结果 + js = STORE.read_text(encoding="utf-8") + cs = js[js.index("clientStatus(platform, kind) {"):] + cs = cs[:cs.index("},")] + assert "return cfg[platform][kind]" in cs + + def test_client_status_reads_backend_detect(self) -> None: + """loadClientConfig 把 GET /api/client-config 的结果整体存进 clientConfig。""" + js = STORE.read_text(encoding="utf-8") + body = js[js.index("async loadClientConfig()"):js.index("async loadClientConfig()") + 600] + assert "api.getClientConfig()" in body + assert "this.clientConfig = data" in body + + def test_guide_execute_uses_kind_key_string(self) -> None: + """回归 #1:guideExecute 必须取 kindMeta.key(字符串),不能把 {key,label,desc} + 对象直接传给 configureClient —— 否则 URL 变 /api/client-config/X/[object Object] + → 后端 400 → 引导一个配置都写不进去。""" + js = STORE.read_text(encoding="utf-8") + ge = js[js.index("async guideExecute()"):js.index("async guideExecute()") + 1600] + assert "this.platformKinds(platform)" in ge + assert "kindMeta.key" in ge, "guideExecute 未从 platformKinds 元信息取 .key" + assert re.search(r"configureClient\(platform,\s*kind\)", ge) + # 不能再出现「直接 for (const kind of this.platformKinds(...)) { ... configureClient(platform, kind) }」 + assert "for (const kind of this.platformKinds(platform))" not in ge + + def test_guide_execute_only_ensures_on(self) -> None: + """回归 #2:guideExecute 只「确保开启」,对已配置 kind 跳过——重新运行 + 初始化引导不能把已有配置误关(configureClient 是双向开关)。""" + js = STORE.read_text(encoding="utf-8") + ge = js[js.index("async guideExecute()"):js.index("async guideExecute()") + 1600] + assert "this.clientStatus(platform, kind) === true" in ge + assert "continue" in ge + + def test_platform_kinds_returns_meta_objects(self) -> None: + """platformKinds 返回 {key,label,desc} 元信息(供调用方取 .key)—— + 钉住这个契约,guideExecute/guideConfigItems 才知道要 .key。""" + js = STORE.read_text(encoding="utf-8") + pk = js[js.index("platformKinds(platform) {"):] + pk = pk[:pk.index("},")] + assert "this.clientKinds.filter" in pk + + def test_claude_code_supports_all_three_kinds(self) -> None: + js = STORE.read_text(encoding="utf-8") + row = next(l for l in js.splitlines() if '"ClaudeCode"' in l and "kinds:" in l) + assert '"mcp", "hooks", "agent"' in row + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. 浏览器(全量 mock /api/**,真跑 Alpine) +# ═══════════════════════════════════════════════════════════════════════════ + +PLATFORMS_META = { + "ClaudeCode": {"display": "Claude Code", "enabled": True, "order": 1, + "kinds": ["mcp", "hooks", "agent"]}, + "ClaudeDesktop": {"display": "Claude Desktop", "enabled": True, "order": 2, + "kinds": ["mcp"]}, + "CodeBuddyIDE": {"display": "CodeBuddy IDE", "enabled": True, "order": 3, + "kinds": ["mcp", "hooks", "agent"]}, + "WorkBuddy": {"display": "WorkBuddy", "enabled": True, "order": 4, + "kinds": ["mcp", "hooks", "agent"]}, + "Enchante": {"display": "Enchanté", "enabled": True, "order": 5, + "kinds": ["mcp", "agent"]}, + "Cursor": {"display": "Cursor", "enabled": True, "order": 6, + "kinds": ["mcp", "hooks", "agent"]}, +} + + +class _MockBackend: + """内存态 AI-client 配置 + 调用记录;驱动 page.route 拦截 /api/**。""" + + def __init__(self, identity: bool = True): + self.cfg = { + p: {"client_installed": True, "connection": "not_connected", + "mcp": False, "hooks": False, "agent": False} + for p in PLATFORMS_META + } + self.calls: list[tuple[str, str, str]] = [] # (method, platform, kind) + self.identity = identity + + def install(self, page) -> None: + page.route("**/api/**", self._handle) + + def _handle(self, route) -> None: + req = route.request + url = req.url + path = url.split("://", 1)[-1].split("/", 1)[-1] + path = "/" + path.split("?", 1)[0] + method = req.method + + if path.startswith("/api/events"): + return route.abort() + + m = re.match(r"/api/client-config/([^/]+)/([^/]+)$", path) + if m: + platform, kind = m.group(1), m.group(2) + self.calls.append((method, platform, kind)) + if method == "POST": + if platform in self.cfg and kind in ("mcp", "hooks", "agent"): + self.cfg[platform][kind] = True + return self._json(route, {"platform": platform, "kind": kind, + "status": "written", "detected": True}) + return self._json(route, {"detail": f"bad kind {kind}"}, status=400) + if method == "DELETE": + if platform in self.cfg and kind in ("mcp", "hooks", "agent"): + self.cfg[platform][kind] = False + return self._json(route, {"platform": platform, "kind": kind, + "status": "removed"}) + + if path == "/api/client-config": + return self._json(route, self.cfg) + if path == "/api/platforms-meta": + return self._json(route, PLATFORMS_META) + if path == "/api/identity": + if self.identity: + return self._json(route, {"nickname": "Tester", "email": "t@example.com"}) + return self._json(route, {"detail": "not set"}, status=404) + if path == "/api/config-status": + return self._json(route, {"configured": False}) + if path == "/api/version": + return self._json(route, {"system": "0.7.7", "kb": ""}) + if path == "/api/lock": + return self._json(route, {"locked": False}) + if path == "/api/mcp": + return self._json(route, {"status": "disconnected"}) + # 其它 GET 一律给空对象(store 里都有 try/catch 兜底) + return self._json(route, {}) + + @staticmethod + def _json(route, data: dict, status: int = 200) -> None: + route.fulfill(status=status, content_type="application/json", + body=json.dumps(data)) + + +@pytest.fixture +def mock_page(browser, static_server): + ctx = browser.new_context(viewport={"width": 1440, "height": 900}) + pg = ctx.new_page() + yield pg + ctx.close() + + +def _open_settings(page, group: str) -> None: + page.locator(".user-menu__trigger").click() + page.locator(".user-menu__item", has_text="设置").click() + page.wait_for_timeout(600) + page.locator(".settings-nav__item", has_text=group).click() + page.wait_for_timeout(400) + + +class TestClaudeCodeToggleBrowser: + def _boot(self, page, static_server, mock: _MockBackend): + mock.install(page) + page.goto(f"{static_server}#dashboard") + try: + page.wait_for_selector(".user-menu__trigger", timeout=8000) + except Exception: + pytest.skip("前端未能在 mock 环境下启动(Alpine/资源加载)") + + def test_switch_reflects_backend_state(self, mock_page, static_server): + """检验一/二/三 的 UI 侧:开关 on/off class 与 mock 的 clientConfig 一致。""" + mock = _MockBackend() + mock.cfg["ClaudeCode"].update(mcp=True, hooks=False, agent=True) + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "MCP") + + def toggle(kind_nav, plat="ClaudeCode"): + mock_page.locator(".settings-nav__item", has_text=kind_nav).click() + mock_page.wait_for_timeout(250) + return mock_page.locator( + f".settings-modal .ai-platform-row[data-platform='{plat}'][data-kind=" + f"'{ {'MCP':'mcp','Hooks':'hooks','Agents':'agent'}[kind_nav] }'] .toggle") + + cls_mcp = toggle("MCP").get_attribute("class") + cls_hooks = toggle("Hooks").get_attribute("class") + cls_agent = toggle("Agents").get_attribute("class") + assert "toggle--on" in cls_mcp and "toggle--off" not in cls_mcp.replace("toggle--off-soft", "") + assert "toggle--off" in cls_hooks + assert "toggle--on" in cls_agent + + def test_toggle_off_sends_delete_then_on_sends_post(self, mock_page, static_server): + """开关双向:当前为开 → 点击发 DELETE;当前为关 → 点击发 POST。回读后 class 翻转。""" + mock = _MockBackend() + mock.cfg["ClaudeCode"]["mcp"] = True + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "MCP") + + row = mock_page.locator( + ".settings-modal .ai-platform-row[data-platform='ClaudeCode'][data-kind='mcp']") + tog = row.locator(".toggle") + assert "toggle--on" in tog.get_attribute("class") + + tog.click() # 开 → 关 + mock_page.wait_for_timeout(600) + assert ("DELETE", "ClaudeCode", "mcp") in mock.calls + assert mock.cfg["ClaudeCode"]["mcp"] is False + assert "toggle--off" in tog.get_attribute("class") + + mock.calls.clear() + tog.click() # 关 → 开 + mock_page.wait_for_timeout(600) + assert ("POST", "ClaudeCode", "mcp") in mock.calls + assert mock.cfg["ClaudeCode"]["mcp"] is True + assert "toggle--on" in tog.get_attribute("class") + + def test_three_switches_independent(self, mock_page, static_server): + """关 hooks 不影响 mcp / agent(只对 hooks 发一次 DELETE)。""" + mock = _MockBackend() + mock.cfg["ClaudeCode"].update(mcp=True, hooks=True, agent=True) + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "Hooks") + + mock_page.locator( + ".settings-modal .ai-platform-row[data-platform='ClaudeCode'][data-kind='hooks'] .toggle" + ).click() + mock_page.wait_for_timeout(600) + + kinds_touched = {(mth, k) for (mth, p, k) in mock.calls if p == "ClaudeCode"} + assert kinds_touched == {("DELETE", "hooks")}, kinds_touched + assert mock.cfg["ClaudeCode"] == { + "client_installed": True, "connection": "not_connected", + "mcp": True, "hooks": False, "agent": True} + + def test_guide_reexecute_does_not_remove_configured(self, mock_page, static_server): + """回归 #1+#2:已完整配置 ClaudeCode 时再跑 guideExecute(重新运行初始化引导), + 不得对 ClaudeCode 发任何 DELETE,也不得用非法 kind([object Object])。""" + mock = _MockBackend() + mock.cfg["ClaudeCode"].update(mcp=True, hooks=True, agent=True) + self._boot(mock_page, static_server, mock) + + mock_page.evaluate( + """async () => { + const s = Alpine.store('app'); + s.clientConfig = JSON.parse(JSON.stringify({ + ClaudeCode: { client_installed: true, connection: 'not_connected', + mcp: true, hooks: true, agent: true } })); + s.guideSelected = ['ClaudeCode']; + await s.guideExecute(); + }""" + ) + mock_page.wait_for_timeout(300) + + cc_calls = [(m, k) for (m, p, k) in mock.calls if p == "ClaudeCode"] + assert all(m != "DELETE" for (m, k) in cc_calls), f"引导误发 DELETE: {cc_calls}" + assert all(k in ("mcp", "hooks", "agent") for (m, k) in cc_calls), \ + f"引导用了非法 kind: {cc_calls}" + assert mock.cfg["ClaudeCode"]["mcp"] and mock.cfg["ClaudeCode"]["hooks"] \ + and mock.cfg["ClaudeCode"]["agent"] + + def test_guide_execute_writes_all_three_when_unconfigured(self, mock_page, static_server): + """回归 #1 正向:全未配置时 guideExecute 应把 mcp/hooks/agent 三个都 POST 写入。""" + mock = _MockBackend() + self._boot(mock_page, static_server, mock) + + mock_page.evaluate( + """async () => { + const s = Alpine.store('app'); + s.clientConfig = JSON.parse(JSON.stringify({ + ClaudeCode: { client_installed: true, connection: 'not_connected', + mcp: false, hooks: false, agent: false } })); + s.guideSelected = ['ClaudeCode']; + await s.guideExecute(); + }""" + ) + mock_page.wait_for_timeout(300) + + posts = sorted(k for (m, p, k) in mock.calls if p == "ClaudeCode" and m == "POST") + assert posts == ["agent", "hooks", "mcp"], f"引导写入不全: {mock.calls}" + assert mock.cfg["ClaudeCode"]["mcp"] and mock.cfg["ClaudeCode"]["hooks"] \ + and mock.cfg["ClaudeCode"]["agent"] diff --git a/tests/test_claude_code_config_verify.py b/tests/test_claude_code_config_verify.py new file mode 100644 index 0000000..5a2d911 --- /dev/null +++ b/tests/test_claude_code_config_verify.py @@ -0,0 +1,389 @@ +"""验收测试:Claude Code 平台 MCP / Hooks / Agent 配置写入 + 设置页开关双向联动。 + +配套测试方案文档:``docs/test-plan-claude-code-config.md``。 + +本模块是**纯本地后端验证**——不需要真的把 Claude Code 跑起来(国内网络下它本就 +无法联网)。要验的是: + 1. 初始化写入后,Claude Code 的三类配置文件内容正确; + 2. 设置页开关调用的 API(``POST`` / ``DELETE /api/client-config//``) + 能正确写入 / 移除 / 恢复配置; + 3. 三个开关互相独立,互不串扰。 + +设置页开关的「开 / 关」视觉态直接来自 ``store.clientStatus(platform, kind)``, +其值 = 后端 ``GET /api/client-config`` 的 ``detect_platform`` 结果。因此 +「开关状态 ↔ 配置文件」一致性 == 「``detect_platform`` 是否如实反映 +``write_kind`` / ``remove_kind`` 对配置文件的改动」——本模块正是逐条核验这一点, +既走 ``client_config`` 函数层,也走前端真正调用的 HTTP 路由层(``TestClient``)。 + +所有测试 monkeypatch ``Path.home()`` 到临时目录,绝不触碰真实 +``~/.claude`` / ``~/.claude.json``。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from backend.client_config import ( + _platform_paths, + detect_platform, + remove_kind, + write_kind, +) + +PLATFORM = "ClaudeCode" +KINDS = ("mcp", "hooks", "agent") + +# Claude Code 三类配置的落盘位置(AiClientConfig/platforms.json,macOS): +# mcp → ~/.claude.json 键 mcpServers.MyKnowledge +# hooks → ~/.claude/settings.json 键 hooks.PreToolUse[] 里 command 为我们的 curl +# agent → ~/.claude/agents/MyKnowledge-agent.md (frontmatter + 正文的 md 文件) +HOOK_ENDPOINT_MARK = "/hooks/pre-tool-use" + + +# ────────────────────────────────────────────────────────────────────────── +# fixtures / helpers +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """把 ``Path.home()`` 指到临时目录,隔离真实用户配置。返回该目录。""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return tmp_path + + +@pytest.fixture +def client(fake_home: Path) -> TestClient: + """FastAPI TestClient —— 打前端真正调用的 HTTP 路由。""" + from backend.main import app + return TestClient(app) + + +def cc_paths() -> dict: + return _platform_paths(PLATFORM) + + +def mcp_file() -> Path: + return cc_paths()["mcp_file"] + + +def hooks_file() -> Path: + return cc_paths()["hooks_file"] + + +def agent_file() -> Path: + return cc_paths()["agents_dir"] / "MyKnowledge-agent.md" + + +def read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + + +def mcp_has_myknowledge() -> bool: + return "MyKnowledge" in (read_json(mcp_file()).get("mcpServers") or {}) + + +def hooks_has_myknowledge() -> bool: + hooks = (read_json(hooks_file()).get("hooks") or {}).get("PreToolUse") or [] + for matcher in hooks: + for h in matcher.get("hooks", []): + if HOOK_ENDPOINT_MARK in h.get("command", ""): + return True + return False + + +def agent_present() -> bool: + return agent_file().exists() + + +def file_state() -> dict: + """当前磁盘上三类配置各自是否存在 MyKnowledge 条目。""" + return { + "mcp": mcp_has_myknowledge(), + "hooks": hooks_has_myknowledge(), + "agent": agent_present(), + } + + +def detect_state() -> dict: + d = detect_platform(PLATFORM) + return {k: d[k] for k in KINDS} + + +def init_all_via_api(client: TestClient) -> None: + for kind in KINDS: + r = client.post(f"/api/client-config/{PLATFORM}/{kind}") + assert r.status_code == 200, r.text + + +# ══════════════════════════════════════════════════════════════════════════ +# 检验一:初始化后 —— 三种配置都已写入;设置页三个开关都应显示为「开」 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck1_InitializedState: + def test_init_writes_all_three_config_files(self, fake_home: Path) -> None: + for kind in KINDS: + res = write_kind(PLATFORM, kind) + assert res["status"] in ("written", "exists"), res + assert file_state() == {"mcp": True, "hooks": True, "agent": True} + + def test_mcp_file_content_correct(self, fake_home: Path) -> None: + write_kind(PLATFORM, "mcp") + srv = read_json(mcp_file())["mcpServers"]["MyKnowledge"] + assert srv["type"] == "stdio" + assert srv["args"] == ["-m", "backend.cli", "mcp"] # 非 frozen 开发路径 + assert srv["command"].endswith(("python", "python3")) or "python" in srv["command"] + assert srv["env"]["MYKNOWLEDGE_CLIENT"] == "ClaudeCode" + assert "MYKNOWLEDGE_ROOT" in srv["env"] + + def test_hooks_file_content_correct(self, fake_home: Path) -> None: + write_kind(PLATFORM, "hooks") + pre = read_json(hooks_file())["hooks"]["PreToolUse"] + mine = [m for m in pre + if any(HOOK_ENDPOINT_MARK in h.get("command", "") + for h in m.get("hooks", []))] + assert len(mine) == 1, pre + assert mine[0]["matcher"] == "Bash|Write|Edit" + cmd = mine[0]["hooks"][0]["command"] + assert cmd.startswith("curl -s -X POST") and cmd.endswith("-d @-") + + def test_agent_file_content_correct(self, fake_home: Path) -> None: + write_kind(PLATFORM, "agent") + text = agent_file().read_text(encoding="utf-8") + assert text.startswith("---\n") # YAML frontmatter + assert "name: MyKnowledge" in text + assert "# MyKnowledge Agent" in text + + def test_detect_reports_all_on_after_init(self, fake_home: Path) -> None: + for kind in KINDS: + write_kind(PLATFORM, kind) + assert detect_state() == {"mcp": True, "hooks": True, "agent": True} + + def test_switch_state_matches_files_after_init(self, client: TestClient) -> None: + """设置页开关状态(= GET /api/client-config)↔ 磁盘配置文件,两者一致且都为「开」。""" + init_all_via_api(client) + api_state = client.get("/api/client-config").json()[PLATFORM] + assert {k: api_state[k] for k in KINDS} == {"mcp": True, "hooks": True, "agent": True} + assert file_state() == {"mcp": True, "hooks": True, "agent": True} + + +# ══════════════════════════════════════════════════════════════════════════ +# 检验二:关掉某开关 —— 配置文件对应项被「移除」(不是保留停用);开关显示为「关」 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck2_ToggleOff: + @pytest.mark.parametrize("kind", KINDS) + def test_toggle_off_removes_from_config_and_switch( + self, client: TestClient, kind: str + ) -> None: + init_all_via_api(client) + r = client.delete(f"/api/client-config/{PLATFORM}/{kind}") + assert r.status_code == 200 + assert r.json()["status"] == "removed" + + # 配置文件层:该 kind 的 MyKnowledge 条目确实消失 + assert file_state()[kind] is False + # 开关层:GET /api/client-config 对应项为 False + assert client.get("/api/client-config").json()[PLATFORM][kind] is False + + def test_off_is_removal_not_disabled_flag(self, fake_home: Path) -> None: + """「关」= 物理删除条目,而非写一个 enabled:false 之类的停用标记。""" + write_kind(PLATFORM, "mcp") + remove_kind(PLATFORM, "mcp") + servers = read_json(mcp_file()).get("mcpServers", {}) + assert "MyKnowledge" not in servers # 键被删除 + assert servers == {} # 没有残留的停用形态 + + write_kind(PLATFORM, "hooks") + remove_kind(PLATFORM, "hooks") + pre = (read_json(hooks_file()).get("hooks") or {}).get("PreToolUse") + assert pre == [] # matcher 被移除,不是标记停用 + + write_kind(PLATFORM, "agent") + remove_kind(PLATFORM, "agent") + assert not agent_file().exists() # 文件被删除 + + def test_toggle_off_preserves_user_other_config(self, fake_home: Path) -> None: + """关开关只动 MyKnowledge 条目——用户自己的 mcpServers / hooks / 设置项保留。""" + write_kind(PLATFORM, "mcp") + data = read_json(mcp_file()) + data["mcpServers"]["RAPID"] = {"type": "stdio", "command": "rapid"} + data["someUserSetting"] = 42 + mcp_file().write_text(json.dumps(data), encoding="utf-8") + + write_kind(PLATFORM, "hooks") + h = read_json(hooks_file()) + h["hooks"]["PreToolUse"].append( + {"matcher": "Bash", "hooks": [{"type": "command", "command": "user-own-hook"}]}) + h["hooks"]["PostToolUse"] = [ + {"matcher": "*", "hooks": [{"type": "command", "command": "user-post"}]}] + hooks_file().write_text(json.dumps(h), encoding="utf-8") + + remove_kind(PLATFORM, "mcp") + remove_kind(PLATFORM, "hooks") + + mcp_data = read_json(mcp_file()) + assert "RAPID" in mcp_data["mcpServers"] and "MyKnowledge" not in mcp_data["mcpServers"] + assert mcp_data["someUserSetting"] == 42 + + h2 = read_json(hooks_file()) + pre_cmds = [x["hooks"][0]["command"] for x in h2["hooks"]["PreToolUse"]] + assert pre_cmds == ["user-own-hook"] + assert "PostToolUse" in h2["hooks"] + + def test_toggle_off_idempotent(self, client: TestClient) -> None: + init_all_via_api(client) + for _ in range(3): + r = client.delete(f"/api/client-config/{PLATFORM}/mcp") + assert r.status_code == 200 and r.json()["status"] == "removed" + assert client.get("/api/client-config").json()[PLATFORM]["mcp"] is False + + +# ══════════════════════════════════════════════════════════════════════════ +# 检验三:再次打开 —— 配置文件对应项恢复写入;开关显示为「开」 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck3_ToggleBackOn: + @pytest.mark.parametrize("kind", KINDS) + def test_off_then_on_restores_config_and_switch( + self, client: TestClient, kind: str + ) -> None: + init_all_via_api(client) + client.delete(f"/api/client-config/{PLATFORM}/{kind}") + assert file_state()[kind] is False + + r = client.post(f"/api/client-config/{PLATFORM}/{kind}") + assert r.status_code == 200 + assert r.json()["status"] in ("written", "exists") + + assert file_state()[kind] is True + assert client.get("/api/client-config").json()[PLATFORM][kind] is True + + def test_restored_content_equivalent_to_original(self, fake_home: Path) -> None: + """恢复写入的内容与初次写入等价(不是残缺/损坏的条目)。""" + write_kind(PLATFORM, "mcp") + first = read_json(mcp_file())["mcpServers"]["MyKnowledge"] + remove_kind(PLATFORM, "mcp") + write_kind(PLATFORM, "mcp") + again = read_json(mcp_file())["mcpServers"]["MyKnowledge"] + assert again == first + + write_kind(PLATFORM, "hooks") + h1 = read_json(hooks_file())["hooks"]["PreToolUse"] + remove_kind(PLATFORM, "hooks") + write_kind(PLATFORM, "hooks") + h2 = read_json(hooks_file())["hooks"]["PreToolUse"] + assert h2 == h1 + + write_kind(PLATFORM, "agent") + a1 = agent_file().read_text(encoding="utf-8") + remove_kind(PLATFORM, "agent") + write_kind(PLATFORM, "agent") + assert agent_file().read_text(encoding="utf-8") == a1 + + def test_multiple_off_on_cycles_stable(self, client: TestClient) -> None: + init_all_via_api(client) + for _ in range(3): + for kind in KINDS: + client.delete(f"/api/client-config/{PLATFORM}/{kind}") + assert file_state() == {"mcp": False, "hooks": False, "agent": False} + for kind in KINDS: + client.post(f"/api/client-config/{PLATFORM}/{kind}") + assert file_state() == {"mcp": True, "hooks": True, "agent": True} + + +# ══════════════════════════════════════════════════════════════════════════ +# 补充:三个开关各自独立、互不影响 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck4_SwitchIndependence: + @pytest.mark.parametrize("toggled", KINDS) + def test_toggling_one_off_does_not_touch_others( + self, client: TestClient, toggled: str + ) -> None: + init_all_via_api(client) + others = [k for k in KINDS if k != toggled] + + client.delete(f"/api/client-config/{PLATFORM}/{toggled}") + state = client.get("/api/client-config").json()[PLATFORM] + assert state[toggled] is False + for o in others: + assert state[o] is True, f"关 {toggled} 影响了 {o}" + assert file_state()[o] is True + + @pytest.mark.parametrize("toggled", KINDS) + def test_toggling_one_back_on_does_not_touch_others( + self, client: TestClient, toggled: str + ) -> None: + init_all_via_api(client) + others = [k for k in KINDS if k != toggled] + # 先把 others 关掉,只留 toggled 关→开,验证不会顺带把 others 打开 + for o in others: + client.delete(f"/api/client-config/{PLATFORM}/{o}") + client.delete(f"/api/client-config/{PLATFORM}/{toggled}") + + client.post(f"/api/client-config/{PLATFORM}/{toggled}") + state = client.get("/api/client-config").json()[PLATFORM] + assert state[toggled] is True + for o in others: + assert state[o] is False, f"开 {toggled} 顺带打开了 {o}" + + def test_mcp_hooks_agent_use_distinct_files(self, fake_home: Path) -> None: + """三类配置落在三个不同文件——天然隔离,不存在「改一个动另一个」。""" + p = cc_paths() + assert len({str(p["mcp_file"]), str(p["hooks_file"]), + str(p["agents_dir"] / "MyKnowledge-agent.md")}) == 3 + + def test_independent_state_matrix(self, client: TestClient) -> None: + """8 种开/关组合逐一核验:设置页开关矩阵 ↔ 配置文件矩阵完全一致。""" + init_all_via_api(client) + for bits in range(8): + want = { + "mcp": bool(bits & 1), + "hooks": bool(bits & 2), + "agent": bool(bits & 4), + } + for kind, on in want.items(): + if on: + client.post(f"/api/client-config/{PLATFORM}/{kind}") + else: + client.delete(f"/api/client-config/{PLATFORM}/{kind}") + api_state = client.get("/api/client-config").json()[PLATFORM] + assert {k: api_state[k] for k in KINDS} == want + assert file_state() == want + + +# ══════════════════════════════════════════════════════════════════════════ +# 初始化引导(rerunGuide → guideExecute)回归:不能把已配置项误删 / 一个都写不进 +# —— 见 docs/test-plan-claude-code-config.md「发现的问题」#1、#2 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestGuideExecuteContract: + """``store.guideExecute()`` 对后端的调用契约(前端 bug 的后端侧护栏)。 + + 前端 bug 详情见测试方案文档;这里从后端角度钉死两条: + - kind 必须是 mcp/hooks/agent 字符串,``[object Object]`` 之类会被 400 拒绝 + (引导漏取 ``kindMeta.key`` 时的症状); + - 对已配置平台重复「初始化」应保持开启(``POST`` 幂等),不应变成关闭。 + """ + + def test_object_like_kind_is_rejected(self, client: TestClient) -> None: + r = client.post(f"/api/client-config/{PLATFORM}/%5Bobject%20Object%5D") + assert r.status_code == 400 + + def test_reinit_keeps_config_on(self, client: TestClient) -> None: + """已配置平台再跑一次初始化(POST 幂等)——配置仍在,不被关闭。""" + init_all_via_api(client) + init_all_via_api(client) # 第二次「初始化」 + assert file_state() == {"mcp": True, "hooks": True, "agent": True} + api_state = client.get("/api/client-config").json()[PLATFORM] + assert {k: api_state[k] for k in KINDS} == {"mcp": True, "hooks": True, "agent": True} From e89f0dd3e3202546de9229cc2fc44579b971a39d Mon Sep 17 00:00:00 2001 From: chenyida7-prog Date: Thu, 3 Sep 2026 01:43:42 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(client-config):=20=E6=8A=8A=20CodeBudd?= =?UTF-8?q?y=20IDE=20=E7=BA=B3=E5=85=A5=20MVP=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=AA=8C=E6=94=B6=EF=BC=88=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8C=96=E4=B8=A4=E5=B9=B3=E5=8F=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP 平台 = ClaudeCode + CodeBuddyIDE(client_config.py 注释),共用同一套 configureClient / write_kind / remove_kind / detect_platform,仅落盘目录不同: ClaudeCode → ~/.claude.json / ~/.claude/settings.json / ~/.claude/agents/ CodeBuddyIDE → ~/.codebuddy/mcp.json / ~/.codebuddy/settings.json / ~/.codebuddy/agents/ hooks matcher: ClaudeCode "Bash|Write|Edit" + curl -d @-;CodeBuddyIDE "*" + hooks_forward agent frontmatter: CodeBuddyIDE 额外带 agentMode/enabled/enabledAutoRun/mcpServers 改动: - 重命名(去平台名,与 tests/test_client_config.py 对齐): tests/test_claude_code_config_verify.py → tests/test_client_config_verify.py tests/frontend/test_claude_code_config.py → tests/frontend/test_client_config_toggle.py - test_client_config_verify.py:platform fixture 参数化 ["ClaudeCode","CodeBuddyIDE"], 三次核心检验 + 开关独立性 8 组合矩阵对两平台各跑一遍;新增 TestCrossPlatformIsolation (同一 home 下两平台配置互不干扰)。27 → 56 例。 - test_client_config_toggle.py:TestConfigToggleBrowser 用 @parametrize("plat",[...]) 对两平台跑开关 UI 双向/独立/关开循环/guideExecute 回归;新增 test_full_off_on_cycle_via_ui。 静态断言补两平台 kinds + toggle markup 平台无关。14 → 24 例。 - docs/test-plan-claude-code-config.md:新增 §2.1b CodeBuddy IDE 对照表、§6 打包产物; 三次检验表格补 CodeBuddyIDE 列;发现的问题补「CodeBuddy IDE 未发现新问题」 (问题 #1/#2 同样影响 CodeBuddyIDE,已由同一处 store.js 修复覆盖,新用例参数化验证)。 CodeBuddy IDE 三次检验 + 独立性全部通过,未发现平台专属 bug。 不改动任何既有测试文件。测试结果: tests/test_client_config_verify.py 56 passed tests/frontend/test_client_config_toggle.py 24 passed 后端全量 787 passed;tests/frontend/ 88 passed / 115 skipped / 3 pre-existing failed Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SSMDrsmrXR5kr68SUS5D5P --- docs/test-plan-claude-code-config.md | 158 +++++-- ...config.py => test_client_config_toggle.py} | 178 ++++--- tests/test_claude_code_config_verify.py | 389 ---------------- tests/test_client_config_verify.py | 435 ++++++++++++++++++ 4 files changed, 655 insertions(+), 505 deletions(-) rename tests/frontend/{test_claude_code_config.py => test_client_config_toggle.py} (69%) delete mode 100644 tests/test_claude_code_config_verify.py create mode 100644 tests/test_client_config_verify.py diff --git a/docs/test-plan-claude-code-config.md b/docs/test-plan-claude-code-config.md index 5127409..2c1dda8 100644 --- a/docs/test-plan-claude-code-config.md +++ b/docs/test-plan-claude-code-config.md @@ -1,27 +1,30 @@ -# 测试方案:Claude Code 平台 MCP / Hooks / Agent 配置写入 + 设置页开关双向联动 +# 测试方案:MVP AI 平台(Claude Code / CodeBuddy IDE)MCP / Hooks / Agent 配置写入 + 设置页开关双向联动 > 状态:待 mentor 审阅 > 分支:`test/claude-code-config-verify` +> 覆盖平台:**ClaudeCode + CodeBuddyIDE**(`backend/client_config.py` 注释里写明的 MVP +> 两平台,共用同一套 `configureClient` 增量合并逻辑,仅落盘目录不同)。 +> WorkBuddy(代码标 not built)/ Cursor / ClaudeDesktop / Enchante 本方案不覆盖。 > 自动化测试: -> - `tests/test_claude_code_config_verify.py`(后端 / API 层,27 例) -> - `tests/frontend/test_claude_code_config.py`(前端静态 9 例 + 浏览器 5 例) +> - `tests/test_client_config_verify.py`(后端 / API 层,参数化 ×2 平台,56 例) +> - `tests/frontend/test_client_config_toggle.py`(前端静态 12 例 + 浏览器 12 例 = 6 用例 ×2 平台) --- ## 1. 验证目标 -验证并保证「Claude Code」这个 AI 平台在**初始化配置后**: +验证并保证「Claude Code」「CodeBuddy IDE」两个 AI 平台在**初始化配置后**: -1. **配置写入正确**:MCP / Hooks / Agent 三种配置分别正确写入 Claude Code 对应的配置文件。 +1. **配置写入正确**:MCP / Hooks / Agent 三种配置分别正确写入平台对应的配置文件。 2. **设置页开关双向控制**:设置页里这三个开关能正确双向控制 - 开 → 写入配置 - 关 → 移除配置 - 再开 → 恢复写入 3. **三个开关互相独立**,互不串扰。 -> **重要前提**:Claude Code 本身在国内网络下无法联网运行,但这**不影响本任务**—— +> **重要前提**:Claude Code / CodeBuddy IDE 本身在国内网络下无法联网运行,但这**不影响本任务**—— > 要验的是「配置文件写入」和「设置页开关读写逻辑」,**全部纯本地验证** -> (检查配置文件内容 + 前端逻辑),不需要真的把 Claude Code 跑起来联网。 +> (检查配置文件内容 + 前端逻辑),不需要真的把它们跑起来联网。 --- @@ -73,6 +76,29 @@ `backend/AiClientConfig/agents/MyKnowledge-agent.md`,frontmatter 取自 `agents/frontmatter.json` 里 ClaudeCode 对应 variant(`name` / `description` / `tools` / `model: inherit`)。 +### 2.1b CodeBuddy IDE 的三种配置(对照 Claude Code) + +单一来源:`backend/AiClientConfig/platforms.json` → `platforms.CodeBuddyIDE`(macOS)。 +落盘逻辑、增量合并、`write_kind` / `remove_kind` / `detect_platform` 与 Claude Code **完全同一套代码**, +只是目录与格式细节不同: + +| kind | Claude Code | CodeBuddy IDE | +|------|-------------|---------------| +| **mcp** 文件 | `~/.claude.json` | `~/.codebuddy/mcp.json` | +| mcp 键 / 形式 | `mcpServers.MyKnowledge`(stdio;`args=["-m","backend.cli","mcp"]`;`env.MYKNOWLEDGE_CLIENT="ClaudeCode"`) | `mcpServers.MyKnowledge`(同结构;`env.MYKNOWLEDGE_CLIENT="CodeBuddyIDE"`) | +| **hooks** 文件 | `~/.claude/settings.json` | `~/.codebuddy/settings.json` | +| hooks matcher | `"Bash\|Write\|Edit"` | `"*"`(match 所有工具;`hooks.py` 内部放行 MCP 调用,宽 matcher 安全) | +| hooks command | `curl -s -X POST …/hooks/pre-tool-use … -d @-`(curl 读 stdin) | dev:`python3 -m backend.hooks_forward`;桌面 App(frozen):`"<二进制>" --hooks-forward`(转发脚本读 stdin) | +| **agent** 文件 | `~/.claude/agents/MyKnowledge-agent.md` | `~/.codebuddy/agents/MyKnowledge-agent.md` | +| agent frontmatter | `name` / `description` / `tools` / `model: inherit` | 同上 **+** `agentMode: manual` / `enabled: true` / `enabledAutoRun: true` / `mcpServers: MyKnowledge`(CodeBuddy 专属字段,`frontmatter.json` variant 提供) | + +其余一致:三类落在三个不同文件(`~/.codebuddy/mcp.json` / `~/.codebuddy/settings.json` / +`~/.codebuddy/agents/…`)→ 天然隔离;关 = 物理删除(`pop` / 移除 matcher / `unlink`),非停用标记; +只动 MyKnowledge 条目,用户其他配置保留;再开 = 增量合并写回,内容逐字段等价。 + +设置页三个 CodeBuddy IDE 开关走的前端代码路径与 Claude Code **完全相同** +(`configureClient(plat.key, kind.key)` + `clientStatus`),见 2.2;仅 `plat.key` 不同。 + ### 2.2 设置页三个开关如何读写这些配置 **读(开关的「开 / 关」视觉态)**: @@ -132,47 +158,52 @@ index.html @click="$store.app.configureClient(plat.key, kind.key)" ## 3. 三次核心检验 每次都对比「设置页开关状态」(`GET /api/client-config` 的结果,= 开关视觉态数据源) -↔「Claude Code 实际配置文件」,两者**必须一致**。 +↔「平台实际配置文件」,两者**必须一致**。**每条检验对 ClaudeCode 与 CodeBuddyIDE +两个平台各跑一遍**(后端测试用 `@pytest.fixture(params=["ClaudeCode","CodeBuddyIDE"])` +参数化,前端浏览器测试用 `@pytest.mark.parametrize("plat", [...])`)。 + +下表以 Claude Code 为例;CodeBuddy IDE 的对应文件 / 格式见 §2.1b,检验项与断言逻辑相同。 ### 检验一:初始化后 -| 项 | 预期 | -|----|------| -| `~/.claude.json` | `mcpServers.MyKnowledge` 存在,`type=stdio`,`args=["-m","backend.cli","mcp"]`,`env.MYKNOWLEDGE_CLIENT="ClaudeCode"` | -| `~/.claude/settings.json` | `hooks.PreToolUse[]` 含 1 个 matcher,`matcher="Bash\|Write\|Edit"`,command 为 `curl … -d @-` | -| `~/.claude/agents/MyKnowledge-agent.md` | 文件存在,以 `---`(frontmatter) 开头,正文含 `# MyKnowledge Agent` | -| `GET /api/client-config` → `ClaudeCode` | `{mcp: true, hooks: true, agent: true}` | -| 设置页三个开关 | 均显示为**开** | +| 项 | 预期(ClaudeCode) | 预期(CodeBuddyIDE) | +|----|------|------| +| mcp 文件 | `~/.claude.json` → `mcpServers.MyKnowledge`,`type=stdio`,`args=["-m","backend.cli","mcp"]`,`env.MYKNOWLEDGE_CLIENT="ClaudeCode"` | `~/.codebuddy/mcp.json` → 同结构,`env.MYKNOWLEDGE_CLIENT="CodeBuddyIDE"` | +| hooks 文件 | `~/.claude/settings.json` → `PreToolUse[]` 含 1 个我们的 matcher,`matcher="Bash\|Write\|Edit"`,`curl … -d @-` | `~/.codebuddy/settings.json` → 同,`matcher="*"`,command 含 `hooks_forward` / `--hooks-forward` | +| agent 文件 | `~/.claude/agents/MyKnowledge-agent.md` 存在,`---` frontmatter 开头,正文含 `# MyKnowledge Agent` | `~/.codebuddy/agents/MyKnowledge-agent.md` 存在,额外含 `agentMode:` / `enabled:` / `mcpServers:` frontmatter | +| `GET /api/client-config` → `<平台>` | `{mcp: true, hooks: true, agent: true}` | 同 | +| 设置页三个开关 | 均显示为**开** | 同 | -自动化:`test_claude_code_config_verify.py::TestCheck1_InitializedState`(6 例)、 -`test_claude_code_config.py::TestClaudeCodeToggleBrowser::test_switch_reflects_backend_state`。 +自动化:`test_client_config_verify.py::TestCheck1_InitializedState`(6 例 ×2 平台)、 +`test_client_config_toggle.py::TestConfigToggleBrowser::test_switch_reflects_backend_state`(×2 平台)。 ### 检验二:关掉某开关 -对 mcp / hooks / agent 分别:`DELETE /api/client-config/ClaudeCode/` +对 mcp / hooks / agent 分别:`DELETE /api/client-config/<平台>/` | 项 | 预期 | |----|------| | 对应配置文件 | 该 kind 的 MyKnowledge 条目**被移除**(键删除 / matcher 删除 / 文件删除),非停用标记 | | 用户其他配置 | 保留(其他 mcpServers、其他 hooks、PostToolUse、无关设置项都在) | -| `GET /api/client-config` → `ClaudeCode[kind]` | `false` | +| `GET /api/client-config` → `<平台>[kind]` | `false` | | 该开关 | 显示为**关**;另外两个开关**不变** | -自动化:`TestCheck2_ToggleOff`(5 例,含「关=删除非停用」「保留用户其他配置」「幂等」)、 -`test_claude_code_config.py::…::test_toggle_off_sends_delete_then_on_sends_post`。 +自动化:`TestCheck2_ToggleOff`(5 例 ×2 平台,含「关=删除非停用」「保留用户其他配置」「幂等」)、 +`test_client_config_toggle.py::…::test_toggle_off_sends_delete_then_on_sends_post`(×2 平台)。 ### 检验三:再次打开 -`POST /api/client-config/ClaudeCode/` +`POST /api/client-config/<平台>/` | 项 | 预期 | |----|------| | 对应配置文件 | 条目**恢复写入**,内容与初次写入**逐字段等价** | -| `GET /api/client-config` → `ClaudeCode[kind]` | `true` | +| `GET /api/client-config` → `<平台>[kind]` | `true` | | 该开关 | 恢复为**开** | | 多轮 关→开→关→开 | 状态稳定,无漂移 | -自动化:`TestCheck3_ToggleBackOn`(3 例,含「恢复内容等价」「多轮循环稳定」)。 +自动化:`TestCheck3_ToggleBackOn`(3 例 ×2 平台,含「恢复内容等价」「多轮循环稳定」)、 +`test_client_config_toggle.py::…::test_full_off_on_cycle_via_ui`(浏览器逐个关再逐个开,×2 平台)。 ### 补充:三个开关各自独立、互不影响 @@ -183,9 +214,11 @@ index.html @click="$store.app.configureClient(plat.key, kind.key)" | 关 agent | mcp / hooks 不变 | | 开某个关着的 kind | 不会顺带打开另外两个 | | 8 种开/关组合矩阵 | 设置页开关矩阵 ↔ 配置文件矩阵**逐一完全一致** | +| ClaudeCode ↔ CodeBuddyIDE | 同一 home 下两平台配置各写各的,关掉一个平台全部不影响另一个 | -自动化:`TestCheck4_SwitchIndependence`(4 例,含 `test_independent_state_matrix` 遍历 8 组合)、 -`test_claude_code_config.py::…::test_three_switches_independent`(浏览器点关 hooks,断言只对 hooks 发一次 DELETE)。 +自动化:`TestCheck4_SwitchIndependence`(4 例 ×2 平台,含 `test_independent_state_matrix` 遍历 8 组合)、 +`TestCrossPlatformIsolation`(2 例,两平台互不干扰)、 +`test_client_config_toggle.py::…::test_three_switches_independent`(浏览器点关 hooks,断言只对 hooks 发一次 DELETE,×2 平台)。 --- @@ -193,25 +226,26 @@ index.html @click="$store.app.configureClient(plat.key, kind.key)" ### 自动化覆盖 -| 层 | 文件 | 覆盖 | +| 层 | 文件 | 覆盖(ClaudeCode + CodeBuddyIDE 均参数化 ×2) | |----|------|------| -| **后端 / API** | `tests/test_claude_code_config_verify.py`(`fake_home` 隔离 + `TestClient` 打真实路由) | 三次核心检验的**配置文件内容 + 检测态 + 开关联动**全链路;关=删除非停用;保留用户配置;独立性 8 组合矩阵;引导契约护栏(拒绝非法 kind、重复初始化保持开启) | -| **前端 · 静态** | `tests/frontend/test_claude_code_config.py::TestClaudeCodeConfigStatic`(源码断言,总是运行) | 开关视觉态绑定 `clientStatus`;点击调 `configureClient(plat.key, kind.key)`;`configureClient` 双向取反 + POST/DELETE 分支 + 回读;单写锁;`guideExecute` 用 `.key` 且只「确保开启」 | -| **前端 · 浏览器** | `tests/frontend/test_claude_code_config.py::TestClaudeCodeToggleBrowser`(Playwright,全量 mock `/api/**`,真跑 Alpine,不碰真实 `~/.claude`、不需 8080 后端) | 开关 on/off class ↔ mock 配置态一致;点击发对方向 HTTP 动词并回读翻转;三开关独立;`guideExecute` 不误删已配置项 / 未配置时写全三种 | +| **后端 / API** | `tests/test_client_config_verify.py`(`fake_home` 隔离 + `TestClient` 打真实路由;`params=["ClaudeCode","CodeBuddyIDE"]`) | 三次核心检验的**配置文件内容 + 检测态 + 开关联动**全链路;关=删除非停用;保留用户配置;独立性 8 组合矩阵;两平台互不干扰;引导契约护栏(拒绝非法 kind、重复初始化保持开启) | +| **前端 · 静态** | `tests/frontend/test_client_config_toggle.py::TestConfigToggleStatic`(源码断言,总是运行) | 开关视觉态绑定 `clientStatus`;点击调 `configureClient(plat.key, kind.key)`;`configureClient` 双向取反 + POST/DELETE 分支 + 回读;单写锁;`guideExecute` 用 `.key` 且只「确保开启」;两平台 `kinds` 与 toggle 标记平台无关 | +| **前端 · 浏览器** | `tests/frontend/test_client_config_toggle.py::TestConfigToggleBrowser`(Playwright,全量 mock `/api/**`,真跑 Alpine,不碰真实 `~/.claude` / `~/.codebuddy`、不需 8080 后端;`@parametrize("plat", ["ClaudeCode","CodeBuddyIDE"])`) | 开关 on/off class ↔ mock 配置态一致;点击发对方向 HTTP 动词并回读翻转;三开关独立;关→开完整循环;`guideExecute` 不误删已配置项 / 未配置时写全三种 | **为什么前端写路径用 mock 而不用真后端**:仓库既有前端浏览器测试(`test_stage3.py`)明确 「只 GET 检测 + 渲染,不实际 POST 写入用户全局配置」——真后端会写到跑测试这台机器的真实 -`~/.claude`。本方案用 `page.route` 拦截全部 `/api/**`,内存态模拟后端,既真实驱动 Alpine -开关逻辑,又零副作用。 +`~/.claude` / `~/.codebuddy`。本方案用 `page.route` 拦截全部 `/api/**`,内存态模拟后端, +既真实驱动 Alpine 开关逻辑,又零副作用。 ### 需手动验证(自动化未覆盖 / 不适合自动化) | 项 | 手动步骤 | |----|----------| -| 桌面 App 内引导页真机走查 | `myknowledge serve` → 浏览器打开 → 初始化引导选 Claude Code → 走完 → 去 `~/.claude.json` / `~/.claude/settings.json` / `~/.claude/agents/` 核对三份文件 | -| 设置页开关视觉/交互细节 | 设置 → MCP/Hooks/Agents 三页,逐个点开关,观察 knob 滑动动画、toast 文案(「已就绪 / 已关闭」)、失败时行内 fallback | -| Claude Code 真机联调(**国内网络不可行,出于完整性列出**) | 有外网环境时:配置写入后启动 Claude Code,确认它能加载 MyKnowledge MCP server(`/mcp` 列出工具)、PreToolUse hook 生效、`@MyKnowledge-agent` 可用 | -| 「重新运行初始化引导」回归 | 已配置 Claude Code → 设置→通用→重新运行初始化引导→选 Claude Code→走完 → 确认三份配置**仍在**(不被清掉) | +| 桌面 App 内引导页真机走查 | `myknowledge serve` → 浏览器打开 → 初始化引导选 Claude Code / CodeBuddy IDE → 走完 → 去 `~/.claude*` / `~/.codebuddy*` 核对三份文件 | +| 设置页开关视觉/交互细节 | 设置 → MCP/Hooks/Agents 三页,对两个平台逐个点开关,观察 knob 滑动动画、toast 文案(「已就绪 / 已关闭」)、失败时行内 fallback | +| Claude Code / CodeBuddy IDE 真机联调(**国内网络不可行,出于完整性列出**) | 有外网环境时:配置写入后启动客户端,确认它能加载 MyKnowledge MCP server、PreToolUse hook 生效、专用 Agent 可用 | +| 「重新运行初始化引导」回归 | 已配置某平台 → 设置→通用→重新运行初始化引导→选同一平台→走完 → 确认三份配置**仍在**(不被清掉) | +| DMG 真机安装 | 见 §6(本次已打包,供 mentor 安装验证六平台默认配置) | --- @@ -230,12 +264,13 @@ index.html @click="$store.app.configureClient(plat.key, kind.key)" (`test_no_modal_visible_on_load` / `test_dashboard_shows_title` / `test_trash_view_renders`) **改动前即失败**(缺少 `backend_running` skip 守卫,无 8080 后端时 dashboard 无数据 → 标题区 `x-show` 隐藏)。**与本次改动无关**,改动后仍是同样这 3 例失败,无新增失败。 -- 改动后: - - `tests/test_claude_code_config_verify.py`:`27 passed` - - `tests/frontend/test_claude_code_config.py`:`14 passed`(静态 9 + 浏览器 5) +- 改动后(含第二部分补的 CodeBuddy IDE 覆盖): + - `tests/test_client_config_verify.py`:`56 passed`(ClaudeCode + CodeBuddyIDE 参数化 + 两平台互不干扰) + - `tests/frontend/test_client_config_toggle.py`:`24 passed`(静态 12 + 浏览器 12 = 6 用例 ×2 平台) - `tests/frontend/test_stage3.py`:`17 passed, 8 skipped`(不变) - `tests/test_client_config.py`:不变全绿 - - 后端全量 `758 passed` + - 后端全量 `787 passed`(基线 758 + 新增 29) + - `tests/frontend/` 全量:`88 passed, 115 skipped, 3 failed`(3 failed = 上述 `test_smoke.py` 既有失败,无新增) - `frontend/check_build.py`:`28/29`(唯一失败 `⑤ 编辑保存往返测试` 缺 `frontend/node_modules` 的 turndown,**改动前即如此**,CI 会装依赖);`④ ?v= 版本化一致性` ✓ @@ -268,9 +303,9 @@ index.html @click="$store.app.configureClient(plat.key, kind.key)" try { await this.configureClient(platform, kind); } catch (e) { /* 不中断后续 */ } } ``` -- **验证**:`test_claude_code_config.py::…::test_guide_execute_writes_all_three_when_unconfigured` +- **验证**:`test_client_config_toggle.py::…::test_guide_execute_writes_all_three_when_unconfigured` (改前红:`guideExecute` 对 ClaudeCode 发出的是非法 kind、0 个有效 POST;改后绿:mcp/hooks/agent 各一个 POST)。 - 后端护栏 `test_claude_code_config_verify.py::TestGuideExecuteContract::test_object_like_kind_is_rejected`。 + 后端护栏 `test_client_config_verify.py::TestGuideExecuteContract::test_object_like_kind_is_rejected`。 ### 问题 #2:重新运行初始化引导会把已配置的项**误关掉** @@ -283,15 +318,44 @@ index.html @click="$store.app.configureClient(plat.key, kind.key)" - **修复**:同上一处,加一行 `if (this.clientStatus(platform, kind) === true) continue;`—— 引导只对**尚未开启**的 kind 调 `configureClient`(写操作幂等,POST 已开启项本也安全, 这里直接跳过更省一次往返)。**不改 `configureClient` 本身**(设置页开关仍需要它的双向语义)。 -- **验证**:`test_claude_code_config.py::…::test_guide_reexecute_does_not_remove_configured` +- **验证**:`test_client_config_toggle.py::…::test_guide_reexecute_does_not_remove_configured` (改前红:对已配置 ClaudeCode 发 DELETE;改后绿:零 DELETE,配置保持)。 静态断言 `test_guide_execute_only_ensures_on`。 - 后端护栏 `test_claude_code_config_verify.py::TestGuideExecuteContract::test_reinit_keeps_config_on`。 + 后端护栏 `test_client_config_verify.py::TestGuideExecuteContract::test_reinit_keeps_config_on`。 -### 未发现问题的部分 +### CodeBuddy IDE:未发现新问题(第二部分) + +把 CodeBuddyIDE 纳入同一套参数化验收后: + +- **三次核心检验 + 开关独立性 + 两平台互不干扰全部通过**,无 CodeBuddyIDE 专属 bug。 +- 问题 #1 / #2(`guideExecute`)**同样影响 CodeBuddyIDE**——它是所有非 Enchante 平台共用的 + 引导执行路径。已由问题 #1/#2 的**同一处修复**覆盖;新增的浏览器用例对 CodeBuddyIDE 也参数化 + 跑了一遍(改前红、改后绿),确认修复对两平台都生效。 +- CodeBuddyIDE 的 hooks 命令是环境相关的(dev `python3 -m backend.hooks_forward` / frozen + `"<二进制>" --hooks-forward`),`detect_platform` 与 `_hooks_cmd_for` 用的是同一个当前环境 + 命令,因此测试里始终自洽——已通过 `_matcher_is_mine` 签名识别(与后端 detect 同源)验证。 + +### 未发现问题的部分(两平台通用) - **设置页三个开关本身**(`configureClient` + `index.html` toggle 绑定)逻辑**正确**: 开→POST、关→DELETE、再开→POST,回读 `loadClientConfig` 权威刷新,单写锁防串扰。 - 三次核心检验在开关这条路径上全部通过,无需修改。 + 三次核心检验在开关这条路径上、两个平台全部通过,无需修改。 - **后端 `write_kind` / `remove_kind` / `detect_platform`** 逻辑**正确**:增量合并、只删自己的条目、 - 幂等、恢复内容等价、三文件天然隔离。既有 `tests/test_client_config.py` + 本次新增均通过。 + 幂等、恢复内容逐字段等价、三文件天然隔离、两平台各写各的。既有 `tests/test_client_config.py` + + 本次新增(ClaudeCode + CodeBuddyIDE)均通过。 + +--- + +## 6. 打包产物(供 mentor 真机验证) + +用当前分支代码打了一个**默认配置(6 平台)**的 macOS DMG: + +- 打包流程:`cd desktop && npm run build:backend`(`scripts/build-backend.sh`,PyInstaller onedir + + MCP 冒烟)→ `npm run build:app`(`scripts/build-desktop.sh`,electron-builder)。 +- 产物:`desktop/dist/MyKnowledge-0.7.7-arm64.dmg`,并复制一份到代码项目目录: + **`~/Desktop/Apple internship/08_代码项目/MyKnowledge-0.7.7-arm64-0903.dmg`** +- 未签名(adhoc):首次打开若被 Gatekeeper 拦,执行 + `xattr -dr com.apple.quarantine "/Applications/MyKnowledge.app"` 后再打开。 +- 验证建议:打开 App → 初始化引导选 Claude Code / CodeBuddy IDE → 走完 → + 终端看 `~/.claude*` / `~/.codebuddy*` 三份文件;再「设置 → 通用 → 重新运行初始化引导」 + 重跑一遍,确认配置**没被清掉**(问题 #2 的修复)。 diff --git a/tests/frontend/test_claude_code_config.py b/tests/frontend/test_client_config_toggle.py similarity index 69% rename from tests/frontend/test_claude_code_config.py rename to tests/frontend/test_client_config_toggle.py index a791cb3..bcaab0a 100644 --- a/tests/frontend/test_claude_code_config.py +++ b/tests/frontend/test_client_config_toggle.py @@ -1,10 +1,13 @@ -"""前端验收:Claude Code 平台 MCP / Hooks / Agent 设置页开关的读写逻辑。 +"""前端验收:MVP AI 平台(ClaudeCode / CodeBuddyIDE)MCP / Hooks / Agent 设置页开关的读写逻辑。 配套测试方案:``docs/test-plan-claude-code-config.md``。 +设置页开关逻辑与平台无关(``configureClient(plat.key, kind.key)`` + ``clientStatus``), +浏览器用例参数化跑 ClaudeCode 和 CodeBuddyIDE 两个平台。 + 分两层: -1. ``TestClaudeCodeConfigStatic`` —— 静态源码断言(不依赖后端 / 浏览器,总是运行)。 +1. ``TestConfigToggleStatic`` —— 静态源码断言(不依赖后端 / 浏览器,总是运行)。 钉死 store.js / api.js / index.html 里开关的双向逻辑: - 开关视觉态绑定 ``clientStatus(platform, kind)``(= 后端 detect 结果); - 点击调 ``configureClient(plat.key, kind.key)``; @@ -14,9 +17,10 @@ - ``guideExecute``(重新运行初始化引导)用 ``kindMeta.key`` 字符串、且只 「确保开启」不误关已配置项 —— 见测试方案「发现的问题」#1 #2。 -2. ``TestClaudeCodeToggleBrowser`` —— Playwright,全量 mock ``/api/**``(不碰真实 - ``~/.claude``、不需要 8080 后端),真跑 Alpine:验证开关 UI ↔ mock 配置态一致、 - 点击发对方向的 HTTP 动词、``guideExecute`` 不误删已配置项。 +2. ``TestConfigToggleBrowser`` —— Playwright,全量 mock ``/api/**``(不碰真实 + ``~/.claude`` / ``~/.codebuddy``、不需要 8080 后端),真跑 Alpine:验证开关 + UI ↔ mock 配置态一致、点击发对方向的 HTTP 动词、三开关独立、 + ``guideExecute`` 不误删已配置项 / 未配置时写全三种。 """ from __future__ import annotations @@ -38,7 +42,7 @@ # ═══════════════════════════════════════════════════════════════════════════ -class TestClaudeCodeConfigStatic: +class TestConfigToggleStatic: def test_api_has_write_and_remove_methods(self) -> None: js = API.read_text(encoding="utf-8") assert "async setClientConfig(platform, kind)" in js @@ -111,11 +115,20 @@ def test_platform_kinds_returns_meta_objects(self) -> None: pk = pk[:pk.index("},")] assert "this.clientKinds.filter" in pk - def test_claude_code_supports_all_three_kinds(self) -> None: + @pytest.mark.parametrize("plat", ["ClaudeCode", "CodeBuddyIDE"]) + def test_mvp_platform_supports_all_three_kinds(self, plat: str) -> None: js = STORE.read_text(encoding="utf-8") - row = next(l for l in js.splitlines() if '"ClaudeCode"' in l and "kinds:" in l) + row = next(l for l in js.splitlines() if f'"{plat}"' in l and "kinds:" in l) assert '"mcp", "hooks", "agent"' in row + @pytest.mark.parametrize("plat", ["ClaudeCode", "CodeBuddyIDE"]) + def test_settings_toggle_markup_is_platform_generic(self, plat: str) -> None: + """设置页每个 kind 页对每个平台渲染同一套 toggle(x-for plat,绑定 plat.key)—— + CodeBuddyIDE 与 ClaudeCode 走完全相同的开关代码路径。""" + html = INDEX.read_text(encoding="utf-8") + assert "x-for=\"plat in $store.app.platformsForKind(kind.key)\"" in html + assert ":data-platform=\"plat.key\"" in html and ":data-kind=\"kind.key\"" in html + # ═══════════════════════════════════════════════════════════════════════════ # 2. 浏览器(全量 mock /api/**,真跑 Alpine) @@ -219,7 +232,15 @@ def _open_settings(page, group: str) -> None: page.wait_for_timeout(400) -class TestClaudeCodeToggleBrowser: + +MVP_PLATFORMS = ["ClaudeCode", "CodeBuddyIDE"] +_NAV = {"mcp": "MCP", "hooks": "Hooks", "agent": "Agents"} + + +@pytest.mark.parametrize("plat", MVP_PLATFORMS) +class TestConfigToggleBrowser: + """全量 mock /api/**,真跑 Alpine。两个 MVP 平台跑同一套开关用例。""" + def _boot(self, page, static_server, mock: _MockBackend): mock.install(page) page.goto(f"{static_server}#dashboard") @@ -228,114 +249,133 @@ def _boot(self, page, static_server, mock: _MockBackend): except Exception: pytest.skip("前端未能在 mock 环境下启动(Alpine/资源加载)") - def test_switch_reflects_backend_state(self, mock_page, static_server): + def _row(self, page, plat: str, kind: str): + return page.locator( + f".settings-modal .ai-platform-row[data-platform='{plat}'][data-kind='{kind}']") + + def test_switch_reflects_backend_state(self, mock_page, static_server, plat): """检验一/二/三 的 UI 侧:开关 on/off class 与 mock 的 clientConfig 一致。""" mock = _MockBackend() - mock.cfg["ClaudeCode"].update(mcp=True, hooks=False, agent=True) + mock.cfg[plat].update(mcp=True, hooks=False, agent=True) self._boot(mock_page, static_server, mock) _open_settings(mock_page, "MCP") - def toggle(kind_nav, plat="ClaudeCode"): - mock_page.locator(".settings-nav__item", has_text=kind_nav).click() + def cls(kind): + mock_page.locator(".settings-nav__item", has_text=_NAV[kind]).click() mock_page.wait_for_timeout(250) - return mock_page.locator( - f".settings-modal .ai-platform-row[data-platform='{plat}'][data-kind=" - f"'{ {'MCP':'mcp','Hooks':'hooks','Agents':'agent'}[kind_nav] }'] .toggle") - - cls_mcp = toggle("MCP").get_attribute("class") - cls_hooks = toggle("Hooks").get_attribute("class") - cls_agent = toggle("Agents").get_attribute("class") - assert "toggle--on" in cls_mcp and "toggle--off" not in cls_mcp.replace("toggle--off-soft", "") - assert "toggle--off" in cls_hooks - assert "toggle--on" in cls_agent - - def test_toggle_off_sends_delete_then_on_sends_post(self, mock_page, static_server): + return self._row(mock_page, plat, kind).locator(".toggle").get_attribute("class") + + assert "toggle--on" in cls("mcp") + assert "toggle--off" in cls("hooks") + assert "toggle--on" in cls("agent") + + def test_toggle_off_sends_delete_then_on_sends_post(self, mock_page, static_server, plat): """开关双向:当前为开 → 点击发 DELETE;当前为关 → 点击发 POST。回读后 class 翻转。""" mock = _MockBackend() - mock.cfg["ClaudeCode"]["mcp"] = True + mock.cfg[plat]["mcp"] = True self._boot(mock_page, static_server, mock) _open_settings(mock_page, "MCP") - row = mock_page.locator( - ".settings-modal .ai-platform-row[data-platform='ClaudeCode'][data-kind='mcp']") - tog = row.locator(".toggle") + tog = self._row(mock_page, plat, "mcp").locator(".toggle") assert "toggle--on" in tog.get_attribute("class") tog.click() # 开 → 关 mock_page.wait_for_timeout(600) - assert ("DELETE", "ClaudeCode", "mcp") in mock.calls - assert mock.cfg["ClaudeCode"]["mcp"] is False + assert ("DELETE", plat, "mcp") in mock.calls + assert mock.cfg[plat]["mcp"] is False assert "toggle--off" in tog.get_attribute("class") mock.calls.clear() tog.click() # 关 → 开 mock_page.wait_for_timeout(600) - assert ("POST", "ClaudeCode", "mcp") in mock.calls - assert mock.cfg["ClaudeCode"]["mcp"] is True + assert ("POST", plat, "mcp") in mock.calls + assert mock.cfg[plat]["mcp"] is True assert "toggle--on" in tog.get_attribute("class") - def test_three_switches_independent(self, mock_page, static_server): + def test_three_switches_independent(self, mock_page, static_server, plat): """关 hooks 不影响 mcp / agent(只对 hooks 发一次 DELETE)。""" mock = _MockBackend() - mock.cfg["ClaudeCode"].update(mcp=True, hooks=True, agent=True) + mock.cfg[plat].update(mcp=True, hooks=True, agent=True) self._boot(mock_page, static_server, mock) _open_settings(mock_page, "Hooks") - mock_page.locator( - ".settings-modal .ai-platform-row[data-platform='ClaudeCode'][data-kind='hooks'] .toggle" - ).click() + self._row(mock_page, plat, "hooks").locator(".toggle").click() mock_page.wait_for_timeout(600) - kinds_touched = {(mth, k) for (mth, p, k) in mock.calls if p == "ClaudeCode"} - assert kinds_touched == {("DELETE", "hooks")}, kinds_touched - assert mock.cfg["ClaudeCode"] == { + touched = {(mth, k) for (mth, p, k) in mock.calls if p == plat} + assert touched == {("DELETE", "hooks")}, touched + assert mock.cfg[plat] == { "client_installed": True, "connection": "not_connected", "mcp": True, "hooks": False, "agent": True} - def test_guide_reexecute_does_not_remove_configured(self, mock_page, static_server): - """回归 #1+#2:已完整配置 ClaudeCode 时再跑 guideExecute(重新运行初始化引导), - 不得对 ClaudeCode 发任何 DELETE,也不得用非法 kind([object Object])。""" + def test_full_off_on_cycle_via_ui(self, mock_page, static_server, plat): + """检验二+三 UI 全链路:三个开关逐个关(发 DELETE)再逐个开(发 POST), + 每步 class 与 mock 配置态一致。""" + mock = _MockBackend() + mock.cfg[plat].update(mcp=True, hooks=True, agent=True) + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "MCP") + + for kind in ("mcp", "hooks", "agent"): + mock_page.locator(".settings-nav__item", has_text=_NAV[kind]).click() + mock_page.wait_for_timeout(250) + tog = self._row(mock_page, plat, kind).locator(".toggle") + tog.click() + mock_page.wait_for_timeout(500) + assert ("DELETE", plat, kind) in mock.calls + assert "toggle--off" in tog.get_attribute("class") + assert not any(mock.cfg[plat][k] for k in ("mcp", "hooks", "agent")) + + mock.calls.clear() + for kind in ("mcp", "hooks", "agent"): + mock_page.locator(".settings-nav__item", has_text=_NAV[kind]).click() + mock_page.wait_for_timeout(250) + tog = self._row(mock_page, plat, kind).locator(".toggle") + tog.click() + mock_page.wait_for_timeout(500) + assert ("POST", plat, kind) in mock.calls + assert "toggle--on" in tog.get_attribute("class") + assert all(mock.cfg[plat][k] for k in ("mcp", "hooks", "agent")) + + def test_guide_reexecute_does_not_remove_configured(self, mock_page, static_server, plat): + """回归 #1+#2:已完整配置时再跑 guideExecute(重新运行初始化引导), + 不得对该平台发任何 DELETE,也不得用非法 kind([object Object])。""" mock = _MockBackend() - mock.cfg["ClaudeCode"].update(mcp=True, hooks=True, agent=True) + mock.cfg[plat].update(mcp=True, hooks=True, agent=True) self._boot(mock_page, static_server, mock) mock_page.evaluate( - """async () => { + """async (plat) => { const s = Alpine.store('app'); - s.clientConfig = JSON.parse(JSON.stringify({ - ClaudeCode: { client_installed: true, connection: 'not_connected', - mcp: true, hooks: true, agent: true } })); - s.guideSelected = ['ClaudeCode']; + const cc = {}; cc[plat] = { client_installed: true, connection: 'not_connected', + mcp: true, hooks: true, agent: true }; + s.clientConfig = JSON.parse(JSON.stringify(cc)); + s.guideSelected = [plat]; await s.guideExecute(); - }""" - ) + }""", plat) mock_page.wait_for_timeout(300) - cc_calls = [(m, k) for (m, p, k) in mock.calls if p == "ClaudeCode"] - assert all(m != "DELETE" for (m, k) in cc_calls), f"引导误发 DELETE: {cc_calls}" - assert all(k in ("mcp", "hooks", "agent") for (m, k) in cc_calls), \ - f"引导用了非法 kind: {cc_calls}" - assert mock.cfg["ClaudeCode"]["mcp"] and mock.cfg["ClaudeCode"]["hooks"] \ - and mock.cfg["ClaudeCode"]["agent"] + calls = [(m, k) for (m, p, k) in mock.calls if p == plat] + assert all(m != "DELETE" for (m, k) in calls), f"引导误发 DELETE: {calls}" + assert all(k in ("mcp", "hooks", "agent") for (m, k) in calls), f"引导用了非法 kind: {calls}" + assert all(mock.cfg[plat][k] for k in ("mcp", "hooks", "agent")) - def test_guide_execute_writes_all_three_when_unconfigured(self, mock_page, static_server): + def test_guide_execute_writes_all_three_when_unconfigured(self, mock_page, static_server, plat): """回归 #1 正向:全未配置时 guideExecute 应把 mcp/hooks/agent 三个都 POST 写入。""" mock = _MockBackend() self._boot(mock_page, static_server, mock) mock_page.evaluate( - """async () => { + """async (plat) => { const s = Alpine.store('app'); - s.clientConfig = JSON.parse(JSON.stringify({ - ClaudeCode: { client_installed: true, connection: 'not_connected', - mcp: false, hooks: false, agent: false } })); - s.guideSelected = ['ClaudeCode']; + const cc = {}; cc[plat] = { client_installed: true, connection: 'not_connected', + mcp: false, hooks: false, agent: false }; + s.clientConfig = JSON.parse(JSON.stringify(cc)); + s.guideSelected = [plat]; await s.guideExecute(); - }""" - ) + }""", plat) mock_page.wait_for_timeout(300) - posts = sorted(k for (m, p, k) in mock.calls if p == "ClaudeCode" and m == "POST") + posts = sorted(k for (m, p, k) in mock.calls if p == plat and m == "POST") assert posts == ["agent", "hooks", "mcp"], f"引导写入不全: {mock.calls}" - assert mock.cfg["ClaudeCode"]["mcp"] and mock.cfg["ClaudeCode"]["hooks"] \ - and mock.cfg["ClaudeCode"]["agent"] + assert all(mock.cfg[plat][k] for k in ("mcp", "hooks", "agent")) diff --git a/tests/test_claude_code_config_verify.py b/tests/test_claude_code_config_verify.py deleted file mode 100644 index 5a2d911..0000000 --- a/tests/test_claude_code_config_verify.py +++ /dev/null @@ -1,389 +0,0 @@ -"""验收测试:Claude Code 平台 MCP / Hooks / Agent 配置写入 + 设置页开关双向联动。 - -配套测试方案文档:``docs/test-plan-claude-code-config.md``。 - -本模块是**纯本地后端验证**——不需要真的把 Claude Code 跑起来(国内网络下它本就 -无法联网)。要验的是: - 1. 初始化写入后,Claude Code 的三类配置文件内容正确; - 2. 设置页开关调用的 API(``POST`` / ``DELETE /api/client-config//``) - 能正确写入 / 移除 / 恢复配置; - 3. 三个开关互相独立,互不串扰。 - -设置页开关的「开 / 关」视觉态直接来自 ``store.clientStatus(platform, kind)``, -其值 = 后端 ``GET /api/client-config`` 的 ``detect_platform`` 结果。因此 -「开关状态 ↔ 配置文件」一致性 == 「``detect_platform`` 是否如实反映 -``write_kind`` / ``remove_kind`` 对配置文件的改动」——本模块正是逐条核验这一点, -既走 ``client_config`` 函数层,也走前端真正调用的 HTTP 路由层(``TestClient``)。 - -所有测试 monkeypatch ``Path.home()`` 到临时目录,绝不触碰真实 -``~/.claude`` / ``~/.claude.json``。 -""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - -from backend.client_config import ( - _platform_paths, - detect_platform, - remove_kind, - write_kind, -) - -PLATFORM = "ClaudeCode" -KINDS = ("mcp", "hooks", "agent") - -# Claude Code 三类配置的落盘位置(AiClientConfig/platforms.json,macOS): -# mcp → ~/.claude.json 键 mcpServers.MyKnowledge -# hooks → ~/.claude/settings.json 键 hooks.PreToolUse[] 里 command 为我们的 curl -# agent → ~/.claude/agents/MyKnowledge-agent.md (frontmatter + 正文的 md 文件) -HOOK_ENDPOINT_MARK = "/hooks/pre-tool-use" - - -# ────────────────────────────────────────────────────────────────────────── -# fixtures / helpers -# ────────────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """把 ``Path.home()`` 指到临时目录,隔离真实用户配置。返回该目录。""" - monkeypatch.setattr(Path, "home", lambda: tmp_path) - return tmp_path - - -@pytest.fixture -def client(fake_home: Path) -> TestClient: - """FastAPI TestClient —— 打前端真正调用的 HTTP 路由。""" - from backend.main import app - return TestClient(app) - - -def cc_paths() -> dict: - return _platform_paths(PLATFORM) - - -def mcp_file() -> Path: - return cc_paths()["mcp_file"] - - -def hooks_file() -> Path: - return cc_paths()["hooks_file"] - - -def agent_file() -> Path: - return cc_paths()["agents_dir"] / "MyKnowledge-agent.md" - - -def read_json(path: Path) -> dict: - return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} - - -def mcp_has_myknowledge() -> bool: - return "MyKnowledge" in (read_json(mcp_file()).get("mcpServers") or {}) - - -def hooks_has_myknowledge() -> bool: - hooks = (read_json(hooks_file()).get("hooks") or {}).get("PreToolUse") or [] - for matcher in hooks: - for h in matcher.get("hooks", []): - if HOOK_ENDPOINT_MARK in h.get("command", ""): - return True - return False - - -def agent_present() -> bool: - return agent_file().exists() - - -def file_state() -> dict: - """当前磁盘上三类配置各自是否存在 MyKnowledge 条目。""" - return { - "mcp": mcp_has_myknowledge(), - "hooks": hooks_has_myknowledge(), - "agent": agent_present(), - } - - -def detect_state() -> dict: - d = detect_platform(PLATFORM) - return {k: d[k] for k in KINDS} - - -def init_all_via_api(client: TestClient) -> None: - for kind in KINDS: - r = client.post(f"/api/client-config/{PLATFORM}/{kind}") - assert r.status_code == 200, r.text - - -# ══════════════════════════════════════════════════════════════════════════ -# 检验一:初始化后 —— 三种配置都已写入;设置页三个开关都应显示为「开」 -# ══════════════════════════════════════════════════════════════════════════ - - -class TestCheck1_InitializedState: - def test_init_writes_all_three_config_files(self, fake_home: Path) -> None: - for kind in KINDS: - res = write_kind(PLATFORM, kind) - assert res["status"] in ("written", "exists"), res - assert file_state() == {"mcp": True, "hooks": True, "agent": True} - - def test_mcp_file_content_correct(self, fake_home: Path) -> None: - write_kind(PLATFORM, "mcp") - srv = read_json(mcp_file())["mcpServers"]["MyKnowledge"] - assert srv["type"] == "stdio" - assert srv["args"] == ["-m", "backend.cli", "mcp"] # 非 frozen 开发路径 - assert srv["command"].endswith(("python", "python3")) or "python" in srv["command"] - assert srv["env"]["MYKNOWLEDGE_CLIENT"] == "ClaudeCode" - assert "MYKNOWLEDGE_ROOT" in srv["env"] - - def test_hooks_file_content_correct(self, fake_home: Path) -> None: - write_kind(PLATFORM, "hooks") - pre = read_json(hooks_file())["hooks"]["PreToolUse"] - mine = [m for m in pre - if any(HOOK_ENDPOINT_MARK in h.get("command", "") - for h in m.get("hooks", []))] - assert len(mine) == 1, pre - assert mine[0]["matcher"] == "Bash|Write|Edit" - cmd = mine[0]["hooks"][0]["command"] - assert cmd.startswith("curl -s -X POST") and cmd.endswith("-d @-") - - def test_agent_file_content_correct(self, fake_home: Path) -> None: - write_kind(PLATFORM, "agent") - text = agent_file().read_text(encoding="utf-8") - assert text.startswith("---\n") # YAML frontmatter - assert "name: MyKnowledge" in text - assert "# MyKnowledge Agent" in text - - def test_detect_reports_all_on_after_init(self, fake_home: Path) -> None: - for kind in KINDS: - write_kind(PLATFORM, kind) - assert detect_state() == {"mcp": True, "hooks": True, "agent": True} - - def test_switch_state_matches_files_after_init(self, client: TestClient) -> None: - """设置页开关状态(= GET /api/client-config)↔ 磁盘配置文件,两者一致且都为「开」。""" - init_all_via_api(client) - api_state = client.get("/api/client-config").json()[PLATFORM] - assert {k: api_state[k] for k in KINDS} == {"mcp": True, "hooks": True, "agent": True} - assert file_state() == {"mcp": True, "hooks": True, "agent": True} - - -# ══════════════════════════════════════════════════════════════════════════ -# 检验二:关掉某开关 —— 配置文件对应项被「移除」(不是保留停用);开关显示为「关」 -# ══════════════════════════════════════════════════════════════════════════ - - -class TestCheck2_ToggleOff: - @pytest.mark.parametrize("kind", KINDS) - def test_toggle_off_removes_from_config_and_switch( - self, client: TestClient, kind: str - ) -> None: - init_all_via_api(client) - r = client.delete(f"/api/client-config/{PLATFORM}/{kind}") - assert r.status_code == 200 - assert r.json()["status"] == "removed" - - # 配置文件层:该 kind 的 MyKnowledge 条目确实消失 - assert file_state()[kind] is False - # 开关层:GET /api/client-config 对应项为 False - assert client.get("/api/client-config").json()[PLATFORM][kind] is False - - def test_off_is_removal_not_disabled_flag(self, fake_home: Path) -> None: - """「关」= 物理删除条目,而非写一个 enabled:false 之类的停用标记。""" - write_kind(PLATFORM, "mcp") - remove_kind(PLATFORM, "mcp") - servers = read_json(mcp_file()).get("mcpServers", {}) - assert "MyKnowledge" not in servers # 键被删除 - assert servers == {} # 没有残留的停用形态 - - write_kind(PLATFORM, "hooks") - remove_kind(PLATFORM, "hooks") - pre = (read_json(hooks_file()).get("hooks") or {}).get("PreToolUse") - assert pre == [] # matcher 被移除,不是标记停用 - - write_kind(PLATFORM, "agent") - remove_kind(PLATFORM, "agent") - assert not agent_file().exists() # 文件被删除 - - def test_toggle_off_preserves_user_other_config(self, fake_home: Path) -> None: - """关开关只动 MyKnowledge 条目——用户自己的 mcpServers / hooks / 设置项保留。""" - write_kind(PLATFORM, "mcp") - data = read_json(mcp_file()) - data["mcpServers"]["RAPID"] = {"type": "stdio", "command": "rapid"} - data["someUserSetting"] = 42 - mcp_file().write_text(json.dumps(data), encoding="utf-8") - - write_kind(PLATFORM, "hooks") - h = read_json(hooks_file()) - h["hooks"]["PreToolUse"].append( - {"matcher": "Bash", "hooks": [{"type": "command", "command": "user-own-hook"}]}) - h["hooks"]["PostToolUse"] = [ - {"matcher": "*", "hooks": [{"type": "command", "command": "user-post"}]}] - hooks_file().write_text(json.dumps(h), encoding="utf-8") - - remove_kind(PLATFORM, "mcp") - remove_kind(PLATFORM, "hooks") - - mcp_data = read_json(mcp_file()) - assert "RAPID" in mcp_data["mcpServers"] and "MyKnowledge" not in mcp_data["mcpServers"] - assert mcp_data["someUserSetting"] == 42 - - h2 = read_json(hooks_file()) - pre_cmds = [x["hooks"][0]["command"] for x in h2["hooks"]["PreToolUse"]] - assert pre_cmds == ["user-own-hook"] - assert "PostToolUse" in h2["hooks"] - - def test_toggle_off_idempotent(self, client: TestClient) -> None: - init_all_via_api(client) - for _ in range(3): - r = client.delete(f"/api/client-config/{PLATFORM}/mcp") - assert r.status_code == 200 and r.json()["status"] == "removed" - assert client.get("/api/client-config").json()[PLATFORM]["mcp"] is False - - -# ══════════════════════════════════════════════════════════════════════════ -# 检验三:再次打开 —— 配置文件对应项恢复写入;开关显示为「开」 -# ══════════════════════════════════════════════════════════════════════════ - - -class TestCheck3_ToggleBackOn: - @pytest.mark.parametrize("kind", KINDS) - def test_off_then_on_restores_config_and_switch( - self, client: TestClient, kind: str - ) -> None: - init_all_via_api(client) - client.delete(f"/api/client-config/{PLATFORM}/{kind}") - assert file_state()[kind] is False - - r = client.post(f"/api/client-config/{PLATFORM}/{kind}") - assert r.status_code == 200 - assert r.json()["status"] in ("written", "exists") - - assert file_state()[kind] is True - assert client.get("/api/client-config").json()[PLATFORM][kind] is True - - def test_restored_content_equivalent_to_original(self, fake_home: Path) -> None: - """恢复写入的内容与初次写入等价(不是残缺/损坏的条目)。""" - write_kind(PLATFORM, "mcp") - first = read_json(mcp_file())["mcpServers"]["MyKnowledge"] - remove_kind(PLATFORM, "mcp") - write_kind(PLATFORM, "mcp") - again = read_json(mcp_file())["mcpServers"]["MyKnowledge"] - assert again == first - - write_kind(PLATFORM, "hooks") - h1 = read_json(hooks_file())["hooks"]["PreToolUse"] - remove_kind(PLATFORM, "hooks") - write_kind(PLATFORM, "hooks") - h2 = read_json(hooks_file())["hooks"]["PreToolUse"] - assert h2 == h1 - - write_kind(PLATFORM, "agent") - a1 = agent_file().read_text(encoding="utf-8") - remove_kind(PLATFORM, "agent") - write_kind(PLATFORM, "agent") - assert agent_file().read_text(encoding="utf-8") == a1 - - def test_multiple_off_on_cycles_stable(self, client: TestClient) -> None: - init_all_via_api(client) - for _ in range(3): - for kind in KINDS: - client.delete(f"/api/client-config/{PLATFORM}/{kind}") - assert file_state() == {"mcp": False, "hooks": False, "agent": False} - for kind in KINDS: - client.post(f"/api/client-config/{PLATFORM}/{kind}") - assert file_state() == {"mcp": True, "hooks": True, "agent": True} - - -# ══════════════════════════════════════════════════════════════════════════ -# 补充:三个开关各自独立、互不影响 -# ══════════════════════════════════════════════════════════════════════════ - - -class TestCheck4_SwitchIndependence: - @pytest.mark.parametrize("toggled", KINDS) - def test_toggling_one_off_does_not_touch_others( - self, client: TestClient, toggled: str - ) -> None: - init_all_via_api(client) - others = [k for k in KINDS if k != toggled] - - client.delete(f"/api/client-config/{PLATFORM}/{toggled}") - state = client.get("/api/client-config").json()[PLATFORM] - assert state[toggled] is False - for o in others: - assert state[o] is True, f"关 {toggled} 影响了 {o}" - assert file_state()[o] is True - - @pytest.mark.parametrize("toggled", KINDS) - def test_toggling_one_back_on_does_not_touch_others( - self, client: TestClient, toggled: str - ) -> None: - init_all_via_api(client) - others = [k for k in KINDS if k != toggled] - # 先把 others 关掉,只留 toggled 关→开,验证不会顺带把 others 打开 - for o in others: - client.delete(f"/api/client-config/{PLATFORM}/{o}") - client.delete(f"/api/client-config/{PLATFORM}/{toggled}") - - client.post(f"/api/client-config/{PLATFORM}/{toggled}") - state = client.get("/api/client-config").json()[PLATFORM] - assert state[toggled] is True - for o in others: - assert state[o] is False, f"开 {toggled} 顺带打开了 {o}" - - def test_mcp_hooks_agent_use_distinct_files(self, fake_home: Path) -> None: - """三类配置落在三个不同文件——天然隔离,不存在「改一个动另一个」。""" - p = cc_paths() - assert len({str(p["mcp_file"]), str(p["hooks_file"]), - str(p["agents_dir"] / "MyKnowledge-agent.md")}) == 3 - - def test_independent_state_matrix(self, client: TestClient) -> None: - """8 种开/关组合逐一核验:设置页开关矩阵 ↔ 配置文件矩阵完全一致。""" - init_all_via_api(client) - for bits in range(8): - want = { - "mcp": bool(bits & 1), - "hooks": bool(bits & 2), - "agent": bool(bits & 4), - } - for kind, on in want.items(): - if on: - client.post(f"/api/client-config/{PLATFORM}/{kind}") - else: - client.delete(f"/api/client-config/{PLATFORM}/{kind}") - api_state = client.get("/api/client-config").json()[PLATFORM] - assert {k: api_state[k] for k in KINDS} == want - assert file_state() == want - - -# ══════════════════════════════════════════════════════════════════════════ -# 初始化引导(rerunGuide → guideExecute)回归:不能把已配置项误删 / 一个都写不进 -# —— 见 docs/test-plan-claude-code-config.md「发现的问题」#1、#2 -# ══════════════════════════════════════════════════════════════════════════ - - -class TestGuideExecuteContract: - """``store.guideExecute()`` 对后端的调用契约(前端 bug 的后端侧护栏)。 - - 前端 bug 详情见测试方案文档;这里从后端角度钉死两条: - - kind 必须是 mcp/hooks/agent 字符串,``[object Object]`` 之类会被 400 拒绝 - (引导漏取 ``kindMeta.key`` 时的症状); - - 对已配置平台重复「初始化」应保持开启(``POST`` 幂等),不应变成关闭。 - """ - - def test_object_like_kind_is_rejected(self, client: TestClient) -> None: - r = client.post(f"/api/client-config/{PLATFORM}/%5Bobject%20Object%5D") - assert r.status_code == 400 - - def test_reinit_keeps_config_on(self, client: TestClient) -> None: - """已配置平台再跑一次初始化(POST 幂等)——配置仍在,不被关闭。""" - init_all_via_api(client) - init_all_via_api(client) # 第二次「初始化」 - assert file_state() == {"mcp": True, "hooks": True, "agent": True} - api_state = client.get("/api/client-config").json()[PLATFORM] - assert {k: api_state[k] for k in KINDS} == {"mcp": True, "hooks": True, "agent": True} diff --git a/tests/test_client_config_verify.py b/tests/test_client_config_verify.py new file mode 100644 index 0000000..5fa486f --- /dev/null +++ b/tests/test_client_config_verify.py @@ -0,0 +1,435 @@ +"""验收测试:MVP AI 平台 MCP / Hooks / Agent 配置写入 + 设置页开关双向联动。 + +覆盖平台:**ClaudeCode** + **CodeBuddyIDE**(``backend/client_config.py`` 注释里 +写明的 MVP 两平台,共用同一套 ``configureClient`` 增量合并逻辑,仅落盘目录不同: +Claude Code → ``~/.claude*``;CodeBuddy IDE → ``~/.codebuddy*``)。 +WorkBuddy(代码标 not built)/ Cursor / ClaudeDesktop / Enchante 本模块不覆盖。 + +配套测试方案文档:``docs/test-plan-claude-code-config.md``。 + +本模块是**纯本地后端验证**——不需要真的把 Claude Code / CodeBuddy IDE 跑起来 +(国内网络下它们本就无法联网)。要验的是: + 1. 初始化写入后,两平台各自的三类配置文件内容正确; + 2. 设置页开关调用的 API(``POST`` / ``DELETE /api/client-config//``) + 能正确写入 / 移除 / 恢复配置; + 3. 三个开关互相独立,互不串扰。 + +设置页开关的「开 / 关」视觉态直接来自 ``store.clientStatus(platform, kind)``, +其值 = 后端 ``GET /api/client-config`` 的 ``detect_platform`` 结果。因此 +「开关状态 ↔ 配置文件」一致性 == 「``detect_platform`` 是否如实反映 +``write_kind`` / ``remove_kind`` 对配置文件的改动」——本模块正是逐条核验这一点, +既走 ``client_config`` 函数层,也走前端真正调用的 HTTP 路由层(``TestClient``)。 + +所有测试 monkeypatch ``Path.home()`` 到临时目录,绝不触碰真实 +``~/.claude`` / ``~/.claude.json`` / ``~/.codebuddy``。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from backend.client_config import ( + _hooks_cmd_for, + _matcher_is_mine, + _platform_paths, + detect_platform, + remove_kind, + write_kind, +) + +KINDS = ("mcp", "hooks", "agent") + +# 每个 MVP 平台的落盘位置与内容特征(AiClientConfig/platforms.json,macOS)。 +# mcp → 键 mcpServers.MyKnowledge +# hooks → 键 hooks.PreToolUse[](matcher 由平台决定) +# agent → /MyKnowledge-agent.md (frontmatter + 正文的 md 文件) +SPECS = { + "ClaudeCode": { + "mcp_file": "~/.claude.json", + "hooks_file": "~/.claude/settings.json", + "agents_dir": "~/.claude/agents", + "hook_matcher": "Bash|Write|Edit", + # ClaudeCode hook = curl 读 stdin(-d @-)打 /hooks/pre-tool-use + "hook_cmd_check": lambda c: c.startswith("curl -s -X POST") and c.endswith("-d @-"), + "agent_frontmatter_keys": ("name:", "description:", "tools:", "model:"), + }, + "CodeBuddyIDE": { + "mcp_file": "~/.codebuddy/mcp.json", + "hooks_file": "~/.codebuddy/settings.json", + "agents_dir": "~/.codebuddy/agents", + "hook_matcher": "*", + # CodeBuddyIDE hook = hooks_forward 转发脚本(dev: python3 -m backend.hooks_forward; + # frozen: "<二进制>" --hooks-forward)——两种形态都含 hooks_forward / --hooks-forward + "hook_cmd_check": lambda c: ("hooks_forward" in c) or ("--hooks-forward" in c), + # CodeBuddy agent 额外带 agentMode/enabled/enabledAutoRun/mcpServers frontmatter + "agent_frontmatter_keys": ("name:", "description:", "tools:", "model:", + "agentMode:", "enabled:", "mcpServers:"), + }, +} +MVP_PLATFORMS = tuple(SPECS) + + +# ────────────────────────────────────────────────────────────────────────── +# fixtures / helpers +# ────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture(params=MVP_PLATFORMS) +def platform(request) -> str: + """参数化:ClaudeCode / CodeBuddyIDE,两平台跑同一套检验。""" + return request.param + + +@pytest.fixture +def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """把 ``Path.home()`` 指到临时目录,隔离真实用户配置。返回该目录。""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + return tmp_path + + +@pytest.fixture +def client(fake_home: Path) -> TestClient: + """FastAPI TestClient —— 打前端真正调用的 HTTP 路由。""" + from backend.main import app + return TestClient(app) + + +def mcp_file(platform: str) -> Path: + return _platform_paths(platform)["mcp_file"] + + +def hooks_file(platform: str) -> Path: + return _platform_paths(platform)["hooks_file"] + + +def agent_file(platform: str) -> Path: + return _platform_paths(platform)["agents_dir"] / "MyKnowledge-agent.md" + + +def read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + + +def mcp_has_myknowledge(platform: str) -> bool: + return "MyKnowledge" in (read_json(mcp_file(platform)).get("mcpServers") or {}) + + +def hooks_has_myknowledge(platform: str) -> bool: + """与 detect_platform 同源:按 command 签名识别我们的 matcher。""" + cmd = _hooks_cmd_for(platform) + pre = (read_json(hooks_file(platform)).get("hooks") or {}).get("PreToolUse") or [] + return any(_matcher_is_mine(m, cmd) for m in pre) + + +def file_state(platform: str) -> dict: + """当前磁盘上三类配置各自是否存在 MyKnowledge 条目。""" + return { + "mcp": mcp_has_myknowledge(platform), + "hooks": hooks_has_myknowledge(platform), + "agent": agent_file(platform).exists(), + } + + +def detect_state(platform: str) -> dict: + d = detect_platform(platform) + return {k: d[k] for k in KINDS} + + +def api_switch_state(client: TestClient, platform: str) -> dict: + st = client.get("/api/client-config").json()[platform] + return {k: st[k] for k in KINDS} + + +def init_all_via_api(client: TestClient, platform: str) -> None: + for kind in KINDS: + r = client.post(f"/api/client-config/{platform}/{kind}") + assert r.status_code == 200, r.text + + +# ══════════════════════════════════════════════════════════════════════════ +# 检验一:初始化后 —— 三种配置都已写入;设置页三个开关都应显示为「开」 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck1_InitializedState: + def test_init_writes_all_three_config_files(self, fake_home: Path, platform: str) -> None: + for kind in KINDS: + res = write_kind(platform, kind) + assert res["status"] in ("written", "exists"), res + assert file_state(platform) == {"mcp": True, "hooks": True, "agent": True} + + def test_mcp_file_content_correct(self, fake_home: Path, platform: str) -> None: + write_kind(platform, "mcp") + data = read_json(mcp_file(platform)) + srv = data["mcpServers"]["MyKnowledge"] + assert srv["type"] == "stdio" + assert srv["args"] == ["-m", "backend.cli", "mcp"] # 非 frozen 开发路径 + assert "python" in srv["command"] + assert srv["env"]["MYKNOWLEDGE_CLIENT"] == platform + assert "MYKNOWLEDGE_ROOT" in srv["env"] + # 落盘到该平台约定的文件 + assert str(mcp_file(platform)).endswith(SPECS[platform]["mcp_file"].lstrip("~")) + + def test_hooks_file_content_correct(self, fake_home: Path, platform: str) -> None: + write_kind(platform, "hooks") + spec = SPECS[platform] + pre = read_json(hooks_file(platform))["hooks"]["PreToolUse"] + cmd = _hooks_cmd_for(platform) + mine = [m for m in pre if _matcher_is_mine(m, cmd)] + assert len(mine) == 1, pre + assert mine[0]["matcher"] == spec["hook_matcher"] + hook_cmd = mine[0]["hooks"][0]["command"] + assert spec["hook_cmd_check"](hook_cmd), hook_cmd + assert str(hooks_file(platform)).endswith(spec["hooks_file"].lstrip("~")) + + def test_agent_file_content_correct(self, fake_home: Path, platform: str) -> None: + write_kind(platform, "agent") + text = agent_file(platform).read_text(encoding="utf-8") + assert text.startswith("---\n") # YAML frontmatter + assert "name: MyKnowledge" in text + assert "# MyKnowledge Agent" in text + for key in SPECS[platform]["agent_frontmatter_keys"]: + assert key in text, f"{platform} agent frontmatter 缺 {key}" + + def test_detect_reports_all_on_after_init(self, fake_home: Path, platform: str) -> None: + for kind in KINDS: + write_kind(platform, kind) + assert detect_state(platform) == {"mcp": True, "hooks": True, "agent": True} + + def test_switch_state_matches_files_after_init( + self, client: TestClient, platform: str + ) -> None: + """设置页开关状态(= GET /api/client-config)↔ 磁盘配置文件,两者一致且都为「开」。""" + init_all_via_api(client, platform) + assert api_switch_state(client, platform) == {"mcp": True, "hooks": True, "agent": True} + assert file_state(platform) == {"mcp": True, "hooks": True, "agent": True} + + +# ══════════════════════════════════════════════════════════════════════════ +# 检验二:关掉某开关 —— 配置文件对应项被「移除」(不是保留停用);开关显示为「关」 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck2_ToggleOff: + @pytest.mark.parametrize("kind", KINDS) + def test_toggle_off_removes_from_config_and_switch( + self, client: TestClient, platform: str, kind: str + ) -> None: + init_all_via_api(client, platform) + r = client.delete(f"/api/client-config/{platform}/{kind}") + assert r.status_code == 200 + assert r.json()["status"] == "removed" + + assert file_state(platform)[kind] is False # 配置文件层:条目消失 + assert api_switch_state(client, platform)[kind] is False # 开关层:False + + def test_off_is_removal_not_disabled_flag(self, fake_home: Path, platform: str) -> None: + """「关」= 物理删除条目,而非写一个 enabled:false 之类的停用标记。""" + write_kind(platform, "mcp") + remove_kind(platform, "mcp") + servers = read_json(mcp_file(platform)).get("mcpServers", {}) + assert "MyKnowledge" not in servers + assert servers == {} # 没有残留的停用形态 + + write_kind(platform, "hooks") + remove_kind(platform, "hooks") + pre = (read_json(hooks_file(platform)).get("hooks") or {}).get("PreToolUse") + assert pre == [] # matcher 被移除,不是标记停用 + + write_kind(platform, "agent") + remove_kind(platform, "agent") + assert not agent_file(platform).exists() # 文件被删除 + + def test_toggle_off_preserves_user_other_config( + self, fake_home: Path, platform: str + ) -> None: + """关开关只动 MyKnowledge 条目——用户自己的 mcpServers / hooks / 设置项保留。""" + write_kind(platform, "mcp") + data = read_json(mcp_file(platform)) + data["mcpServers"]["RAPID"] = {"type": "stdio", "command": "rapid"} + data["someUserSetting"] = 42 + mcp_file(platform).write_text(json.dumps(data), encoding="utf-8") + + write_kind(platform, "hooks") + h = read_json(hooks_file(platform)) + h["hooks"]["PreToolUse"].append( + {"matcher": "Foo", "hooks": [{"type": "command", "command": "user-own-hook"}]}) + h["hooks"]["PostToolUse"] = [ + {"matcher": "*", "hooks": [{"type": "command", "command": "user-post"}]}] + hooks_file(platform).write_text(json.dumps(h), encoding="utf-8") + + remove_kind(platform, "mcp") + remove_kind(platform, "hooks") + + mcp_data = read_json(mcp_file(platform)) + assert "RAPID" in mcp_data["mcpServers"] and "MyKnowledge" not in mcp_data["mcpServers"] + assert mcp_data["someUserSetting"] == 42 + + h2 = read_json(hooks_file(platform)) + pre_cmds = [x["hooks"][0]["command"] for x in h2["hooks"]["PreToolUse"]] + assert pre_cmds == ["user-own-hook"] + assert "PostToolUse" in h2["hooks"] + + def test_toggle_off_idempotent(self, client: TestClient, platform: str) -> None: + init_all_via_api(client, platform) + for _ in range(3): + r = client.delete(f"/api/client-config/{platform}/mcp") + assert r.status_code == 200 and r.json()["status"] == "removed" + assert api_switch_state(client, platform)["mcp"] is False + + +# ══════════════════════════════════════════════════════════════════════════ +# 检验三:再次打开 —— 配置文件对应项恢复写入;开关显示为「开」 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck3_ToggleBackOn: + @pytest.mark.parametrize("kind", KINDS) + def test_off_then_on_restores_config_and_switch( + self, client: TestClient, platform: str, kind: str + ) -> None: + init_all_via_api(client, platform) + client.delete(f"/api/client-config/{platform}/{kind}") + assert file_state(platform)[kind] is False + + r = client.post(f"/api/client-config/{platform}/{kind}") + assert r.status_code == 200 + assert r.json()["status"] in ("written", "exists") + + assert file_state(platform)[kind] is True + assert api_switch_state(client, platform)[kind] is True + + def test_restored_content_equivalent_to_original( + self, fake_home: Path, platform: str + ) -> None: + """恢复写入的内容与初次写入逐字段等价(不是残缺/损坏的条目)。""" + write_kind(platform, "mcp") + first = read_json(mcp_file(platform))["mcpServers"]["MyKnowledge"] + remove_kind(platform, "mcp") + write_kind(platform, "mcp") + assert read_json(mcp_file(platform))["mcpServers"]["MyKnowledge"] == first + + write_kind(platform, "hooks") + h1 = read_json(hooks_file(platform))["hooks"]["PreToolUse"] + remove_kind(platform, "hooks") + write_kind(platform, "hooks") + assert read_json(hooks_file(platform))["hooks"]["PreToolUse"] == h1 + + write_kind(platform, "agent") + a1 = agent_file(platform).read_text(encoding="utf-8") + remove_kind(platform, "agent") + write_kind(platform, "agent") + assert agent_file(platform).read_text(encoding="utf-8") == a1 + + def test_multiple_off_on_cycles_stable(self, client: TestClient, platform: str) -> None: + init_all_via_api(client, platform) + for _ in range(3): + for kind in KINDS: + client.delete(f"/api/client-config/{platform}/{kind}") + assert file_state(platform) == {"mcp": False, "hooks": False, "agent": False} + for kind in KINDS: + client.post(f"/api/client-config/{platform}/{kind}") + assert file_state(platform) == {"mcp": True, "hooks": True, "agent": True} + + +# ══════════════════════════════════════════════════════════════════════════ +# 补充:三个开关各自独立、互不影响 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCheck4_SwitchIndependence: + @pytest.mark.parametrize("toggled", KINDS) + def test_toggling_one_off_does_not_touch_others( + self, client: TestClient, platform: str, toggled: str + ) -> None: + init_all_via_api(client, platform) + others = [k for k in KINDS if k != toggled] + + client.delete(f"/api/client-config/{platform}/{toggled}") + state = api_switch_state(client, platform) + assert state[toggled] is False + for o in others: + assert state[o] is True, f"关 {toggled} 影响了 {o}" + assert file_state(platform)[o] is True + + @pytest.mark.parametrize("toggled", KINDS) + def test_toggling_one_back_on_does_not_touch_others( + self, client: TestClient, platform: str, toggled: str + ) -> None: + init_all_via_api(client, platform) + others = [k for k in KINDS if k != toggled] + for o in others: + client.delete(f"/api/client-config/{platform}/{o}") + client.delete(f"/api/client-config/{platform}/{toggled}") + + client.post(f"/api/client-config/{platform}/{toggled}") + state = api_switch_state(client, platform) + assert state[toggled] is True + for o in others: + assert state[o] is False, f"开 {toggled} 顺带打开了 {o}" + + def test_mcp_hooks_agent_use_distinct_files(self, fake_home: Path, platform: str) -> None: + """三类配置落在三个不同文件——天然隔离,不存在「改一个动另一个」。""" + p = _platform_paths(platform) + assert len({str(p["mcp_file"]), str(p["hooks_file"]), + str(p["agents_dir"] / "MyKnowledge-agent.md")}) == 3 + + def test_independent_state_matrix(self, client: TestClient, platform: str) -> None: + """8 种开/关组合逐一核验:设置页开关矩阵 ↔ 配置文件矩阵完全一致。""" + init_all_via_api(client, platform) + for bits in range(8): + want = {"mcp": bool(bits & 1), "hooks": bool(bits & 2), "agent": bool(bits & 4)} + for kind, on in want.items(): + if on: + client.post(f"/api/client-config/{platform}/{kind}") + else: + client.delete(f"/api/client-config/{platform}/{kind}") + assert api_switch_state(client, platform) == want + assert file_state(platform) == want + + +# ══════════════════════════════════════════════════════════════════════════ +# 两平台互不干扰(同一 home 下 ClaudeCode 与 CodeBuddyIDE 配置各写各的) +# ══════════════════════════════════════════════════════════════════════════ + + +class TestCrossPlatformIsolation: + def test_claudecode_and_codebuddy_independent(self, client: TestClient) -> None: + init_all_via_api(client, "ClaudeCode") + init_all_via_api(client, "CodeBuddyIDE") + + # 关掉 ClaudeCode 全部,CodeBuddyIDE 不受影响 + for kind in KINDS: + client.delete(f"/api/client-config/ClaudeCode/{kind}") + assert api_switch_state(client, "ClaudeCode") == {"mcp": False, "hooks": False, "agent": False} + assert api_switch_state(client, "CodeBuddyIDE") == {"mcp": True, "hooks": True, "agent": True} + assert file_state("CodeBuddyIDE") == {"mcp": True, "hooks": True, "agent": True} + + def test_mcp_files_are_separate_paths(self, fake_home: Path) -> None: + assert mcp_file("ClaudeCode") != mcp_file("CodeBuddyIDE") + assert hooks_file("ClaudeCode") != hooks_file("CodeBuddyIDE") + + +# ══════════════════════════════════════════════════════════════════════════ +# 初始化引导(rerunGuide → guideExecute)回归:不能把已配置项误删 / 一个都写不进 +# —— 见 docs/test-plan-claude-code-config.md「发现的问题」#1、#2 +# ══════════════════════════════════════════════════════════════════════════ + + +class TestGuideExecuteContract: + """``store.guideExecute()`` 对后端的调用契约(前端 bug 的后端侧护栏,两平台都盯)。""" + + def test_object_like_kind_is_rejected(self, client: TestClient, platform: str) -> None: + r = client.post(f"/api/client-config/{platform}/%5Bobject%20Object%5D") + assert r.status_code == 400 + + def test_reinit_keeps_config_on(self, client: TestClient, platform: str) -> None: + """已配置平台再跑一次初始化(POST 幂等)——配置仍在,不被关闭。""" + init_all_via_api(client, platform) + init_all_via_api(client, platform) # 第二次「初始化」 + assert file_state(platform) == {"mcp": True, "hooks": True, "agent": True} + assert api_switch_state(client, platform) == {"mcp": True, "hooks": True, "agent": True}