Files
lingxi/e2e/a6-tasks.spec.ts
T
Stev_Wang 4825626bdf test(e2e): A6 任务中心端到端用例与本地 Playwright 通道
背景:A6-QA-01 要求验证幂等 / 取消 / 重试 / 断线续推 / 权限三态;同时本地无 ~/Library/Caches 写权限,需要可复现的浏览器安装方式。
改动:
- 新增 e2e/a6-tasks.spec.ts(A6_E2E=1 门控):6 条用例覆盖提交 → 列表 → 详情 SSE → 部分失败 → 仅重试失败项、立即取消、同 Idempotency-Key 复用与 40902、Last-Event-ID 断点续推、只读不渲染写操作、无 material:read → 403;beforeAll 用 Prisma 建临时超管 / 只读 / 无权限账号与角色,afterAll 清理任务、幂等键、outbox 与账号角色(审计 append-only 保留)。
- playwright.config.ts 支持 PLAYWRIGHT_CHANNEL:未设置时本地用系统 Chrome,显式空串(CI)用 Playwright 自带 Chromium。
- eslint.config.mjs 忽略本地 .dev(Playwright 浏览器与临时脚本)。
- e2e/README.md 补充 A6 用例与运行方式。
影响面:默认(无 A6_E2E=1)仍 skip,CI pnpm test:e2e 行为不变;仅本地 e2e 配置增加环境变量支持。
回滚:撤销提交,删除 spec 与 channel 逻辑即可。
验证:A6_E2E=1 pnpm exec playwright test e2e/a6-tasks.spec.ts 6/6(约 39s,连跑多次稳定);pnpm test:e2e 默认套件不受影响。
2026-11-05 11:41:14 +08:00

563 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <reference lib="dom" />
/**
* A6-QA-01 · 任务中心端到端用例(真实 API + 真实 worker + 真实浏览器)
*
* 覆盖:
* ① 提交演练任务(混合模式)→ 列表出现 → 详情 SSE 推进到「部分失败」→ 仅重试失败项;
* ② 顶栏任务角标显示进行中数量,面板可进入任务详情;
* ③ 提交后立即取消 → 状态「已取消」,未开始项「已跳过」;
* ④ 同一 Idempotency-Key + 同一 body 返回同一 taskId,不同 body 40902(API 断言);
* ⑤ SSE Last-Event-ID 断点续推:重连首帧 seq 更大、不重发历史;
* ⑥ 权限三态:仅 material:read 不渲染写操作;无 material:read → /403。
*
* 门控:仅 A6_E2E=1 时执行;默认 skip,不影响 smoke / CI。
* 前置:API 3000、Web 5173、worker(QUEUE=all 或 verify)与真实 PG / Redis,已 `pnpm db:init`。
* 数据自建自清:临时账号 qa6.tasks.e2e / qa6.tasks.ro / qa6.tasks.none + 两个临时角色;
* 结束后删除临时任务(级联任务项 / 事件)、幂等键、outbox 事件、账号与角色;审计记录保留(预期留痕)。
*
* 运行:A6_E2E=1 pnpm exec playwright test e2e/a6-tasks.spec.ts --reporter=list
*/
import { randomBytes } from 'node:crypto';
import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test';
import { PrismaClient } from '@prisma/client';
const API_BASE = process.env.A6_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
const WEB_BASE = process.env.A6_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
const ENABLED = process.env.A6_E2E === '1';
const PASSWORD = 'lingxi2026';
/** 与 a1 / a3 / a4 / a5 共用(明文 lingxi2026) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const ADMIN_ACCOUNT = 'qa6.tasks.e2e';
const ADMIN_NAME = 'A6 E2E 超管';
const READONLY_ACCOUNT = 'qa6.tasks.ro';
const READONLY_NAME = 'A6 E2E 只读';
const NONE_ACCOUNT = 'qa6.tasks.none';
const NONE_NAME = 'A6 E2E 无权限';
const READONLY_ROLE_CODE = 'a6e2e_task_viewer';
const READONLY_ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZA6E1';
const NONE_ROLE_CODE = 'a6e2e_no_material';
const NONE_ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZA6E2';
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const prisma = new PrismaClient();
let apiContext: APIRequestContext | null = null;
let adminSession: QaSession | null = null;
let readonlySession: QaSession | null = null;
let noneSession: QaSession | null = null;
let customerId = '';
let adminUserId = '';
let readonlyUserId = '';
let noneUserId = '';
const createdTaskIds: string[] = [];
interface QaSession {
accessToken: string;
refreshToken: string;
user: { id: string; name: string; account: string };
permissions: string[];
mustChangePassword: boolean;
}
interface TaskDetail {
id: string;
status: string;
itemFailed: number;
itemSkipped: number;
}
test.skip(!ENABLED, 'A6_E2E=1 才运行任务中心 e2e(依赖 API :3000 + worker + web :5173 + PG)');
test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
// ---------------------------------------------------------------------------
// 基础工具
// ---------------------------------------------------------------------------
function ulid(): string {
let time = Date.now();
const timeChars = new Array<string>(10);
for (let index = 9; index >= 0; index -= 1) {
timeChars[index] = ENCODING[time % 32] ?? '0';
time = Math.floor(time / 32);
}
const random = randomBytes(16);
return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} 不是 JSON 对象`);
return value;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function requireApi(): APIRequestContext {
if (apiContext === null) throw new Error('APIRequestContext 未初始化');
return apiContext;
}
function requireAdminSession(): QaSession {
if (adminSession === null) throw new Error('超管会话未就绪');
return adminSession;
}
function requireReadonlySession(): QaSession {
if (readonlySession === null) throw new Error('只读会话未就绪');
return readonlySession;
}
function requireNoneSession(): QaSession {
if (noneSession === null) throw new Error('无权限会话未就绪');
return noneSession;
}
async function injectSession(page: Page, session: QaSession): Promise<void> {
const persisted = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
user: session.user,
permissions: session.permissions,
mustChangePassword: session.mustChangePassword,
};
await page.addInitScript((value: unknown) => {
window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
}, persisted);
}
async function loginViaApi(account: string): Promise<QaSession> {
const context = requireApi();
const login = await context.post(`${API_BASE}/auth/login`, {
data: { account, password: PASSWORD },
});
expect(login.status(), `POST /auth/login ${account}`).toBe(200);
const rawLogin = (await login.text()) || '{}';
const body = requireRecord(JSON.parse(rawLogin) as unknown, '登录响应');
if (typeof body.accessToken !== 'string' || body.accessToken === '') {
throw new Error(
`登录响应缺少 accessToken:HTTP ${String(login.status())} body=${rawLogin.slice(0, 300)}`,
);
}
const accessToken = body.accessToken;
const refreshToken = String(body.refreshToken);
const meResponse = await context.get(`${API_BASE}/me`, {
headers: { authorization: `Bearer ${accessToken}` },
});
expect(meResponse.status(), `GET /me ${account}`).toBe(200);
const rawMe = (await meResponse.text()) || '{}';
const me = requireRecord(JSON.parse(rawMe) as unknown, '/me 响应');
const user = requireRecord(me.user, '/me.user');
return {
accessToken,
refreshToken,
user: {
id: String(user.id),
name: String(user.name),
account: String(user.account),
},
permissions: stringArray(me.permissions),
mustChangePassword: me.mustChangePassword === true,
};
}
async function createRole(
code: string,
id: string,
name: string,
permissions: string[],
): Promise<void> {
await prisma.role.deleteMany({ where: { code } });
await prisma.role.create({
data: {
id,
code,
name,
description: 'A6 e2e 临时角色',
isSystem: false,
status: 'ACTIVE',
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
dataScopes: { create: [{ id: ulid(), scopeType: 'all' }] },
},
});
}
async function createUser(
account: string,
name: string,
roleCodes: readonly string[],
): Promise<string> {
const [department, roles] = await Promise.all([
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
prisma.role.findMany({ where: { code: { in: [...roleCodes] } }, select: { id: true } }),
]);
await prisma.user.deleteMany({ where: { account } });
const userId = ulid();
await prisma.user.create({
data: {
id: userId,
name,
account,
email: `${account}@lingxi.cn`,
phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: roles.map((role) => ({ roleId: role.id })) },
},
});
return userId;
}
async function createTaskViaApi(
token: string,
input: { itemCount: number; mode: 'success' | 'mixed' | 'fail' },
idempotencyKey: string,
): Promise<{ status: number; taskId: string | null }> {
const response = await fetch(`${API_BASE}/media-tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
type: 'demo',
customerId,
itemCount: input.itemCount,
mode: input.mode,
}),
});
const body: unknown = await response.json().catch(() => null);
const taskId = isRecord(body) && typeof body.taskId === 'string' ? body.taskId : null;
return { status: response.status, taskId };
}
async function fetchTaskDetail(token: string, taskId: string): Promise<TaskDetail> {
const response = await fetch(`${API_BASE}/media-tasks/${taskId}`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok, `GET /media-tasks/${taskId}`).toBe(true);
const rawDetail = (await response.text()) || '{}';
const body = requireRecord(JSON.parse(rawDetail) as unknown, '任务详情');
return {
id: String(body.id),
status: String(body.status),
itemFailed: typeof body.itemFailed === 'number' ? body.itemFailed : 0,
itemSkipped: typeof body.itemSkipped === 'number' ? body.itemSkipped : 0,
};
}
/** 读取 SSE 首个事件帧的 id(用于 Last-Event-ID 续推验证),读完即断开 */
async function readSseFirstEventId(
taskId: string,
token: string,
lastEventId?: string,
): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, 20_000);
try {
const query =
lastEventId === undefined ? '' : `?last_event_id=${encodeURIComponent(lastEventId)}`;
const response = await fetch(`${API_BASE}/media-tasks/${taskId}/events${query}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
signal: controller.signal,
});
expect(response.ok, 'SSE 响应').toBe(true);
const reader = response.body?.getReader();
if (reader === undefined) throw new Error('SSE 响应无 body');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const match = /^id:\s*(\d+)$/m.exec(buffer);
if (match?.[1] !== undefined) {
await reader.cancel().catch(() => undefined);
return match[1];
}
}
throw new Error('SSE 未收到带 id 的事件');
} finally {
clearTimeout(timer);
controller.abort();
}
}
async function waitToast(page: Page, text: RegExp): Promise<void> {
await expect(page.locator('[role="alert"]').filter({ hasText: text }).first()).toBeVisible({
timeout: 15_000,
});
}
// ---------------------------------------------------------------------------
// 全局编排
// ---------------------------------------------------------------------------
test.beforeAll(async () => {
if (!ENABLED) return;
apiContext = await request.newContext();
try {
const web = await apiContext.get(`${WEB_BASE}/`);
expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
const customer = await prisma.customer.findFirst({
where: { status: 'ACTIVE' },
select: { id: true },
orderBy: { createdAt: 'asc' },
});
if (customer === null) throw new Error('缺少可提交任务的启用客户,请先执行 seed');
customerId = customer.id;
// 清理上次异常中断的遗留(按临时账号名 / 角色 code)
await prisma.mediaTask.deleteMany({ where: { createdByName: { in: [ADMIN_NAME] } } });
await createRole(READONLY_ROLE_CODE, READONLY_ROLE_ID, 'A6 端到端任务只读', ['material:read']);
await createRole(NONE_ROLE_CODE, NONE_ROLE_ID, 'A6 端到端无任务权限', ['delivery:read']);
adminUserId = await createUser(ADMIN_ACCOUNT, ADMIN_NAME, ['super_admin']);
readonlyUserId = await createUser(READONLY_ACCOUNT, READONLY_NAME, [READONLY_ROLE_CODE]);
noneUserId = await createUser(NONE_ACCOUNT, NONE_NAME, [NONE_ROLE_CODE]);
adminSession = await loginViaApi(ADMIN_ACCOUNT);
readonlySession = await loginViaApi(READONLY_ACCOUNT);
noneSession = await loginViaApi(NONE_ACCOUNT);
} catch (error) {
throw error instanceof Error ? error : new Error(String(error));
}
});
test.afterAll(async () => {
if (!ENABLED) {
await prisma.$disconnect();
return;
}
const ids = createdTaskIds.filter((id) => id !== '');
try {
await prisma.outboxEvent.deleteMany({
where: { aggregateType: 'media_task', aggregateId: { in: ids } },
});
await prisma.mediaTask.deleteMany({ where: { id: { in: ids } } });
await prisma.mediaTask.deleteMany({
where: {
createdBy: { in: [adminUserId, readonlyUserId, noneUserId].filter((id) => id !== '') },
},
});
await prisma.idempotencyKey.deleteMany({ where: { key: { startsWith: 'a6e2e-' } } });
await prisma.user.deleteMany({
where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT, NONE_ACCOUNT] } },
});
await prisma.role.deleteMany({ where: { code: { in: [READONLY_ROLE_CODE, NONE_ROLE_CODE] } } });
} finally {
await apiContext?.dispose();
await prisma.$disconnect();
}
});
// ---------------------------------------------------------------------------
// 用例
// ---------------------------------------------------------------------------
test('① 提交演练任务 → 列表 → 详情 SSE → 部分失败 → 仅重试失败项', async ({ page }) => {
const session = requireAdminSession();
await injectSession(page, session);
await page.goto('/material/tasks');
await expect(page.getByRole('heading', { name: '任务中心', level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: /提交演练任务/ }).first()).toBeVisible();
await page
.getByRole('button', { name: /提交演练任务/ })
.first()
.click();
const submitModal = page.locator('.semi-modal').filter({ hasText: '提交演练任务' });
await expect(submitModal).toBeVisible();
// 客户:选第一个可选项;条数 8;混合模式
await submitModal.locator('.semi-select').first().click();
await page.locator('.semi-select-option').first().click();
await submitModal.locator('.semi-input-number input').fill('8');
await submitModal.getByRole('button', { name: /混合成功/ }).click();
const submitResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname.endsWith('/media-tasks'),
);
await submitModal.getByRole('button', { name: '提交演练任务' }).click();
const submitResponse = await submitResponsePromise;
expect(submitResponse.status()).toBe(202);
const submitBody = requireRecord((await submitResponse.json()) as unknown, '提交响应');
const taskId = String(submitBody.taskId);
if (taskId === '' || taskId === 'undefined') {
throw new Error(`提交响应缺少 taskId:${JSON.stringify(submitBody).slice(0, 300)}`);
}
createdTaskIds.push(taskId);
await waitToast(page, /已提交/);
const taskNo = `T-${taskId.slice(-6).toUpperCase()}`;
await expect(
page.locator('.semi-table-tbody tr').filter({ hasText: taskNo }).first(),
).toBeVisible({ timeout: 15_000 });
// 顶栏角标出现进行中任务,面板可打开
await page.locator('.lx-task-badge-trigger button').click();
await expect(page.getByText('进行中的任务').first()).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.lx-task-badge-row').filter({ hasText: /演练/ }).first()).toBeVisible({
timeout: 10_000,
});
await page.keyboard.press('Escape');
await page.goto(`/material/tasks/${taskId}`);
await expect(page.getByRole('heading', { level: 1 })).toContainText('项');
await expect
.poll(async () => (await fetchTaskDetail(session.accessToken, taskId)).status, {
timeout: 40_000,
message: '等待演练任务收敛为部分失败',
})
.toBe('partial_failed');
await expect(page.getByText('部分失败').first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByText('演练模拟失败').first()).toBeVisible({ timeout: 15_000 });
const retryButton = page.getByRole('button', { name: /重试失败项/ }).first();
await expect(retryButton).toBeVisible();
await retryButton.click();
const confirmModal = page.locator('.semi-modal').filter({ hasText: '重试失败项' });
await expect(confirmModal).toBeVisible();
await confirmModal.getByRole('button', { name: /重试 \d+ 项/ }).click();
await waitToast(page, /已提交重试/);
await expect
.poll(async () => (await fetchTaskDetail(session.accessToken, taskId)).status, {
timeout: 40_000,
message: '等待重试后的任务再次收敛',
})
.toBe('partial_failed');
});
test('② 提交后立即取消 → 已取消且未开始项跳过', async ({ page }) => {
const session = requireAdminSession();
const { status, taskId } = await createTaskViaApi(
session.accessToken,
{ itemCount: 20, mode: 'success' },
`a6e2e-cancel-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
);
expect(status).toBe(202);
if (taskId === null) throw new Error('取消用例未拿到 taskId');
createdTaskIds.push(taskId);
await injectSession(page, session);
await page.goto(`/material/tasks/${taskId}`);
await expect(page.getByRole('button', { name: '取消任务' }).first()).toBeVisible({
timeout: 15_000,
});
await page.getByRole('button', { name: '取消任务' }).first().click();
const confirmModal = page.locator('.semi-modal').filter({ hasText: '取消后不可恢复' });
await expect(confirmModal).toBeVisible();
await confirmModal.getByRole('button', { name: '取消任务' }).click();
await waitToast(page, /任务已取消/);
await expect
.poll(async () => (await fetchTaskDetail(session.accessToken, taskId)).status, {
timeout: 30_000,
message: '等待任务取消',
})
.toBe('canceled');
await expect(page.getByText('已取消').first()).toBeVisible({ timeout: 15_000 });
const detail = await fetchTaskDetail(session.accessToken, taskId);
expect(detail.itemSkipped).toBeGreaterThan(0);
});
test('③ 同一 Idempotency-Key 重复提交返回同一任务,不同 body 冲突', async () => {
const session = requireAdminSession();
const key = `a6e2e-idem-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`;
const first = await createTaskViaApi(session.accessToken, { itemCount: 3, mode: 'success' }, key);
const second = await createTaskViaApi(
session.accessToken,
{ itemCount: 3, mode: 'success' },
key,
);
expect(first.status).toBe(202);
expect(second.status).toBe(202);
expect(second.taskId).toBe(first.taskId);
if (first.taskId !== null) createdTaskIds.push(first.taskId);
const fetchConflict = await fetch(`${API_BASE}/media-tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
'Idempotency-Key': key,
},
body: JSON.stringify({ type: 'demo', customerId, itemCount: 4, mode: 'success' }),
});
expect(fetchConflict.status).toBe(409);
const conflictBody = requireRecord((await fetchConflict.json()) as unknown, '冲突响应');
expect(conflictBody.code).toBe(40902);
});
test('④ SSE Last-Event-ID 断点续推不重发历史', async () => {
const session = requireAdminSession();
const { status, taskId } = await createTaskViaApi(
session.accessToken,
{ itemCount: 20, mode: 'success' },
`a6e2e-sse-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
);
expect(status).toBe(202);
if (taskId === null) throw new Error('SSE 用例未拿到 taskId');
createdTaskIds.push(taskId);
const firstId = await readSseFirstEventId(taskId, session.accessToken);
const secondId = await readSseFirstEventId(taskId, session.accessToken, firstId);
expect(Number(secondId)).toBeGreaterThan(Number(firstId));
});
test('⑤ 只读用户不渲染提交 / 取消 / 重试', async ({ page }) => {
const admin = requireAdminSession();
const readonly = requireReadonlySession();
const { status, taskId } = await createTaskViaApi(
admin.accessToken,
{ itemCount: 4, mode: 'mixed' },
`a6e2e-ro-${Date.now().toString(36)}-${randomBytes(3).toString('hex')}`,
);
expect(status).toBe(202);
if (taskId === null) throw new Error('只读用例未拿到 taskId');
createdTaskIds.push(taskId);
// 等任务执行结束,保证详情页展示的是可取消 / 可重试的状态而不是排队中
await expect
.poll(async () => (await fetchTaskDetail(admin.accessToken, taskId)).status, {
timeout: 40_000,
})
.not.toBe('queued');
await injectSession(page, readonly);
await page.goto('/material/tasks');
await expect(page.getByRole('heading', { name: '任务中心', level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: /提交演练任务/ })).toHaveCount(0);
await expect(page.getByText(/只读权限/).first()).toBeVisible({ timeout: 10_000 });
await page.goto(`/material/tasks/${taskId}`);
await expect(page.getByRole('heading', { level: 1 })).toContainText('项');
await expect(page.getByRole('button', { name: '取消任务' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /重试失败项/ })).toHaveCount(0);
});
test('⑥ 无 material:read 用户访问任务中心 → 403', async ({ page }) => {
const none = requireNoneSession();
await injectSession(page, none);
await page.goto('/material/tasks');
await expect(page).toHaveURL(/\/403$/, { timeout: 15_000 });
});