Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
29 KiB
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 模块
// src/core/path-entry.ts
export interface PathEntry {
path: string;
enabled: boolean;
}
- Step 2: undo-redo.ts 新增 TOGGLE,更新类型
// 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 中新增:
case OperationType.TOGGLE:
target[rec.index] = rec.oldPaths[0];
break;
在 redo() 的 switch 中新增:
case OperationType.TOGGLE:
target[rec.index] = rec.newPaths[0];
break;
- Step 4: 运行 undo-redo 测试确认类型兼容
npx vitest run tests/unit/undo-redo.test.ts
预期:编译错误,因为测试用 string[] 构建 OpRecord 而现在是 PathEntry[]。下一步 Task 修测试。
- Step 5: Commit
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: 在测试文件顶部添加辅助函数
// 每个测试文件顶部添加
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):
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 末尾添加:
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: 运行所有测试确认编译错误全部消除
npx tsc --noEmit && npx vitest run
预期:测试失败(store 和 core 逻辑还没改),但编译通过。
- Step 5: Commit
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 适配
// 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 接口改为:
export interface ExportData {
system: PathEntry[];
user: PathEntry[];
}
exportToJson / exportToCsv 只取 entry.path(导出不包含 enabled 状态,因为 JSON/CSV 是跨系统的交换格式):
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:
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: 运行测试
npx tsc --noEmit && npx vitest run
预期:import-export 和 path-manager 相关测试失败(返回类型变了)。
- Step 5: Commit
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
// 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
// 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 宏中新增:
commands::disabled::save_disabled_state,
commands::disabled::load_disabled_state,
- Step 5: 编译 + 测试
cd src-tauri && cargo check && cargo test
- Step 6: Commit
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: 接口类型更新
// 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
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 适配
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 适配
deletePaths: (indices, target) => {
// ... 同上,oldPaths = sortedAsc.map(i => list[i])
// list[i] 现在返回 PathEntry,oldPaths 自动成为 PathEntry[]
},
- Step 5: 新增 togglePath
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 合并禁用状态
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 路径
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: 编译 + 类型检查
npx tsc --noEmit
预期:编译通过。
- Step 11: Commit
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 — 新增复选框列
把表头 # 替换为复选框:
// 表头
<th className="w-10 px-2 py-1">
<span className="text-xs opacity-50">#</span>
</th>
每行渲染(在 <td> 序号列之后、路径列之前,新增复选框列):
// 序号列改为更窄
<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 状态添加样式:
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 接口:
interface PathRow {
path: string;
index: number;
enabled: boolean;
}
filtered useMemo:
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 的路径或全部显示(含灰显禁用的)。先保持简单:全部显示,禁用路径同样灰显。
// 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不变
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: 编译检查
npx tsc --noEmit
- Step 6: Commit
git add src/components/ src/hooks/
git commit -m "feat: UI 组件适配 PathEntry — 复选框列、禁用行灰显删除线"
Task 7: 整合测试 + 修正所有失败
Files:
-
Modify: 所有之前修改过的测试文件(微调)
-
Step 1: 运行全部测试
npx vitest run
预期:部分测试失败,因为测试中的期望值与实现不一致。逐个修复。
- Step 2: 修复 import-export 测试
导出测试需改为 ExportData.system.map(e => e.path) 断言:
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测试:新增
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: 全部通过
npx vitest run
预期:所有测试通过。
- Step 5: 编译 + Rust 检查
npx tsc --noEmit && cd src-tauri && cargo check && cargo clippy -- -D warnings
- Step 6: Commit
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
npm install -D @playwright/test
npx playwright install chromium
- Step 2: package.json 新增 script
"test:e2e": "playwright test --config e2e/playwright.config.ts"
- Step 3: 创建 playwright.config.ts
// 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
// 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
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: 编写启动加载测试
// 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 + 撤销测试
// 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: 编写禁用 + 保存测试
// 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: 编写搜索 + 清理测试
// 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
npx playwright test --config e2e/playwright.config.ts
- Step 6: Commit
git add e2e/
git commit -m "test: 新增 4 条 E2E 测试 — 启动加载、CRUD撤销、禁用保存、搜索清理"
Task 10: 最终验证
- Step 1: 全栈检查
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
git push origin v4.2
确认 GitHub Actions CI 全部通过。
- Step 3: Tag release (可选)
如果一切就绪:
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 完成)