diff --git a/docs/test-plan-claude-code-config.md b/docs/test-plan-claude-code-config.md new file mode 100644 index 0000000..2c1dda8 --- /dev/null +++ b/docs/test-plan-claude-code-config.md @@ -0,0 +1,361 @@ +# 测试方案: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_client_config_verify.py`(后端 / API 层,参数化 ×2 平台,56 例) +> - `tests/frontend/test_client_config_toggle.py`(前端静态 12 例 + 浏览器 12 例 = 6 用例 ×2 平台) + +--- + +## 1. 验证目标 + +验证并保证「Claude Code」「CodeBuddy IDE」两个 AI 平台在**初始化配置后**: + +1. **配置写入正确**:MCP / Hooks / Agent 三种配置分别正确写入平台对应的配置文件。 +2. **设置页开关双向控制**:设置页里这三个开关能正确双向控制 + - 开 → 写入配置 + - 关 → 移除配置 + - 再开 → 恢复写入 +3. **三个开关互相独立**,互不串扰。 + +> **重要前提**:Claude Code / CodeBuddy IDE 本身在国内网络下无法联网运行,但这**不影响本任务**—— +> 要验的是「配置文件写入」和「设置页开关读写逻辑」,**全部纯本地验证** +> (检查配置文件内容 + 前端逻辑),不需要真的把它们跑起来联网。 + +--- + +## 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.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 设置页三个开关如何读写这些配置 + +**读(开关的「开 / 关」视觉态)**: + +``` +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` 的结果,= 开关视觉态数据源) +↔「平台实际配置文件」,两者**必须一致**。**每条检验对 ClaudeCode 与 CodeBuddyIDE +两个平台各跑一遍**(后端测试用 `@pytest.fixture(params=["ClaudeCode","CodeBuddyIDE"])` +参数化,前端浏览器测试用 `@pytest.mark.parametrize("plat", [...])`)。 + +下表以 Claude Code 为例;CodeBuddy IDE 的对应文件 / 格式见 §2.1b,检验项与断言逻辑相同。 + +### 检验一:初始化后 + +| 项 | 预期(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_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/<平台>/` + +| 项 | 预期 | +|----|------| +| 对应配置文件 | 该 kind 的 MyKnowledge 条目**被移除**(键删除 / matcher 删除 / 文件删除),非停用标记 | +| 用户其他配置 | 保留(其他 mcpServers、其他 hooks、PostToolUse、无关设置项都在) | +| `GET /api/client-config` → `<平台>[kind]` | `false` | +| 该开关 | 显示为**关**;另外两个开关**不变** | + +自动化:`TestCheck2_ToggleOff`(5 例 ×2 平台,含「关=删除非停用」「保留用户其他配置」「幂等」)、 +`test_client_config_toggle.py::…::test_toggle_off_sends_delete_then_on_sends_post`(×2 平台)。 + +### 检验三:再次打开 + +`POST /api/client-config/<平台>/` + +| 项 | 预期 | +|----|------| +| 对应配置文件 | 条目**恢复写入**,内容与初次写入**逐字段等价** | +| `GET /api/client-config` → `<平台>[kind]` | `true` | +| 该开关 | 恢复为**开** | +| 多轮 关→开→关→开 | 状态稳定,无漂移 | + +自动化:`TestCheck3_ToggleBackOn`(3 例 ×2 平台,含「恢复内容等价」「多轮循环稳定」)、 +`test_client_config_toggle.py::…::test_full_off_on_cycle_via_ui`(浏览器逐个关再逐个开,×2 平台)。 + +### 补充:三个开关各自独立、互不影响 + +| 场景 | 预期 | +|------|------| +| 关 mcp | hooks / agent 的检测态与文件不变 | +| 关 hooks | mcp / agent 不变 | +| 关 agent | mcp / hooks 不变 | +| 开某个关着的 kind | 不会顺带打开另外两个 | +| 8 种开/关组合矩阵 | 设置页开关矩阵 ↔ 配置文件矩阵**逐一完全一致** | +| ClaudeCode ↔ CodeBuddyIDE | 同一 home 下两平台配置各写各的,关掉一个平台全部不影响另一个 | + +自动化:`TestCheck4_SwitchIndependence`(4 例 ×2 平台,含 `test_independent_state_matrix` 遍历 8 组合)、 +`TestCrossPlatformIsolation`(2 例,两平台互不干扰)、 +`test_client_config_toggle.py::…::test_three_switches_independent`(浏览器点关 hooks,断言只对 hooks 发一次 DELETE,×2 平台)。 + +--- + +## 4. 自动化测试 vs 手动验证 + +### 自动化覆盖 + +| 层 | 文件 | 覆盖(ClaudeCode + CodeBuddyIDE 均参数化 ×2) | +|----|------|------| +| **后端 / 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` / `~/.codebuddy`。本方案用 `page.route` 拦截全部 `/api/**`,内存态模拟后端, +既真实驱动 Alpine 开关逻辑,又零副作用。 + +### 需手动验证(自动化未覆盖 / 不适合自动化) + +| 项 | 手动步骤 | +|----|----------| +| 桌面 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 安装验证六平台默认配置) | + +--- + +## 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 例失败,无新增失败。 +- 改动后(含第二部分补的 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`:不变全绿 + - 后端全量 `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= 版本化一致性` ✓ + +--- + +## 发现的问题(测 → 修 → 再测) + +### 问题 #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_client_config_toggle.py::…::test_guide_execute_writes_all_three_when_unconfigured` + (改前红:`guideExecute` 对 ClaudeCode 发出的是非法 kind、0 个有效 POST;改后绿:mcp/hooks/agent 各一个 POST)。 + 后端护栏 `test_client_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_client_config_toggle.py::…::test_guide_reexecute_does_not_remove_configured` + (改前红:对已配置 ClaudeCode 发 DELETE;改后绿:零 DELETE,配置保持)。 + 静态断言 `test_guide_execute_only_ensures_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` + + 本次新增(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/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_client_config_toggle.py b/tests/frontend/test_client_config_toggle.py new file mode 100644 index 0000000..bcaab0a --- /dev/null +++ b/tests/frontend/test_client_config_toggle.py @@ -0,0 +1,381 @@ +"""前端验收:MVP AI 平台(ClaudeCode / CodeBuddyIDE)MCP / Hooks / Agent 设置页开关的读写逻辑。 + +配套测试方案:``docs/test-plan-claude-code-config.md``。 + +设置页开关逻辑与平台无关(``configureClient(plat.key, kind.key)`` + ``clientStatus``), +浏览器用例参数化跑 ClaudeCode 和 CodeBuddyIDE 两个平台。 + +分两层: + +1. ``TestConfigToggleStatic`` —— 静态源码断言(不依赖后端 / 浏览器,总是运行)。 + 钉死 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. ``TestConfigToggleBrowser`` —— Playwright,全量 mock ``/api/**``(不碰真实 + ``~/.claude`` / ``~/.codebuddy``、不需要 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 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 + 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 + + @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 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) +# ═══════════════════════════════════════════════════════════════════════════ + +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) + + + +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") + try: + page.wait_for_selector(".user-menu__trigger", timeout=8000) + except Exception: + pytest.skip("前端未能在 mock 环境下启动(Alpine/资源加载)") + + 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[plat].update(mcp=True, hooks=False, agent=True) + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "MCP") + + def cls(kind): + mock_page.locator(".settings-nav__item", has_text=_NAV[kind]).click() + mock_page.wait_for_timeout(250) + 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[plat]["mcp"] = True + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "MCP") + + 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", 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", 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, plat): + """关 hooks 不影响 mcp / agent(只对 hooks 发一次 DELETE)。""" + mock = _MockBackend() + mock.cfg[plat].update(mcp=True, hooks=True, agent=True) + self._boot(mock_page, static_server, mock) + _open_settings(mock_page, "Hooks") + + self._row(mock_page, plat, "hooks").locator(".toggle").click() + mock_page.wait_for_timeout(600) + + 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_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[plat].update(mcp=True, hooks=True, agent=True) + self._boot(mock_page, static_server, mock) + + mock_page.evaluate( + """async (plat) => { + const s = Alpine.store('app'); + 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) + + 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, plat): + """回归 #1 正向:全未配置时 guideExecute 应把 mcp/hooks/agent 三个都 POST 写入。""" + mock = _MockBackend() + self._boot(mock_page, static_server, mock) + + mock_page.evaluate( + """async (plat) => { + const s = Alpine.store('app'); + 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 == plat and m == "POST") + assert posts == ["agent", "hooks", "mcp"], f"引导写入不全: {mock.calls}" + assert all(mock.cfg[plat][k] for k in ("mcp", "hooks", "agent")) 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}