/** * # 脚本 ui-test-user.mjs * 说明:浏览器 GUI 级用户后台全流程测试(Playwright + 系统 Brave)。 * 覆盖:登录(错误/正确密码)、dashboard、发布工具(含重复检测)、 * 投票/取消、收藏增删 + dashboard 查看、评论增/改/删/有帮助、认领、登出、 * 未登录拦截(评论/提交跳登录)。 * 运行:cd packages/core && bun scripts/ui-test-user.mjs [baseURL] * 例:bun scripts/ui-test-user.mjs https://ai-stack-bdev.opentest.cc */ 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 STAMP = new Date() .toISOString() .replace(/[-:T]/g, "") .slice(0, 12) .slice(2); const TOOL_NAME = `E2E Test Tool ${STAMP}`; const TOOL_URL = `https://e2e-test-${STAMP}.example.com`; const TOOL_HREF = "/tools/chatgpt"; // 3 条他人评论、无认领、alice 无评论 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 }); /* 等待 better-auth 会话请求返回,规避“session 未解析被误判未登录”的竞态 */ const waitSession = async (p) => { await p .waitForResponse( (r) => r.url().includes("/api/auth/get-session") && r.status() < 400, { timeout: 20000 } ) .catch(() => {}); await p.waitForTimeout(500); }; /* goto + 等待网络空闲:工具页/提交页水合后才有可交互组件,直接交互会抖动 */ const gotoWait = async (p, url) => { await p.goto(url, { waitUntil: "domcontentloaded" }); await p.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {}); await p.waitForTimeout(800); }; /* 水合标记:登录状态下工具页水合完成后必然发起 server action POST (getVotedToolIds / getFavoriteToolIds)。在导航前先挂监听, 收到 POST 即证明 React 已水合、会话已解析,可安全点击。 */ const withHydration = async (p, navAction) => { const marker = p .waitForResponse( (r) => r.request().method() === "POST" && !r.url().includes("cdn-cgi") && !r.url().includes("/api/auth/"), { timeout: 30000 } ) .catch(() => null); await navAction(); await marker; await p.waitForTimeout(300); }; const gotoHydrated = (p, url) => withHydration(p, () => p.goto(url, { waitUntil: "domcontentloaded" })); const reloadHydrated = (p) => withHydration(p, () => p.reload({ waitUntil: "domcontentloaded" })); /* 等待页面上某个 server action 响应返回(避免 reload 取消在途写入) */ const waitActionSettled = (p) => p .waitForResponse( (r) => r.request().method() === "POST" && !r.url().includes("cdn-cgi") && !r.url().includes("/api/auth/"), { timeout: 30000 } ) .catch(() => null); /* 轮询按钮文本直到匹配正则:React 乐观更新 + server action 回填期间 文本会变化,locator.waitFor 对文本过滤存在竞态,手动轮询更稳 */ const waitText = async (p, btn, re, timeoutMs = 20000) => { const deadline = Date.now() + timeoutMs; let last = ""; while (Date.now() < deadline) { last = await btn.innerText().catch((e) => `(err:${String(e).split("\n")[0].slice(0, 80)})`); if (re.test(last)) return last; await p.waitForTimeout(300); } throw new Error( `按钮文本等待超时(期望 ${re}),当前: ${JSON.stringify(last)},URL: ${p.url()}` ); }; /* 带重试的状态切换点击:React 重渲染瞬间点击可能落在已卸载节点上被吞, 若 2.5s 内状态未按预期变化则重试点击,命中后等网络空闲(写入落库) */ const clickToggle = async (p, btn, re, attempts = 3) => { for (let i = 0; i < attempts; i++) { await btn.click(); try { const text = await waitText(p, btn, re, 2500); await p .waitForLoadState("networkidle", { timeout: 15000 }) .catch(() => {}); return text; } catch (e) { if (i === attempts - 1) throw e; } } }; /* 读取稳定值:reload 后按钮先渲染 SSR 默认态,客户端拉取投票/收藏 状态后才翻转;连续两次读数一致才视为最终状态 */ const readStable = async (p, btn, rounds = 12) => { let prev = await btn.innerText(); for (let i = 0; i < rounds; i++) { await p.waitForTimeout(1000); const cur = await btn.innerText(); if (cur === prev) return cur; prev = cur; } return prev; }; const browser = await chromium.launch({ executablePath: "/opt/brave.com/brave/brave", args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); page.setDefaultTimeout(30000); /* 主区域按钮:详情页主操作按钮(button 变体)带可见文字, 候选卡(card)与行内(icon)变体只有 aria-label/数字、innerText 无文字, 用 :has-text 精确匹配主按钮,避免点错目标 */ const mainVoteBtn = (p) => p.locator('button:has-text("Upvote"), button:has-text("Upvoted")').first(); const mainFavBtn = (p) => p .locator('button:has-text("Save to Favorites"), button:has-text("Remove from Favorites")') .first(); const mainClaimBtn = (p) => p.locator('button:has-text("Claim this tool")').first(); /* ---------- 1. 登录 ---------- */ await step("打开首页", async () => { await page.goto(BASE, { waitUntil: "domcontentloaded" }); await page.getByRole("heading", { level: 1 }).first().waitFor(); }); await step("未登录访问 /dashboard 被重定向", async () => { await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" }); await page.waitForURL(/login/); }); await step("登录页渲染(邮箱/密码/按钮)", async () => { await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); await page.getByLabel(/email/i).waitFor(); await shot(page, "01-login-page"); }); await step("错误密码登录 → 显示错误", async () => { await page.getByLabel(/email/i).fill("alice@demo.dev"); await page.getByLabel(/password/i).fill("wrong-password-123"); await page.getByRole("button", { name: /sign in/i }).first().click(); await page.getByText(/failed|invalid|incorrect/i).first().waitFor(); await shot(page, "02-login-wrong-password"); }); await step("正确密码登录成功(alice@demo.dev)", async () => { await page.getByLabel(/password/i).fill("password123"); await page.getByRole("button", { name: /sign in/i }).first().click(); await page.waitForURL((u) => !u.pathname.includes("/login"), { timeout: 20000, }); }); let toolName = ""; await step("dashboard 显示欢迎语与用户名", async () => { await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" }); await page.getByText("Welcome back").waitFor(); await page.getByText("Alice Chen").first().waitFor(); await shot(page, "03-dashboard-alice"); }); /* ---------- 2. 工具详情页:投票 / 收藏 / 评论 / 认领 ---------- */ let favBefore = false; await step("打开工具详情页并记录工具名", async () => { await gotoHydrated(page, `${BASE}${TOOL_HREF}`); toolName = (await page.getByRole("heading", { level: 1 }).first().innerText()).trim(); console.log(` 测试工具: ${toolName} (${TOOL_HREF})`); }); await step("dashboard 收藏基线(操作前不含该工具)", async () => { await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" }); await page.getByText("My Favorites").waitFor(); await page.waitForLoadState("networkidle").catch(() => {}); const body = await page.locator("body").innerText(); favBefore = body.includes(toolName); console.log(` 操作前收藏含该工具: ${favBefore}`); await shot(page, "04-favorites-before"); }); let voteCount0; await step("投票:Upvote → 计数 +1 变 Upvoted(刷新后状态持久)", async () => { await gotoHydrated(page, `${BASE}${TOOL_HREF}`); const btn = mainVoteBtn(page); await btn.waitFor(); // 若已有投票(种子数据),先取消并等落库,保证从 Upvote 状态开始 for (let i = 0; i < 3; i++) { const cur = await readStable(page, btn); if (!/Upvoted/i.test(cur)) break; await clickToggle(page, btn, /^Upvote ·/); await reloadHydrated(page); await btn.waitFor(); } voteCount0 = parseInt((await readStable(page, btn)).replace(/\D+/g, ""), 10) || 0; await clickToggle(page, btn, /Upvoted ·/); await reloadHydrated(page); await btn.waitFor(); const after = await readStable(page, btn); const n = parseInt(after.replace(/\D+/g, ""), 10) || 0; if (!/Upvoted/i.test(after)) throw new Error("刷新后按钮未保持 Upvoted"); if (n !== voteCount0 + 1) throw new Error(`计数 ${voteCount0} → ${n}`); await shot(page, "05-voted"); }); await step("取消投票:刷新后计数恢复", async () => { const btn = mainVoteBtn(page); await clickToggle(page, btn, /^Upvote ·/); await reloadHydrated(page); await btn.waitFor(); const label = await readStable(page, btn); const n = parseInt(label.replace(/\D+/g, ""), 10) || 0; if (/Upvoted/i.test(label)) throw new Error("刷新后仍为 Upvoted"); if (n !== voteCount0) throw new Error(`计数应恢复 ${voteCount0},实际 ${n}`); }); await step("再次投票(保留 +1 状态)", async () => { const btn = mainVoteBtn(page); await clickToggle(page, btn, /Upvoted ·/); }); await step("收藏:Save to Favorites → Remove from Favorites", async () => { const add = mainFavBtn(page); await add.waitFor(); await clickToggle(page, add, /Remove from Favorites/i); }); await step("写评论:5 星 + 标题 + 正文 → 发布成功", async () => { await waitSession(page); await page.getByRole("button", { name: /Write a review/i }).first().click(); const group = page.getByRole("radiogroup", { name: "rating" }); await group.waitFor(); await group.getByRole("button", { name: "5" }).click(); await page.getByPlaceholder(/Sum it up in one line/i).fill("E2E 自动化测试评论"); await page .getByPlaceholder(/What did you use it for/i) .fill( "这是一条由 Playwright 自动化测试发布的评论,用于验证评论功能增删改查。" ); await page.getByPlaceholder(/What do you like/i).fill("自动化测试优点"); await page.getByPlaceholder(/What could be better/i).fill("自动化测试缺点"); await page.getByRole("button", { name: /Publish review/i }).click(); await page.getByText(/用于验证评论功能增删改查/).first().waitFor(); await shot(page, "06-review-created"); }); await step("有帮助:点击他人评论 Helpful → 计数 +1", async () => { // 跳过自己刚发布的评论,找他人评论的 Helpful 按钮 const articles = page.locator("#reviews article"); const cnt = await articles.count(); let target = null; for (let i = 0; i < cnt; i++) { const a = articles.nth(i); if (!(await a.innerText()).includes("Alice Chen")) { const h = a.getByRole("button", { name: /Helpful · /i }).first(); if (await h.count()) { target = h; break; } } } if (!target) { console.log(" (无他人评论,跳过计数断言)"); return; } const before = parseInt((await target.innerText()).replace(/\D+/g, ""), 10) || 0; await target.click(); // Helpful 是开关:可能 +1(标记)或 -1(取消标记),断言计数发生±1变化 const up = new RegExp(`Helpful · ${before + 1}`); const down = new RegExp(`Helpful · ${Math.max(before - 1, 0)}`); await page.getByRole("button", { name: up }).first().waitFor({ timeout: 5000 }).catch(() => {}); const now = parseInt((await target.innerText()).replace(/\D+/g, ""), 10) || 0; if (Math.abs(now - before) !== 1) throw new Error(`Helpful 计数未变化: ${before} → ${now}`); }); await step("认领工具:填写身份与验证说明 → 发送成功", async () => { await mainClaimBtn(page).click(); await page.getByPlaceholder(/Your role/i).fill("QA Engineer"); await page .getByPlaceholder(/How should we verify you/i) .fill("E2E 自动化测试认领请求,用于验证 claim 流程是否可用。"); await page.getByRole("button", { name: /Send claim/i }).click(); await page .getByText(/Claim sent!|already have a pending claim/i) .first() .waitFor(); await shot(page, "07-claim-sent"); }); await step("dashboard 收藏列表出现该工具", async () => { await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" }); await page.getByText("My Favorites").waitFor(); await page.waitForLoadState("networkidle").catch(() => {}); const body = await page.locator("body").innerText(); if (!body.includes(toolName)) throw new Error(`收藏列表未包含 "${toolName}"`); await shot(page, "08-favorites-after"); }); await step("编辑评论:修改正文 → Update review 生效", async () => { await gotoHydrated(page, `${BASE}${TOOL_HREF}`); await page.getByRole("button", { name: /Write a review/i }).first().waitFor(); await waitSession(page); await page.getByRole("button", { name: /Write a review/i }).first().click(); await page.getByRole("button", { name: /Update review/i }).waitFor(); await page .getByPlaceholder(/What did you use it for/i) .fill("编辑后的评论内容:Playwright E2E 更新验证 OK。"); await page.getByRole("button", { name: /Update review/i }).click(); await page.getByText(/编辑后的评论内容/).first().waitFor(); await shot(page, "09-review-updated"); }); await step("删除评论 → 列表移除", async () => { await page.getByRole("button", { name: /Write a review/i }).first().click(); await page.getByRole("button", { name: /Update review/i }).waitFor(); await page.getByRole("button", { name: /Delete my review/i }).click(); await page .getByText(/Review deleted|No reviews yet|用于验证评论功能增删改查/) .first() .waitFor({ timeout: 20000 }); // 确认自己的评论内容已从列表消失(等待 refresh 完成) await page.waitForTimeout(1500); const reviewsText = await page.locator("body").innerText(); if (reviewsText.includes("编辑后的评论内容")) throw new Error("评论内容仍显示在列表中"); await shot(page, "10-review-deleted"); }); await step("取消收藏成功", async () => { const rm = mainFavBtn(page); await rm.waitFor(); await clickToggle(page, rm, /Save to Favorites/i); }); /* ---------- 3. 发布工具(登录态) ---------- */ await step("发布工具:填写完整表单 → 提交成功", async () => { await gotoWait(page, `${BASE}/submit`); await page.getByPlaceholder("ChatGPT").fill(TOOL_NAME); await page.getByPlaceholder("https://example.com").fill(TOOL_URL); await page .getByPlaceholder(/One-line pitch/i) .fill("Playwright E2E 测试自动创建的工具条目"); await page .getByPlaceholder(/What does it do/i) .fill( "该条目由自动化测试创建,用于验证用户提交工具→管理员审批→上线的完整流程,测试结束后可删除。" ); await page.locator("select").nth(0).selectOption({ index: 1 }).catch(() => {}); await page .locator("select") .nth(1) .selectOption({ label: "Free" }) .catch(() => {}); await page .getByPlaceholder(/https:\/\/x\.com/i) .fill("https://x.com/e2etest"); await shot(page, "11-submit-form-filled"); // 捕获提交按钮触发的 server action POST 响应,失败时用于定位 const actionRespPromise = page .waitForResponse((r) => r.request().method() === "POST", { timeout: 30000, }) .catch(() => null); await page.getByRole("button", { name: /Submit for review/i }).click(); const actionResp = await actionRespPromise; if (actionResp && actionResp.status() >= 400) { const body = await actionResp.text().catch(() => ""); throw new Error( `server action 响应 ${actionResp.status()} ${actionResp.url().slice(0, 100)}: ${body.slice(0, 200)}` ); } await page .getByText(/Submitted! We'll review it within 48 hours/i) .first() .waitFor(); await shot(page, "12-submit-success"); }); await step("重复提交检测:同名同址出现重复提醒", async () => { await gotoWait(page, `${BASE}/submit`); await page.getByPlaceholder("ChatGPT").fill("ChatGPT"); await page .getByPlaceholder("https://example.com") .fill("https://chat.openai.com"); await page.locator("body").click(); // 触发 onBlur 查重 await page.getByText(/already be listed/i).first().waitFor({ timeout: 20000 }); await shot(page, "13-duplicate-warning"); }); /* ---------- 4. 登出 + 未登录拦截 ---------- */ await step("登出成功", async () => { await page.goto(`${BASE}/dashboard`, { waitUntil: "domcontentloaded" }); await page.getByText("Welcome back").waitFor(); await page.getByRole("button", { name: /Sign Out/i }).click(); await page.waitForURL((u) => !u.pathname.includes("/dashboard")); }); await step("未登录写评论 → 跳转登录页并带 redirect", async () => { await gotoHydrated(page, `${BASE}${TOOL_HREF}`); await page.getByRole("button", { name: /Write a review/i }).first().click(); await page.waitForURL(/login\?redirect=/); }); await step("未登录提交工具 → 跳转登录页", async () => { await gotoWait(page, `${BASE}/submit`); await page.getByPlaceholder("ChatGPT").fill("Anon Tool"); await page .getByPlaceholder("https://example.com") .fill("https://anon.example.com"); await page .getByPlaceholder(/What does it do/i) .fill( "未登录提交测试:应当被登录拦截而不是创建数据。测试用例内容足够长以通过最小长度校验。" ); await page.getByRole("button", { name: /Submit for review/i }).click(); await page.waitForURL(/login\?redirect=/, { timeout: 20000 }); }); 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);