添加 Playwright E2E 测试及构建修复

- 修复 vite.config.mjs base 路径为 "./" 解决 file:// 协议加载失败
- 添加测试专用 mockDialog IPC 绕过原生文件选择对话框
- 重建 better-sqlite3 以适配 Electron 内嵌 Node ABI
- 新增 6 个 E2E 测试:模板增删改、批量生成、文件解析

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL 2026-05-05 15:01:13 +08:00
parent fcf3d5c7c2
commit 9738d2cd67
8 changed files with 1329 additions and 8 deletions

View File

@ -55,12 +55,19 @@ ipcMain.handle("template:parse", async (_, filePath) => {
return callPython({ action: "parse_template", file_path: filePath });
});
let _mockDialogPath = null;
if (process.env.NODE_ENV !== "production") {
ipcMain.handle("test:mockDialog", (_, p) => { _mockDialogPath = p; });
}
ipcMain.handle("file:select", async (_, filters = []) => {
if (_mockDialogPath) { const p = _mockDialogPath; _mockDialogPath = null; return p; }
const result = await dialog.showOpenDialog({ filters, properties: ["openFile"] });
return result.canceled ? null : result.filePaths[0];
});
ipcMain.handle("file:selectDir", async () => {
if (_mockDialogPath) { const p = _mockDialogPath; _mockDialogPath = null; return p; }
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
return result.canceled ? null : result.filePaths[0];
});

View File

@ -14,4 +14,7 @@ contextBridge.exposeInMainWorld("api", {
ipcRenderer.invoke("file:copyToAppData", srcPath, templateId),
generate: (req) => ipcRenderer.invoke("generate:run", req),
// 仅测试环境使用:注入下一次对话框返回值
mockDialog: (p) => ipcRenderer.invoke("test:mockDialog", p),
});

1184
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,20 +4,22 @@
"main": "electron/main.js",
"scripts": {
"start": "electron .",
"dev": "concurrently \"npm run dev:renderer\" \"wait-on http://localhost:5173 && electron .\"",
"dev:renderer": "vite --config renderer/vite.config.mjs",
"dev": "concurrently \"npm run dev:renderer\" \"wait-on http://localhost:5173 && NODE_ENV=development electron .\"",
"dev:renderer": "vite --config renderer/vite.config.mjs --port 5173 --strictPort",
"build": "vite build --config renderer/vite.config.mjs && electron-builder",
"test": "cd /Users/mikivl/workspace/excel-batch-editor && python3 -m pytest tests/python/ -v"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.2.0",
"concurrently": "^8.2.2",
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"concurrently": "^8.2.2",
"wait-on": "^7.2.0",
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.2.0",
"electron-rebuild": "^3.2.9",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0"
"vite": "^5.0.0",
"wait-on": "^7.2.0"
},
"dependencies": {
"better-sqlite3": "^11.0.0",

10
playwright.config.js Normal file
View File

@ -0,0 +1,10 @@
const { defineConfig } = require("@playwright/test");
module.exports = defineConfig({
testDir: "./tests/e2e",
timeout: 30000,
reporter: "list",
use: {
headless: false,
},
});

View File

@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
base: "./",
build: {
outDir: "dist",
emptyOutDir: true,

116
tests/e2e/app.spec.js Normal file
View File

@ -0,0 +1,116 @@
const { test, expect, _electron: electron } = require("@playwright/test");
const path = require("path");
const fs = require("fs");
const os = require("os");
const APP_ROOT = path.join(__dirname, "../..");
const FIXTURE_TEMPLATE = path.join(__dirname, "../fixtures/sample_template.xlsx");
const OUTPUT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ebe-test-"));
let app;
let page;
test.beforeAll(async () => {
app = await electron.launch({
args: [APP_ROOT],
// 用 test 模式:不是 productionmock IPC 注册;不是 development加载 dist
env: { ...process.env, NODE_ENV: "test" },
});
page = await app.firstWindow();
page.on("console", (msg) => {
if (msg.type() === "error") console.error("[renderer]", msg.text());
});
await page.waitForSelector("button:has-text('+ 新建模板')", { timeout: 10000 });
});
test.afterAll(async () => {
await app.close();
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
});
test("T1: 显示模板列表页", async () => {
await expect(page.locator("button:has-text('+ 新建模板')")).toBeVisible();
});
test("T2: 新建模板", async () => {
await page.click("button:has-text('+ 新建模板')");
await page.waitForSelector("input[placeholder='如:月度报告模板']", { timeout: 5000 });
await page.fill("input[placeholder='如:月度报告模板']", "测试模板");
await page.fill("input[placeholder='如:财务部']", "测试分组");
// 注入下次对话框返回值,再点击选择文件
await page.evaluate((p) => window.api.mockDialog(p), FIXTURE_TEMPLATE);
await page.click("button:has-text('选择文件')");
// 等待文件路径填入
await page.waitForFunction(
() => Array.from(document.querySelectorAll("input[readonly]")).some(i => i.value.includes(".xlsx")),
{ timeout: 8000 }
);
// 等待 parse 完成
await page.waitForTimeout(2000);
await page.click("button:has-text('保存模板')");
await page.waitForSelector("span:has-text('测试模板')", { timeout: 8000 });
await expect(page.locator("span:has-text('测试模板')")).toBeVisible();
});
test("T3: 模板列表显示详情", async () => {
await page.click("span:has-text('测试模板')");
await expect(page.locator("h1")).toContainText("测试模板", { timeout: 3000 });
await expect(page.locator("button:has-text('批量生成')")).toBeVisible();
});
test("T4: 批量生成 - 手动输入", async () => {
await page.click("button:has-text('批量生成')");
await page.waitForSelector("h1:has-text('批量生成')", { timeout: 5000 });
await page.click("label:has-text('手动填写')");
await page.waitForTimeout(500);
const inputs = page.locator("table tbody tr:first-child input");
const count = await inputs.count();
for (let i = 0; i < count; i++) {
await inputs.nth(i).fill(`${i + 1}`);
}
// 注入输出目录
await page.evaluate((dir) => window.api.mockDialog(dir), OUTPUT_DIR);
await page.click("button:has-text('选择目录')");
await page.waitForFunction(
() => Array.from(document.querySelectorAll("input[readonly]")).some(i => i.value.length > 0 && !i.value.includes(".xlsx")),
{ timeout: 5000 }
);
await page.click("button:has-text('开始生成')");
await expect(page.locator("text=生成结果")).toBeVisible({ timeout: 20000 });
const files = fs.readdirSync(OUTPUT_DIR);
console.log("生成的文件:", files);
expect(files.length).toBeGreaterThan(0);
});
test("T5: 编辑模板名称", async () => {
await page.click("button:has-text('← 返回模板列表')");
await page.waitForSelector("button:has-text('+ 新建模板')", { timeout: 5000 });
await page.click("span:has-text('测试模板')");
await page.waitForSelector("button:has-text('编辑字段映射')", { timeout: 3000 });
await page.click("button:has-text('编辑字段映射')");
await page.waitForSelector("input[placeholder='如:月度报告模板']", { timeout: 5000 });
await page.fill("input[placeholder='如:月度报告模板']", "测试模板(已修改)");
await page.click("button:has-text('保存模板')");
await page.waitForSelector("span:has-text('测试模板(已修改)')", { timeout: 5000 });
await expect(page.locator("span:has-text('测试模板(已修改)')")).toBeVisible();
});
test("T6: 删除模板", async () => {
page.once("dialog", (d) => d.accept());
await page.locator("div.flex.items-center.justify-between button:has-text('删除')").first().click();
await page.waitForTimeout(800);
await expect(page.locator("span:has-text('测试模板(已修改)')")).not.toBeVisible({ timeout: 3000 });
});

Binary file not shown.