Files
ai-directory-platform/packages/core/scripts/ui-test-user.mjs
T
bigcode 715152f159 fix: 登录态快速点击被误判未登录踢到登录页 + 乐观计数闪烁; 新增三套浏览器 GUI 测试脚本
- 投票/收藏/评论入口在 session.isPending(会话解析中)时不再把
  已登录用户误判为未登录跳转登录页(刷新后快速点击可复现)
- 工具详情页 VotesProvider 传入 initialCounts,消除投票后计数闪现 1 的回退
- 新增 packages/core/scripts/ui-test-{user,admin,cross-site}.mjs:
  用户全流程 24 项 / 管理员后台 9 项 / 跨站抽查 20 项,实测全部通过
2026-09-16 06:10:41 +08:00

460 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 脚本 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);