diff --git a/packages/core/scripts/ui-test-admin.mjs b/packages/core/scripts/ui-test-admin.mjs new file mode 100644 index 0000000..90c734b --- /dev/null +++ b/packages/core/scripts/ui-test-admin.mjs @@ -0,0 +1,252 @@ +/** + * # 脚本 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); diff --git a/packages/core/scripts/ui-test-cross-site.mjs b/packages/core/scripts/ui-test-cross-site.mjs new file mode 100644 index 0000000..eeeedc5 --- /dev/null +++ b/packages/core/scripts/ui-test-cross-site.mjs @@ -0,0 +1,174 @@ +/** + * # 脚本 ui-test-cross-site.mjs + * 说明:五站跨站抽查(同一底座、同一数据库,核心链路每站验证)。 + * 每站:登录 alice → 打开指定工具页 → 发评论再删除(无残留)→ + * 投票再取消(恢复原状)→ 登出。 + * 运行:cd packages/core && bun scripts/ui-test-cross-site.mjs + */ +import { chromium } from "playwright"; +import { mkdirSync } from "node:fs"; + +const SHOT_DIR = "/tmp/ui-test"; +mkdirSync(SHOT_DIR, { recursive: true }); + +/* 每站抽查的工具 slug */ +const SITES = [ + { name: "launchai", base: "https://launchai-bdev.opentest.cc", slug: "/tools/perplexity" }, + { name: "the-ai-ledger", base: "https://the-ai-ledger-bdev.opentest.cc", slug: "/tools/midjourney" }, + { name: "devai-tools", base: "https://devai-tools-bdev.opentest.cc", slug: "/tools/github-copilot" }, + { name: "findai", base: "https://findai-bdev.opentest.cc", slug: "/tools/figma-ai" }, +]; + +const results = []; +const step = (site, name) => async (fn) => { + try { + await fn(); + results.push(["PASS", `${site} ${name}`]); + console.log(`PASS [${site}] ${name}`); + } catch (e) { + results.push(["FAIL", `${site} ${name}`]); + console.log(`FAIL [${site}] ${name}\n ${String(e).split("\n")[0]}`); + } +}; + +const browser = await chromium.launch({ + executablePath: "/opt/brave.com/brave/brave", + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); + +/* 稳定交互辅助(同用户侧脚本) */ +const waitText = async (p, loc, re, timeoutMs = 20000) => { + const deadline = Date.now() + timeoutMs; + let last = ""; + while (Date.now() < deadline) { + last = await loc + .innerText() + .catch((e) => `(err:${String(e).split("\n")[0].slice(0, 60)})`); + if (re.test(last)) return last; + await p.waitForTimeout(300); + } + throw new Error(`等待超时(期望 ${re}),当前: ${JSON.stringify(last)}`); +}; +const waitActionSettled = (p) => + p + .waitForLoadState("networkidle", { timeout: 15000 }) + .catch(() => {}); +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 waitActionSettled(p); + return text; + } catch (e) { + if (i === attempts - 1) throw e; + } + } +}; +const gotoHydrated = async (p, url) => { + const marker = p + .waitForResponse( + (r) => + r.request().method() === "POST" && + !r.url().includes("cdn-cgi") && + !r.url().includes("/api/auth/"), + { timeout: 30000 } + ) + .catch(() => null); + await p.goto(url, { waitUntil: "domcontentloaded" }); + await marker; + await p.waitForTimeout(500); +}; + +for (const { name: site, base, slug } of SITES) { + const s = (name, fn) => step(site, name)(fn); + const page = await ( + await browser.newContext({ viewport: { width: 1440, height: 900 } }) + ).newPage(); + page.setDefaultTimeout(30000); + const shot = (n) => page.screenshot({ path: `${SHOT_DIR}/cs-${site}-${n}.png` }); + + const mainVoteBtn = () => + page.locator('button:has-text("Upvote"), button:has-text("Upvoted")').first(); + + await s("登录 alice", async () => { + await gotoHydrated(page, `${base}/login`).catch(() => {}); + await page.goto(`${base}/login`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 30000 }).catch(() => {}); + await page.waitForTimeout(500); + await page.getByLabel(/email/i).fill("alice@demo.dev"); + 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, + }); + }); + + await s(`打开 ${slug} 并水合`, async () => { + await gotoHydrated(page, `${base}${slug}`); + await page.getByRole("heading", { level: 1 }).first().waitFor(); + }); + + await s("发评论 → 删除(无残留)", async () => { + 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(/What did you use it for/i) + .fill(`跨站抽查评论(${site}):验证各站点评论功能,随后删除。`); + await page.getByRole("button", { name: /Publish review/i }).click(); + await page.getByText(/跨站抽查评论/).first().waitFor(); + await shot("review-created"); + // 删除 + 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 }); + await page.waitForTimeout(1500); + const body = await page.locator("body").innerText(); + if (body.includes("跨站抽查评论")) throw new Error("评论删除后仍显示"); + await shot("review-deleted"); + }); + + await s("投票 → 取消(恢复原状)", async () => { + const btn = mainVoteBtn(); + await btn.waitFor(); + let base0; + if (/Upvoted/i.test(await btn.innerText())) { + await clickToggle(page, btn, /^Upvote ·/); + } + base0 = parseInt((await btn.innerText()).replace(/\D+/g, ""), 10) || 0; + await clickToggle(page, btn, /Upvoted ·/); + await clickToggle(page, btn, /^Upvote ·/); + const after = parseInt((await btn.innerText()).replace(/\D+/g, ""), 10) || 0; + if (after !== base0) throw new Error(`计数未恢复: ${base0} → ${after}`); + await shot("vote-toggled"); + }); + + await s("登出", 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 page.close(); +} + +await browser.close(); + +const fails = results.filter(([s]) => s === "FAIL"); +console.log( + `\n===== 跨站抽查:${results.length - fails.length}/${results.length} 通过 =====` +); +if (fails.length) process.exit(1); diff --git a/packages/core/scripts/ui-test-user.mjs b/packages/core/scripts/ui-test-user.mjs new file mode 100644 index 0000000..7bdfa6e --- /dev/null +++ b/packages/core/scripts/ui-test-user.mjs @@ -0,0 +1,459 @@ +/** + * # 脚本 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); diff --git a/packages/core/src/features/reviews/components/reviews-section.tsx b/packages/core/src/features/reviews/components/reviews-section.tsx index 2c91b61..3fed970 100644 --- a/packages/core/src/features/reviews/components/reviews-section.tsx +++ b/packages/core/src/features/reviews/components/reviews-section.tsx @@ -51,6 +51,8 @@ export function ReviewsSection({ const pageSize = 10; const openForm = async () => { + // 会话解析中不做任何动作,避免把已登录用户误判为未登录踢到登录页 + if (session.isPending) return; if (!isAuthenticated) { router.push( `/login?redirect=${encodeURIComponent(window.location.pathname)}` @@ -73,6 +75,8 @@ export function ReviewsSection({ }; const onHelpful = async (reviewId: string) => { + // 会话解析中不做任何动作,避免误判未登录(同 openForm) + if (session.isPending) return; if (!isAuthenticated) { router.push( `/login?redirect=${encodeURIComponent(window.location.pathname)}` diff --git a/packages/core/src/features/tools/components/favorite-button.tsx b/packages/core/src/features/tools/components/favorite-button.tsx index 18c8ac7..f70e244 100644 --- a/packages/core/src/features/tools/components/favorite-button.tsx +++ b/packages/core/src/features/tools/components/favorite-button.tsx @@ -51,6 +51,9 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) { const toggle = useCallback( (toolId: string) => { + // 会话解析中不做任何动作:此时 session.data 为空, + // 若直接判未登录会把已登录用户误踢到登录页(刷新后快速点击可复现) + if (session.isPending) return; if (!isAuthenticated) { toast(t("loginRequired")); router.push( @@ -104,7 +107,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) { }); }); }, - [isAuthenticated, locale, pendingIds, router, t] + [isAuthenticated, locale, pendingIds, router, t, session.isPending] ); const value = useMemo( diff --git a/packages/core/src/features/voting/components/vote-button.tsx b/packages/core/src/features/voting/components/vote-button.tsx index 0f42b29..cfbf013 100644 --- a/packages/core/src/features/voting/components/vote-button.tsx +++ b/packages/core/src/features/voting/components/vote-button.tsx @@ -62,6 +62,9 @@ export function VotesProvider({ const toggle = useCallback( (toolId: string) => { + // 会话解析中不做任何动作:此时 session.data 为空, + // 若直接判未登录会把已登录用户误踢到登录页(刷新后快速点击可复现) + if (session.isPending) return; if (!isAuthenticated) { // 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数) toast(t("loginRequired")); @@ -129,7 +132,7 @@ export function VotesProvider({ }); }); }, - [isAuthenticated, locale, pendingIds, router, t, votedIds] + [isAuthenticated, locale, pendingIds, router, t, session.isPending] ); const value = useMemo( diff --git a/packages/core/src/pages/[locale]/(marketing)/tools/[slug]/page.tsx b/packages/core/src/pages/[locale]/(marketing)/tools/[slug]/page.tsx index 28bb413..d5fc557 100644 --- a/packages/core/src/pages/[locale]/(marketing)/tools/[slug]/page.tsx +++ b/packages/core/src/pages/[locale]/(marketing)/tools/[slug]/page.tsx @@ -508,7 +508,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {