fix: 登录态快速点击被误判未登录踢到登录页 + 乐观计数闪烁; 新增三套浏览器 GUI 测试脚本

- 投票/收藏/评论入口在 session.isPending(会话解析中)时不再把
  已登录用户误判为未登录跳转登录页(刷新后快速点击可复现)
- 工具详情页 VotesProvider 传入 initialCounts,消除投票后计数闪现 1 的回退
- 新增 packages/core/scripts/ui-test-{user,admin,cross-site}.mjs:
  用户全流程 24 项 / 管理员后台 9 项 / 跨站抽查 20 项,实测全部通过
This commit is contained in:
bigcode
2026-09-16 06:10:41 +08:00
parent 3f87153ce3
commit 715152f159
7 changed files with 898 additions and 3 deletions
+252
View File
@@ -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);
@@ -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);
+459
View File
@@ -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);