import { test, expect, type Page, type Locator } from "@playwright/test"; import * as fs from "node:fs"; import * as path from "node:path"; type TimingRow = { step: string; ms: number; ok: boolean; note?: string; }; const rows: TimingRow[] = []; /** antd 两字中文按钮常插入空格(登录→登 录) */ function btn(page: Page, label: string): Locator { const re = new RegExp(`^${label.split("").map(escapeRe).join("\\s*")}$`); return page.getByRole("button", { name: re }); } function escapeRe(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } async function timed( step: string, fn: () => Promise, ): Promise { const t0 = Date.now(); try { const note = (await fn()) || undefined; rows.push({ step, ms: Date.now() - t0, ok: true, note }); } catch (e) { const msg = e instanceof Error ? e.message : String(e); rows.push({ step, ms: Date.now() - t0, ok: false, note: msg.slice(0, 240) }); throw e; } } async function waitQuiet(page: Page, ms = 150) { await page.waitForLoadState("domcontentloaded").catch(() => undefined); await page.waitForTimeout(ms); } async function login(page: Page) { await page.goto("/login"); await expect(btn(page, "登录")).toBeVisible(); // Form 已有 initialValues;再 fill 会叠字 const user = page.getByLabel("用户名"); await user.click(); await user.fill(""); await user.fill("admin"); const pwd = page.getByLabel("密码"); await pwd.click(); await pwd.fill(""); await pwd.fill("admin123"); await btn(page, "登录").click(); await expect(page).toHaveURL(/\/mails/, { timeout: 20_000 }); await expect(page.getByRole("heading", { name: "邮件预报" })).toBeVisible({ timeout: 20_000, }); } test.describe("UX timing smoke", () => { test("modules + buttons", async ({ page }) => { test.setTimeout(240_000); rows.length = 0; const writeReport = () => { const outDir = path.join(process.cwd(), "data", "logs"); fs.mkdirSync(outDir, { recursive: true }); const total = rows.reduce((s, r) => s + r.ms, 0); const summary = { at: new Date().toISOString(), total_ms: total, fail: rows.filter((r) => !r.ok).length, rows, }; fs.writeFileSync( path.join(outDir, "ux-timing-smoke.json"), JSON.stringify(summary, null, 2), "utf8", ); const md = [ "# UX Timing Smoke", "", `| step | ms | ok | note |`, `|---|---:|:---:|---|`, ...rows.map( (r) => `| ${r.step} | ${r.ms} | ${r.ok ? "Y" : "N"} | ${(r.note ?? "").replace(/\|/g, "/")} |`, ), "", `**total**: ${total} ms`, "", `**判定**:单步导航 <1.5s 优,<3s 可;刷新/查询 <2s 优;弹窗开闭 <1s 优`, ].join("\n"); fs.writeFileSync(path.join(outDir, "ux-timing-smoke.md"), md, "utf8"); }; try { await timed("01 打开登录页", async () => { await page.goto("/login"); await expect(btn(page, "登录")).toBeVisible({ timeout: 15_000 }); }); await timed("02 登录 → /mails", async () => { await login(page); return page.url(); }); await timed("03 邮件列表:刷新", async () => { const refreshBtn = page.getByRole("button", { name: /刷\s*新/ }).first(); await expect(refreshBtn).toBeVisible({ timeout: 20_000 }); // 等非 loading 再点,避免叠请求 await expect( page.getByRole("button", { name: /^刷\s*新$/ }), ).toBeVisible({ timeout: 30_000 }); const waitApi = page.waitForResponse( (r) => r.url().includes("/api/mails") && r.request().method() === "GET" && r.ok(), { timeout: 20_000 }, ); await page.getByRole("button", { name: /^刷\s*新$/ }).click(); const res = await waitApi; await expect( page.getByRole("button", { name: /^刷\s*新$/ }), ).toBeVisible({ timeout: 30_000 }); return `status=${res.status()}`; }); await timed("04 邮件列表:查询", async () => { await expect(page.getByRole("button", { name: /^查\s*询$/ })).toBeVisible({ timeout: 20_000, }); await page.getByPlaceholder("来源 / 主题 / 正文").fill("MATU"); const wait1 = page.waitForResponse( (r) => r.url().includes("/api/mails") && r.request().method() === "GET" && r.status() === 200, { timeout: 20_000 }, ); await page.getByRole("button", { name: /^查\s*询$/ }).click(); await wait1; await expect(page.getByRole("button", { name: /^查\s*询$/ })).toBeVisible({ timeout: 20_000, }); await page.getByPlaceholder("来源 / 主题 / 正文").fill(""); const wait2 = page.waitForResponse( (r) => r.url().includes("/api/mails") && r.request().method() === "GET" && r.status() === 200, { timeout: 20_000 }, ); await page.getByRole("button", { name: /^查\s*询$/ }).click(); await wait2; await expect(page.getByRole("button", { name: /^查\s*询$/ })).toBeVisible({ timeout: 20_000, }); }); await timed("05 邮件列表:类型筛选", async () => { await page.getByRole("combobox").nth(0).click(); await page.getByTitle("新增预报").click(); await waitQuiet(page); const clear = page.locator(".ant-select-clear").first(); if (await clear.isVisible().catch(() => false)) await clear.click(); await waitQuiet(page); }); await timed("06 导航:邮件列表 → 日志", async () => { await page.getByRole("link", { name: "日志" }).click(); await expect(page).toHaveURL(/\/logs/); await expect(page.getByText("日志与审计")).toBeVisible({ timeout: 15_000, }); await waitQuiet(page); }); await timed("07 日志:查询", async () => { const query = btn(page, "查询").first(); await expect(query).toBeVisible(); await query.click(); await waitQuiet(page); }); await timed("08 日志:切操作审计 Tab", async () => { await page.getByRole("tab", { name: /操作审计/ }).click(); await waitQuiet(page); await page.getByRole("tab", { name: "导入日志" }).click(); await waitQuiet(page); }); await timed("09 导航:日志 → 设置", async () => { await page.getByRole("link", { name: "设置" }).click(); await expect(page).toHaveURL(/\/settings/); await expect(page.getByText("邮箱绑定")).toBeVisible({ timeout: 15_000 }); await waitQuiet(page); }); await timed("10 设置:打开授权码绑定弹窗并取消", async () => { await page.getByRole("button", { name: "授权码绑定" }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await btn(page, "取消").click(); await expect(page.getByRole("dialog")).toBeHidden({ timeout: 5_000 }); }); await timed("11 设置:打开 OAuth 配置弹窗并关闭", async () => { const oauthBtn = page.getByRole("button", { name: /配置 OAuth/ }); await expect(oauthBtn).toBeVisible(); await oauthBtn.click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); // antd Modal:优先点取消;Escape 在部分版本不关 const cancel = dialog.getByRole("button", { name: /^取\s*消$|^Cancel$/ }); if (await cancel.isVisible().catch(() => false)) { await cancel.click(); } else { await dialog.locator(".ant-modal-close").click(); } await expect(dialog).toBeHidden({ timeout: 5_000 }); }); await timed("12 设置:CC 测试登录(mock)", async () => { const ccTest = page.getByRole("button", { name: /测试\s*登录/ }); await expect(ccTest).toBeVisible(); await ccTest.click(); await waitQuiet(page, 500); }); await timed("13 导航:设置 → 邮件列表", async () => { await page.getByRole("link", { name: "邮件列表" }).click(); await expect(page).toHaveURL(/\/mails$/); await expect(page.getByRole("heading", { name: "邮件预报" })).toBeVisible({ timeout: 15_000, }); await waitQuiet(page); }); await timed("14 打开第一封邮件详情", async () => { const detail = page.getByRole("button", { name: "详情" }).first(); await expect(detail).toBeVisible({ timeout: 15_000 }); await detail.click(); await expect(page).toHaveURL(/\/mails\/[^/]+$/); await expect(page.getByRole("button", { name: /回列表/ })).toBeVisible({ timeout: 15_000, }); await waitQuiet(page); }); await timed("15 详情:打开改类型弹窗并取消", async () => { const b = page.getByRole("button", { name: /改\s*类型/ }); if (!(await b.isVisible().catch(() => false))) { return "skip: no override button"; } await b.click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); const cancel = dialog.getByRole("button", { name: /^取\s*消$|^Cancel$/ }); if (await cancel.isVisible().catch(() => false)) { await cancel.click(); } else { await dialog.locator(".ant-modal-close").click(); } await expect(dialog).toBeHidden({ timeout: 5_000 }); }); await timed("16 详情:回列表", async () => { await page.getByRole("button", { name: /回列表/ }).click(); await expect(page).toHaveURL(/\/mails$/); await waitQuiet(page); }); await timed("17 模块来回切换 x3(mails↔logs↔settings)", async () => { const t0 = Date.now(); for (let i = 0; i < 3; i++) { await page.getByRole("link", { name: "日志" }).click(); await expect(page).toHaveURL(/\/logs/); await page.getByRole("link", { name: "设置" }).click(); await expect(page).toHaveURL(/\/settings/); await page.getByRole("link", { name: "邮件列表" }).click(); await expect(page).toHaveURL(/\/mails$/); } await waitQuiet(page); return `avg_roundtrip≈${Math.round((Date.now() - t0) / 3)}ms`; }); await timed("18 退出登录", async () => { await btn(page, "退出").click(); await expect(page).toHaveURL(/\/login/, { timeout: 15_000 }); }); } finally { writeReport(); } expect(rows.every((r) => r.ok)).toBeTruthy(); }); });