afab4e7cba
- 新增 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)均通过
194 lines
6.4 KiB
TypeScript
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("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)");
|
|
}
|