import fs from 'node:fs' import path from 'node:path' import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test' import { attachJson, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, envelopeData } from './helpers' type JsonRecord = Record type Headers = Record interface VersionedRef { id: string dataVersion: number } interface Activity { module: string action: string result: 'PASS' | 'FAIL' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] const versioned = (value: unknown): VersionedRef => { const item = asRecord(value) const id = String(item.id ?? '') const dataVersion = Number(item.dataVersion) if (!id || !Number.isInteger(dataVersion) || dataVersion < 0) { throw new Error('API 响应缺少有效的资源 id 或 dataVersion') } return { id, dataVersion } } const assertStatus = (response: APIResponse, expected: number | number[], label: string) => { const values = Array.isArray(expected) ? expected : [expected] expect(values, `${label}:HTTP ${response.status()}`).toContain(response.status()) } async function responseData( response: APIResponse, expected: number | number[], label: string, ): Promise { assertStatus(response, expected, label) let body: unknown try { body = await response.json() } catch { throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) } return envelopeData(body) } const addPass = ( activities: Activity[], module: string, action: string, response: APIResponse, resourceId?: string, detail?: string, ) => activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail, }) async function attachCaseReport( testInfo: TestInfo, title: string, activities: Activity[], cleanupErrors: string[], ) { await attachJson(testInfo, `${title}-生命周期与清理`, { runId, runPrefix, title, activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, security: '报告未记录登录密码、访问令牌、智能体一次性 API Key 或请求头。', }) } async function cleanupVersioned( request: APIRequestContext, headers: Headers, reference: VersionedRef | null, module: string, detailPath: (id: string) => string, deletePath: (id: string) => string, activities: Activity[], cleanupErrors: string[], ) { if (!reference) return try { for (let attempt = 0; attempt < 2; attempt += 1) { const detail = await request.get(detailPath(reference.id), { headers }) if (detail.status() === 404) { activities.push({ module, action: '失败兜底清理', result: 'CLEANED', resourceId: reference.id, detail: '资源已不存在' }) return } if (detail.status() !== 200) { const message = `${module} 清理读取失败:HTTP ${detail.status()}` cleanupErrors.push(message) activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: detail.status(), resourceId: reference.id, detail: message }) return } const current = versioned(envelopeData(await detail.json())) const removed = await request.delete(deletePath(reference.id), { headers, params: { dataVersion: String(current.dataVersion) }, }) if (removed.status() === 200 || removed.status() === 404) { const absent = await request.get(detailPath(reference.id), { headers }) if (absent.status() === 404) { activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: reference.id }) return } } if (removed.status() !== 409 || attempt === 1) { const message = `${module} 清理删除失败:HTTP ${removed.status()}` cleanupErrors.push(message) activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: reference.id, detail: message }) return } } } catch (error) { const message = `${module} 清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: reference.id, detail: message }) } } async function cleanupKnowledgeBase( request: APIRequestContext, headers: Headers, reference: VersionedRef | null, activities: Activity[], cleanupErrors: string[], ) { if (!reference) return try { for (let attempt = 0; attempt < 2; attempt += 1) { const listed = await request.get('/api/v1/knowledge/bases', { headers, params: { page: '1', pageSize: '200', keyword: runPrefix }, }) if (listed.status() !== 200) { const message = `知识库清理读取失败:HTTP ${listed.status()}` cleanupErrors.push(message) activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: listed.status(), resourceId: reference.id, detail: message }) return } const page = asRecord(envelopeData(await listed.json())) const current = asRecords(page.items).find((item) => String(item.id ?? '') === reference.id) if (!current) { activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANED', resourceId: reference.id, detail: '资源已不存在' }) return } const removed = await request.delete(`/api/v1/knowledge/bases/${reference.id}`, { headers, params: { dataVersion: String(Number(current.dataVersion)) }, }) if (removed.status() === 200 || removed.status() === 404) { activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: reference.id }) return } if (removed.status() !== 409 || attempt === 1) { const message = `知识库清理删除失败:HTTP ${removed.status()}` cleanupErrors.push(message) activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: reference.id, detail: message }) return } } } catch (error) { const message = `知识库清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: reference.id, detail: message }) } } async function waitForDocumentReady( request: APIRequestContext, headers: Headers, documentId: string, timeoutMs = 30_000, ) { const deadline = Date.now() + timeoutMs let latest: JsonRecord = {} while (Date.now() < deadline) { const response = await request.get(`/api/v1/knowledge/documents/${documentId}`, { headers }) latest = await responseData(response, 200, '读取知识资料处理状态') const status = String(latest.status ?? '').toLowerCase() if (status === 'ready') return latest if (status === 'failed') throw new Error(`知识资料处理失败:${String(latest.errorMessage ?? '未返回原因')}`) await new Promise((resolve) => setTimeout(resolve, 300)) } throw new Error(`知识资料在 ${timeoutMs}ms 内未进入 ready 状态,最后状态:${String(latest.status ?? 'unknown')}`) } test.describe('正式业务 API 数据生命周期', () => { test('能力资产:LLM 创建、读取、编辑、状态、校验与删除', async ({ request }, testInfo) => { const module = '能力资产' const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) let resource: VersionedRef | null = null let primaryError: unknown try { const createdResponse = await request.post('/api/v1/capabilities', { headers, data: { capabilityType: 'LLM', name: `${runPrefix}-本地语言模型`, type: '本地模型', description: 'APE2E 生命周期验证,不调用外部模型。', reference: `${runPrefix.toLowerCase()}-local-llm`, embedUrl: '', hotWords: [], enabled: false, }, }) const created = await responseData(createdResponse, 201, '创建 LLM 能力') resource = versioned(created) expect(created.capabilityType).toBe('LLM') expect(created.enabled).toBe(false) addPass(activities, module, '创建禁用的本地 LLM 能力', createdResponse, resource.id) const detailResponse = await request.get(`/api/v1/capabilities/${resource.id}`, { headers }) const detail = await responseData(detailResponse, 200, '读取 LLM 能力') expect(detail.name).toBe(`${runPrefix}-本地语言模型`) addPass(activities, module, '详情读取', detailResponse, resource.id) const listResponse = await request.get('/api/v1/capabilities', { headers, params: { capabilityType: 'LLM', keyword: runPrefix, page: '1', pageSize: '20' }, }) const page = asRecord(await responseData(listResponse, 200, '筛选 LLM 能力')) expect(asRecords(page.items).some((item) => String(item.id) === resource?.id)).toBeTruthy() addPass(activities, module, '列表筛选', listResponse, resource.id) const updateResponse = await request.put(`/api/v1/capabilities/${resource.id}`, { headers, data: { name: `${runPrefix}-本地语言模型-修订`, description: 'APE2E 已提交编辑。', dataVersion: resource.dataVersion, }, }) const updated = await responseData(updateResponse, 200, '编辑 LLM 能力') const staleVersion = resource.dataVersion resource = versioned(updated) expect(updated.description).toBe('APE2E 已提交编辑。') addPass(activities, module, '编辑并推进数据版本', updateResponse, resource.id) const staleResponse = await request.put(`/api/v1/capabilities/${resource.id}/status`, { headers, data: { enabled: true, dataVersion: staleVersion }, }) assertStatus(staleResponse, 409, '能力资产过期版本必须冲突') addPass(activities, module, 'CAS 过期写入拦截', staleResponse, resource.id) const enabledResponse = await request.put(`/api/v1/capabilities/${resource.id}/status`, { headers, data: { enabled: true, dataVersion: resource.dataVersion }, }) const enabled = await responseData(enabledResponse, 200, '启用 LLM 能力') resource = versioned(enabled) expect(enabled.enabled).toBe(true) addPass(activities, module, '启用状态提交', enabledResponse, resource.id) const verifyResponse = await request.post(`/api/v1/capabilities/${resource.id}/verify`, { headers }) const verified = await responseData(verifyResponse, 200, '校验本地 LLM 配置') expect(verified.valid).toBe(true) addPass(activities, module, '本地配置校验(无外部请求)', verifyResponse, resource.id) const refreshedResponse = await request.get(`/api/v1/capabilities/${resource.id}`, { headers }) const refreshed = await responseData(refreshedResponse, 200, '刷新能力版本') resource = versioned(refreshed) expect(refreshed.validationStatus).toBe('AVAILABLE') const id = resource.id const deleteResponse = await request.delete(`/api/v1/capabilities/${id}`, { headers, params: { dataVersion: String(resource.dataVersion) }, }) await responseData(deleteResponse, 200, '删除 LLM 能力') resource = null addPass(activities, module, '版本化删除', deleteResponse, id) const absent = await request.get(`/api/v1/capabilities/${id}`, { headers }) assertStatus(absent, 404, '删除后的能力不可读取') addPass(activities, module, '删除后不可读取', absent, id) } catch (error) { primaryError = error } finally { await cleanupVersioned( request, headers, resource, module, (id) => `/api/v1/capabilities/${id}`, (id) => `/api/v1/capabilities/${id}`, activities, cleanupErrors, ) await attachCaseReport(testInfo, module, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '能力资产测试数据必须清理干净').toEqual([]) }) test('知识库:知识库与 TXT 文档写入、检索问答、编辑和删除', async ({ request }, testInfo) => { test.setTimeout(120_000) const module = '知识库' const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) let knowledgeBase: VersionedRef | null = null let document: VersionedRef | null = null let primaryError: unknown const marker = `${runPrefix}-液压校验标记` try { const baseResponse = await request.post('/api/v1/knowledge/bases', { headers, data: { name: `${runPrefix}-设备检修知识库`, description: 'APE2E 真实知识库数据提交。', category: '端到端验证', tags: ['APE2E', '设备检修'], }, }) const base = await responseData(baseResponse, 201, '创建知识库') knowledgeBase = versioned(base) expect(base.scope).toBe('personal') addPass(activities, module, '创建知识库', baseResponse, knowledgeBase.id) const baseListResponse = await request.get('/api/v1/knowledge/bases', { headers, params: { keyword: runPrefix, page: '1', pageSize: '20' }, }) const basePage = asRecord(await responseData(baseListResponse, 200, '筛选知识库')) expect(asRecords(basePage.items).some((item) => String(item.id) === knowledgeBase?.id)).toBeTruthy() addPass(activities, module, '知识库列表筛选', baseListResponse, knowledgeBase.id) const baseUpdateResponse = await request.put(`/api/v1/knowledge/bases/${knowledgeBase.id}`, { headers, data: { name: `${runPrefix}-设备检修知识库-修订`, description: 'APE2E 已提交知识库编辑。', category: '端到端验证', tags: ['APE2E', '知识问答'], enabled: true, dataVersion: knowledgeBase.dataVersion, }, }) const baseUpdated = await responseData(baseUpdateResponse, 200, '编辑知识库') knowledgeBase = versioned(baseUpdated) addPass(activities, module, '编辑知识库', baseUpdateResponse, knowledgeBase.id) const initialContent = `设备检修知识:${marker}。拆卸液压管路前必须停机、断电并完成卸压。` const uploadResponse = await request.post('/api/v1/knowledge/documents', { headers, multipart: { knowledgeBaseId: knowledgeBase.id, file: { name: `${runPrefix}-检修规程.txt`, mimeType: 'text/plain', buffer: Buffer.from(initialContent, 'utf8'), }, }, }) const uploaded = asRecord(await responseData(uploadResponse, 201, '上传 TXT 知识资料')) const uploadedDocument = asRecord(uploaded.document) document = versioned(uploadedDocument) expect(String(asRecord(uploaded.job).id ?? '')).not.toBe('') addPass(activities, module, '上传 TXT 并创建索引任务', uploadResponse, document.id) let current = await waitForDocumentReady(request, headers, document.id) document = versioned(current) expect(Number(current.chunkCount)).toBeGreaterThanOrEqual(1) activities.push({ module, action: '轮询等待 TXT 索引 ready', result: 'PASS', resourceId: document.id }) const documentsResponse = await request.get('/api/v1/knowledge/documents', { headers, params: { knowledgeBaseId: knowledgeBase.id, page: '1', pageSize: '20' }, }) const documentsPage = asRecord(await responseData(documentsResponse, 200, '按知识库读取资料')) expect(asRecords(documentsPage.items).some((item) => String(item.id) === document?.id)).toBeTruthy() addPass(activities, module, '资料列表筛选', documentsResponse, document.id) const downloadResponse = await request.get(`/api/v1/knowledge/documents/${document.id}/file`, { headers }) assertStatus(downloadResponse, 200, '下载 TXT 原始资料') expect((await downloadResponse.body()).toString('utf8')).toContain(marker) addPass(activities, module, '下载并核对 TXT 内容', downloadResponse, document.id) const searchResponse = await request.post('/api/v1/knowledge/search', { headers, data: { query: marker, knowledgeBaseId: knowledgeBase.id, documentIds: [document.id], topK: 5 }, }) const search = asRecord(await responseData(searchResponse, 200, '检索 TXT 知识')) expect(asRecords(search.results).some((item) => String(item.documentId) === document?.id)).toBeTruthy() addPass(activities, module, '本地知识检索', searchResponse, document.id) const policyResponse = await request.get('/api/v1/knowledge/provider-policy', { headers }) const policy = await responseData(policyResponse, 200, '读取知识问答提供方策略') const mode = String(policy.mode ?? 'local').toLowerCase() if (policy.enabled === false || mode === 'local') { const answerResponse = await request.post('/api/v1/knowledge/answer', { headers, data: { query: marker, knowledgeBaseId: knowledgeBase.id, documentIds: [document.id], topK: 5 }, }) const answer = asRecord(await responseData(answerResponse, 200, '执行本地知识问答')) expect(String(answer.answer ?? '')).toContain(marker) expect(asRecords(answer.results).some((item) => String(item.documentId) === document?.id)).toBeTruthy() addPass(activities, module, '知识问答与引用核对', answerResponse, document.id, `providerMode=${mode}`) } else { activities.push({ module, action: '第三方/混合知识问答', result: 'SKIP', detail: `能力探测到 providerMode=${mode};为避免真实调用外部上游,仅完成本地 search 契约验证。`, }) } const updatedContent = `修订后的设备检修知识:${marker}-修订。复装后必须进行低压泄漏检测。` const documentUpdateResponse = await request.put(`/api/v1/knowledge/documents/${document.id}`, { headers, data: { name: `${runPrefix}-检修规程-修订.txt`, enabled: true, content: updatedContent, dataVersion: document.dataVersion, }, }) const documentUpdated = await responseData(documentUpdateResponse, 200, '编辑知识资料正文') document = versioned(documentUpdated) addPass(activities, module, '编辑 TXT 名称与正文', documentUpdateResponse, document.id) current = await waitForDocumentReady(request, headers, document.id) document = versioned(current) expect(String(current.content ?? '')).toContain(`${marker}-修订`) const documentId = document.id const documentDeleteResponse = await request.delete(`/api/v1/knowledge/documents/${documentId}`, { headers, params: { dataVersion: String(document.dataVersion) }, }) await responseData(documentDeleteResponse, 200, '删除知识资料') document = null addPass(activities, module, '删除 TXT 资料', documentDeleteResponse, documentId) const documentAbsent = await request.get(`/api/v1/knowledge/documents/${documentId}`, { headers }) assertStatus(documentAbsent, 404, '删除后的知识资料不可读取') const baseId = knowledgeBase.id const baseDeleteResponse = await request.delete(`/api/v1/knowledge/bases/${baseId}`, { headers, params: { dataVersion: String(knowledgeBase.dataVersion) }, }) await responseData(baseDeleteResponse, 200, '删除知识库') knowledgeBase = null addPass(activities, module, '删除空知识库', baseDeleteResponse, baseId) const afterDeleteResponse = await request.get('/api/v1/knowledge/bases', { headers, params: { keyword: runPrefix, page: '1', pageSize: '200' }, }) const afterDeletePage = asRecord(await responseData(afterDeleteResponse, 200, '复查知识库删除结果')) expect(asRecords(afterDeletePage.items).some((item) => String(item.id) === baseId)).toBe(false) addPass(activities, module, '删除后列表不可见', afterDeleteResponse, baseId) } catch (error) { primaryError = error } finally { await cleanupVersioned( request, headers, document, '知识资料', (id) => `/api/v1/knowledge/documents/${id}`, (id) => `/api/v1/knowledge/documents/${id}`, activities, cleanupErrors, ) await cleanupKnowledgeBase(request, headers, knowledgeBase, activities, cleanupErrors) await attachCaseReport(testInfo, module, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '知识库测试数据必须清理干净').toEqual([]) }) test('智能体:项目与 Draft 智能体的创建、编辑、成员关系和删除', async ({ request }, testInfo) => { const module = '智能体项目' const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) let project: VersionedRef | null = null let agent: VersionedRef | null = null let primaryError: unknown try { const projectResponse = await request.post('/api/v1/agent-projects', { headers, data: { name: `${runPrefix}-实训智能体项目`, code: `${runPrefix.toLowerCase()}-agent-project`, description: 'APE2E 项目创建数据。', unit: '端到端测试单元', status: 'enabled', }, }) const projectCreated = await responseData(projectResponse, 201, '创建智能体项目') project = versioned(projectCreated) addPass(activities, module, '创建项目', projectResponse, project.id) const projectListResponse = await request.get('/api/v1/agent-projects', { headers, params: { keyword: runPrefix, page: '1', pageSize: '20' }, }) const projectPage = asRecord(await responseData(projectListResponse, 200, '筛选智能体项目')) expect(asRecords(projectPage.items).some((item) => String(item.id) === project?.id)).toBeTruthy() addPass(activities, module, '项目列表筛选', projectListResponse, project.id) const projectUpdateResponse = await request.put(`/api/v1/agent-projects/${project.id}`, { headers, data: { name: `${runPrefix}-实训智能体项目-修订`, code: `${runPrefix.toLowerCase()}-agent-project`, description: 'APE2E 已提交项目编辑。', unit: '端到端测试单元', status: 'enabled', dataVersion: project.dataVersion, }, }) project = versioned(await responseData(projectUpdateResponse, 200, '编辑智能体项目')) addPass(activities, module, '编辑项目', projectUpdateResponse, project.id) const disabledResponse = await request.put(`/api/v1/agent-projects/${project.id}/status`, { headers, data: { status: 'disabled', dataVersion: project.dataVersion }, }) const disabled = await responseData(disabledResponse, 200, '停用智能体项目') project = versioned(disabled) expect(disabled.status).toBe('disabled') addPass(activities, module, '停用项目', disabledResponse, project.id) const enabledResponse = await request.put(`/api/v1/agent-projects/${project.id}/status`, { headers, data: { status: 'enabled', dataVersion: project.dataVersion }, }) project = versioned(await responseData(enabledResponse, 200, '重新启用智能体项目')) addPass(activities, module, '重新启用项目', enabledResponse, project.id) const agentResponse = await request.post('/api/v1/realtime-agents', { headers, data: { name: `${runPrefix}-Draft数字教员`, projectId: project.id, description: 'APE2E Draft 智能体。', interactionModes: ['text'], expiresAt: '2030-08-15T12:00:00+08:00', }, }) const agentCreated = await responseData(agentResponse, 201, '创建 Draft 智能体') agent = versioned(agentCreated) expect(agentCreated.status).toBe('draft') addPass(activities, module, '创建 Draft 智能体(一次性 Key 不写报告)', agentResponse, agent.id) const agentDetailResponse = await request.get(`/api/v1/realtime-agents/${agent.id}`, { headers }) const agentDetail = await responseData(agentDetailResponse, 200, '读取 Draft 智能体') expect(agentDetail.projectId).toBe(project.id) expect(agentDetail.apiKey).toBeUndefined() expect(agentDetail.issuedApiKey).toBeUndefined() addPass(activities, module, '详情读取且不重复泄露一次性 Key', agentDetailResponse, agent.id) const agentUpdateResponse = await request.put(`/api/v1/realtime-agents/${agent.id}/sections/core`, { headers, data: { description: 'APE2E 已提交 Draft 核心配置编辑。', systemPrompt: '你是设备检修数字教员,仅依据正式安全规程回答。', welcomeMessage: '欢迎进行设备检修实训。', fallbackMessage: '当前资料不足,请联系教员。', interactionModes: ['text'], dataVersion: agent.dataVersion, }, }) const agentUpdated = await responseData(agentUpdateResponse, 200, '编辑 Draft 智能体') agent = versioned(agentUpdated) expect(agentUpdated.description).toContain('Draft 核心配置编辑') addPass(activities, module, '编辑 Draft 核心配置', agentUpdateResponse, agent.id) const membersResponse = await request.put(`/api/v1/agent-projects/${project.id}/members`, { headers, data: { agentIds: [agent.id], dataVersion: project.dataVersion }, }) const members = await responseData(membersResponse, 200, '提交项目成员') expect(Number(members.memberCount)).toBe(1) const projectAfterMembersResponse = await request.get(`/api/v1/agent-projects/${project.id}`, { headers }) project = versioned(await responseData(projectAfterMembersResponse, 200, '刷新项目成员版本')) addPass(activities, module, '项目成员关系提交', membersResponse, project.id) const publishResponse = await request.post(`/api/v1/realtime-agents/${agent.id}/publish`, { headers, data: { dataVersion: agent.dataVersion }, }) assertStatus(publishResponse, 409, '配置不完整的 Draft 智能体不得发布') addPass(activities, module, 'Draft 发布前完整性校验拦截', publishResponse, agent.id) const agentId = agent.id const agentDeleteResponse = await request.delete(`/api/v1/realtime-agents/${agentId}`, { headers, params: { dataVersion: String(agent.dataVersion) }, }) await responseData(agentDeleteResponse, 200, '删除 Draft 智能体') agent = null addPass(activities, module, '删除 Draft 智能体', agentDeleteResponse, agentId) const agentAbsent = await request.get(`/api/v1/realtime-agents/${agentId}`, { headers }) assertStatus(agentAbsent, 404, '删除后的 Draft 智能体不可读取') const refreshedProjectResponse = await request.get(`/api/v1/agent-projects/${project.id}`, { headers }) project = versioned(await responseData(refreshedProjectResponse, 200, '刷新项目版本')) const projectId = project.id const projectDeleteResponse = await request.delete(`/api/v1/agent-projects/${projectId}`, { headers, params: { dataVersion: String(project.dataVersion) }, }) await responseData(projectDeleteResponse, 200, '删除智能体项目') project = null addPass(activities, module, '删除空项目', projectDeleteResponse, projectId) const projectAbsent = await request.get(`/api/v1/agent-projects/${projectId}`, { headers }) assertStatus(projectAbsent, 404, '删除后的项目不可读取') } catch (error) { primaryError = error } finally { await cleanupVersioned( request, headers, agent, 'Draft智能体', (id) => `/api/v1/realtime-agents/${id}`, (id) => `/api/v1/realtime-agents/${id}`, activities, cleanupErrors, ) await cleanupVersioned( request, headers, project, module, (id) => `/api/v1/agent-projects/${id}`, (id) => `/api/v1/agent-projects/${id}`, activities, cleanupErrors, ) await attachCaseReport(testInfo, module, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '智能体与项目测试数据必须清理干净').toEqual([]) }) test('运营配置:敏感词、唤醒词、热词和工具的完整 CRUD', async ({ request }, testInfo) => { const module = '运营配置' const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) let sensitive: VersionedRef | null = null let wake: VersionedRef | null = null let hot: VersionedRef | null = null let tool: VersionedRef | null = null let primaryError: unknown try { const sensitiveResponse = await request.post('/api/v1/sensitive-words', { headers, data: { word: `${runPrefix}-违规操作`, category: 'APE2E安全', matchMode: '包含匹配', level: '高', replacement: '请遵守设备检修安全规程。', remark: 'APE2E 创建', enabled: true, }, }) const sensitiveCreated = await responseData(sensitiveResponse, 201, '创建敏感词') sensitive = versioned(sensitiveCreated) addPass(activities, '敏感词', '创建', sensitiveResponse, sensitive.id) const sensitiveListResponse = await request.get('/api/v1/sensitive-words', { headers, params: { keyword: runPrefix, page: '1', pageSize: '20' }, }) const sensitivePage = asRecord(await responseData(sensitiveListResponse, 200, '筛选敏感词')) expect(asRecords(sensitivePage.items).some((item) => String(item.id) === sensitive?.id)).toBeTruthy() addPass(activities, '敏感词', '列表筛选', sensitiveListResponse, sensitive.id) const sensitiveUpdateResponse = await request.put(`/api/v1/sensitive-words/${sensitive.id}`, { headers, data: { remark: 'APE2E 已编辑', replacement: '请停止违规操作并联系教员。', dataVersion: sensitive.dataVersion }, }) sensitive = versioned(await responseData(sensitiveUpdateResponse, 200, '编辑敏感词')) addPass(activities, '敏感词', '编辑', sensitiveUpdateResponse, sensitive.id) const sensitiveStatusResponse = await request.put(`/api/v1/sensitive-words/${sensitive.id}/status`, { headers, data: { enabled: false, dataVersion: sensitive.dataVersion }, }) const sensitiveDisabled = await responseData(sensitiveStatusResponse, 200, '停用敏感词') sensitive = versioned(sensitiveDisabled) expect(sensitiveDisabled.enabled).toBe(false) addPass(activities, '敏感词', '停用', sensitiveStatusResponse, sensitive.id) const sensitiveId = sensitive.id const sensitiveDeleteResponse = await request.delete(`/api/v1/sensitive-words/${sensitiveId}`, { headers, params: { dataVersion: String(sensitive.dataVersion) }, }) await responseData(sensitiveDeleteResponse, 200, '删除敏感词') sensitive = null addPass(activities, '敏感词', '删除', sensitiveDeleteResponse, sensitiveId) const sensitiveAbsent = await request.get(`/api/v1/sensitive-words/${sensitiveId}`, { headers }) assertStatus(sensitiveAbsent, 404, '删除后的敏感词不可读取') const invalidWakeResponse = await request.post('/api/v1/wake-words', { headers, data: { name: `${runPrefix}-非法时段探针`, phrase: `${runPrefix}非法时段探针`, reply: '该请求不应写入数据库。', sensitivity: 50, activePeriod: '24:00-25:00', enabled: true, }, }) assertStatus(invalidWakeResponse, 400, '非法唤醒时段必须在写入前被拒绝') activities.push({ module: '唤醒词', action: '非法时段写入前校验', result: 'PASS', httpStatus: invalidWakeResponse.status(), detail: '24:00-25:00 被拒绝,未创建不可清理的数据。', }) const wakeResponse = await request.post('/api/v1/wake-words', { headers, data: { name: `${runPrefix}-检修唤醒`, phrase: `${runPrefix}你好教员`, reply: '您好,请描述设备现象。', sensitivity: 78, activePeriod: '06:00-22:00', enabled: true, }, }) const wakeCreated = await responseData(wakeResponse, 201, '创建唤醒词') wake = versioned(wakeCreated) expect(wakeCreated.activePeriod).toBe('06:00-22:00') addPass(activities, '唤醒词', '创建自定义时段', wakeResponse, wake.id, 'activePeriod=06:00-22:00') const wakeDetailResponse = await request.get(`/api/v1/wake-words/${wake.id}`, { headers }) const wakeDetail = await responseData(wakeDetailResponse, 200, '读取自定义时段唤醒词') expect(wakeDetail.activePeriod).toBe('06:00-22:00') addPass(activities, '唤醒词', '读取自定义时段', wakeDetailResponse, wake.id) const wakeListResponse = await request.get('/api/v1/wake-words', { headers, params: { keyword: `${runPrefix}你好教员`, page: '1', pageSize: '20' }, }) const wakeList = asRecord(await responseData(wakeListResponse, 200, '检索自定义时段唤醒词')) expect(asRecords(wakeList.items).some((item) => String(item.id) === wake?.id && item.activePeriod === '06:00-22:00')).toBeTruthy() addPass(activities, '唤醒词', '列表检索自定义时段', wakeListResponse, wake.id) const wakeTestResponse = await request.post(`/api/v1/wake-words/${wake.id}/test`, { headers }) if (wakeTestResponse.status() === 200) { const wakeTest = await responseData(wakeTestResponse, 200, '测试唤醒词匹配') expect(wakeTest.matched).toBe(true) addPass(activities, '唤醒词', '本地匹配测试', wakeTestResponse, wake.id) } else { activities.push({ module: '唤醒词', action: '本地匹配测试', result: 'FAIL', httpStatus: wakeTestResponse.status(), resourceId: wake.id, detail: '已启用且刚创建的唤醒词测试接口返回非 200;继续执行后续 CRUD 并在报告中保留该真实缺陷。', }) expect.soft(wakeTestResponse.status(), '已启用全天唤醒词的本地匹配测试接口应返回 HTTP 200').toBe(200) } const wakeUpdateResponse = await request.put(`/api/v1/wake-words/${wake.id}`, { headers, data: { reply: 'APE2E 已编辑唤醒回复。', sensitivity: 82, activePeriod: '06:30-21:30', dataVersion: wake.dataVersion }, }) if (wakeUpdateResponse.status() === 200) { const wakeUpdated = await responseData(wakeUpdateResponse, 200, '编辑唤醒词') expect(wakeUpdated.activePeriod).toBe('06:30-21:30') wake = versioned(wakeUpdated) addPass(activities, '唤醒词', '编辑并更新自定义时段', wakeUpdateResponse, wake.id, 'activePeriod=06:30-21:30') } else { activities.push({ module: '唤醒词', action: '编辑', result: 'FAIL', httpStatus: wakeUpdateResponse.status(), resourceId: wake.id, detail: '真实编辑请求失败;继续验证其他模块。' }) expect.soft(wakeUpdateResponse.status(), '唤醒词编辑接口应返回 HTTP 200').toBe(200) } const wakeStatusResponse = await request.put(`/api/v1/wake-words/${wake.id}/status`, { headers, data: { enabled: false, dataVersion: wake.dataVersion }, }) if (wakeStatusResponse.status() === 200) { const wakeDisabled = await responseData(wakeStatusResponse, 200, '停用唤醒词') expect(wakeDisabled.activePeriod).toBe('06:30-21:30') wake = versioned(wakeDisabled) addPass(activities, '唤醒词', '停用', wakeStatusResponse, wake.id) } else { activities.push({ module: '唤醒词', action: '停用', result: 'FAIL', httpStatus: wakeStatusResponse.status(), resourceId: wake.id, detail: '真实状态提交失败;继续验证其他模块。' }) expect.soft(wakeStatusResponse.status(), '唤醒词状态接口应返回 HTTP 200').toBe(200) } const wakeId = wake.id const wakeDeleteResponse = await request.delete(`/api/v1/wake-words/${wakeId}`, { headers, params: { dataVersion: String(wake.dataVersion) }, }) if (wakeDeleteResponse.status() === 200) { await responseData(wakeDeleteResponse, 200, '删除唤醒词') wake = null addPass(activities, '唤醒词', '删除', wakeDeleteResponse, wakeId) const wakeAbsent = await request.get(`/api/v1/wake-words/${wakeId}`, { headers }) assertStatus(wakeAbsent, 404, '删除后的唤醒词不可读取') } else { activities.push({ module: '唤醒词', action: '删除', result: 'FAIL', httpStatus: wakeDeleteResponse.status(), resourceId: wake.id, detail: 'API 删除失败,finally 将再次按当前版本尝试兜底清理。' }) expect.soft(wakeDeleteResponse.status(), '唤醒词删除接口应返回 HTTP 200').toBe(200) } const hotResponse = await request.post('/api/v1/hot-words', { headers, data: { term: `${runPrefix}-液压泵`, aliases: [`${runPrefix}-液压蹦`, `${runPrefix}-液压蹦`, `${runPrefix}-液压崩`], category: 'APE2E液压系统', description: '端到端热词。', enabled: true, }, }) const hotCreated = await responseData(hotResponse, 201, '创建热词') hot = versioned(hotCreated) expect(Array.isArray(hotCreated.aliases) ? hotCreated.aliases.length : 0).toBe(2) addPass(activities, '热词', '创建并去重别名', hotResponse, hot.id) const hotListResponse = await request.get('/api/v1/hot-words', { headers, params: { keyword: runPrefix, page: '1', pageSize: '20' }, }) const hotPage = asRecord(await responseData(hotListResponse, 200, '筛选热词')) expect(asRecords(hotPage.items).some((item) => String(item.id) === hot?.id)).toBeTruthy() addPass(activities, '热词', '列表筛选', hotListResponse, hot.id) const hotUpdateResponse = await request.put(`/api/v1/hot-words/${hot.id}`, { headers, data: { aliases: [`${runPrefix}-液压棒`], description: 'APE2E 已编辑热词。', dataVersion: hot.dataVersion }, }) const hotUpdated = await responseData(hotUpdateResponse, 200, '编辑热词') hot = versioned(hotUpdated) expect(hotUpdated.aliases).toEqual([`${runPrefix}-液压棒`]) addPass(activities, '热词', '编辑', hotUpdateResponse, hot.id) const hotStatusResponse = await request.put(`/api/v1/hot-words/${hot.id}/status`, { headers, data: { enabled: false, dataVersion: hot.dataVersion }, }) hot = versioned(await responseData(hotStatusResponse, 200, '停用热词')) addPass(activities, '热词', '停用', hotStatusResponse, hot.id) const hotId = hot.id const hotDeleteResponse = await request.delete(`/api/v1/hot-words/${hotId}`, { headers, params: { dataVersion: String(hot.dataVersion) }, }) await responseData(hotDeleteResponse, 200, '删除热词') hot = null addPass(activities, '热词', '删除', hotDeleteResponse, hotId) const hotAbsent = await request.get(`/api/v1/hot-words/${hotId}`, { headers }) assertStatus(hotAbsent, 404, '删除后的热词不可读取') const toolResponse = await request.post('/api/v1/tools', { headers, data: { name: `${runPrefix}-检修工具入口`, domain: 'https://example.com', entryPath: '/ape2e/start', category: 'APE2E业务协同', description: '仅验证平台工具配置,不探测外部网络。', openMode: '新窗口打开', enabled: false, sortOrder: 9900, allowedRoleCodes: [], }, }) const toolCreated = await responseData(toolResponse, 201, '创建工具') tool = versioned(toolCreated) expect(toolCreated.connectionStatus).toBe('untested') addPass(activities, '工具', '创建禁用工具', toolResponse, tool.id) const toolListResponse = await request.get('/api/v1/tools', { headers, params: { keyword: runPrefix, page: '1', pageSize: '20' }, }) const toolPage = asRecord(await responseData(toolListResponse, 200, '筛选工具')) expect(asRecords(toolPage.items).some((item) => String(item.id) === tool?.id)).toBeTruthy() addPass(activities, '工具', '列表筛选', toolListResponse, tool.id) const toolUpdateResponse = await request.put(`/api/v1/tools/${tool.id}`, { headers, data: { entryPath: '/ape2e/updated', description: 'APE2E 已提交工具编辑。', dataVersion: tool.dataVersion, }, }) tool = versioned(await responseData(toolUpdateResponse, 200, '编辑工具')) addPass(activities, '工具', '编辑', toolUpdateResponse, tool.id) const toolStatusResponse = await request.put(`/api/v1/tools/${tool.id}/status`, { headers, data: { enabled: true, dataVersion: tool.dataVersion }, }) tool = versioned(await responseData(toolStatusResponse, 200, '启用工具')) addPass(activities, '工具', '启用', toolStatusResponse, tool.id) const launchResponse = await request.get(`/api/v1/tools/${tool.id}/launch`, { headers }) const launch = await responseData(launchResponse, 200, '读取工具启动参数') expect(launch.url).toBe('https://example.com/ape2e/updated') addPass(activities, '工具', '生成启动地址(未发起外部请求)', launchResponse, tool.id) activities.push({ module: '工具', action: '外部连接探测', result: 'SKIP', resourceId: tool.id, detail: '工具配置 CRUD 与启动地址已验证;/check 会访问外部域名,按能力边界不执行且不使用 Mock。', }) const toolId = tool.id const toolDeleteResponse = await request.delete(`/api/v1/tools/${toolId}`, { headers, params: { dataVersion: String(tool.dataVersion) }, }) await responseData(toolDeleteResponse, 200, '删除工具') tool = null addPass(activities, '工具', '删除', toolDeleteResponse, toolId) const toolAbsent = await request.get(`/api/v1/tools/${toolId}`, { headers }) assertStatus(toolAbsent, 404, '删除后的工具不可读取') } catch (error) { primaryError = error } finally { await cleanupVersioned(request, headers, tool, '工具', (id) => `/api/v1/tools/${id}`, (id) => `/api/v1/tools/${id}`, activities, cleanupErrors) await cleanupVersioned(request, headers, hot, '热词', (id) => `/api/v1/hot-words/${id}`, (id) => `/api/v1/hot-words/${id}`, activities, cleanupErrors) await cleanupVersioned(request, headers, wake, '唤醒词', (id) => `/api/v1/wake-words/${id}`, (id) => `/api/v1/wake-words/${id}`, activities, cleanupErrors) await cleanupVersioned(request, headers, sensitive, '敏感词', (id) => `/api/v1/sensitive-words/${id}`, (id) => `/api/v1/sensitive-words/${id}`, activities, cleanupErrors) await attachCaseReport(testInfo, module, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '运营配置测试数据必须清理干净').toEqual([]) }) test('视频工程:创建、编辑、四轨时间线、CAS 与删除', async ({ request }, testInfo) => { test.setTimeout(240_000) const module = '视频工程' const activities: Activity[] = [] const cleanupErrors: string[] = [] const headers = await authHeaders(request) let project: VersionedRef | null = null let generatedAvatar: VersionedRef | null = null let primaryError: unknown try { const avatarsResponse = await request.get('/api/v1/avatars', { headers, params: { page: '1', pageSize: '100', status: 'ready' }, }) const avatarsPage = asRecord(await responseData(avatarsResponse, 200, '读取可用数字形象')) let avatarId = String(asRecords(avatarsPage.items)[0]?.id ?? '') if (!avatarId) { const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') expect(fs.existsSync(fixturePath), `视频工程依赖的真实形象素材不存在:${fixturePath}`).toBeTruthy() const createAvatarResponse = await request.post('/api/v1/avatars', { headers, multipart: { name: `${runPrefix}-视频工程依赖形象`, resize: 'true', matting: 'off', file: { name: `${runPrefix}-video-project-avatar.mp4`, mimeType: 'video/mp4', buffer: fs.readFileSync(fixturePath), }, }, timeout: 120_000, }) const createdPayload = asRecord(await responseData(createAvatarResponse, 201, '创建视频工程依赖形象')) generatedAvatar = versioned(createdPayload.avatar) avatarId = generatedAvatar.id const jobId = String(asRecord(createdPayload.job).id ?? '') expect(jobId).not.toBe('') const deadline = Date.now() + 150_000 let ready = false while (Date.now() < deadline) { const jobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) const job = asRecord(await responseData(jobResponse, 200, '等待视频工程依赖形象')) const status = String(job.status ?? '').toLowerCase() if (status === 'succeeded') { ready = true break } if (['failed', 'cancelled'].includes(status)) throw new Error(`视频工程依赖形象生成失败:${status}`) await new Promise((resolve) => setTimeout(resolve, 750)) } expect(ready, '视频工程依赖形象应在 150 秒内生成完成').toBeTruthy() const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) const avatarDetail = await responseData(avatarDetailResponse, 200, '刷新视频工程依赖形象') generatedAvatar = versioned(avatarDetail) expect(String(avatarDetail.status).toLowerCase()).toBe('ready') addPass(activities, module, '通过真实上传生成工程依赖形象', createAvatarResponse, avatarId) } const voicesResponse = await request.get('/api/v1/capabilities', { headers, params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' }, }) const voicesPage = asRecord(await responseData(voicesResponse, 200, '读取可用 TTS 能力')) const voice = asRecords(voicesPage.items)[0] const voiceId = String(voice?.id ?? '') expect(voiceId, '视频工程必须选择平台已启用的正式 TTS 能力').not.toBe('') activities.push({ module, action: '依赖能力探测', result: 'PASS', detail: '已选择平台现有 ready 数字形象和已启用音色;报告不复制资源配置。' }) const createResponse = await request.post('/api/v1/video-projects', { headers, data: { name: `${runPrefix}-设备检修口播工程`, scriptText: '停机、断电、卸压后方可拆卸液压管路。', avatarId, voiceCapabilityId: voiceId, voiceSpeed: 1, orientation: 'portrait', backgroundType: 'workshop', subtitleEnabled: true, subtitleFont: 'Microsoft YaHei', subtitleFontSize: 42, subtitlePosition: 'bottom', durationMs: 4500, }, }) const created = await responseData(createResponse, 201, '创建视频工程') project = versioned(created) expect(created.outputWidth).toBe(1080) expect(created.outputHeight).toBe(1920) addPass(activities, module, '创建竖屏工程', createResponse, project.id) const listResponse = await request.get('/api/v1/video-projects', { headers, params: { keyword: runPrefix, page: '1', pageSize: '20' }, }) const page = asRecord(await responseData(listResponse, 200, '筛选视频工程')) expect(asRecords(page.items).some((item) => String(item.id) === project?.id)).toBeTruthy() addPass(activities, module, '列表筛选', listResponse, project.id) const updateResponse = await request.put(`/api/v1/video-projects/${project.id}`, { headers, data: { name: `${runPrefix}-设备检修口播工程-修订`, scriptText: '停机、断电、验电、卸压后,确认压力表回零。', voiceSpeed: 1.1, dataVersion: project.dataVersion, }, }) const updated = await responseData(updateResponse, 200, '编辑视频工程') project = versioned(updated) expect(updated.voiceSpeed).toBe(1.1) addPass(activities, module, '编辑工程参数', updateResponse, project.id) const timeline = { dataVersion: project.dataVersion, durationMs: 4500, tracks: [ { code: 'AVATAR', name: '数字人', order: 0, clips: [{ id: `${runPrefix}-avatar-clip`, type: 'AVATAR', startMs: 0, endMs: 4500 }], }, { code: 'MEDIA', name: '画面素材', order: 1, clips: [] }, { code: 'AUDIO', name: '本地配音', order: 2, clips: [{ id: `${runPrefix}-audio-clip`, type: 'AUDIO', capabilityId: voiceId, startMs: 0, endMs: 4500 }], }, { code: 'SUBTITLE', name: '同步字幕', order: 3, clips: [{ id: `${runPrefix}-subtitle-clip`, type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 4500, style: { font: 'Microsoft YaHei', size: 42 } }], }, ], } const timelineResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline }) const timelineSaved = await responseData(timelineResponse, 200, '保存四轨时间线') project = versioned(timelineSaved) const savedTimeline = asRecord(timelineSaved.timeline) expect(asRecords(savedTimeline.tracks).map((track) => track.code)).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE']) addPass(activities, module, '保存四轨时间线', timelineResponse, project.id) const staleResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline }) assertStatus(staleResponse, 409, '过期时间线版本必须冲突') addPass(activities, module, 'CAS 过期时间线拦截', staleResponse, project.id) const detailResponse = await request.get(`/api/v1/video-projects/${project.id}`, { headers }) const detail = await responseData(detailResponse, 200, '读取视频工程详情') project = versioned(detail) expect(asRecords(asRecord(detail.timeline).tracks).length).toBe(4) addPass(activities, module, '读取持久化时间线', detailResponse, project.id) const projectId = project.id const deleteResponse = await request.delete(`/api/v1/video-projects/${projectId}`, { headers, params: { dataVersion: String(project.dataVersion) }, }) await responseData(deleteResponse, 200, '删除视频工程') project = null addPass(activities, module, '删除工程', deleteResponse, projectId) const absent = await request.get(`/api/v1/video-projects/${projectId}`, { headers }) assertStatus(absent, 404, '删除后的视频工程不可读取') addPass(activities, module, '删除后不可读取', absent, projectId) } catch (error) { primaryError = error } finally { await cleanupVersioned( request, headers, project, module, (id) => `/api/v1/video-projects/${id}`, (id) => `/api/v1/video-projects/${id}`, activities, cleanupErrors, ) await cleanupVersioned( request, headers, generatedAvatar, '视频工程依赖形象', (id) => `/api/v1/avatars/${id}`, (id) => `/api/v1/avatars/${id}`, activities, cleanupErrors, ) await attachCaseReport(testInfo, module, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, '视频工程测试数据必须清理干净').toEqual([]) }) })