|
|
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<string | void>,
|
|
|
): Promise<void> {
|
|
|
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();
|
|
|
});
|
|
|
});
|