From afab4e7cba1959a3abde3ad4e5ea1877615b87ce Mon Sep 17 00:00:00 2001 From: root Date: Fri, 18 Sep 2026 11:23:20 +0800 Subject: [PATCH] =?UTF-8?q?test(web):=20=E8=A1=A5=E5=85=85=20Web=20MVP=20?= =?UTF-8?q?=E7=AB=AF=E5=88=B0=E7=AB=AF=E6=B5=8B=E8=AF=95=E4=B8=8E=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=B5=8B=E8=AF=95=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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)均通过 --- apps/web/vite.config.ts | 30 +++++- package.json | 2 + playwright.config.ts | 46 ++++++++ pnpm-lock.yaml | 28 +++++ tests/e2e/admin.spec.ts | 105 ++++++++++++++++++ tests/e2e/auth.spec.ts | 39 +++++++ tests/e2e/bottle-chat.spec.ts | 112 ++++++++++++++++++++ tests/e2e/fixtures.ts | 22 ++++ tests/e2e/global-setup.ts | 91 ++++++++++++++++ tests/e2e/helpers.ts | 193 ++++++++++++++++++++++++++++++++++ tests/e2e/safety.spec.ts | 111 +++++++++++++++++++ tsconfig.base.json | 4 +- vitest.config.ts | 13 +++ 13 files changed, 793 insertions(+), 3 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/admin.spec.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/bottle-chat.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/safety.spec.ts create mode 100644 vitest.config.ts diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 197914f..5e3a93e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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" }, + }, }, }, }); diff --git a/package.json b/package.json index 1142a68..a3eafb0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2749d9e --- /dev/null +++ b/playwright.config.ts @@ -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"], + }, + }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2847d29..9cb3ba9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts new file mode 100644 index 0000000..239d69a --- /dev/null +++ b/tests/e2e/admin.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..4a18ec9 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -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=/); + }); +}); diff --git a/tests/e2e/bottle-chat.spec.ts b/tests/e2e/bottle-chat.spec.ts new file mode 100644 index 0000000..22cc21a --- /dev/null +++ b/tests/e2e/bottle-chat.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..806227a --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -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 }; diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..b7dc919 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -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 { + 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 { + await resetDatabase(); +} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..2024c76 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -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 { + 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("多次尝试后仍未捞到瓶子(审核入池超时或池中没有候选)"); +} diff --git a/tests/e2e/safety.spec.ts b/tests/e2e/safety.spec.ts new file mode 100644 index 0000000..6097703 --- /dev/null +++ b/tests/e2e/safety.spec.ts @@ -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(); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index b1ad68c..e67075b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,6 +16,8 @@ "packages/**/*.ts", "apps/**/*.ts", "prisma/**/*.ts", - "tests/**/*.ts" + "tests/**/*.ts", + "playwright.config.ts", + "vitest.config.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..29e689a --- /dev/null +++ b/vitest.config.ts @@ -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/**"], + }, +});