///
/**
* 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(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 {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 });
});