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 { 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 { 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("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)"); }