Files
plp/tests/e2e/helpers.ts
T
root afab4e7cba test(web): 补充 Web MVP 端到端测试与本地测试编排
- 新增 Playwright E2E(治理、登录、投瓶捞瓶回复与实时聊天、举报拉黑与断线恢复)共 9 条用例
- 每个用例前重置数据库、用例后关闭全部 browser context,消除跨用例污染与资源泄漏
- 前端改由 Playwright 托管构建与 preview(关闭 SW);Vite 代理注入 Origin 头以通过接口 Origin 校验
- 根级 vitest 排除 Playwright 用例与需专用配置的 web 用例,避免误收集导致的假失败
- 测试手机号按实测校验结果收敛白名单,避免用例传入会被后端拒绝的号段

验证结果:
- Web E2E 9/9 连续两轮通过(含运行前后服务健康检查)
- API E2E 146/146 连续两轮通过
- 单测:根级 14 文件/119 用例、web 包 8 文件/31 用例通过
- typecheck、lint、build、依赖审计(audit)均通过
2026-09-18 11:23:20 +08:00

194 lines
6.4 KiB
TypeScript

import { expect, type APIRequestContext } from "@playwright/test";
export interface DemoAccount {
phone: string;
deviceId: string;
}
/**
* libphonenumber 的 CN 元数据比公开号段表更严(实测 146/148/149 会被后端
* 拒绝为 Validation failed),这里只列实测通过 IsPhoneNumber("CN") 的号段,
* 避免用例传入看似合法却会被拒的前缀。
*/
const VALID_PREFIXES = [
"130",
"131",
"132",
"133",
"134",
"135",
"136",
"137",
"138",
"139",
"145",
"147",
"150",
"151",
"152",
"155",
"156",
"157",
"158",
"159",
"165",
"166",
"167",
"170",
"171",
"172",
"173",
"175",
"176",
"177",
"178",
"180",
"181",
"182",
"183",
"184",
"185",
"186",
"187",
"188",
"189",
"190",
"191",
"198",
"199",
] as const;
/** 生成合法中国大陆手机号(3 位前缀 + 8 位随机 = 11 位,后端 IsPhoneNumber("CN") 校验通过) */
export function freshAccount(prefix?: string): DemoAccount {
const chosen =
prefix ??
VALID_PREFIXES[Math.floor(Math.random() * VALID_PREFIXES.length)]!;
if (!(VALID_PREFIXES as readonly string[]).includes(chosen))
throw new Error(`测试手机号前缀不在已验证的合法号段内: ${chosen}`);
const suffix = String(Math.floor(Math.random() * 100_000_000)).padStart(
8,
"0",
);
const phone = `${chosen}${suffix}`;
if (!/^1(3\d|4[5-9]|5[0-35-9]|6[26]|7[0-8]|8\d|9[0-35-9])\d{8}$/.test(phone))
throw new Error(`生成的测试手机号不合法: ${phone}`);
return {
phone,
deviceId: `e2e-${chosen}-${phone}-${Math.floor(Math.random() * 1e6)}`,
};
}
/** 通过页面 UI 完成演示验证码登录,并确保匿名资料已设置且审核通过(否则投的瓶无法被他人捞到) */
export async function loginViaUi(
page: import("@playwright/test").Page,
account: DemoAccount,
): Promise<void> {
await page.goto("/login");
await page.getByPlaceholder(/\+86/).fill(account.phone);
await page.getByRole("button", { name: /获取演示验证码/ }).click();
const codeInput = page.locator('input[autocomplete="one-time-code"]');
await expect(codeInput).toBeVisible({ timeout: 10_000 });
const demo = page.locator(".demo");
await expect(demo).toBeVisible();
const text = await demo.innerText();
const code = text.match(/\d{6}/)?.[0];
if (!code) throw new Error("未从演示验证码区域解析到 6 位验证码");
await codeInput.fill(code);
await page.getByRole("button", { name: /进入海面/ }).click();
await expect(page).toHaveURL(/\/$/, { timeout: 15_000 });
// 注意:页面内 Vue 只把 accessToken 存在内存,page.request 无法获取,
// 因此这里经 API 再领一个 token,后续 AuthGuard 接口都带 Bearer 头。
const { accessToken } = await smsAndLogin(page.request, account);
const bearer = { Authorization: `Bearer ${accessToken}` };
// 设置匿名资料并等待 worker 审核通过(新用户否则无法参与匹配闭环)
const me = await page.request.get("/api/v1/me", { headers: bearer });
const meBody = (await me.json()) as {
data?: { profileReviewStatus?: string | null };
};
if (meBody.data?.profileReviewStatus !== "APPROVED") {
const patch = await page.request.patch("/api/v1/me/anonymous-profile", {
headers: bearer,
data: {
nickname: `海客${account.phone.slice(-4)}`,
avatarColor: "#66CCFF",
bio: null,
},
});
if (!patch.ok()) {
throw new Error(
`匿名资料设置失败: ${patch.status()} ${await patch.text()}`,
);
}
// 轮询等待资料审核通过
const deadline = Date.now() + 25_000;
let status: string | null | undefined;
while (Date.now() < deadline) {
const latest = await page.request.get("/api/v1/me", { headers: bearer });
status = (
(await latest.json()) as { data?: { profileReviewStatus?: string } }
).data?.profileReviewStatus;
if (status === "APPROVED") return;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`匿名资料未在时限内通过审核,最后状态: ${status}`);
}
}
/** 直接经 API 完成演示登录,返回 accessToken 与 set-cookie(用于注入管理会话) */
export async function smsAndLogin(
request: APIRequestContext,
account: DemoAccount,
): Promise<{ accessToken: string; cookie: string }> {
const send = await request.post("/api/v1/auth/sms/send", {
data: { phone: account.phone, deviceId: account.deviceId },
});
expect(send.ok()).toBeTruthy();
const body = (await send.json()) as { data?: { debugCode?: string } };
const code = body.data?.debugCode;
if (!code) throw new Error("sms/send 未返回 debugCode(演示登录未开启?)");
const login = await request.post("/api/v1/auth/sms/login", {
data: { phone: account.phone, code, deviceId: account.deviceId },
});
expect(login.ok()).toBeTruthy();
const loginBody = (await login.json()) as {
data?: { accessToken?: string };
};
const accessToken = loginBody.data?.accessToken;
if (!accessToken) throw new Error("sms/login 未返回 accessToken");
return { accessToken, cookie: login.headers()["set-cookie"] ?? "" };
}
/**
* 点击「伸手捞取」并在瓶子卡片出现前自动重试。
* 投出的瓶子需等 Worker 审核入池(约 1-2s),立即捞会得到空池;
* 上一次请求进行中按钮文案会变成「正在寻找…」,需等它恢复可点击。
*/
export async function pickBottle(
page: import("@playwright/test").Page,
attempts = 30,
): Promise<void> {
const card = page.locator(".bottle-card");
const pickButton = page.getByRole("button", { name: /伸手捞取/ });
for (let attempt = 0; attempt < attempts; attempt++) {
// 上一轮点击后卡片可能姗姗来迟:先确认是否已经捞到
if (await card.isVisible().catch(() => false)) return;
await pickButton.waitFor({ state: "visible", timeout: 20_000 });
await expect(pickButton).toBeEnabled({ timeout: 20_000 });
await pickButton.click({ timeout: 10_000 });
try {
// 卡片出现即成功;给足渲染时间,避免误判后又去等已经消失的按钮
await card.waitFor({ state: "visible", timeout: 10_000 });
return;
} catch {
// 池中暂无候选(投出的瓶子还在审核中),短暂等待后重试
await page.waitForTimeout(600);
}
}
throw new Error("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)");
}