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:
+28
-2
@@ -5,6 +5,8 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
// E2E 跑产物时关闭 Service Worker,避免缓存干扰断言
|
||||||
|
disable: process.env.DISABLE_PWA === "1",
|
||||||
registerType: "autoUpdate",
|
registerType: "autoUpdate",
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "漂流瓶 · 深海回声",
|
name: "漂流瓶 · 深海回声",
|
||||||
@@ -28,8 +30,32 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:3000",
|
"/api": {
|
||||||
"/socket.io": { target: "http://localhost:3000", ws: true },
|
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" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"test:database": "vitest run tests/integration/database.spec.ts",
|
"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: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: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",
|
"typecheck": "tsc --noEmit -p tsconfig.base.json",
|
||||||
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
||||||
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "22.19.3",
|
"@types/node": "22.19.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
"@typescript-eslint/parser": "^8.18.0",
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Generated
+28
@@ -17,6 +17,9 @@ importers:
|
|||||||
specifier: 6.19.0
|
specifier: 6.19.0
|
||||||
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.63.0
|
||||||
|
version: 1.63.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 22.19.3
|
specifier: 22.19.3
|
||||||
version: 22.19.3
|
version: 22.19.3
|
||||||
@@ -1221,6 +1224,11 @@ packages:
|
|||||||
'@paralleldrive/cuid2@2.3.1':
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
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':
|
'@prisma/client@6.19.0':
|
||||||
resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==}
|
resolution: {integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
@@ -3045,6 +3053,16 @@ packages:
|
|||||||
pkg-types@2.3.3:
|
pkg-types@2.3.3:
|
||||||
resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==}
|
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:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4885,6 +4903,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.8.0
|
'@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)':
|
'@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 6.19.0(typescript@5.9.3)
|
prisma: 6.19.0(typescript@5.9.3)
|
||||||
@@ -6925,6 +6947,12 @@ snapshots:
|
|||||||
exsolve: 1.1.1
|
exsolve: 1.1.1
|
||||||
pathe: 2.0.3
|
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: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss@8.5.28:
|
postcss@8.5.28:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-1
@@ -16,6 +16,8 @@
|
|||||||
"packages/**/*.ts",
|
"packages/**/*.ts",
|
||||||
"apps/**/*.ts",
|
"apps/**/*.ts",
|
||||||
"prisma/**/*.ts",
|
"prisma/**/*.ts",
|
||||||
"tests/**/*.ts"
|
"tests/**/*.ts",
|
||||||
|
"playwright.config.ts",
|
||||||
|
"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/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user