343 lines
18 KiB
TypeScript
343 lines
18 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"));
|
|
// 统一登录未启用时(v2.9.0):登录页不显示 ZITADEL 按钮;/auth/status 可探测
|
|
await page.waitForTimeout(400); // 等 /auth/status 探测返回
|
|
check(
|
|
"登录页 ZITADEL 按钮按启用状态显示(默认关闭不显示)",
|
|
(await page.locator("button:has-text('ZITADEL 统一登录')").count()) === 0,
|
|
);
|
|
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" },
|
|
{ path: "/logs", expect: "日志", shot: "06b-logs.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());
|
|
check(
|
|
"详情抽屉不再展示「邮箱」/「Message-ID」",
|
|
(await page.locator(".ant-drawer .ant-descriptions-item-label:has-text('邮箱')").count()) === 0 &&
|
|
(await page.locator(".ant-drawer .ant-descriptions-item-label:has-text('Message-ID')").count()) === 0,
|
|
);
|
|
await page.waitForTimeout(500); // 等抽屉滑入动画结束再截图
|
|
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 接入 / ZITADEL 统一登录 / 账号密码 四 tab)",
|
|
tabLabels.length === 4 && tabLabels.some((t) => t.includes("Cloudflare 接入")) && tabLabels.some((t) => t.includes("ZITADEL 统一登录")),
|
|
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` });
|
|
|
|
// ZITADEL 统一登录 tab(v2.9.0):回调地址 /auth/callback + Issuer/Client ID 表单 + 启用/JIT 开关
|
|
await page.click(".ant-modal .ant-tabs-tab:has-text('ZITADEL 统一登录')");
|
|
await page.waitForSelector(".ant-modal :text('Issuer')", { timeout: 8000 });
|
|
const oidcModalText = (await page.locator(".ant-modal").innerText()) ?? "";
|
|
check(
|
|
"ZITADEL tab 显示回调地址 /auth/callback 与启用/JIT 开关",
|
|
oidcModalText.includes("/auth/callback") &&
|
|
(await page.locator(".ant-modal .ant-switch").count()) === 2 &&
|
|
oidcModalText.includes("管理员"),
|
|
oidcModalText.split("\n").slice(0, 6).join(" / "),
|
|
);
|
|
await page.screenshot({ path: `${SHOT_DIR}/09c-settings-oidc.png` });
|
|
|
|
// 账号密码 tab(v2.9.0):用户名修改表单 + 密码表单
|
|
await page.click(".ant-modal .ant-tabs-tab:has-text('账号密码')");
|
|
await page.waitForSelector(".ant-modal :text('新用户名')", { timeout: 8000 });
|
|
const accountModalText = (await page.locator(".ant-modal").innerText()) ?? "";
|
|
check(
|
|
"账号密码 tab 含用户名修改(新用户名 + 当前密码)与密码表单",
|
|
accountModalText.includes("新用户名") && accountModalText.includes("更新用户名") && accountModalText.includes("更新密码"),
|
|
);
|
|
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` });
|
|
}
|
|
|
|
// 6b. 添加账号模态窗口:OAuth 按钮 + API Token 表单同窗平铺,无需切换控件
|
|
await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector(".ant-table", { timeout: 8000 });
|
|
check(
|
|
"账号列表不再有「Email Worker」列(统一命名,无需展示)",
|
|
(await page.locator(".ant-table-thead th:has-text('Email Worker')").count()) === 0,
|
|
);
|
|
await page.click("button:has-text('添加账号')");
|
|
await page.waitForSelector(".ant-modal:has-text('添加 Cloudflare 账号')", { timeout: 8000 });
|
|
check(
|
|
"两种授权方式同窗平铺(两个按钮 + 两个字段同时可见),无切换控件",
|
|
(await page.locator(".ant-modal button:has-text('Cloudflare OAuth 授权')").count()) === 1 &&
|
|
(await page.locator(".ant-modal button:has-text('API Token 授权')").count()) === 1 &&
|
|
(await page.locator(".ant-modal .ant-radio-button-wrapper").count()) === 0 &&
|
|
(await page.locator(".ant-modal .ant-tabs-tab").count()) === 0 &&
|
|
(await page.locator(".ant-modal:visible").count()) === 1,
|
|
);
|
|
check(
|
|
"窗口内不再有说明文字(Alert)与账户 ID 字段",
|
|
(await page.locator(".ant-modal .ant-alert").count()) === 0 &&
|
|
(await page.locator(".ant-modal :text('账户 ID')").count()) === 0,
|
|
);
|
|
check(
|
|
"API Token 表单只含 账户名称 / API Token 两个字段(1 个密码框)",
|
|
(await page.locator(".ant-modal input[type=password]").count()) === 1,
|
|
);
|
|
await page.waitForTimeout(500); // 等开窗动画结束再截图
|
|
await page.screenshot({ path: `${SHOT_DIR}/12a-add-account.png` });
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(400);
|
|
|
|
// 6c. 添加邮箱模态窗口:域名只列已装 Email Workers 的、随机用户名、catch-all 开关禁用地址
|
|
await page.goto(`${BASE}/mailboxes`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector(".ant-table", { timeout: 8000 });
|
|
check(
|
|
"邮箱列表不再有「状态」列(已由「启用」开关承担)",
|
|
(await page.locator(".ant-table-thead th:has-text('状态')").count()) === 0,
|
|
);
|
|
check(
|
|
"地址列带复制按钮",
|
|
(await page.locator(".ant-table-tbody button.copy-btn").count()) > 0,
|
|
);
|
|
await page.click("button:has-text('添加邮箱')");
|
|
await page.waitForSelector(".ant-modal:has-text('添加邮箱')", { timeout: 8000 });
|
|
await page.locator(".ant-modal .ant-select").first().click();
|
|
await page.waitForTimeout(600);
|
|
const domainOptions = await page.locator(".ant-select-dropdown .ant-select-item-option-content").allInnerTexts();
|
|
check("添加邮箱的域名下拉有可选项", domainOptions.length > 0, domainOptions.join(" | "));
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(300);
|
|
// 随机用户名:点一下应把名字库返回的名字填进地址框
|
|
await page.click(".ant-modal button:has(.anticon-thunderbolt)");
|
|
await page.waitForFunction(
|
|
() => [...document.querySelectorAll(".ant-modal input")].some((i) => (i as HTMLInputElement).value?.length),
|
|
undefined,
|
|
{ timeout: 15000 },
|
|
);
|
|
const addressInput = page.locator(".ant-modal input").nth(1);
|
|
const filled = (await addressInput.inputValue()).trim();
|
|
check("随机用户名可从名字库取到并填入地址框", filled.length > 0, filled);
|
|
// 勾上 catch-all 后地址输入框应被禁用
|
|
await page.locator(".ant-modal .ant-switch").first().click();
|
|
await page.waitForTimeout(600);
|
|
check("勾选 catch-all 后地址输入框被禁用", await addressInput.isDisabled());
|
|
await page.screenshot({ path: `${SHOT_DIR}/13-add-mailbox.png` });
|
|
await page.keyboard.press("Escape");
|
|
await page.waitForTimeout(400);
|
|
|
|
// 6d. 「设置 Email Workers」点击后先弹确认框,不直接发请求
|
|
await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector(".ant-table-tbody button", { timeout: 8000 });
|
|
check(
|
|
"域名列表不再有「Email Worker」列(统一命名,状态列已足够)",
|
|
(await page.locator(".ant-table-thead th:has-text('Email Worker')").count()) === 0,
|
|
);
|
|
const setupBtn = page
|
|
.locator(".ant-table-tbody button")
|
|
.filter({ hasText: /设置 Email Workers|重新配置/ })
|
|
.first();
|
|
if ((await setupBtn.count()) > 0) {
|
|
await setupBtn.click();
|
|
await page.waitForSelector(".ant-modal-confirm, .ant-popconfirm, .ant-popover", { timeout: 8000 });
|
|
const confirmText =
|
|
(await page.locator(".ant-modal-confirm, .ant-popconfirm, .ant-popover").first().innerText().catch(() => "")) ?? "";
|
|
check(
|
|
"点「设置 Email Workers」先弹确认框(说明会改 MX 与路由)",
|
|
confirmText.includes("Email Routing"),
|
|
confirmText.split("\n")[0],
|
|
);
|
|
await page.waitForTimeout(600); // 等确认框进场动画,截图才有内容
|
|
await page.screenshot({ path: `${SHOT_DIR}/14-setup-confirm.png` });
|
|
await page.keyboard.press("Escape");
|
|
} else {
|
|
check("点「设置 Email Workers」先弹确认框(说明会改 MX 与路由)", false, "找不到配置按钮");
|
|
}
|
|
|
|
// 6e. 域名列表复制按钮
|
|
await page.goto(`${BASE}/domains`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
|
|
check(
|
|
"域名列表域名右侧带复制按钮",
|
|
(await page.locator(".ant-table-tbody button.copy-btn").count()) > 0,
|
|
);
|
|
await page.waitForTimeout(400);
|
|
await page.screenshot({ path: `${SHOT_DIR}/15-domains-copy.png` });
|
|
|
|
// 6f. 收件箱:发件人/收件人复制按钮 + 「操作」列保留按钮 + 批量保留
|
|
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
|
|
check(
|
|
"列表表头是「收件人」(不再叫「邮箱」)",
|
|
(await page.locator(".ant-table-thead th:has-text('收件人')").count()) === 1 &&
|
|
(await page.locator(".ant-table-thead th:has-text('邮箱')").count()) === 0,
|
|
);
|
|
const inboxCopyBtns = await page.locator(".ant-table-tbody button.copy-btn").count();
|
|
check("发件人与收件人右侧都有复制按钮", inboxCopyBtns >= 2, `copy-btn x${inboxCopyBtns}`);
|
|
check(
|
|
"「操作」列有保留按钮(图钉,无边框)",
|
|
(await page.locator(".ant-table-thead th:has-text('操作')").count()) === 1 &&
|
|
(await page.locator(".ant-table-tbody button.pin-btn").count()) > 0,
|
|
);
|
|
// 勾选一行 → 工具条出现「保留选中」
|
|
await page.locator(".ant-table-tbody .ant-checkbox-input").first().click({ force: true });
|
|
await page.waitForSelector("button:has-text('保留选中')", { timeout: 6000 });
|
|
check("勾选后工具条出现「保留选中(批量保留)」按钮", true);
|
|
await page.waitForTimeout(400);
|
|
await page.screenshot({ path: `${SHOT_DIR}/16-inbox-retain.png` });
|
|
|
|
// 7. 文档页:顶栏文档图标进入 /docs,含 MCP 与 REST API 说明
|
|
await page.goto(`${BASE}/inboxs`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForSelector(".ant-table-tbody", { timeout: 8000 });
|
|
check("顶栏文档图标存在(主题切换左侧)", (await page.locator("button[aria-label='打开文档']").count()) === 1);
|
|
await page.click("button[aria-label='打开文档']");
|
|
await page.waitForURL(`${BASE}/docs`, { timeout: 8000 });
|
|
await page.waitForSelector(".ant-card:has-text('MCP 接入')", { timeout: 8000 });
|
|
const headerTitle = (await page.locator(".app-header .title").textContent()) ?? "";
|
|
const docsText = (await page.locator(".app-content").innerText()) ?? "";
|
|
check(
|
|
"文档页内容完整(认证 / MCP 接入 / REST API / mcp 端点)",
|
|
headerTitle.trim() === "文档" &&
|
|
docsText.includes("认证") &&
|
|
docsText.includes("MCP 接入") &&
|
|
docsText.includes("REST API") &&
|
|
docsText.includes("/api/v1/mcp") &&
|
|
docsText.includes("Authorization: Bearer wmh_"),
|
|
);
|
|
check(
|
|
"文档页两张清单表(6 个 MCP 工具 + 7 个 API 端点)",
|
|
(await page.locator(".ant-table-tbody tr").count()) >= 13,
|
|
);
|
|
await page.waitForTimeout(500);
|
|
await page.screenshot({ path: `${SHOT_DIR}/17-docs.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);
|