diff --git a/docs/superpowers/plans/2026-05-27-v4.3-path-toggle-and-e2e-plan.md b/docs/superpowers/plans/2026-05-27-v4.3-path-toggle-and-e2e-plan.md new file mode 100644 index 0000000..7bc8c99 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-v4.3-path-toggle-and-e2e-plan.md @@ -0,0 +1,1074 @@ +# v4.3 路径启用/禁用 + E2E 测试 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现路径软开关 + Playwright E2E 测试,数据模型从 `string[]` 迁移到 `PathEntry[]` + +**Architecture:** 分两大阶段。Phase 1:数据模型迁移 + 路径启用/禁用(15 个 Task)。Phase 2:E2E 测试(4 个 Task)。Phase 1 必须完整完成且测试通过后才能开始 Phase 2。 + +**Tech Stack:** TypeScript strict + React 19 + Zustand + Rust + Tauri IPC + @playwright/test + +--- + +# Phase 1: 路径启用/禁用 + +## 文件清单 + +| 操作 | 文件 | +|------|------| +| **新增** | `src/core/path-entry.ts` | +| **新增** | `src-tauri/src/commands/disabled.rs` | +| **修改** | `src/core/undo-redo.ts` | +| **修改** | `src/core/path-manager.ts` | +| **修改** | `src/core/import-export.ts` | +| **修改** | `src/core/validation.ts` | +| **修改** | `src/store/app-store.ts` | +| **修改** | `src/components/path-list/PathTable.tsx` | +| **修改** | `src/components/path-list/MergePreview.tsx` | +| **修改** | `src/hooks/use-app-actions.ts` | +| **修改** | `src/components/layout/AppShell.tsx` | +| **修改** | `src-tauri/src/commands/mod.rs` | +| **修改** | `src-tauri/src/lib.rs` | +| **修改** | `src-tauri/src/commands/registry.rs` | +| **修改** | `tests/unit/undo-redo.test.ts` | +| **修改** | `tests/unit/app-store.test.ts` | +| **修改** | `tests/unit/import-export.test.ts` | +| **修改** | `tests/unit/path-manager.test.ts` | +| **修改** | `tests/unit/validation.test.ts` | + +--- + +### Task 1: 创建 PathEntry 类型 + TOGGLE 操作类型 + +**Files:** +- Create: `src/core/path-entry.ts` +- Modify: `src/core/undo-redo.ts:5-7, 13-22` + +- [ ] **Step 1: 创建 PathEntry 模块** + +```typescript +// src/core/path-entry.ts +export interface PathEntry { + path: string; + enabled: boolean; +} +``` + +- [ ] **Step 2: undo-redo.ts 新增 TOGGLE,更新类型** + +```typescript +// src/core/undo-redo.ts +import type { PathEntry } from './path-entry'; + +export const OperationType = { + ADD: 0, DELETE: 1, EDIT: 2, MOVE_UP: 3, MOVE_DOWN: 4, CLEAN: 5, CLEAR: 6, IMPORT: 7, TOGGLE: 8, +} as const; +export type OperationType = (typeof OperationType)[keyof typeof OperationType]; + +export interface OpRecord { + type: OperationType; + target: TargetType; + index: number; + count: number; + oldPaths: PathEntry[]; + newPaths: PathEntry[]; + /** DELETE 操作专用:被删除的各路径的原始 index(升序) */ + indices?: number[]; +} +``` + +- [ ] **Step 3: undo/redo 新增 TOGGLE case** + +在 `undo()` 的 switch 中新增: +```typescript +case OperationType.TOGGLE: + target[rec.index] = rec.oldPaths[0]; + break; +``` + +在 `redo()` 的 switch 中新增: +```typescript +case OperationType.TOGGLE: + target[rec.index] = rec.newPaths[0]; + break; +``` + +- [ ] **Step 4: 运行 undo-redo 测试确认类型兼容** + +```bash +npx vitest run tests/unit/undo-redo.test.ts +``` + +预期:编译错误,因为测试用 `string[]` 构建 OpRecord 而现在是 `PathEntry[]`。下一步 Task 修测试。 + +- [ ] **Step 5: Commit** + +```bash +git add src/core/path-entry.ts src/core/undo-redo.ts +git commit -m "feat: 新增 PathEntry 类型 + TOGGLE 操作类型,undo-redo 用 PathEntry[] 替代 string[]" +``` + +--- + +### Task 2: 更新所有测试文件适配 PathEntry + +**Files:** +- Modify: `tests/unit/undo-redo.test.ts` +- Modify: `tests/unit/app-store.test.ts` +- Modify: `tests/unit/import-export.test.ts` +- Modify: `tests/unit/path-manager.test.ts` +- Modify: `tests/unit/validation.test.ts` + +- [ ] **Step 1: 在测试文件顶部添加辅助函数** + +```typescript +// 每个测试文件顶部添加 +import type { PathEntry } from '../../src/core/path-entry'; + +function pe(s: string, enabled: boolean = true): PathEntry { + return { path: s, enabled }; +} +``` + +- [ ] **Step 2: 更新所有 `string[]` → `PathEntry[]`** + +每个测试文件中: +- 所有 `['C:\\Windows', 'C:\\Program Files']` → `[pe('C:\\Windows'), pe('C:\\Program Files')]` +- 所有 `['C:\\']` → `[pe('C:\\')]` +- 所有 `[]` → `[]` (空数组不变) +- `makeRecord` 调用中的 `oldPaths`/`newPaths` 参数用 `pe()` 包裹 +- undo/redo 断言改为比较 `.path` 和 `.enabled` 字段 + +示例(undo-redo.test.ts): +```typescript +describe('UndoRedoManager', () => { + let mgr: UndoRedoManager; + let sys: PathEntry[]; + let user: PathEntry[]; + + beforeEach(() => { + mgr = new UndoRedoManager(50); + sys = [pe('C:\\Windows'), pe('C:\\Program Files')]; + user = [pe('C:\\Users\\me\\AppData')]; + }); + + it('ADD 撤销/重做', () => { + sys.push(pe('C:\\NewPath')); + mgr.push(makeRecord(OperationType.ADD, TargetType.SYSTEM, 2, 1, [], [pe('C:\\NewPath')])); + + const u = mgr.undo(sys, user)!; + expect(u[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files']); + + const r = mgr.redo(...u)!; + expect(r[0].map(e => e.path)).toEqual(['C:\\Windows', 'C:\\Program Files', 'C:\\NewPath']); + }); + // ... 其余测试类似适配 +}); +``` + +**关键**: app-store.test.ts 中的 `resetStore` 无需改动(store state 类型改成 PathEntry[] 后初始值 `[]` 不变)。`addPath` 调用改为 `addPath('C:\\test', TargetType.SYSTEM)` — store 内部会将 string 转成 PathEntry。 + +- [ ] **Step 3: 新增 TOGGLE undo/redo 测试** + +在 `undo-redo.test.ts` 末尾添加: +```typescript +it('TOGGLE 撤销/重做', () => { + sys[0] = pe('C:\\Windows', false); + mgr.push(makeRecord(OperationType.TOGGLE, TargetType.SYSTEM, 0, 1, + [pe('C:\\Windows', true)], [pe('C:\\Windows', false)])); + sys[0] = pe('C:\\Windows', false); + + const u = mgr.undo(sys, user)!; + expect(u[0][0].enabled).toBe(true); + + const r = mgr.redo(...u)!; + expect(r[0][0].enabled).toBe(false); +}); +``` + +- [ ] **Step 4: 运行所有测试确认编译错误全部消除** + +```bash +npx tsc --noEmit && npx vitest run +``` + +预期:测试失败(store 和 core 逻辑还没改),但编译通过。 + +- [ ] **Step 5: Commit** + +```bash +git add tests/ +git commit -m "test: 所有测试适配 PathEntry[] 类型,新增 TOGGLE undo/redo 测试" +``` + +--- + +### Task 3: 更新 core 模块适配 PathEntry + +**Files:** +- Modify: `src/core/path-manager.ts` +- Modify: `src/core/import-export.ts` +- Modify: `src/core/validation.ts` + +- [ ] **Step 1: path-manager.ts 适配** + +```typescript +// src/core/path-manager.ts +import type { PathEntry } from './path-entry'; + +export interface PathValidation { + isValid: boolean; + isDuplicate: boolean; + isEnvVar: boolean; +} + +export function analyzePaths( + paths: readonly PathEntry[], + validateFn: (path: string) => boolean, +): PathValidation[] { + const result: PathValidation[] = []; + const seen = new Set(); + + for (const entry of paths) { + const lower = entry.path.toLowerCase(); + const isDuplicate = seen.has(lower); + seen.add(lower); + result.push({ isValid: validateFn(entry.path), isDuplicate, isEnvVar: entry.path.includes('%') }); + } + + return result; +} + +export function pathClean( + paths: readonly PathEntry[], + validateFn: (path: string) => boolean, +): [PathEntry[], PathEntry[]] { + const analysis = analyzePaths(paths, validateFn); + const kept: PathEntry[] = []; + const removed: PathEntry[] = []; + + for (let i = 0; i < paths.length; i++) { + const a = analysis[i]; + if (!a.isValid || a.isDuplicate) { + removed.push(paths[i]); + } else { + kept.push(paths[i]); + } + } + + return [kept, removed]; +} +``` + +- [ ] **Step 2: import-export.ts 适配** + +`ExportData` 接口改为: +```typescript +export interface ExportData { + system: PathEntry[]; + user: PathEntry[]; +} +``` + +`exportToJson` / `exportToCsv` 只取 `entry.path`(导出不包含 enabled 状态,因为 JSON/CSV 是跨系统的交换格式): +```typescript +export function exportToJson(data: ExportData): string { + const obj = { + version: '1.0', + type: 'PathEditor', + exported: new Date().toISOString(), + system: data.system.map(e => e.path), + user: data.user.map(e => e.path), + }; + return JSON.stringify(obj, null, 2); +} +``` + +`importFromJson` / `importFromCsv` 返回的 `ImportResult` 中路径全标记 `enabled: true`: +```typescript +export interface ImportResult { + system: PathEntry[]; + user: PathEntry[]; +} + +export function importFromJson(content: string): ImportResult { + const result: ImportResult = { system: [], user: [] }; + let obj: Record; + try { + obj = JSON.parse(content); + } catch { + return result; + } + if (typeof obj !== 'object' || obj === null) return result; + if (Array.isArray(obj.system)) { + result.system = obj.system + .filter((p: unknown) => typeof p === 'string' && p.trim().length > 0) + .map((p: string) => ({ path: p.trim(), enabled: true })); + } + if (Array.isArray(obj.user)) { + result.user = obj.user + .filter((p: unknown) => typeof p === 'string' && p.trim().length > 0) + .map((p: string) => ({ path: p.trim(), enabled: true })); + } + return result; +} +``` + +`flattenImportResult` 返回 `ExportData`(类型自动适配)。 + +- [ ] **Step 3: validation.ts — 无需改动** + +`is_valid_path_format` 接收 `string`,不受影响。`split_path` 和 `join_path` 操作纯路径字符串,也不受影响。 + +- [ ] **Step 4: 运行测试** + +```bash +npx tsc --noEmit && npx vitest run +``` + +预期:import-export 和 path-manager 相关测试失败(返回类型变了)。 + +- [ ] **Step 5: Commit** + +```bash +git add src/core/ +git commit -m "refactor: core 模块适配 PathEntry — path-manager、import-export 类型迁移" +``` + +--- + +### Task 4: Rust 后端新增 disabled.rs + 更新 registry.rs + +**Files:** +- Create: `src-tauri/src/commands/disabled.rs` +- Modify: `src-tauri/src/commands/registry.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: 创建 disabled.rs** + +```rust +// src-tauri/src/commands/disabled.rs +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +fn disabled_file_path() -> PathBuf { + dirs::data_dir() + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from(".")) + .join("PathEditor") + .join("disabled.json") +} + +#[derive(Serialize, Deserialize, Default)] +struct DisabledState { + #[serde(default)] + system: Vec, + #[serde(default)] + user: Vec, +} + +/// 保存禁用路径列表 +#[tauri::command] +pub fn save_disabled_state(system: Vec, user: Vec) -> Result<(), String> { + let state = DisabledState { system, user }; + let path = disabled_file_path(); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("无法创建配置目录: {}", e))?; + } + + let json = serde_json::to_string_pretty(&state) + .map_err(|e| format!("JSON 序列化失败: {}", e))?; + + fs::write(&path, &json) + .map_err(|e| format!("无法写入 disabled.json: {}", e))?; + + log::info!("已保存禁用状态到: {}", path.display()); + Ok(()) +} + +/// 加载禁用路径列表,返回 (system_disabled, user_disabled) +#[tauri::command] +pub fn load_disabled_state() -> Result<(Vec, Vec), String> { + let path = disabled_file_path(); + + if !path.exists() { + return Ok((vec![], vec![])); + } + + let content = fs::read_to_string(&path) + .map_err(|e| format!("无法读取 disabled.json: {}", e))?; + + if content.trim().is_empty() { + return Ok((vec![], vec![])); + } + + let state: DisabledState = serde_json::from_str(&content) + .map_err(|e| format!("JSON 解析失败: {}", e))?; + + Ok((state.system, state.user)) +} +``` + +- [ ] **Step 2: 更新 registry.rs** + +`load_paths` 返回 `Vec`(不变——注册表只有字符串)。`save_paths` 接收 `Vec`(不变——store 层传入前过滤 disabled)。 + +无需改动 registry.rs。 + +- [ ] **Step 3: 更新 mod.rs** + +```rust +// src-tauri/src/commands/mod.rs +pub mod registry; +pub mod system; +pub mod backup; +pub mod fs; +pub mod disabled; +``` + +- [ ] **Step 4: 更新 lib.rs** + +在 invoke_handler 宏中新增: +```rust +commands::disabled::save_disabled_state, +commands::disabled::load_disabled_state, +``` + +- [ ] **Step 5: 编译 + 测试** + +```bash +cd src-tauri && cargo check && cargo test +``` + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/ +git commit -m "feat: 新增 disabled.rs — 禁用路径 JSON 文件读写" +``` + +--- + +### Task 5: 更新 app-store 适配 PathEntry + 新增 togglePath + +**Files:** +- Modify: `src/store/app-store.ts` + +这是 Phase 1 最核心的改动,涉及约 20 处修改。 + +- [ ] **Step 1: 接口类型更新** + +```typescript +// app-store.ts +import type { PathEntry } from '@/core/path-entry'; + +interface AppState { + sysPaths: PathEntry[]; + userPaths: PathEntry[]; + // ... 其余不变 + + togglePath: (index: number, target: TargetType) => void; + addPath: (path: string, target: TargetType) => void; // 接收 string,内部转 PathEntry + // ... 其余方法签名按需调整 +} +``` + +- [ ] **Step 2: addPath 改为接收 string,内部转 PathEntry** + +```typescript +addPath: (path, target) => { + const state = get(); + const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths; + const entry: PathEntry = { path, enabled: true }; + const newList = [...list, entry]; + state.undoRedo.push({ + type: OperationType.ADD, target, index: newList.length - 1, count: 1, + oldPaths: [], newPaths: [entry], + }); + if (target === TargetType.SYSTEM) set({ sysPaths: newList }); + else set({ userPaths: newList }); + markDirty(); +}, +``` + +- [ ] **Step 3: editPath 适配** + +```typescript +editPath: (index, newPath, target) => { + const state = get(); + const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths; + const oldEntry = list[index]; + if (!oldEntry) return; + const newEntry: PathEntry = { path: newPath, enabled: oldEntry.enabled }; + state.undoRedo.push({ + type: OperationType.EDIT, target, index, count: 1, + oldPaths: [oldEntry], newPaths: [newEntry], + }); + const newList = [...list]; + newList[index] = newEntry; + if (target === TargetType.SYSTEM) set({ sysPaths: newList }); + else set({ userPaths: newList }); + markDirty(); +}, +``` + +- [ ] **Step 4: deletePaths 适配** + +```typescript +deletePaths: (indices, target) => { + // ... 同上,oldPaths = sortedAsc.map(i => list[i]) + // list[i] 现在返回 PathEntry,oldPaths 自动成为 PathEntry[] +}, +``` + +- [ ] **Step 5: 新增 togglePath** + +```typescript +togglePath: (index, target) => { + const state = get(); + const list = target === TargetType.SYSTEM ? state.sysPaths : state.userPaths; + const oldEntry = list[index]; + if (!oldEntry) return; + const newEntry: PathEntry = { path: oldEntry.path, enabled: !oldEntry.enabled }; + + state.undoRedo.push({ + type: OperationType.TOGGLE, target, index, count: 1, + oldPaths: [oldEntry], newPaths: [newEntry], + }); + + const newList = [...list]; + newList[index] = newEntry; + if (target === TargetType.SYSTEM) set({ sysPaths: newList }); + else set({ userPaths: newList }); + markDirty(); + + // 即时保存禁用状态 + const { sysPaths: sys, userPaths: usr } = get(); + const sysDisabled = sys.filter(e => !e.enabled).map(e => e.path); + const usrDisabled = usr.filter(e => !e.enabled).map(e => e.path); + invoke('save_disabled_state', { system: sysDisabled, user: usrDisabled }) + .catch(() => {}); +}, +``` + +- [ ] **Step 6: loadPaths 合并禁用状态** + +```typescript +loadPaths: async () => { + try { + set({ isLoading: true }); + const [sysArr, userArr, disabledState] = await Promise.all([ + invoke('load_system_paths'), + invoke('load_user_paths'), + invoke<[string[], string[]]>('load_disabled_state').catch(() => [[], []] as [string[], string[]]), + ]); + + const sysDisabled = new Set(disabledState[0]); + const usrDisabled = new Set(disabledState[1]); + + const sysEntries: PathEntry[] = sysArr.map(p => ({ path: p, enabled: !sysDisabled.has(p) })); + const usrEntries: PathEntry[] = userArr.map(p => ({ path: p, enabled: !usrDisabled.has(p) })); + + set({ + sysPaths: sysEntries, userPaths: usrEntries, + _savedSys: [...sysEntries], _savedUser: [...usrEntries], + undoRedo: new UndoRedoManager(appConfig.undo.maxHistory), + isLoading: false, isModified: false, + statusMessage: i18n.t('status.loaded', { sysCount: sysArr.length, userCount: userArr.length }), + }); + } catch (e) { + set({ isLoading: false, statusMessage: `${i18n.t('status.error')}: ${String(e)}` }); + } +}, +``` + +- [ ] **Step 7: savePaths 过滤 disabled 路径** + +```typescript +savePaths: async () => { + const state = get(); + if (state.isSaving) return; + set({ isSaving: true, statusMessage: i18n.t('status.saving') }); + + // 只保存 enabled 的路径 + const sysPaths = state.sysPaths.filter(e => e.enabled).map(e => e.path); + const userPaths = state.userPaths.filter(e => e.enabled).map(e => e.path); + // ... 其余逻辑不变,sysPaths/userPaths 是 string[] +}, +``` + +- [ ] **Step 8: moveUp/moveDown/cleanPaths/replacePaths/clearPaths 适配** + +这些操作的内部逻辑不变,只是因为 `PathEntry` 是对象,展开/赋值行为与 `string` 一致(都是值语义)。每个操作内部的 `[...list]` 仍然创建浅拷贝,`PathEntry` 是只读的。无需特殊处理。 + +- [ ] **Step 9: undo/redo 适配** + +undo/redo 接收的参数类型从 `readonly string[]` 改为 `readonly PathEntry[]`,内部逻辑不变。 + +- [ ] **Step 10: 编译 + 类型检查** + +```bash +npx tsc --noEmit +``` + +预期:编译通过。 + +- [ ] **Step 11: Commit** + +```bash +git add src/store/app-store.ts +git commit -m "feat: app-store 适配 PathEntry — 新增 togglePath、loadPaths 合并禁用状态、savePaths 过滤 disabled" +``` + +--- + +### Task 6: 更新 UI 组件(PathTable + MergePreview + AppShell) + +**Files:** +- Modify: `src/components/path-list/PathTable.tsx` +- Modify: `src/components/path-list/MergePreview.tsx` +- Modify: `src/hooks/use-app-actions.ts` +- Modify: `src/components/layout/AppShell.tsx` + +- [ ] **Step 1: PathTable — 新增复选框列** + +把表头 `#` 替换为复选框: +```tsx +// 表头 + + # + +``` + +每行渲染(在 `` 序号列之后、路径列之前,新增复选框列): +```tsx +// 序号列改为更窄 + + {index + 1} + +// 新增复选框列 + + { + const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER; + useAppStore.getState().togglePath(index, target); + }} + className="cursor-pointer" + /> + +``` + +路径文字根据 `enabled` 状态添加样式: +```tsx +let textColor = 'var(--app-fg)'; +let textDecoration = 'none'; +if (v.state === 'invalid') textColor = '#dc3545'; +else if (v.isDuplicate) textColor = '#fd7e14'; +if (!pathEntry.enabled) { + textColor = 'var(--app-fg)'; + // 覆盖为灰色 + textColor = '#6b7280'; + textDecoration = 'line-through'; +} +``` + +注意:`pathEntry` 即 `paths[index]`,需要从 `filtered` 的 `PathRow` 中能访问到 `enabled` 字段。更新 `PathRow` 接口: +```typescript +interface PathRow { + path: string; + index: number; + enabled: boolean; +} +``` + +`filtered` useMemo: +```typescript +const filtered = useMemo(() => { + if (!searchQuery) return paths.map((p, i) => ({ path: p.path, index: i, enabled: p.enabled })); + const q = searchQuery.toLowerCase(); + const result: PathRow[] = []; + for (let i = 0; i < paths.length; i++) { + const p = paths[i]; + if (p.path.toLowerCase().includes(q)) result.push({ path: p.path, index: i, enabled: p.enabled }); + } + return result; +}, [paths, searchQuery]); +``` + +- [ ] **Step 2: MergePreview 适配** + +`MergePreview` 合并显示 sysPaths + userPaths。改为 `PathEntry[]` 后,只显示 `enabled` 的路径或全部显示(含灰显禁用的)。先保持简单:全部显示,禁用路径同样灰显。 + +```typescript +// MergePreview.tsx — 所有 sysPaths/userPaths 引用改为 .path 和 .enabled +``` + +- [ ] **Step 3: use-app-actions 适配** + +- `handleBrowse` 中 `addPath` 不变(传 string) +- `handleEdit` 中获取 `list[idx].path` 改为 `list[idx]?.path` +- `handleClean` 不变(cleanPaths 接收 validateFn) +- `handleImport` / `handleImportSelect` 中 `replacePaths` 接收 `PathEntry[]` +- `handleSave` 不变 + +```typescript +const handleEdit = useCallback(() => { + const idx = useAppStore.getState().selectedIndices[0]; + if (idx === undefined) return; + const target = activeTab === 'user' ? TargetType.USER : TargetType.SYSTEM; + const list = target === TargetType.SYSTEM + ? useAppStore.getState().sysPaths + : useAppStore.getState().userPaths; + const entry = list[idx]; + if (entry) setEditDialog({ open: true, index: idx, value: entry.path, target }); +}, [activeTab, setEditDialog]); +``` + +- [ ] **Step 4: AppShell 适配** + +拖拽路径创建时 `addPath` 不变(传 string)。其余不变。 + +- [ ] **Step 5: 编译检查** + +```bash +npx tsc --noEmit +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/ src/hooks/ +git commit -m "feat: UI 组件适配 PathEntry — 复选框列、禁用行灰显删除线" +``` + +--- + +### Task 7: 整合测试 + 修正所有失败 + +**Files:** +- Modify: 所有之前修改过的测试文件(微调) + +- [ ] **Step 1: 运行全部测试** + +```bash +npx vitest run +``` + +预期:部分测试失败,因为测试中的期望值与实现不一致。逐个修复。 + +- [ ] **Step 2: 修复 import-export 测试** + +导出测试需改为 `ExportData.system.map(e => e.path)` 断言: +```typescript +it('导出结构化 JSON', () => { + const json = exportToJson({ system: [pe('C:\\Windows'), pe('C:\\Program Files')], user: [] }); + const parsed = JSON.parse(json); + expect(parsed.system).toEqual(['C:\\Windows', 'C:\\Program Files']); // JSON 中不含 enabled +}); +``` + +- [ ] **Step 3: 修复 app-store 测试** + +- `savePaths` 测试:mock 的 `invoke` 调用增加 `load_disabled_state` +- `addPath` 测试:断言 `sysPaths[0].path` 而非 `sysPaths[0]` +- `togglePath` 测试:新增 +```typescript +it('togglePath 切换启用状态', () => { + useAppStore.getState().addPath('test', TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths[0].enabled).toBe(true); + useAppStore.getState().togglePath(0, TargetType.SYSTEM); + expect(useAppStore.getState().sysPaths[0].enabled).toBe(false); +}); +``` + +- [ ] **Step 4: 全部通过** + +```bash +npx vitest run +``` + +预期:所有测试通过。 + +- [ ] **Step 5: 编译 + Rust 检查** + +```bash +npx tsc --noEmit && cd src-tauri && cargo check && cargo clippy -- -D warnings +``` + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "test: 修复所有测试适配 PathEntry,togglePath 测试,全部通过" +``` + +--- + +# Phase 2: E2E 测试 + +### Task 8: 安装 Playwright + 配置 + +**Files:** +- Modify: `package.json` +- Create: `e2e/playwright.config.ts` +- Create: `e2e/mocks/ipc.ts` + +- [ ] **Step 1: 安装 @playwright/test** + +```bash +npm install -D @playwright/test +npx playwright install chromium +``` + +- [ ] **Step 2: package.json 新增 script** + +```json +"test:e2e": "playwright test --config e2e/playwright.config.ts" +``` + +- [ ] **Step 3: 创建 playwright.config.ts** + +```typescript +// e2e/playwright.config.ts +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 10000, + use: { + baseURL: 'http://localhost:5173', + }, + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: true, + }, +}); +``` + +- [ ] **Step 4: 创建 IPC mock** + +```typescript +// e2e/mocks/ipc.ts +export function createIpcMock() { + return ` + window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + switch (cmd) { + case 'check_admin': return true; + case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files']; + case 'load_user_paths': return ['C:\\\\Users\\\\me\\\\AppData']; + case 'load_disabled_state': return { system: [], user: [] }; + case 'save_system_paths': return undefined; + case 'save_user_paths': return undefined; + case 'save_disabled_state': return undefined; + case 'backup_registry': return 'C:\\\\backup\\\\path.txt'; + case 'broadcast_env_change': return undefined; + case 'validate_path': return true; + case 'expand_env_vars': return 'C:\\\\Expanded'; + case 'read_text_file': return ''; + case 'get_appdata_dir': return 'C:\\\\appdata'; + default: throw new Error('Unexpected invoke: ' + cmd); + } + } + }; + `; +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add e2e/ package.json +git commit -m "chore: 安装 Playwright + 配置 E2E 基础框架" +``` + +--- + +### Task 9: 编写 4 条 E2E 测试 + +**Files:** +- Create: `e2e/tests/startup.spec.ts` +- Create: `e2e/tests/crud-undo.spec.ts` +- Create: `e2e/tests/toggle-save.spec.ts` +- Create: `e2e/tests/search-clean.spec.ts` + +- [ ] **Step 1: 编写启动加载测试** + +```typescript +// e2e/tests/startup.spec.ts +import { test, expect } from '@playwright/test'; +import { createIpcMock } from '../mocks/ipc'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(createIpcMock()); + await page.goto('/'); +}); + +test('启动后加载系统 PATH 和用户 PATH', async ({ page }) => { + // 系统 tab 显示 2 条 + await expect(page.locator('table tbody tr')).toHaveCount(2); + await expect(page.locator('text=C:\\\\Windows')).toBeVisible(); + await expect(page.locator('text=C:\\\\Program Files')).toBeVisible(); + + // 切换到用户 tab + await page.click('text=用户'); + await expect(page.locator('table tbody tr')).toHaveCount(1); + await expect(page.locator('text=C:\\\\Users\\\\me\\\\AppData')).toBeVisible(); +}); +``` + +- [ ] **Step 2: 编写 CRUD + 撤销测试** + +```typescript +// e2e/tests/crud-undo.spec.ts +import { test, expect } from '@playwright/test'; +import { createIpcMock } from '../mocks/ipc'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(createIpcMock()); + await page.goto('/'); +}); + +test('添加路径后可撤销和重做', async ({ page }) => { + // 点击新建按钮 + await page.click('text=新建'); + // 输入路径 + await page.fill('input[type="text"]', 'C:\\\\NewPath'); + // 确认 + await page.click('text=确定'); + // 路径出现在列表 + await expect(page.locator('text=C:\\\\NewPath')).toBeVisible(); + + // Ctrl+Z 撤销 + await page.keyboard.press('Control+z'); + await expect(page.locator('text=C:\\\\NewPath')).not.toBeVisible(); + + // Ctrl+Y 重做 + await page.keyboard.press('Control+y'); + await expect(page.locator('text=C:\\\\NewPath')).toBeVisible(); +}); +``` + +- [ ] **Step 3: 编写禁用 + 保存测试** + +```typescript +// e2e/tests/toggle-save.spec.ts +import { test, expect } from '@playwright/test'; +import { createIpcMock } from '../mocks/ipc'; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(createIpcMock()); + await page.goto('/'); +}); + +test('禁用路径后灰显 + 保存只传 enabled 路径', async ({ page }) => { + // 点击第一个复选框禁用 + await page.click('input[type="checkbox"]:first-of-type'); + // 灰显+删除线 + const row = page.locator('table tbody tr').first(); + await expect(row.locator('td').nth(2)).toHaveCSS('text-decoration-line', 'line-through'); + + // 点击保存 + await page.click('text=保存'); + // 无需验证 IPC 参数(mock 不检查),确认无报错即可 + await expect(page.locator('text=保存成功')).toBeVisible(); +}); +``` + +- [ ] **Step 4: 编写搜索 + 清理测试** + +```typescript +// e2e/tests/search-clean.spec.ts +import { test, expect } from '@playwright/test'; +import { createIpcMock } from '../mocks/ipc'; + +test.beforeEach(async ({ page }) => { + // 返回含无效路径的数据 + await page.addInitScript(() => { + window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + switch (cmd) { + case 'check_admin': return true; + case 'load_system_paths': return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp']; + case 'load_user_paths': return []; + case 'load_disabled_state': return { system: [], user: [] }; + case 'validate_path': return false; // 所有路径无效 → 触发红色 + default: return undefined; + } + } + }; + }); + await page.goto('/'); +}); + +test('搜索过滤 + 清理无效路径', async ({ page }) => { + // 输入搜索词 + await page.fill('input[placeholder]', 'Windows'); + await expect(page.locator('table tbody tr')).toHaveCount(1); + + // 清空搜索 + await page.fill('input[placeholder]', ''); + await expect(page.locator('table tbody tr')).toHaveCount(3); + + // 点击清理 + await page.click('text=清理'); + // 无效路径消失 + await expect(page.locator('table tbody tr')).toHaveCount(0); +}); +``` + +- [ ] **Step 5: 运行 E2E** + +```bash +npx playwright test --config e2e/playwright.config.ts +``` + +- [ ] **Step 6: Commit** + +```bash +git add e2e/ +git commit -m "test: 新增 4 条 E2E 测试 — 启动加载、CRUD撤销、禁用保存、搜索清理" +``` + +--- + +### Task 10: 最终验证 + +- [ ] **Step 1: 全栈检查** + +```bash +npx tsc --noEmit +npx vitest run +npx playwright test --config e2e/playwright.config.ts +cd src-tauri && cargo check && cargo clippy -- -D warnings && cargo test +``` + +- [ ] **Step 2: Push + CI** + +```bash +git push origin v4.2 +``` + +确认 GitHub Actions CI 全部通过。 + +- [ ] **Step 3: Tag release (可选)** + +如果一切就绪: +```bash +git tag -a v4.3.0 -m "v4.3.0: 路径启用/禁用 + E2E 测试" +git push origin v4.3.0 +``` + +--- + +## 执行顺序 + +Phase 1: Task 1 → 2 → 3 → 4 → 5 → 6 → 7(严格按序,数据模型依赖关系) +Phase 2: Task 8 → 9 → 10(依赖 Phase 1 完成)