/** * # 脚本 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);