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:
root
2026-09-18 11:23:20 +08:00
parent 7ca855f19c
commit afab4e7cba
13 changed files with 793 additions and 3 deletions
+28 -2
View File
@@ -5,6 +5,8 @@ export default defineConfig({
plugins: [
vue(),
VitePWA({
// E2E 跑产物时关闭 Service Worker,避免缓存干扰断言
disable: process.env.DISABLE_PWA === "1",
registerType: "autoUpdate",
manifest: {
name: "漂流瓶 · 深海回声",
@@ -28,8 +30,32 @@ export default defineConfig({
],
server: {
proxy: {
"/api": "http://localhost:3000",
"/socket.io": { target: "http://localhost:3000", ws: true },
"/api": {
target: "http://127.0.0.1:3000",
changeOrigin: true,
headers: { Origin: "http://127.0.0.1:5173" },
},
"/socket.io": {
target: "http://127.0.0.1:3000",
ws: true,
headers: { Origin: "http://127.0.0.1:5173" },
},
},
},
// E2E 跑产物预览时同样需要把 /api 与 /socket.io 代理到 API,
// 并由代理注入 Origin,满足 StateChangingOriginGuard 的校验。
preview: {
proxy: {
"/api": {
target: "http://127.0.0.1:3000",
changeOrigin: true,
headers: { Origin: "http://127.0.0.1:5173" },
},
"/socket.io": {
target: "http://127.0.0.1:3000",
ws: true,
headers: { Origin: "http://127.0.0.1:5173" },
},
},
},
});
+2
View File
@@ -8,6 +8,7 @@
"test:database": "vitest run tests/integration/database.spec.ts",
"test:integration": "vitest run tests/integration --no-file-parallelism && corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
"test:security": "corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts src/auth/auth.e2e-spec.ts src/conversation/conversation.e2e-spec.ts --no-file-parallelism",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit -p tsconfig.base.json",
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
@@ -26,6 +27,7 @@
}
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "22.19.3",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
+46
View File
@@ -0,0 +1,46 @@
import { defineConfig, devices } from "@playwright/test";
// 浏览器用例跑构建产物(vite preview),不再用按需转译的 dev server:
// dev server 每次整页加载要拉数百个模块,Chromium 会以
// ERR_INSUFFICIENT_RESOURCES 放弃加载,页面根本挂载不起来。
const PORT = Number(process.env.WEB_PORT ?? 4173);
export default defineConfig({
testDir: "./tests/e2e",
globalSetup: "./tests/e2e/global-setup.ts",
// 双用户用例需要等 Worker 审核资料/瓶子(每个 1-2s)+ 两次登录,
// 60s 不够,会在用例中途被拆除 context 而误报失败。
timeout: 150_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 0,
reporter: [["list"]],
use: {
baseURL: `http://127.0.0.1:${PORT}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
},
// 由 Playwright 保证被测前端可用:先构建(关闭 Service Worker,避免缓存
// 干扰断言),再起 preview。服务中途消失会让整轮报 ERR_CONNECTION_REFUSED,
// 交由 Playwright 管理可避免这类假失败。
webServer: {
command: `DISABLE_PWA=1 corepack pnpm --filter @drift/web exec vite build && corepack pnpm --filter @drift/web exec vite preview --host 127.0.0.1 --port ${PORT} --strictPort`,
url: `http://127.0.0.1:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 240_000,
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
// 该机器内存较小,避免 /dev/shm 与多余缓存把渲染进程拖垮
launchOptions: {
args: ["--disable-dev-shm-usage", "--disable-gpu"],
},
},
},
],
});
+28
View File
@@ -17,6 +17,9 @@ importers:
specifier: 6.19.0
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
devDependencies:
'@playwright/test':
specifier: ^1.63.0
version: 1.63.0
'@types/node':
specifier: 22.19.3
version: 22.19.3
@@ -1221,6 +1224,11 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@playwright/test@1.63.0':
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
engines: {node: '>=20'}
hasBin: true
'@prisma/client@6.19.0':
resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==}
engines: {node: '>=18.18'}
@@ -3045,6 +3053,16 @@ packages:
pkg-types@2.3.3:
resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==}
playwright-core@1.63.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
engines: {node: '>=20'}
hasBin: true
playwright@1.63.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
engines: {node: '>=20'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -4885,6 +4903,10 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@playwright/test@1.63.0':
dependencies:
playwright: 1.63.0
'@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)':
optionalDependencies:
prisma: 6.19.0(typescript@5.9.3)
@@ -6925,6 +6947,12 @@ snapshots:
exsolve: 1.1.1
pathe: 2.0.3
playwright-core@1.63.0: {}
playwright@1.63.0:
dependencies:
playwright-core: 1.63.0
possible-typed-array-names@1.1.0: {}
postcss@8.5.28:
+105
View File
@@ -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();
});
});
+39
View File
@@ -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=/);
});
});
+112
View File
@@ -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();
});
});
+22
View File
@@ -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 };
+91
View File
@@ -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();
}
+193
View File
@@ -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("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)");
}
+111
View File
@@ -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();
});
});
+3 -1
View File
@@ -16,6 +16,8 @@
"packages/**/*.ts",
"apps/**/*.ts",
"prisma/**/*.ts",
"tests/**/*.ts"
"tests/**/*.ts",
"playwright.config.ts",
"vitest.config.ts"
]
}
+13
View File
@@ -0,0 +1,13 @@
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
/**
* 根级 `pnpm test` 是聚合入口,排除两类不该由它收集的用例:
* - tests/e2e:Playwright 用例,由 `pnpm test:e2e` 驱动,vitest 无法执行;
* - apps/web:需要 packages 自己的 vitest.config.ts(vue 插件 + jsdom),
* 由 `corepack pnpm --filter @drift/web test` 运行。
*/
exclude: [...configDefaults.exclude, "tests/e2e/**", "apps/web/**"],
},
});