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:
@@ -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);
|
||||
Reference in New Issue
Block a user