Files
PathEditor/docs/superpowers/plans/2026-05-27-v4.3-path-toggle-and-e2e-plan.md
T

1075 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 2E2E 测试(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<string>();
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<string, unknown>;
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<String>,
#[serde(default)]
user: Vec<String>,
}
/// 保存禁用路径列表
#[tauri::command]
pub fn save_disabled_state(system: Vec<String>, user: Vec<String>) -> 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<String>, Vec<String>), 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<String>`(不变——注册表只有字符串)。`save_paths` 接收 `Vec<String>`(不变——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] 现在返回 PathEntryoldPaths 自动成为 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<string[]>('load_system_paths'),
invoke<string[]>('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
// 表头
<th className="w-10 px-2 py-1">
<span className="text-xs opacity-50">#</span>
</th>
```
每行渲染(在 `<td>` 序号列之后、路径列之前,新增复选框列):
```tsx
// 序号列改为更窄
<td className="w-6 px-1 py-0.5 text-xs opacity-50 text-right" style={{ color: 'var(--app-fg)' }}>
{index + 1}
</td>
// 新增复选框列
<td className="w-6 px-1 py-0.5">
<input
type="checkbox"
checked={pathEntry.enabled}
onChange={() => {
const target = tabId === 'system' ? TargetType.SYSTEM : TargetType.USER;
useAppStore.getState().togglePath(index, target);
}}
className="cursor-pointer"
/>
</td>
```
路径文字根据 `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<PathRow[]>(() => {
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: 修复所有测试适配 PathEntrytogglePath 测试,全部通过"
```
---
# 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 完成)