/** * 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` }); } // 6b. 添加账号模态窗口:OAuth / API Token 两种授权方式 + 各自说明 await page.goto(`${BASE}/accounts`, { waitUntil: "domcontentloaded" }); await page.waitForSelector(".ant-table", { timeout: 8000 }); await page.click("button:has-text('添加账号')"); await page.waitForSelector(".ant-modal:has-text('添加 Cloudflare 账号')", { timeout: 8000 }); const addTabs = await page.locator(".ant-modal .ant-tabs-tab").allInnerTexts(); check( "添加账号模态窗口含 OAuth / API Token 两种授权方式", addTabs.some((t) => t.includes("OAuth")) && addTabs.some((t) => t.includes("API Token")), addTabs.join(" | "), ); check( "OAuth 方式带说明(不需在本系统保存密钥)", (await page.locator(".ant-modal :text('不需要在本机保存任何密钥')").count()) > 0, ); await page.screenshot({ path: `${SHOT_DIR}/12a-add-account-oauth.png` }); await page.click(".ant-modal .ant-tabs-tab:has-text('API Token')"); await page.waitForSelector(".ant-modal :text('账户名称')", { timeout: 8000 }); check( "API Token 方式含 账户名称 / 账户 ID / API Token 三个字段", (await page.locator(".ant-modal :text('账户 ID')").count()) > 0 && (await page.locator(".ant-modal input[type=password]").count()) > 0, ); check( "API Token 方式带说明(会先校验再接入)", (await page.locator(".ant-modal :text('确认可用后才建账号')").count()) > 0, ); await page.screenshot({ path: `${SHOT_DIR}/12b-add-account-token.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, "找不到配置按钮"); } 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);