- 投票/收藏/评论入口在 session.isPending(会话解析中)时不再把
已登录用户误判为未登录跳转登录页(刷新后快速点击可复现)
- 工具详情页 VotesProvider 传入 initialCounts,消除投票后计数闪现 1 的回退
- 新增 packages/core/scripts/ui-test-{user,admin,cross-site}.mjs:
用户全流程 24 项 / 管理员后台 9 项 / 跨站抽查 20 项,实测全部通过
460 lines
18 KiB
JavaScript
460 lines
18 KiB
JavaScript
/**
|
||
* # 脚本 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);
|