# 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 完成)