/** * # 脚本 ui-test-admin.mjs * 说明:管理员后台 GUI 测试(Playwright + 系统 Brave)。 * 覆盖:admin 登录、后台面板、提交审批(Approve → 工具上线验证 → * 后台删除工具)、拒绝流(Reject 清理其余 E2E 提交)、认领审批、 * 评论隐藏/恢复开关、用户列表。 * 运行:cd packages/core && bun scripts/ui-test-admin.mjs [baseURL] */ import { chromium } from "playwright"; import { mkdirSync } from "node:fs"; const BASE = (process.argv[2] ?? "http://localhost:3001").replace(/\/$/, ""); const SHOT_DIR = "/tmp/ui-test"; mkdirSync(SHOT_DIR, { recursive: true }); const results = []; const step = async (name, fn) => { try { await fn(); results.push(["PASS", name]); console.log(`PASS ${name}`); } catch (e) { results.push(["FAIL", name]); console.log(`FAIL ${name}\n ${String(e).split("\n")[0]}`); } }; const shot = (page, name) => page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: false }); const gotoWait = async (p, url) => { await p.goto(url, { waitUntil: "domcontentloaded" }); await p.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {}); await p.waitForTimeout(800); }; /* 重试直到 check() 通过:admin 页水合完成前点击会被吞, 每次重试间隔 2s,最多 attempts 次 */ const retryUntil = async (p, act, check, attempts = 4) => { for (let i = 0; i < attempts; i++) { await act(); try { await check(); return; } catch (e) { if (i === attempts - 1) throw e; } await p.waitForTimeout(2000); } }; const browser = await chromium.launch({ executablePath: "/opt/brave.com/brave/brave", args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const page = await ( await browser.newContext({ viewport: { width: 1440, height: 900 } }) ).newPage(); page.setDefaultTimeout(30000); // window.confirm 自动接受(后台删除工具用) page.on("dialog", (d) => d.accept()); /* ---------- 1. 管理员登录 ---------- */ await step("admin 登录成功", async () => { await gotoWait(page, `${BASE}/login`); await page.getByLabel(/email/i).fill("admin@aidirectory.dev"); // 安全审计时已轮换的管理员密码(非种子密码 password123) await page.getByLabel(/password/i).fill("PRmeSJeyr73x1JjW-1Aa1!"); await page.getByRole("button", { name: /sign in/i }).first().click(); await page.waitForURL((u) => !u.pathname.includes("/login"), { timeout: 20000, }); }); await step("/admin 后台面板渲染(侧栏 + 统计)", async () => { await gotoWait(page, `${BASE}/admin`); await page.getByText("Admin Panel").first().waitFor(); await shot(page, "20-admin-dashboard"); }); /* ---------- 2. 提交审批:批准最新 E2E 提交并验证上线 ---------- */ let approvedSlug = ""; await step("审批提交:Approve 最新 E2E 提交", async () => { await gotoWait(page, `${BASE}/admin/submissions`); const cards = () => page .locator("div.rounded-xl.border") .filter({ hasText: "E2E Test Tool" }) .filter({ has: page.getByRole("button", { name: "Approve" }) }); if ((await cards().count()) === 0) throw new Error("未找到待审 E2E 提交卡片"); const card = cards().first(); approvedSlug = (await card.locator("h3").first().innerText()) .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); console.log(` 批准: ${approvedSlug}`); await retryUntil( page, () => card.getByRole("button", { name: "Approve" }).click(), () => page.getByText(/Approved and published/i).first().waitFor({ timeout: 4000 }) ); await shot(page, "22-admin-submission-approved"); }); await step("审批后工具已上线(前台详情页可访问)", async () => { await gotoWait(page, `${BASE}/tools/${approvedSlug}`); const h1 = await page.getByRole("heading", { level: 1 }).first().innerText(); if (!/E2E Test Tool/i.test(h1)) throw new Error(`详情页标题异常: ${h1}`); await shot(page, "23-e2e-tool-live"); }); /* ---------- 3. 拒绝流:清理其余 E2E 提交 ---------- */ await step("拒绝流:Reject 其余 E2E 提交(附管理员备注)", async () => { await gotoWait(page, `${BASE}/admin/submissions`); // 重新加载,避开批准后的重渲染 let rejected = 0; for (let i = 0; i < 20; i++) { const cards = page .locator("div.rounded-xl.border") .filter({ hasText: "E2E Test Tool" }) .filter({ has: page.getByRole("button", { name: "Approve" }) }); if ((await cards.count()) === 0) break; const card = cards.first(); await retryUntil( page, async () => { await card.getByRole("button", { name: "Reject" }).click(); const note = page.getByPlaceholder(/Reason for rejection/i); await note.waitFor({ timeout: 8000 }); await note.fill("E2E 测试数据清理"); await page.getByRole("button", { name: /Confirm reject/i }).click(); }, () => page .locator("div.rounded-xl.border") .filter({ hasText: "E2E Test Tool" }) .filter({ has: page.getByRole("button", { name: "Approve" }) }) .first() .waitFor({ state: "detached", timeout: 4000 }) .catch(async (e) => { // 等待的第一条卡片可能已被处理(列表前移),改为校验总数下降 const n = await page .locator("div.rounded-xl.border") .filter({ hasText: "E2E Test Tool" }) .filter({ has: page.getByRole("button", { name: "Approve" }) }) .count(); if (n >= 20 - rejected) throw e; }) ); rejected++; await page.waitForTimeout(800); } console.log(` 共拒绝 ${rejected} 条`); if (rejected === 0) throw new Error("一条都没有拒绝(预期应清理多条)"); await shot(page, "24-admin-submissions-rejected"); }); /* ---------- 4. 后台删除已上线的 E2E 工具 ---------- */ await step("后台删除 E2E 工具(含确认弹窗)", async () => { await gotoWait(page, `${BASE}/admin/tools`); await page.getByRole("heading", { name: /Tools/i }).first().waitFor(); const search = page.getByPlaceholder(/Search tool/i); if (await search.count()) await search.fill("E2E Test Tool"); await page.waitForTimeout(1000); const row = () => page.locator("tr", { hasText: "E2E Test Tool" }).first(); if ((await row().count()) === 0) throw new Error("后台工具列表未找到 E2E 工具"); await retryUntil( page, () => row().getByRole("button", { name: /^Delete/i }).first().click(), async () => { await page.waitForTimeout(1000); if ((await row().count()) > 0) throw new Error("行仍在"); } ); await shot(page, "25-admin-tool-deleted"); // 删除后详情页应 404 await gotoWait(page, `${BASE}/tools/${approvedSlug}`); const body = await page.locator("body").innerText(); if (/E2E Test Tool/i.test(body) && !/not found|404/i.test(body)) throw new Error("工具删除后详情页仍可访问"); }); /* ---------- 5. 认领审批(幂等:已批准则视为通过) ---------- */ await step("认领审批:Approve alice 对 ChatGPT 的认领", async () => { await gotoWait(page, `${BASE}/admin/claims`); const card = page .locator("div.rounded-xl.border") .filter({ hasText: "Alice Chen" }) .filter({ has: page.getByRole("button", { name: "Approve" }) }) .first(); if ((await card.count()) === 0) { console.log(" (无待审认领,可能此前已批准)"); } else { await retryUntil( page, () => card.getByRole("button", { name: "Approve" }).click(), () => page .locator("div.rounded-xl.border") .filter({ hasText: "Alice Chen" }) .filter({ has: page.getByRole("button", { name: "Approve" }) }) .first() .waitFor({ state: "detached", timeout: 4000 }) ); } await shot(page, "26-admin-claim-approved"); }); /* ---------- 6. 评论管理开关 ---------- */ await step("评论管理:Hide → Restore 一条评论", async () => { await gotoWait(page, `${BASE}/admin/reviews`); const card = () => page .locator("div.rounded-xl.border") .filter({ has: page.getByRole("button", { name: "Hide" }) }) .first(); if ((await card().count()) === 0) throw new Error("未找到可隐藏的评论"); const content = (await card().locator("p").last().innerText()).slice(0, 20); await retryUntil( page, () => card().getByRole("button", { name: "Hide" }).click(), () => page.getByText(/Review hidden/i).first().waitFor({ timeout: 4000 }) ); // 状态徽标变 hidden,按钮变 Restore const hiddenCard = page .locator("div.rounded-xl.border") .filter({ hasText: content }) .filter({ has: page.getByRole("button", { name: "Restore" }) }) .first(); await hiddenCard.waitFor({ timeout: 15000 }); await retryUntil( page, () => hiddenCard.getByRole("button", { name: "Restore" }).click(), () => page.getByText(/Review restored/i).first().waitFor({ timeout: 4000 }) ); await shot(page, "27-admin-review-toggled"); }); /* ---------- 7. 用户列表 ---------- */ await step("用户列表渲染(含演示用户)", async () => { await gotoWait(page, `${BASE}/admin/users`); const body = await page.locator("body").innerText(); if (!body.includes("alice@demo.dev")) throw new Error("用户列表缺 alice"); await shot(page, "28-admin-users"); }); await browser.close(); const fails = results.filter(([s]) => s === "FAIL"); console.log( `\n===== 管理员后台测试(${BASE}):${results.length - fails.length}/${results.length} 通过 =====` ); if (fails.length) process.exit(1);