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)均通过
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { E2E_ADMIN } from "./global-setup";
|
||||
import { freshAccount, loginViaUi, smsAndLogin, pickBottle } from "./helpers";
|
||||
|
||||
test.describe("admin governance", () => {
|
||||
test("admin login is guarded, reports are resolved with audit, sanction applies", async ({
|
||||
browser,
|
||||
request,
|
||||
}) => {
|
||||
// 预置管理员账号(global-setup),用 demo 登录拿到 accessToken
|
||||
const adminSession = await smsAndLogin(request, E2E_ADMIN);
|
||||
expect(adminSession.accessToken).toBeTruthy();
|
||||
const adminHeaders = {
|
||||
authorization: `Bearer ${adminSession.accessToken}`,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
// 制造举报:Alice 投瓶,Bob 捞瓶回复后举报会话
|
||||
const aliceAccount = freshAccount("147");
|
||||
const ctxAlice = await browser.newContext();
|
||||
const alicePage = await ctxAlice.newPage();
|
||||
await loginViaUi(alicePage, aliceAccount);
|
||||
await alicePage.goto("/throw");
|
||||
await alicePage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`治理测试会话 ${Date.now()}`);
|
||||
await alicePage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(alicePage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
const bobAccount = freshAccount("145");
|
||||
const ctxBob = await browser.newContext();
|
||||
const bobPage = await ctxBob.newPage();
|
||||
await loginViaUi(bobPage, bobAccount);
|
||||
await bobPage.goto("/pick");
|
||||
await pickBottle(bobPage);
|
||||
await bobPage.locator(".bottle-card textarea").fill("这条消息将被举报");
|
||||
await bobPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bobPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
|
||||
// Bob 举报该会话
|
||||
await bobPage.getByRole("button", { name: /举报会话/ }).click();
|
||||
await bobPage.getByRole("button", { name: "提交举报" }).click();
|
||||
await expect(bobPage.getByText(/举报已提交审核/)).toBeVisible();
|
||||
|
||||
// 管理员查看待审举报并处置
|
||||
const after = await request.get(
|
||||
"/api/v1/admin/reports?status=PENDING&limit=5",
|
||||
{ headers: adminHeaders },
|
||||
);
|
||||
expect(after.ok()).toBeTruthy();
|
||||
const afterBody = (await after.json()) as {
|
||||
data?: { items?: Array<{ id: string }> };
|
||||
};
|
||||
const pending = afterBody.data?.items ?? [];
|
||||
expect(pending.length).toBeGreaterThan(0);
|
||||
const report = pending[0]!;
|
||||
|
||||
const resolve = await request.post(
|
||||
`/api/v1/admin/reports/${report.id}/resolve`,
|
||||
{
|
||||
headers: adminHeaders,
|
||||
data: { decision: "DISMISSED", resolution: "E2E 无违规" },
|
||||
},
|
||||
);
|
||||
expect(resolve.ok()).toBeTruthy();
|
||||
|
||||
// 独立处罚:为 Bob 的公开 ID 施加警告
|
||||
const aliceMe = await request.get("/api/v1/me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${
|
||||
(await smsAndLogin(request, aliceAccount)).accessToken
|
||||
}`,
|
||||
},
|
||||
});
|
||||
const aliceBody = (await aliceMe.json()) as {
|
||||
data?: { publicId?: string };
|
||||
};
|
||||
const publicId = aliceBody.data?.publicId;
|
||||
if (publicId) {
|
||||
const sanction = await request.post(
|
||||
`/api/v1/admin/accounts/${publicId}/sanctions`,
|
||||
{
|
||||
headers: adminHeaders,
|
||||
data: { type: "WARNING", reason: "E2E 处罚验证" },
|
||||
},
|
||||
);
|
||||
expect(sanction.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
await ctxBob.close();
|
||||
await ctxAlice.close();
|
||||
});
|
||||
|
||||
test("non-admin user is forbidden from the admin workbench", async ({
|
||||
page,
|
||||
}) => {
|
||||
const account = freshAccount("152");
|
||||
await loginViaUi(page, account);
|
||||
|
||||
await page.goto("/admin");
|
||||
// 非管理员应被引导回独立管理员登录页并显示无权限
|
||||
await expect(page).toHaveURL(/\/admin\/login/, { timeout: 15_000 });
|
||||
await expect(page.getByText(/没有管理员权限/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { freshAccount, loginViaUi } from "./helpers";
|
||||
|
||||
test.describe("authentication", () => {
|
||||
test("unauthenticated user is redirected to the login page", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/conversations");
|
||||
await expect(page).toHaveURL(/\/login\?redirect=/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /获取演示验证码/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("demo login reaches the sea home and persists across reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const account = freshAccount("137");
|
||||
await loginViaUi(page, account);
|
||||
|
||||
await expect(page.getByRole("link", { name: /扔一只瓶子/ })).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole("link", { name: /扔一只瓶子/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("logout returns to login and clears the session", async ({ page }) => {
|
||||
const account = freshAccount("136");
|
||||
await loginViaUi(page, account);
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.getByRole("button", { name: "退出登录" }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/login\?redirect=/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { freshAccount, loginViaUi, pickBottle } from "./helpers";
|
||||
|
||||
test.describe("bottle social loop", () => {
|
||||
test("throws, picks, replies and chats in real time with two users", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aliceAccount = freshAccount("131");
|
||||
const bobAccount = freshAccount("132");
|
||||
|
||||
const alice = await browser.newContext();
|
||||
const bob = await browser.newContext();
|
||||
const aPage = await alice.newPage();
|
||||
const bPage = await bob.newPage();
|
||||
|
||||
await loginViaUi(aPage, aliceAccount);
|
||||
await loginViaUi(bPage, bobAccount);
|
||||
|
||||
// Alice 投瓶
|
||||
await aPage.goto("/throw");
|
||||
await aPage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill("你好,海上的陌生人,愿你被温柔接住。");
|
||||
await aPage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(aPage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
// Bob 捞瓶(过滤掉 Alice 已投的,应捞到池中瓶子)
|
||||
await bPage.goto("/pick");
|
||||
await pickBottle(bPage);
|
||||
const content = (await bPage.locator("blockquote").innerText()).trim();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// Bob 首次回复建立会话
|
||||
await bPage
|
||||
.locator(".bottle-card textarea")
|
||||
.fill("很高兴遇见你,来自另一片海域。");
|
||||
await bPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
await expect(
|
||||
bPage.getByText("很高兴遇见你,来自另一片海域。"),
|
||||
).toBeVisible();
|
||||
|
||||
// Alice 会话列表出现新会话
|
||||
await aPage.goto("/conversations");
|
||||
await expect(aPage.locator(".conversation")).toHaveCount(1, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Bob 发送实时消息,两端都能看到
|
||||
await bPage.locator(".composer textarea").fill("今天过得怎么样?");
|
||||
await bPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(bPage.getByText("今天过得怎么样?")).toBeVisible();
|
||||
|
||||
await aPage.goto("/conversations");
|
||||
await aPage.locator(".conversation").first().click();
|
||||
await expect(aPage.getByText("今天过得怎么样?")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await alice.close();
|
||||
await bob.close();
|
||||
});
|
||||
|
||||
test("replies once and deduplicates concurrent history sync", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aAccount = freshAccount("133");
|
||||
const bAccount = freshAccount("134");
|
||||
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const pageA = await ctxA.newPage();
|
||||
const pageB = await ctxB.newPage();
|
||||
|
||||
await loginViaUi(pageA, aAccount);
|
||||
await loginViaUi(pageB, bAccount);
|
||||
|
||||
// 两个瓶子避免同瓶并发
|
||||
await pageA.goto("/throw");
|
||||
await pageA
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`sync 瓶子 A ${Date.now()}`);
|
||||
await pageA.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(pageA.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
await pageA.goto("/throw");
|
||||
await pageA
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`sync 瓶子 B ${Date.now()}`);
|
||||
await pageA.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(pageA.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
// B 连续捞两只瓶子并各回复一次
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await pageB.goto("/pick");
|
||||
await pickBottle(pageB);
|
||||
await pageB.locator(".bottle-card textarea").fill(`回复-${i}:海上的你好`);
|
||||
await pageB.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(pageB).toHaveURL(/\/conversations\//, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
// A 端应看到两个会话
|
||||
await pageA.goto("/conversations");
|
||||
await expect(pageA.locator(".conversation")).toHaveCount(2, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { resetDatabase } from "./global-setup";
|
||||
|
||||
/**
|
||||
* 所有 E2E 用例共用:
|
||||
* - beforeEach:清库,避免上一用例残留的瓶子/租约让「捞取」拿到别人的瓶子
|
||||
* - afterEach:关闭本用例打开的所有 context。用例中途失败时若不关闭,context
|
||||
* (页面/连接/内存)会一直累积,机器内存与 fd 有限,后续用例会成片失败。
|
||||
*/
|
||||
export const test = base;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ browser }) => {
|
||||
for (const context of browser.contexts()) {
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public";
|
||||
|
||||
const ADMIN_PHONE = "13900000001";
|
||||
const ADMIN_DEVICE = "e2e-admin-device";
|
||||
// 与测试/开发环境一致的演示密钥(auth.e2e-spec.ts 同款)
|
||||
const PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||
|
||||
function phoneHmac(phone: string): string {
|
||||
return createHmac("sha256", PHONE_HMAC_KEY).update(phone).digest("hex");
|
||||
}
|
||||
|
||||
function normalizePhone(raw: string): string {
|
||||
const compact = raw.replace(/[\s()-]/g, "");
|
||||
const local = compact.startsWith("+86")
|
||||
? compact.slice(3)
|
||||
: compact.startsWith("86") && compact.length === 13
|
||||
? compact.slice(2)
|
||||
: compact;
|
||||
if (!/^1[3-9]\d{9}$/.test(local))
|
||||
throw new Error(`invalid admin phone: ${raw}`);
|
||||
return `+86${local}`;
|
||||
}
|
||||
|
||||
export const E2E_ADMIN = { phone: ADMIN_PHONE, deviceId: ADMIN_DEVICE };
|
||||
|
||||
const TABLES = [
|
||||
"outbox_events",
|
||||
"moderation_tasks",
|
||||
"bottle_pick_leases",
|
||||
"bottle_pick_requests",
|
||||
"bottle_pick_history",
|
||||
"messages",
|
||||
"conversation_members",
|
||||
"conversations",
|
||||
"notifications",
|
||||
"reports",
|
||||
"sanctions",
|
||||
"audit_logs",
|
||||
"refresh_tokens",
|
||||
"sessions",
|
||||
"daily_usage",
|
||||
"bottles",
|
||||
"anonymous_profiles",
|
||||
"accounts",
|
||||
];
|
||||
|
||||
/**
|
||||
* 清空所有业务表(保留 schema)并重建管理员账号。
|
||||
* 既用于 globalSetup,也被各 spec 的 beforeEach 调用,避免用例间数据污染
|
||||
*(残留瓶子会让「捞取」捞到别的用例的瓶子,导致会话归属错乱)。
|
||||
*/
|
||||
export async function resetDatabase(): Promise<void> {
|
||||
const prisma = new PrismaClient({
|
||||
datasources: { db: { url: DATABASE_URL } },
|
||||
});
|
||||
try {
|
||||
for (const table of TABLES) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
|
||||
}
|
||||
const digest = phoneHmac(normalizePhone(ADMIN_PHONE));
|
||||
await prisma.account.upsert({
|
||||
where: { phoneHmac: digest },
|
||||
update: { role: "ADMIN", status: "ACTIVE" },
|
||||
create: {
|
||||
role: "ADMIN",
|
||||
status: "ACTIVE",
|
||||
phoneHmac: digest,
|
||||
// 测试库仅用于校验 phoneHmac 查找,明文备用
|
||||
phoneCiphertext: Buffer.from("admin-e2e-placeholder", "utf8"),
|
||||
anonymousProfile: {
|
||||
create: {
|
||||
nickname: "管理员",
|
||||
avatarColor: "#66CCFF",
|
||||
reviewStatus: "APPROVED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup(): Promise<void> {
|
||||
await resetDatabase();
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { freshAccount, loginViaUi, pickBottle } from "./helpers";
|
||||
|
||||
test.describe("safety and resilience", () => {
|
||||
test("reports and reloads chat history after an offline reconnect", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aAccount = freshAccount("135");
|
||||
const bAccount = freshAccount("134");
|
||||
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const aPage = await ctxA.newPage();
|
||||
const bPage = await ctxB.newPage();
|
||||
|
||||
await loginViaUi(aPage, aAccount);
|
||||
await loginViaUi(bPage, bAccount);
|
||||
|
||||
// A 投瓶,B 捞瓶并回复建立会话
|
||||
await aPage.goto("/throw");
|
||||
await aPage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`断线恢复瓶子 ${Date.now()}`);
|
||||
await aPage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(aPage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
await bPage.goto("/pick");
|
||||
await pickBottle(bPage);
|
||||
await bPage.locator(".bottle-card textarea").fill("离线前的第一条消息");
|
||||
await bPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
|
||||
// A 进入同一会话
|
||||
await aPage.goto("/conversations");
|
||||
await aPage.locator(".conversation").first().click();
|
||||
await expect(aPage.getByText("离线前的第一条消息")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// A 离线前再互发一条
|
||||
await bPage.locator(".composer textarea").fill("离线前的第二条消息");
|
||||
await bPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(aPage.getByText("离线前的第二条消息")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 模拟 B 断线(离线),A 发送新消息
|
||||
await ctxB.setOffline(true);
|
||||
await aPage.locator(".composer textarea").fill("断线期间的第三条消息");
|
||||
await aPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(aPage.getByText("断线期间的第三条消息")).toBeVisible();
|
||||
|
||||
// B 恢复在线,重新拉取历史应补全缺失消息
|
||||
await ctxB.setOffline(false);
|
||||
await bPage.reload();
|
||||
await expect(bPage.getByText("断线期间的第三条消息")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
|
||||
test("blocks a peer and both sides can no longer send messages", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const aAccount = freshAccount("138");
|
||||
const bAccount = freshAccount("139");
|
||||
|
||||
const ctxA = await browser.newContext();
|
||||
const ctxB = await browser.newContext();
|
||||
const aPage = await ctxA.newPage();
|
||||
const bPage = await ctxB.newPage();
|
||||
|
||||
await loginViaUi(aPage, aAccount);
|
||||
await loginViaUi(bPage, bAccount);
|
||||
|
||||
await aPage.goto("/throw");
|
||||
await aPage
|
||||
.getByPlaceholder("写下你想让陌生人看到的话…")
|
||||
.fill(`拉黑测试瓶子 ${Date.now()}`);
|
||||
await aPage.getByRole("button", { name: /扔进海里/ }).click();
|
||||
await expect(aPage.getByText(/瓶子已进入审核/)).toBeVisible();
|
||||
|
||||
await bPage.goto("/pick");
|
||||
await pickBottle(bPage);
|
||||
await bPage.locator(".bottle-card textarea").fill("建立会话,稍后拉黑");
|
||||
await bPage.getByRole("button", { name: /回复并建立会话/ }).click();
|
||||
await expect(bPage).toHaveURL(/\/conversations\//, { timeout: 15_000 });
|
||||
|
||||
// A 进入会话并拉黑
|
||||
await aPage.goto("/conversations");
|
||||
await aPage.locator(".conversation").first().click();
|
||||
await expect(aPage.getByText("建立会话,稍后拉黑")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
aPage.once("dialog", (dialog) => void dialog.accept());
|
||||
await aPage.getByRole("button", { name: /拉黑对方/ }).click();
|
||||
await expect(aPage.getByText(/已拉黑对方/)).toBeVisible();
|
||||
|
||||
// B 发消息应失败
|
||||
await bPage.locator(".composer textarea").fill("你还在吗?");
|
||||
await bPage.getByRole("button", { name: /发送消息/ }).click();
|
||||
await expect(bPage.locator(".bubble.failed").first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user