Files
wmailhub/tests/ui-smoke.ts
T
bigcode 3be6e483b4 feat: v2.3.0 设置内配置 Cloudflare OAuth 客户端 + 全列表批量删除
- 设置新增「Cloudflare 接入」tab:Client ID / Client Secret(Secret AES-GCM 加密入库,
  只回显末 4 位),并展示可复制的授权回调地址;settings 优先、env 变量兜底,
  保存后立即生效无需重新部署;DELETE /api/settings/cf-oauth 清除
- 四个资源列表(账号/域名/邮箱/收件箱)加复选框批量选择与批量删除
- 新增 POST /api/{accounts,domains,mailboxes,emails}/batch-delete:
  账号软删+撤销凭据;域名/邮箱级联删除其下邮件;邮件连 R2 原文与附件一并清理
- 抽出 deleteEmailById / deleteEmailsByMailbox / revokeAccount 供单条与批量共用
- 点击复选框不再触发行点击(避免误开详情抽屉)
- 新增 tests/batch.test.ts(6 例)与 settings OAuth 凭据用例(2 例);ui-smoke 扩到 17 步
2026-09-30 15:53:18 +08:00

155 lines
7.9 KiB
TypeScript

/**
* UI 冒烟测试(Playwright headless):
* 登录 admin/admin → 概览/账号/域名/邮箱/邮件 各页渲染 → 列表复选框批量选择 →
* 设置模态窗口(含 Cloudflare 接入 tab)→ 生成 Key → 截图。
* bun tests/ui-smoke.ts
*/
import { chromium } from "playwright-core";
const BASE = process.env.WMAILHUI_BASE ?? "http://127.0.0.1:18787";
const EXEC = `${process.env.HOME}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`;
const SHOT_DIR = "/tmp/wmh-shots";
await Bun.$`mkdir -p ${SHOT_DIR}`.quiet();
const browser = await chromium.launch({
executablePath: EXEC,
headless: true,
args: ["--no-sandbox"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(`pageerror: ${err.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(`console: ${msg.text()}`);
});
const results: { step: string; ok: boolean; note?: string }[] = [];
const check = (step: string, ok: boolean, note?: string) => {
results.push({ step, ok, note });
console.log(`${ok ? "✓" : "✗"} ${step}${note ? ` — ${note}` : ""}`);
};
// 1. 登录页
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("input[placeholder='用户名']", { timeout: 8000 });
check("登录页渲染(含 LOGO/表单)", (await page.title()).includes("WmailHub"));
await page.screenshot({ path: `${SHOT_DIR}/01-login.png` });
// 2. 登录 admin/admin
await page.fill("input[placeholder='用户名']", "admin");
await page.fill("input[placeholder='密码']", "admin");
await page.click("button[type=submit]");
await page.waitForURL(`${BASE}/`, { timeout: 8000 });
await page.waitForSelector("text=概览", { timeout: 8000 });
check("登录成功进入概览页", true);
// 品牌区版本号 tag(v2.1.0,来源 package.json)
const versionTag = await page.locator(".brand-version").textContent();
check("LOGO 版本号 tag 显示", Boolean(versionTag && /^v\d+\.\d+\.\d+$/.test(versionTag.trim())), versionTag?.trim());
await page.waitForTimeout(600);
await page.screenshot({ path: `${SHOT_DIR}/02-dashboard.png` });
// 3. 各页面导航渲染
const pages = [
{ path: "/accounts", expect: "账号", shot: "03-accounts.png" },
{ path: "/domains", expect: "域名", shot: "04-domains.png" },
{ path: "/mailboxes", expect: "邮箱", shot: "05-mailboxes.png" },
{ path: "/inboxs", expect: "收件箱", shot: "06-inboxs.png" },
];
for (const p of pages) {
await page.goto(`${BASE}${p.path}`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(`text=${p.expect}`, { timeout: 8000 });
await page.waitForTimeout(500);
const hasTable = (await page.locator(".ant-table").count()) > 0;
const hasSearch = (await page.locator("input[placeholder*='搜索']").count()) > 0;
check(`页面 ${p.path} 渲染${hasTable ? "(含表格)" : ""}${hasSearch ? "(含搜索框)" : ""}`, hasTable);
await page.screenshot({ path: `${SHOT_DIR}/${p.shot}` });
}
// 4. 域名详情抽屉(点击第一行:Email Workers + Webhook 配置)
await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
await page.waitForSelector("text=域名级 Webhook", { timeout: 8000 });
check("域名详情抽屉(Email Workers + Webhook 配置)", true);
await page.screenshot({ path: `${SHOT_DIR}/07-domain-drawer.png` });
await page.keyboard.press("Escape");
// 5. 邮件详情(点击第一行)
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-row", { timeout: 8000 });
await page.locator(".ant-table-row").first().click();
await page.waitForSelector(".ant-drawer-open", { timeout: 8000 });
const drawerText = (await page.locator(".ant-drawer-title").textContent()) ?? "";
check("邮件详情抽屉打开(标题=邮件主题)", drawerText.trim().length > 0, drawerText.trim());
await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` });
await page.keyboard.press("Escape");
// 6. 设置模态窗口:基本设置 + 生成 MCP Key
// 头像下拉里打开设置(设置按钮已移入下拉菜单)
await page.locator(".ant-dropdown-trigger, .app-header .ant-dropdown-trigger").first().hover();
await page.waitForSelector(".ant-dropdown:not(.ant-dropdown-hidden)", { timeout: 8000 });
await page.click(".ant-dropdown .ant-dropdown-menu-item:has-text('设置')");
await page.waitForSelector(".ant-modal:has-text('全局 Webhook')", { timeout: 8000 });
const tabLabels = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts();
check(
"设置模态窗口经头像下拉打开(全局 Webhook / Cloudflare 接入 / 账号密码 三 tab)",
tabLabels.length === 3 && tabLabels.some((t) => t.includes("Cloudflare 接入")),
tabLabels.join(" | "),
);
await page.screenshot({ path: `${SHOT_DIR}/09-settings-webhook.png` });
// Cloudflare 接入 tab:回调地址可复制 + Client ID/Secret 表单
await page.click(".ant-modal .ant-tabs-tab:has-text('Cloudflare 接入')");
await page.waitForSelector(".ant-modal :text('Client ID')", { timeout: 8000 });
// 回调地址是 Typography.Paragraph(className=mono);同模态内还有 API Key 的 ****last4,按文本过滤
const redirectText =
(await page.locator(".ant-modal .mono").filter({ hasText: "/api/oauth/cloudflare/callback" }).first().textContent()) ?? "";
check("Cloudflare 接入 tab 显示授权回调地址", redirectText.includes("/api/oauth/cloudflare/callback"), redirectText.trim());
await page.screenshot({ path: `${SHOT_DIR}/09b-settings-cloudflare.png` });
await page.click(".ant-modal .ant-tabs-tab:has-text('全局 Webhook')");
await page.waitForSelector(".ant-modal :text('Webhook URL')", { timeout: 8000 });
// 单一 API/MCP Key:在 全局 Webhook tab 内生成
const genBtn = page.getByRole("button", { name: /生成/ });
await genBtn.waitFor({ state: "visible", timeout: 8000 });
await genBtn.click();
await page.waitForSelector(".ant-modal:has-text('仅显示一次')", { timeout: 8000 });
const keyText = await page.locator(".ant-modal:has-text('仅显示一次') .mono").first().textContent();
check("生成 API/MCP Key 弹窗(wmh_ 32 位,单一 Key)", Boolean(keyText && keyText.trim().startsWith("wmh_") && keyText.trim().length === 32), keyText?.trim());
await page.screenshot({ path: `${SHOT_DIR}/10-api-key-created.png` });
// 主题切换按钮存在(头像左侧)
const themeBtn = await page.locator("button[aria-label='切换主题']").count();
check("主题切换按钮存在(头像左侧)", themeBtn === 1);
// 列表复选框批量选择:四个列表页勾选表头全选后出现批量操作条
for (const path of ["/accounts", "/domains", "/mailboxes", "/inboxs"]) {
await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" });
await page.waitForSelector(".ant-table-thead .ant-checkbox-input", { timeout: 8000 });
const boxes = page.locator(".ant-table-thead .ant-checkbox-input");
if ((await boxes.count()) === 0) {
check(`${path} 复选框批量选择`, false, "无表头复选框");
continue;
}
await boxes.first().click({ force: true });
const barVisible = await page
.waitForSelector("button:has-text('批量删除')", { timeout: 6000 })
.then(() => true)
.catch(() => false);
const selectedText = (await page.locator("text=/已选 \\d+ 项/").first().textContent().catch(() => "")) ?? "";
check(`${path} 复选框批量选择`, barVisible && /已选/.test(selectedText), selectedText.trim());
if (path === "/mailboxes") await page.screenshot({ path: `${SHOT_DIR}/11-batch-selected.png` });
}
await browser.close();
const failed = results.filter((r) => !r.ok);
console.log(`\n==== UI 冒烟结果: ${results.length - failed.length}/${results.length} 通过 ====`);
if (errors.length > 0) {
console.log("浏览器错误:");
for (const e of errors.slice(0, 10)) console.log(" ", e);
}
process.exit(failed.length > 0 ? 1 : 0);