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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -51,6 +51,8 @@ export function ReviewsSection({
|
|||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
const openForm = async () => {
|
const openForm = async () => {
|
||||||
|
// 会话解析中不做任何动作,避免把已登录用户误判为未登录踢到登录页
|
||||||
|
if (session.isPending) return;
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
router.push(
|
router.push(
|
||||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||||
@@ -73,6 +75,8 @@ export function ReviewsSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onHelpful = async (reviewId: string) => {
|
const onHelpful = async (reviewId: string) => {
|
||||||
|
// 会话解析中不做任何动作,避免误判未登录(同 openForm)
|
||||||
|
if (session.isPending) return;
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
router.push(
|
router.push(
|
||||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
(toolId: string) => {
|
(toolId: string) => {
|
||||||
|
// 会话解析中不做任何动作:此时 session.data 为空,
|
||||||
|
// 若直接判未登录会把已登录用户误踢到登录页(刷新后快速点击可复现)
|
||||||
|
if (session.isPending) return;
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
toast(t("loginRequired"));
|
toast(t("loginRequired"));
|
||||||
router.push(
|
router.push(
|
||||||
@@ -104,7 +107,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isAuthenticated, locale, pendingIds, router, t]
|
[isAuthenticated, locale, pendingIds, router, t, session.isPending]
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export function VotesProvider({
|
|||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
(toolId: string) => {
|
(toolId: string) => {
|
||||||
|
// 会话解析中不做任何动作:此时 session.data 为空,
|
||||||
|
// 若直接判未登录会把已登录用户误踢到登录页(刷新后快速点击可复现)
|
||||||
|
if (session.isPending) return;
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
|
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
|
||||||
toast(t("loginRequired"));
|
toast(t("loginRequired"));
|
||||||
@@ -129,7 +132,7 @@ export function VotesProvider({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[isAuthenticated, locale, pendingIds, router, t, votedIds]
|
[isAuthenticated, locale, pendingIds, router, t, session.isPending]
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
|||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="sticky top-20 space-y-4">
|
<div className="sticky top-20 space-y-4">
|
||||||
{/* 投票 + 访问 */}
|
{/* 投票 + 访问 */}
|
||||||
<VotesProvider>
|
<VotesProvider initialCounts={{ [tool.id]: tool.voteCount ?? 0 }}>
|
||||||
<VoteButton
|
<VoteButton
|
||||||
toolId={tool.id}
|
toolId={tool.id}
|
||||||
variant="button"
|
variant="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user