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

29 KiB
Raw Permalink Blame History

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 模块

// 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_pathjoin_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] 现在返回 PathEntryoldPaths 自动成为 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';
}

注意:pathEntrypaths[index],需要从 filteredPathRow 中能访问到 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 适配

  • handleBrowseaddPath 不变(传 string

  • handleEdit 中获取 list[idx].path 改为 list[idx]?.path

  • handleClean 不变(cleanPaths 接收 validateFn

  • handleImport / handleImportSelectreplacePaths 接收 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: 修复所有测试适配 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

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