Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

1178 linhas
54 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test'
  4. import { attachJson, expect, runId, runPrefix, test } from './fixtures'
  5. import { authHeaders, envelopeData } from './helpers'
  6. type JsonRecord = Record<string, unknown>
  7. type Headers = Record<string, string>
  8. interface VersionedRef {
  9. id: string
  10. dataVersion: number
  11. }
  12. interface Activity {
  13. module: string
  14. action: string
  15. result: 'PASS' | 'FAIL' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED'
  16. httpStatus?: number
  17. resourceId?: string
  18. detail?: string
  19. }
  20. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object'
  21. ? value as JsonRecord
  22. : {}
  23. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value)
  24. ? value.map(asRecord)
  25. : []
  26. const versioned = (value: unknown): VersionedRef => {
  27. const item = asRecord(value)
  28. const id = String(item.id ?? '')
  29. const dataVersion = Number(item.dataVersion)
  30. if (!id || !Number.isInteger(dataVersion) || dataVersion < 0) {
  31. throw new Error('API 响应缺少有效的资源 id 或 dataVersion')
  32. }
  33. return { id, dataVersion }
  34. }
  35. const assertStatus = (response: APIResponse, expected: number | number[], label: string) => {
  36. const values = Array.isArray(expected) ? expected : [expected]
  37. expect(values, `${label}:HTTP ${response.status()}`).toContain(response.status())
  38. }
  39. async function responseData<T = JsonRecord>(
  40. response: APIResponse,
  41. expected: number | number[],
  42. label: string,
  43. ): Promise<T> {
  44. assertStatus(response, expected, label)
  45. let body: unknown
  46. try {
  47. body = await response.json()
  48. } catch {
  49. throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`)
  50. }
  51. return envelopeData<T>(body)
  52. }
  53. const addPass = (
  54. activities: Activity[],
  55. module: string,
  56. action: string,
  57. response: APIResponse,
  58. resourceId?: string,
  59. detail?: string,
  60. ) => activities.push({
  61. module,
  62. action,
  63. result: 'PASS',
  64. httpStatus: response.status(),
  65. resourceId,
  66. detail,
  67. })
  68. async function attachCaseReport(
  69. testInfo: TestInfo,
  70. title: string,
  71. activities: Activity[],
  72. cleanupErrors: string[],
  73. ) {
  74. await attachJson(testInfo, `${title}-生命周期与清理`, {
  75. runId,
  76. runPrefix,
  77. title,
  78. activities,
  79. cleanupComplete: cleanupErrors.length === 0,
  80. cleanupErrors,
  81. security: '报告未记录登录密码、访问令牌、智能体一次性 API Key 或请求头。',
  82. })
  83. }
  84. async function cleanupVersioned(
  85. request: APIRequestContext,
  86. headers: Headers,
  87. reference: VersionedRef | null,
  88. module: string,
  89. detailPath: (id: string) => string,
  90. deletePath: (id: string) => string,
  91. activities: Activity[],
  92. cleanupErrors: string[],
  93. ) {
  94. if (!reference) return
  95. try {
  96. for (let attempt = 0; attempt < 2; attempt += 1) {
  97. const detail = await request.get(detailPath(reference.id), { headers })
  98. if (detail.status() === 404) {
  99. activities.push({ module, action: '失败兜底清理', result: 'CLEANED', resourceId: reference.id, detail: '资源已不存在' })
  100. return
  101. }
  102. if (detail.status() !== 200) {
  103. const message = `${module} 清理读取失败:HTTP ${detail.status()}`
  104. cleanupErrors.push(message)
  105. activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: detail.status(), resourceId: reference.id, detail: message })
  106. return
  107. }
  108. const current = versioned(envelopeData(await detail.json()))
  109. const removed = await request.delete(deletePath(reference.id), {
  110. headers,
  111. params: { dataVersion: String(current.dataVersion) },
  112. })
  113. if (removed.status() === 200 || removed.status() === 404) {
  114. const absent = await request.get(detailPath(reference.id), { headers })
  115. if (absent.status() === 404) {
  116. activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: reference.id })
  117. return
  118. }
  119. }
  120. if (removed.status() !== 409 || attempt === 1) {
  121. const message = `${module} 清理删除失败:HTTP ${removed.status()}`
  122. cleanupErrors.push(message)
  123. activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: reference.id, detail: message })
  124. return
  125. }
  126. }
  127. } catch (error) {
  128. const message = `${module} 清理异常:${error instanceof Error ? error.message : String(error)}`
  129. cleanupErrors.push(message)
  130. activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: reference.id, detail: message })
  131. }
  132. }
  133. async function cleanupKnowledgeBase(
  134. request: APIRequestContext,
  135. headers: Headers,
  136. reference: VersionedRef | null,
  137. activities: Activity[],
  138. cleanupErrors: string[],
  139. ) {
  140. if (!reference) return
  141. try {
  142. for (let attempt = 0; attempt < 2; attempt += 1) {
  143. const listed = await request.get('/api/v1/knowledge/bases', {
  144. headers,
  145. params: { page: '1', pageSize: '200', keyword: runPrefix },
  146. })
  147. if (listed.status() !== 200) {
  148. const message = `知识库清理读取失败:HTTP ${listed.status()}`
  149. cleanupErrors.push(message)
  150. activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: listed.status(), resourceId: reference.id, detail: message })
  151. return
  152. }
  153. const page = asRecord(envelopeData(await listed.json()))
  154. const current = asRecords(page.items).find((item) => String(item.id ?? '') === reference.id)
  155. if (!current) {
  156. activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANED', resourceId: reference.id, detail: '资源已不存在' })
  157. return
  158. }
  159. const removed = await request.delete(`/api/v1/knowledge/bases/${reference.id}`, {
  160. headers,
  161. params: { dataVersion: String(Number(current.dataVersion)) },
  162. })
  163. if (removed.status() === 200 || removed.status() === 404) {
  164. activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: reference.id })
  165. return
  166. }
  167. if (removed.status() !== 409 || attempt === 1) {
  168. const message = `知识库清理删除失败:HTTP ${removed.status()}`
  169. cleanupErrors.push(message)
  170. activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: reference.id, detail: message })
  171. return
  172. }
  173. }
  174. } catch (error) {
  175. const message = `知识库清理异常:${error instanceof Error ? error.message : String(error)}`
  176. cleanupErrors.push(message)
  177. activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: reference.id, detail: message })
  178. }
  179. }
  180. async function waitForDocumentReady(
  181. request: APIRequestContext,
  182. headers: Headers,
  183. documentId: string,
  184. timeoutMs = 30_000,
  185. ) {
  186. const deadline = Date.now() + timeoutMs
  187. let latest: JsonRecord = {}
  188. while (Date.now() < deadline) {
  189. const response = await request.get(`/api/v1/knowledge/documents/${documentId}`, { headers })
  190. latest = await responseData<JsonRecord>(response, 200, '读取知识资料处理状态')
  191. const status = String(latest.status ?? '').toLowerCase()
  192. if (status === 'ready') return latest
  193. if (status === 'failed') throw new Error(`知识资料处理失败:${String(latest.errorMessage ?? '未返回原因')}`)
  194. await new Promise((resolve) => setTimeout(resolve, 300))
  195. }
  196. throw new Error(`知识资料在 ${timeoutMs}ms 内未进入 ready 状态,最后状态:${String(latest.status ?? 'unknown')}`)
  197. }
  198. test.describe('正式业务 API 数据生命周期', () => {
  199. test('能力资产:LLM 创建、读取、编辑、状态、校验与删除', async ({ request }, testInfo) => {
  200. const module = '能力资产'
  201. const activities: Activity[] = []
  202. const cleanupErrors: string[] = []
  203. const headers = await authHeaders(request)
  204. let resource: VersionedRef | null = null
  205. let primaryError: unknown
  206. try {
  207. const createdResponse = await request.post('/api/v1/capabilities', {
  208. headers,
  209. data: {
  210. capabilityType: 'LLM',
  211. name: `${runPrefix}-本地语言模型`,
  212. type: '本地模型',
  213. description: 'APE2E 生命周期验证,不调用外部模型。',
  214. reference: `${runPrefix.toLowerCase()}-local-llm`,
  215. embedUrl: '',
  216. hotWords: [],
  217. enabled: false,
  218. },
  219. })
  220. const created = await responseData<JsonRecord>(createdResponse, 201, '创建 LLM 能力')
  221. resource = versioned(created)
  222. expect(created.capabilityType).toBe('LLM')
  223. expect(created.enabled).toBe(false)
  224. addPass(activities, module, '创建禁用的本地 LLM 能力', createdResponse, resource.id)
  225. const detailResponse = await request.get(`/api/v1/capabilities/${resource.id}`, { headers })
  226. const detail = await responseData<JsonRecord>(detailResponse, 200, '读取 LLM 能力')
  227. expect(detail.name).toBe(`${runPrefix}-本地语言模型`)
  228. addPass(activities, module, '详情读取', detailResponse, resource.id)
  229. const listResponse = await request.get('/api/v1/capabilities', {
  230. headers,
  231. params: { capabilityType: 'LLM', keyword: runPrefix, page: '1', pageSize: '20' },
  232. })
  233. const page = asRecord(await responseData(listResponse, 200, '筛选 LLM 能力'))
  234. expect(asRecords(page.items).some((item) => String(item.id) === resource?.id)).toBeTruthy()
  235. addPass(activities, module, '列表筛选', listResponse, resource.id)
  236. const updateResponse = await request.put(`/api/v1/capabilities/${resource.id}`, {
  237. headers,
  238. data: {
  239. name: `${runPrefix}-本地语言模型-修订`,
  240. description: 'APE2E 已提交编辑。',
  241. dataVersion: resource.dataVersion,
  242. },
  243. })
  244. const updated = await responseData<JsonRecord>(updateResponse, 200, '编辑 LLM 能力')
  245. const staleVersion = resource.dataVersion
  246. resource = versioned(updated)
  247. expect(updated.description).toBe('APE2E 已提交编辑。')
  248. addPass(activities, module, '编辑并推进数据版本', updateResponse, resource.id)
  249. const staleResponse = await request.put(`/api/v1/capabilities/${resource.id}/status`, {
  250. headers,
  251. data: { enabled: true, dataVersion: staleVersion },
  252. })
  253. assertStatus(staleResponse, 409, '能力资产过期版本必须冲突')
  254. addPass(activities, module, 'CAS 过期写入拦截', staleResponse, resource.id)
  255. const enabledResponse = await request.put(`/api/v1/capabilities/${resource.id}/status`, {
  256. headers,
  257. data: { enabled: true, dataVersion: resource.dataVersion },
  258. })
  259. const enabled = await responseData<JsonRecord>(enabledResponse, 200, '启用 LLM 能力')
  260. resource = versioned(enabled)
  261. expect(enabled.enabled).toBe(true)
  262. addPass(activities, module, '启用状态提交', enabledResponse, resource.id)
  263. const verifyResponse = await request.post(`/api/v1/capabilities/${resource.id}/verify`, { headers })
  264. const verified = await responseData<JsonRecord>(verifyResponse, 200, '校验本地 LLM 配置')
  265. expect(verified.valid).toBe(true)
  266. addPass(activities, module, '本地配置校验(无外部请求)', verifyResponse, resource.id)
  267. const refreshedResponse = await request.get(`/api/v1/capabilities/${resource.id}`, { headers })
  268. const refreshed = await responseData<JsonRecord>(refreshedResponse, 200, '刷新能力版本')
  269. resource = versioned(refreshed)
  270. expect(refreshed.validationStatus).toBe('AVAILABLE')
  271. const id = resource.id
  272. const deleteResponse = await request.delete(`/api/v1/capabilities/${id}`, {
  273. headers,
  274. params: { dataVersion: String(resource.dataVersion) },
  275. })
  276. await responseData(deleteResponse, 200, '删除 LLM 能力')
  277. resource = null
  278. addPass(activities, module, '版本化删除', deleteResponse, id)
  279. const absent = await request.get(`/api/v1/capabilities/${id}`, { headers })
  280. assertStatus(absent, 404, '删除后的能力不可读取')
  281. addPass(activities, module, '删除后不可读取', absent, id)
  282. } catch (error) {
  283. primaryError = error
  284. } finally {
  285. await cleanupVersioned(
  286. request,
  287. headers,
  288. resource,
  289. module,
  290. (id) => `/api/v1/capabilities/${id}`,
  291. (id) => `/api/v1/capabilities/${id}`,
  292. activities,
  293. cleanupErrors,
  294. )
  295. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  296. }
  297. if (primaryError) throw primaryError
  298. expect(cleanupErrors, '能力资产测试数据必须清理干净').toEqual([])
  299. })
  300. test('知识库:知识库与 TXT 文档写入、检索问答、编辑和删除', async ({ request }, testInfo) => {
  301. test.setTimeout(120_000)
  302. const module = '知识库'
  303. const activities: Activity[] = []
  304. const cleanupErrors: string[] = []
  305. const headers = await authHeaders(request)
  306. let knowledgeBase: VersionedRef | null = null
  307. let document: VersionedRef | null = null
  308. let primaryError: unknown
  309. const marker = `${runPrefix}-液压校验标记`
  310. try {
  311. const baseResponse = await request.post('/api/v1/knowledge/bases', {
  312. headers,
  313. data: {
  314. name: `${runPrefix}-设备检修知识库`,
  315. description: 'APE2E 真实知识库数据提交。',
  316. category: '端到端验证',
  317. tags: ['APE2E', '设备检修'],
  318. },
  319. })
  320. const base = await responseData<JsonRecord>(baseResponse, 201, '创建知识库')
  321. knowledgeBase = versioned(base)
  322. expect(base.scope).toBe('personal')
  323. addPass(activities, module, '创建知识库', baseResponse, knowledgeBase.id)
  324. const baseListResponse = await request.get('/api/v1/knowledge/bases', {
  325. headers,
  326. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  327. })
  328. const basePage = asRecord(await responseData(baseListResponse, 200, '筛选知识库'))
  329. expect(asRecords(basePage.items).some((item) => String(item.id) === knowledgeBase?.id)).toBeTruthy()
  330. addPass(activities, module, '知识库列表筛选', baseListResponse, knowledgeBase.id)
  331. const baseUpdateResponse = await request.put(`/api/v1/knowledge/bases/${knowledgeBase.id}`, {
  332. headers,
  333. data: {
  334. name: `${runPrefix}-设备检修知识库-修订`,
  335. description: 'APE2E 已提交知识库编辑。',
  336. category: '端到端验证',
  337. tags: ['APE2E', '知识问答'],
  338. enabled: true,
  339. dataVersion: knowledgeBase.dataVersion,
  340. },
  341. })
  342. const baseUpdated = await responseData<JsonRecord>(baseUpdateResponse, 200, '编辑知识库')
  343. knowledgeBase = versioned(baseUpdated)
  344. addPass(activities, module, '编辑知识库', baseUpdateResponse, knowledgeBase.id)
  345. const initialContent = `设备检修知识:${marker}。拆卸液压管路前必须停机、断电并完成卸压。`
  346. const uploadResponse = await request.post('/api/v1/knowledge/documents', {
  347. headers,
  348. multipart: {
  349. knowledgeBaseId: knowledgeBase.id,
  350. file: {
  351. name: `${runPrefix}-检修规程.txt`,
  352. mimeType: 'text/plain',
  353. buffer: Buffer.from(initialContent, 'utf8'),
  354. },
  355. },
  356. })
  357. const uploaded = asRecord(await responseData(uploadResponse, 201, '上传 TXT 知识资料'))
  358. const uploadedDocument = asRecord(uploaded.document)
  359. document = versioned(uploadedDocument)
  360. expect(String(asRecord(uploaded.job).id ?? '')).not.toBe('')
  361. addPass(activities, module, '上传 TXT 并创建索引任务', uploadResponse, document.id)
  362. let current = await waitForDocumentReady(request, headers, document.id)
  363. document = versioned(current)
  364. expect(Number(current.chunkCount)).toBeGreaterThanOrEqual(1)
  365. activities.push({ module, action: '轮询等待 TXT 索引 ready', result: 'PASS', resourceId: document.id })
  366. const documentsResponse = await request.get('/api/v1/knowledge/documents', {
  367. headers,
  368. params: { knowledgeBaseId: knowledgeBase.id, page: '1', pageSize: '20' },
  369. })
  370. const documentsPage = asRecord(await responseData(documentsResponse, 200, '按知识库读取资料'))
  371. expect(asRecords(documentsPage.items).some((item) => String(item.id) === document?.id)).toBeTruthy()
  372. addPass(activities, module, '资料列表筛选', documentsResponse, document.id)
  373. const downloadResponse = await request.get(`/api/v1/knowledge/documents/${document.id}/file`, { headers })
  374. assertStatus(downloadResponse, 200, '下载 TXT 原始资料')
  375. expect((await downloadResponse.body()).toString('utf8')).toContain(marker)
  376. addPass(activities, module, '下载并核对 TXT 内容', downloadResponse, document.id)
  377. const searchResponse = await request.post('/api/v1/knowledge/search', {
  378. headers,
  379. data: { query: marker, knowledgeBaseId: knowledgeBase.id, documentIds: [document.id], topK: 5 },
  380. })
  381. const search = asRecord(await responseData(searchResponse, 200, '检索 TXT 知识'))
  382. expect(asRecords(search.results).some((item) => String(item.documentId) === document?.id)).toBeTruthy()
  383. addPass(activities, module, '本地知识检索', searchResponse, document.id)
  384. const policyResponse = await request.get('/api/v1/knowledge/provider-policy', { headers })
  385. const policy = await responseData<JsonRecord>(policyResponse, 200, '读取知识问答提供方策略')
  386. const mode = String(policy.mode ?? 'local').toLowerCase()
  387. if (policy.enabled === false || mode === 'local') {
  388. const answerResponse = await request.post('/api/v1/knowledge/answer', {
  389. headers,
  390. data: { query: marker, knowledgeBaseId: knowledgeBase.id, documentIds: [document.id], topK: 5 },
  391. })
  392. const answer = asRecord(await responseData(answerResponse, 200, '执行本地知识问答'))
  393. expect(String(answer.answer ?? '')).toContain(marker)
  394. expect(asRecords(answer.results).some((item) => String(item.documentId) === document?.id)).toBeTruthy()
  395. addPass(activities, module, '知识问答与引用核对', answerResponse, document.id, `providerMode=${mode}`)
  396. } else {
  397. activities.push({
  398. module,
  399. action: '第三方/混合知识问答',
  400. result: 'SKIP',
  401. detail: `能力探测到 providerMode=${mode};为避免真实调用外部上游,仅完成本地 search 契约验证。`,
  402. })
  403. }
  404. const updatedContent = `修订后的设备检修知识:${marker}-修订。复装后必须进行低压泄漏检测。`
  405. const documentUpdateResponse = await request.put(`/api/v1/knowledge/documents/${document.id}`, {
  406. headers,
  407. data: {
  408. name: `${runPrefix}-检修规程-修订.txt`,
  409. enabled: true,
  410. content: updatedContent,
  411. dataVersion: document.dataVersion,
  412. },
  413. })
  414. const documentUpdated = await responseData<JsonRecord>(documentUpdateResponse, 200, '编辑知识资料正文')
  415. document = versioned(documentUpdated)
  416. addPass(activities, module, '编辑 TXT 名称与正文', documentUpdateResponse, document.id)
  417. current = await waitForDocumentReady(request, headers, document.id)
  418. document = versioned(current)
  419. expect(String(current.content ?? '')).toContain(`${marker}-修订`)
  420. const documentId = document.id
  421. const documentDeleteResponse = await request.delete(`/api/v1/knowledge/documents/${documentId}`, {
  422. headers,
  423. params: { dataVersion: String(document.dataVersion) },
  424. })
  425. await responseData(documentDeleteResponse, 200, '删除知识资料')
  426. document = null
  427. addPass(activities, module, '删除 TXT 资料', documentDeleteResponse, documentId)
  428. const documentAbsent = await request.get(`/api/v1/knowledge/documents/${documentId}`, { headers })
  429. assertStatus(documentAbsent, 404, '删除后的知识资料不可读取')
  430. const baseId = knowledgeBase.id
  431. const baseDeleteResponse = await request.delete(`/api/v1/knowledge/bases/${baseId}`, {
  432. headers,
  433. params: { dataVersion: String(knowledgeBase.dataVersion) },
  434. })
  435. await responseData(baseDeleteResponse, 200, '删除知识库')
  436. knowledgeBase = null
  437. addPass(activities, module, '删除空知识库', baseDeleteResponse, baseId)
  438. const afterDeleteResponse = await request.get('/api/v1/knowledge/bases', {
  439. headers,
  440. params: { keyword: runPrefix, page: '1', pageSize: '200' },
  441. })
  442. const afterDeletePage = asRecord(await responseData(afterDeleteResponse, 200, '复查知识库删除结果'))
  443. expect(asRecords(afterDeletePage.items).some((item) => String(item.id) === baseId)).toBe(false)
  444. addPass(activities, module, '删除后列表不可见', afterDeleteResponse, baseId)
  445. } catch (error) {
  446. primaryError = error
  447. } finally {
  448. await cleanupVersioned(
  449. request,
  450. headers,
  451. document,
  452. '知识资料',
  453. (id) => `/api/v1/knowledge/documents/${id}`,
  454. (id) => `/api/v1/knowledge/documents/${id}`,
  455. activities,
  456. cleanupErrors,
  457. )
  458. await cleanupKnowledgeBase(request, headers, knowledgeBase, activities, cleanupErrors)
  459. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  460. }
  461. if (primaryError) throw primaryError
  462. expect(cleanupErrors, '知识库测试数据必须清理干净').toEqual([])
  463. })
  464. test('智能体:项目与 Draft 智能体的创建、编辑、成员关系和删除', async ({ request }, testInfo) => {
  465. const module = '智能体项目'
  466. const activities: Activity[] = []
  467. const cleanupErrors: string[] = []
  468. const headers = await authHeaders(request)
  469. let project: VersionedRef | null = null
  470. let agent: VersionedRef | null = null
  471. let primaryError: unknown
  472. try {
  473. const projectResponse = await request.post('/api/v1/agent-projects', {
  474. headers,
  475. data: {
  476. name: `${runPrefix}-实训智能体项目`,
  477. code: `${runPrefix.toLowerCase()}-agent-project`,
  478. description: 'APE2E 项目创建数据。',
  479. unit: '端到端测试单元',
  480. status: 'enabled',
  481. },
  482. })
  483. const projectCreated = await responseData<JsonRecord>(projectResponse, 201, '创建智能体项目')
  484. project = versioned(projectCreated)
  485. addPass(activities, module, '创建项目', projectResponse, project.id)
  486. const projectListResponse = await request.get('/api/v1/agent-projects', {
  487. headers,
  488. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  489. })
  490. const projectPage = asRecord(await responseData(projectListResponse, 200, '筛选智能体项目'))
  491. expect(asRecords(projectPage.items).some((item) => String(item.id) === project?.id)).toBeTruthy()
  492. addPass(activities, module, '项目列表筛选', projectListResponse, project.id)
  493. const projectUpdateResponse = await request.put(`/api/v1/agent-projects/${project.id}`, {
  494. headers,
  495. data: {
  496. name: `${runPrefix}-实训智能体项目-修订`,
  497. code: `${runPrefix.toLowerCase()}-agent-project`,
  498. description: 'APE2E 已提交项目编辑。',
  499. unit: '端到端测试单元',
  500. status: 'enabled',
  501. dataVersion: project.dataVersion,
  502. },
  503. })
  504. project = versioned(await responseData(projectUpdateResponse, 200, '编辑智能体项目'))
  505. addPass(activities, module, '编辑项目', projectUpdateResponse, project.id)
  506. const disabledResponse = await request.put(`/api/v1/agent-projects/${project.id}/status`, {
  507. headers,
  508. data: { status: 'disabled', dataVersion: project.dataVersion },
  509. })
  510. const disabled = await responseData<JsonRecord>(disabledResponse, 200, '停用智能体项目')
  511. project = versioned(disabled)
  512. expect(disabled.status).toBe('disabled')
  513. addPass(activities, module, '停用项目', disabledResponse, project.id)
  514. const enabledResponse = await request.put(`/api/v1/agent-projects/${project.id}/status`, {
  515. headers,
  516. data: { status: 'enabled', dataVersion: project.dataVersion },
  517. })
  518. project = versioned(await responseData(enabledResponse, 200, '重新启用智能体项目'))
  519. addPass(activities, module, '重新启用项目', enabledResponse, project.id)
  520. const agentResponse = await request.post('/api/v1/realtime-agents', {
  521. headers,
  522. data: {
  523. name: `${runPrefix}-Draft数字教员`,
  524. projectId: project.id,
  525. description: 'APE2E Draft 智能体。',
  526. interactionModes: ['text'],
  527. expiresAt: '2030-08-15T12:00:00+08:00',
  528. },
  529. })
  530. const agentCreated = await responseData<JsonRecord>(agentResponse, 201, '创建 Draft 智能体')
  531. agent = versioned(agentCreated)
  532. expect(agentCreated.status).toBe('draft')
  533. addPass(activities, module, '创建 Draft 智能体(一次性 Key 不写报告)', agentResponse, agent.id)
  534. const agentDetailResponse = await request.get(`/api/v1/realtime-agents/${agent.id}`, { headers })
  535. const agentDetail = await responseData<JsonRecord>(agentDetailResponse, 200, '读取 Draft 智能体')
  536. expect(agentDetail.projectId).toBe(project.id)
  537. expect(agentDetail.apiKey).toBeUndefined()
  538. expect(agentDetail.issuedApiKey).toBeUndefined()
  539. addPass(activities, module, '详情读取且不重复泄露一次性 Key', agentDetailResponse, agent.id)
  540. const agentUpdateResponse = await request.put(`/api/v1/realtime-agents/${agent.id}/sections/core`, {
  541. headers,
  542. data: {
  543. description: 'APE2E 已提交 Draft 核心配置编辑。',
  544. systemPrompt: '你是设备检修数字教员,仅依据正式安全规程回答。',
  545. welcomeMessage: '欢迎进行设备检修实训。',
  546. fallbackMessage: '当前资料不足,请联系教员。',
  547. interactionModes: ['text'],
  548. dataVersion: agent.dataVersion,
  549. },
  550. })
  551. const agentUpdated = await responseData<JsonRecord>(agentUpdateResponse, 200, '编辑 Draft 智能体')
  552. agent = versioned(agentUpdated)
  553. expect(agentUpdated.description).toContain('Draft 核心配置编辑')
  554. addPass(activities, module, '编辑 Draft 核心配置', agentUpdateResponse, agent.id)
  555. const membersResponse = await request.put(`/api/v1/agent-projects/${project.id}/members`, {
  556. headers,
  557. data: { agentIds: [agent.id], dataVersion: project.dataVersion },
  558. })
  559. const members = await responseData<JsonRecord>(membersResponse, 200, '提交项目成员')
  560. expect(Number(members.memberCount)).toBe(1)
  561. const projectAfterMembersResponse = await request.get(`/api/v1/agent-projects/${project.id}`, { headers })
  562. project = versioned(await responseData(projectAfterMembersResponse, 200, '刷新项目成员版本'))
  563. addPass(activities, module, '项目成员关系提交', membersResponse, project.id)
  564. const publishResponse = await request.post(`/api/v1/realtime-agents/${agent.id}/publish`, {
  565. headers,
  566. data: { dataVersion: agent.dataVersion },
  567. })
  568. assertStatus(publishResponse, 409, '配置不完整的 Draft 智能体不得发布')
  569. addPass(activities, module, 'Draft 发布前完整性校验拦截', publishResponse, agent.id)
  570. const agentId = agent.id
  571. const agentDeleteResponse = await request.delete(`/api/v1/realtime-agents/${agentId}`, {
  572. headers,
  573. params: { dataVersion: String(agent.dataVersion) },
  574. })
  575. await responseData(agentDeleteResponse, 200, '删除 Draft 智能体')
  576. agent = null
  577. addPass(activities, module, '删除 Draft 智能体', agentDeleteResponse, agentId)
  578. const agentAbsent = await request.get(`/api/v1/realtime-agents/${agentId}`, { headers })
  579. assertStatus(agentAbsent, 404, '删除后的 Draft 智能体不可读取')
  580. const refreshedProjectResponse = await request.get(`/api/v1/agent-projects/${project.id}`, { headers })
  581. project = versioned(await responseData(refreshedProjectResponse, 200, '刷新项目版本'))
  582. const projectId = project.id
  583. const projectDeleteResponse = await request.delete(`/api/v1/agent-projects/${projectId}`, {
  584. headers,
  585. params: { dataVersion: String(project.dataVersion) },
  586. })
  587. await responseData(projectDeleteResponse, 200, '删除智能体项目')
  588. project = null
  589. addPass(activities, module, '删除空项目', projectDeleteResponse, projectId)
  590. const projectAbsent = await request.get(`/api/v1/agent-projects/${projectId}`, { headers })
  591. assertStatus(projectAbsent, 404, '删除后的项目不可读取')
  592. } catch (error) {
  593. primaryError = error
  594. } finally {
  595. await cleanupVersioned(
  596. request,
  597. headers,
  598. agent,
  599. 'Draft智能体',
  600. (id) => `/api/v1/realtime-agents/${id}`,
  601. (id) => `/api/v1/realtime-agents/${id}`,
  602. activities,
  603. cleanupErrors,
  604. )
  605. await cleanupVersioned(
  606. request,
  607. headers,
  608. project,
  609. module,
  610. (id) => `/api/v1/agent-projects/${id}`,
  611. (id) => `/api/v1/agent-projects/${id}`,
  612. activities,
  613. cleanupErrors,
  614. )
  615. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  616. }
  617. if (primaryError) throw primaryError
  618. expect(cleanupErrors, '智能体与项目测试数据必须清理干净').toEqual([])
  619. })
  620. test('运营配置:敏感词、唤醒词、热词和工具的完整 CRUD', async ({ request }, testInfo) => {
  621. const module = '运营配置'
  622. const activities: Activity[] = []
  623. const cleanupErrors: string[] = []
  624. const headers = await authHeaders(request)
  625. let sensitive: VersionedRef | null = null
  626. let wake: VersionedRef | null = null
  627. let hot: VersionedRef | null = null
  628. let tool: VersionedRef | null = null
  629. let primaryError: unknown
  630. try {
  631. const sensitiveResponse = await request.post('/api/v1/sensitive-words', {
  632. headers,
  633. data: {
  634. word: `${runPrefix}-违规操作`,
  635. category: 'APE2E安全',
  636. matchMode: '包含匹配',
  637. level: '高',
  638. replacement: '请遵守设备检修安全规程。',
  639. remark: 'APE2E 创建',
  640. enabled: true,
  641. },
  642. })
  643. const sensitiveCreated = await responseData<JsonRecord>(sensitiveResponse, 201, '创建敏感词')
  644. sensitive = versioned(sensitiveCreated)
  645. addPass(activities, '敏感词', '创建', sensitiveResponse, sensitive.id)
  646. const sensitiveListResponse = await request.get('/api/v1/sensitive-words', {
  647. headers,
  648. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  649. })
  650. const sensitivePage = asRecord(await responseData(sensitiveListResponse, 200, '筛选敏感词'))
  651. expect(asRecords(sensitivePage.items).some((item) => String(item.id) === sensitive?.id)).toBeTruthy()
  652. addPass(activities, '敏感词', '列表筛选', sensitiveListResponse, sensitive.id)
  653. const sensitiveUpdateResponse = await request.put(`/api/v1/sensitive-words/${sensitive.id}`, {
  654. headers,
  655. data: { remark: 'APE2E 已编辑', replacement: '请停止违规操作并联系教员。', dataVersion: sensitive.dataVersion },
  656. })
  657. sensitive = versioned(await responseData(sensitiveUpdateResponse, 200, '编辑敏感词'))
  658. addPass(activities, '敏感词', '编辑', sensitiveUpdateResponse, sensitive.id)
  659. const sensitiveStatusResponse = await request.put(`/api/v1/sensitive-words/${sensitive.id}/status`, {
  660. headers,
  661. data: { enabled: false, dataVersion: sensitive.dataVersion },
  662. })
  663. const sensitiveDisabled = await responseData<JsonRecord>(sensitiveStatusResponse, 200, '停用敏感词')
  664. sensitive = versioned(sensitiveDisabled)
  665. expect(sensitiveDisabled.enabled).toBe(false)
  666. addPass(activities, '敏感词', '停用', sensitiveStatusResponse, sensitive.id)
  667. const sensitiveId = sensitive.id
  668. const sensitiveDeleteResponse = await request.delete(`/api/v1/sensitive-words/${sensitiveId}`, {
  669. headers,
  670. params: { dataVersion: String(sensitive.dataVersion) },
  671. })
  672. await responseData(sensitiveDeleteResponse, 200, '删除敏感词')
  673. sensitive = null
  674. addPass(activities, '敏感词', '删除', sensitiveDeleteResponse, sensitiveId)
  675. const sensitiveAbsent = await request.get(`/api/v1/sensitive-words/${sensitiveId}`, { headers })
  676. assertStatus(sensitiveAbsent, 404, '删除后的敏感词不可读取')
  677. const invalidWakeResponse = await request.post('/api/v1/wake-words', {
  678. headers,
  679. data: {
  680. name: `${runPrefix}-非法时段探针`,
  681. phrase: `${runPrefix}非法时段探针`,
  682. reply: '该请求不应写入数据库。',
  683. sensitivity: 50,
  684. activePeriod: '24:00-25:00',
  685. enabled: true,
  686. },
  687. })
  688. assertStatus(invalidWakeResponse, 400, '非法唤醒时段必须在写入前被拒绝')
  689. activities.push({
  690. module: '唤醒词',
  691. action: '非法时段写入前校验',
  692. result: 'PASS',
  693. httpStatus: invalidWakeResponse.status(),
  694. detail: '24:00-25:00 被拒绝,未创建不可清理的数据。',
  695. })
  696. const wakeResponse = await request.post('/api/v1/wake-words', {
  697. headers,
  698. data: {
  699. name: `${runPrefix}-检修唤醒`,
  700. phrase: `${runPrefix}你好教员`,
  701. reply: '您好,请描述设备现象。',
  702. sensitivity: 78,
  703. activePeriod: '06:00-22:00',
  704. enabled: true,
  705. },
  706. })
  707. const wakeCreated = await responseData<JsonRecord>(wakeResponse, 201, '创建唤醒词')
  708. wake = versioned(wakeCreated)
  709. expect(wakeCreated.activePeriod).toBe('06:00-22:00')
  710. addPass(activities, '唤醒词', '创建自定义时段', wakeResponse, wake.id, 'activePeriod=06:00-22:00')
  711. const wakeDetailResponse = await request.get(`/api/v1/wake-words/${wake.id}`, { headers })
  712. const wakeDetail = await responseData<JsonRecord>(wakeDetailResponse, 200, '读取自定义时段唤醒词')
  713. expect(wakeDetail.activePeriod).toBe('06:00-22:00')
  714. addPass(activities, '唤醒词', '读取自定义时段', wakeDetailResponse, wake.id)
  715. const wakeListResponse = await request.get('/api/v1/wake-words', {
  716. headers,
  717. params: { keyword: `${runPrefix}你好教员`, page: '1', pageSize: '20' },
  718. })
  719. const wakeList = asRecord(await responseData(wakeListResponse, 200, '检索自定义时段唤醒词'))
  720. expect(asRecords(wakeList.items).some((item) => String(item.id) === wake?.id && item.activePeriod === '06:00-22:00')).toBeTruthy()
  721. addPass(activities, '唤醒词', '列表检索自定义时段', wakeListResponse, wake.id)
  722. const wakeTestResponse = await request.post(`/api/v1/wake-words/${wake.id}/test`, { headers })
  723. if (wakeTestResponse.status() === 200) {
  724. const wakeTest = await responseData<JsonRecord>(wakeTestResponse, 200, '测试唤醒词匹配')
  725. expect(wakeTest.matched).toBe(true)
  726. addPass(activities, '唤醒词', '本地匹配测试', wakeTestResponse, wake.id)
  727. } else {
  728. activities.push({
  729. module: '唤醒词',
  730. action: '本地匹配测试',
  731. result: 'FAIL',
  732. httpStatus: wakeTestResponse.status(),
  733. resourceId: wake.id,
  734. detail: '已启用且刚创建的唤醒词测试接口返回非 200;继续执行后续 CRUD 并在报告中保留该真实缺陷。',
  735. })
  736. expect.soft(wakeTestResponse.status(), '已启用全天唤醒词的本地匹配测试接口应返回 HTTP 200').toBe(200)
  737. }
  738. const wakeUpdateResponse = await request.put(`/api/v1/wake-words/${wake.id}`, {
  739. headers,
  740. data: { reply: 'APE2E 已编辑唤醒回复。', sensitivity: 82, activePeriod: '06:30-21:30', dataVersion: wake.dataVersion },
  741. })
  742. if (wakeUpdateResponse.status() === 200) {
  743. const wakeUpdated = await responseData<JsonRecord>(wakeUpdateResponse, 200, '编辑唤醒词')
  744. expect(wakeUpdated.activePeriod).toBe('06:30-21:30')
  745. wake = versioned(wakeUpdated)
  746. addPass(activities, '唤醒词', '编辑并更新自定义时段', wakeUpdateResponse, wake.id, 'activePeriod=06:30-21:30')
  747. } else {
  748. activities.push({ module: '唤醒词', action: '编辑', result: 'FAIL', httpStatus: wakeUpdateResponse.status(), resourceId: wake.id, detail: '真实编辑请求失败;继续验证其他模块。' })
  749. expect.soft(wakeUpdateResponse.status(), '唤醒词编辑接口应返回 HTTP 200').toBe(200)
  750. }
  751. const wakeStatusResponse = await request.put(`/api/v1/wake-words/${wake.id}/status`, {
  752. headers,
  753. data: { enabled: false, dataVersion: wake.dataVersion },
  754. })
  755. if (wakeStatusResponse.status() === 200) {
  756. const wakeDisabled = await responseData<JsonRecord>(wakeStatusResponse, 200, '停用唤醒词')
  757. expect(wakeDisabled.activePeriod).toBe('06:30-21:30')
  758. wake = versioned(wakeDisabled)
  759. addPass(activities, '唤醒词', '停用', wakeStatusResponse, wake.id)
  760. } else {
  761. activities.push({ module: '唤醒词', action: '停用', result: 'FAIL', httpStatus: wakeStatusResponse.status(), resourceId: wake.id, detail: '真实状态提交失败;继续验证其他模块。' })
  762. expect.soft(wakeStatusResponse.status(), '唤醒词状态接口应返回 HTTP 200').toBe(200)
  763. }
  764. const wakeId = wake.id
  765. const wakeDeleteResponse = await request.delete(`/api/v1/wake-words/${wakeId}`, {
  766. headers,
  767. params: { dataVersion: String(wake.dataVersion) },
  768. })
  769. if (wakeDeleteResponse.status() === 200) {
  770. await responseData(wakeDeleteResponse, 200, '删除唤醒词')
  771. wake = null
  772. addPass(activities, '唤醒词', '删除', wakeDeleteResponse, wakeId)
  773. const wakeAbsent = await request.get(`/api/v1/wake-words/${wakeId}`, { headers })
  774. assertStatus(wakeAbsent, 404, '删除后的唤醒词不可读取')
  775. } else {
  776. activities.push({ module: '唤醒词', action: '删除', result: 'FAIL', httpStatus: wakeDeleteResponse.status(), resourceId: wake.id, detail: 'API 删除失败,finally 将再次按当前版本尝试兜底清理。' })
  777. expect.soft(wakeDeleteResponse.status(), '唤醒词删除接口应返回 HTTP 200').toBe(200)
  778. }
  779. const hotResponse = await request.post('/api/v1/hot-words', {
  780. headers,
  781. data: {
  782. term: `${runPrefix}-液压泵`,
  783. aliases: [`${runPrefix}-液压蹦`, `${runPrefix}-液压蹦`, `${runPrefix}-液压崩`],
  784. category: 'APE2E液压系统',
  785. description: '端到端热词。',
  786. enabled: true,
  787. },
  788. })
  789. const hotCreated = await responseData<JsonRecord>(hotResponse, 201, '创建热词')
  790. hot = versioned(hotCreated)
  791. expect(Array.isArray(hotCreated.aliases) ? hotCreated.aliases.length : 0).toBe(2)
  792. addPass(activities, '热词', '创建并去重别名', hotResponse, hot.id)
  793. const hotListResponse = await request.get('/api/v1/hot-words', {
  794. headers,
  795. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  796. })
  797. const hotPage = asRecord(await responseData(hotListResponse, 200, '筛选热词'))
  798. expect(asRecords(hotPage.items).some((item) => String(item.id) === hot?.id)).toBeTruthy()
  799. addPass(activities, '热词', '列表筛选', hotListResponse, hot.id)
  800. const hotUpdateResponse = await request.put(`/api/v1/hot-words/${hot.id}`, {
  801. headers,
  802. data: { aliases: [`${runPrefix}-液压棒`], description: 'APE2E 已编辑热词。', dataVersion: hot.dataVersion },
  803. })
  804. const hotUpdated = await responseData<JsonRecord>(hotUpdateResponse, 200, '编辑热词')
  805. hot = versioned(hotUpdated)
  806. expect(hotUpdated.aliases).toEqual([`${runPrefix}-液压棒`])
  807. addPass(activities, '热词', '编辑', hotUpdateResponse, hot.id)
  808. const hotStatusResponse = await request.put(`/api/v1/hot-words/${hot.id}/status`, {
  809. headers,
  810. data: { enabled: false, dataVersion: hot.dataVersion },
  811. })
  812. hot = versioned(await responseData(hotStatusResponse, 200, '停用热词'))
  813. addPass(activities, '热词', '停用', hotStatusResponse, hot.id)
  814. const hotId = hot.id
  815. const hotDeleteResponse = await request.delete(`/api/v1/hot-words/${hotId}`, {
  816. headers,
  817. params: { dataVersion: String(hot.dataVersion) },
  818. })
  819. await responseData(hotDeleteResponse, 200, '删除热词')
  820. hot = null
  821. addPass(activities, '热词', '删除', hotDeleteResponse, hotId)
  822. const hotAbsent = await request.get(`/api/v1/hot-words/${hotId}`, { headers })
  823. assertStatus(hotAbsent, 404, '删除后的热词不可读取')
  824. const toolResponse = await request.post('/api/v1/tools', {
  825. headers,
  826. data: {
  827. name: `${runPrefix}-检修工具入口`,
  828. domain: 'https://example.com',
  829. entryPath: '/ape2e/start',
  830. category: 'APE2E业务协同',
  831. description: '仅验证平台工具配置,不探测外部网络。',
  832. openMode: '新窗口打开',
  833. enabled: false,
  834. sortOrder: 9900,
  835. allowedRoleCodes: [],
  836. },
  837. })
  838. const toolCreated = await responseData<JsonRecord>(toolResponse, 201, '创建工具')
  839. tool = versioned(toolCreated)
  840. expect(toolCreated.connectionStatus).toBe('untested')
  841. addPass(activities, '工具', '创建禁用工具', toolResponse, tool.id)
  842. const toolListResponse = await request.get('/api/v1/tools', {
  843. headers,
  844. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  845. })
  846. const toolPage = asRecord(await responseData(toolListResponse, 200, '筛选工具'))
  847. expect(asRecords(toolPage.items).some((item) => String(item.id) === tool?.id)).toBeTruthy()
  848. addPass(activities, '工具', '列表筛选', toolListResponse, tool.id)
  849. const toolUpdateResponse = await request.put(`/api/v1/tools/${tool.id}`, {
  850. headers,
  851. data: {
  852. entryPath: '/ape2e/updated',
  853. description: 'APE2E 已提交工具编辑。',
  854. dataVersion: tool.dataVersion,
  855. },
  856. })
  857. tool = versioned(await responseData(toolUpdateResponse, 200, '编辑工具'))
  858. addPass(activities, '工具', '编辑', toolUpdateResponse, tool.id)
  859. const toolStatusResponse = await request.put(`/api/v1/tools/${tool.id}/status`, {
  860. headers,
  861. data: { enabled: true, dataVersion: tool.dataVersion },
  862. })
  863. tool = versioned(await responseData(toolStatusResponse, 200, '启用工具'))
  864. addPass(activities, '工具', '启用', toolStatusResponse, tool.id)
  865. const launchResponse = await request.get(`/api/v1/tools/${tool.id}/launch`, { headers })
  866. const launch = await responseData<JsonRecord>(launchResponse, 200, '读取工具启动参数')
  867. expect(launch.url).toBe('https://example.com/ape2e/updated')
  868. addPass(activities, '工具', '生成启动地址(未发起外部请求)', launchResponse, tool.id)
  869. activities.push({
  870. module: '工具',
  871. action: '外部连接探测',
  872. result: 'SKIP',
  873. resourceId: tool.id,
  874. detail: '工具配置 CRUD 与启动地址已验证;/check 会访问外部域名,按能力边界不执行且不使用 Mock。',
  875. })
  876. const toolId = tool.id
  877. const toolDeleteResponse = await request.delete(`/api/v1/tools/${toolId}`, {
  878. headers,
  879. params: { dataVersion: String(tool.dataVersion) },
  880. })
  881. await responseData(toolDeleteResponse, 200, '删除工具')
  882. tool = null
  883. addPass(activities, '工具', '删除', toolDeleteResponse, toolId)
  884. const toolAbsent = await request.get(`/api/v1/tools/${toolId}`, { headers })
  885. assertStatus(toolAbsent, 404, '删除后的工具不可读取')
  886. } catch (error) {
  887. primaryError = error
  888. } finally {
  889. await cleanupVersioned(request, headers, tool, '工具', (id) => `/api/v1/tools/${id}`, (id) => `/api/v1/tools/${id}`, activities, cleanupErrors)
  890. await cleanupVersioned(request, headers, hot, '热词', (id) => `/api/v1/hot-words/${id}`, (id) => `/api/v1/hot-words/${id}`, activities, cleanupErrors)
  891. await cleanupVersioned(request, headers, wake, '唤醒词', (id) => `/api/v1/wake-words/${id}`, (id) => `/api/v1/wake-words/${id}`, activities, cleanupErrors)
  892. await cleanupVersioned(request, headers, sensitive, '敏感词', (id) => `/api/v1/sensitive-words/${id}`, (id) => `/api/v1/sensitive-words/${id}`, activities, cleanupErrors)
  893. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  894. }
  895. if (primaryError) throw primaryError
  896. expect(cleanupErrors, '运营配置测试数据必须清理干净').toEqual([])
  897. })
  898. test('视频工程:创建、编辑、四轨时间线、CAS 与删除', async ({ request }, testInfo) => {
  899. test.setTimeout(240_000)
  900. const module = '视频工程'
  901. const activities: Activity[] = []
  902. const cleanupErrors: string[] = []
  903. const headers = await authHeaders(request)
  904. let project: VersionedRef | null = null
  905. let generatedAvatar: VersionedRef | null = null
  906. let primaryError: unknown
  907. try {
  908. const avatarsResponse = await request.get('/api/v1/avatars', {
  909. headers,
  910. params: { page: '1', pageSize: '100', status: 'ready' },
  911. })
  912. const avatarsPage = asRecord(await responseData(avatarsResponse, 200, '读取可用数字形象'))
  913. let avatarId = String(asRecords(avatarsPage.items)[0]?.id ?? '')
  914. if (!avatarId) {
  915. const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  916. expect(fs.existsSync(fixturePath), `视频工程依赖的真实形象素材不存在:${fixturePath}`).toBeTruthy()
  917. const createAvatarResponse = await request.post('/api/v1/avatars', {
  918. headers,
  919. multipart: {
  920. name: `${runPrefix}-视频工程依赖形象`,
  921. specialty: '视频口播教学',
  922. gender: '男',
  923. resize: 'true',
  924. matting: 'off',
  925. file: {
  926. name: `${runPrefix}-video-project-avatar.mp4`,
  927. mimeType: 'video/mp4',
  928. buffer: fs.readFileSync(fixturePath),
  929. },
  930. },
  931. timeout: 120_000,
  932. })
  933. const createdPayload = asRecord(await responseData(createAvatarResponse, 201, '创建视频工程依赖形象'))
  934. generatedAvatar = versioned(createdPayload.avatar)
  935. avatarId = generatedAvatar.id
  936. const jobId = String(asRecord(createdPayload.job).id ?? '')
  937. expect(jobId).not.toBe('')
  938. const deadline = Date.now() + 150_000
  939. let ready = false
  940. while (Date.now() < deadline) {
  941. const jobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  942. const job = asRecord(await responseData(jobResponse, 200, '等待视频工程依赖形象'))
  943. const status = String(job.status ?? '').toLowerCase()
  944. if (status === 'succeeded') {
  945. ready = true
  946. break
  947. }
  948. if (['failed', 'cancelled'].includes(status)) throw new Error(`视频工程依赖形象生成失败:${status}`)
  949. await new Promise((resolve) => setTimeout(resolve, 750))
  950. }
  951. expect(ready, '视频工程依赖形象应在 150 秒内生成完成').toBeTruthy()
  952. const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  953. const avatarDetail = await responseData<JsonRecord>(avatarDetailResponse, 200, '刷新视频工程依赖形象')
  954. generatedAvatar = versioned(avatarDetail)
  955. expect(String(avatarDetail.status).toLowerCase()).toBe('ready')
  956. addPass(activities, module, '通过真实上传生成工程依赖形象', createAvatarResponse, avatarId)
  957. }
  958. const voicesResponse = await request.get('/api/v1/capabilities', {
  959. headers,
  960. params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' },
  961. })
  962. const voicesPage = asRecord(await responseData(voicesResponse, 200, '读取可用 TTS 能力'))
  963. const voice = asRecords(voicesPage.items)[0]
  964. const voiceId = String(voice?.id ?? '')
  965. expect(voiceId, '视频工程必须选择平台已启用的正式 TTS 能力').not.toBe('')
  966. activities.push({ module, action: '依赖能力探测', result: 'PASS', detail: '已选择平台现有 ready 数字形象和已启用音色;报告不复制资源配置。' })
  967. const createResponse = await request.post('/api/v1/video-projects', {
  968. headers,
  969. data: {
  970. name: `${runPrefix}-设备检修口播工程`,
  971. scriptText: '停机、断电、卸压后方可拆卸液压管路。',
  972. avatarId,
  973. voiceCapabilityId: voiceId,
  974. voiceSpeed: 1,
  975. orientation: 'portrait',
  976. backgroundType: 'workshop',
  977. subtitleEnabled: true,
  978. subtitleFont: 'Microsoft YaHei',
  979. subtitleFontSize: 42,
  980. subtitlePosition: 'bottom',
  981. durationMs: 4500,
  982. },
  983. })
  984. const created = await responseData<JsonRecord>(createResponse, 201, '创建视频工程')
  985. project = versioned(created)
  986. expect(created.outputWidth).toBe(1080)
  987. expect(created.outputHeight).toBe(1920)
  988. addPass(activities, module, '创建竖屏工程', createResponse, project.id)
  989. const listResponse = await request.get('/api/v1/video-projects', {
  990. headers,
  991. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  992. })
  993. const page = asRecord(await responseData(listResponse, 200, '筛选视频工程'))
  994. expect(asRecords(page.items).some((item) => String(item.id) === project?.id)).toBeTruthy()
  995. addPass(activities, module, '列表筛选', listResponse, project.id)
  996. const updateResponse = await request.put(`/api/v1/video-projects/${project.id}`, {
  997. headers,
  998. data: {
  999. name: `${runPrefix}-设备检修口播工程-修订`,
  1000. scriptText: '停机、断电、验电、卸压后,确认压力表回零。',
  1001. voiceSpeed: 1.1,
  1002. dataVersion: project.dataVersion,
  1003. },
  1004. })
  1005. const updated = await responseData<JsonRecord>(updateResponse, 200, '编辑视频工程')
  1006. project = versioned(updated)
  1007. expect(updated.voiceSpeed).toBe(1.1)
  1008. addPass(activities, module, '编辑工程参数', updateResponse, project.id)
  1009. const timeline = {
  1010. dataVersion: project.dataVersion,
  1011. durationMs: 4500,
  1012. tracks: [
  1013. {
  1014. code: 'AVATAR',
  1015. name: '数字人',
  1016. order: 0,
  1017. clips: [{ id: `${runPrefix}-avatar-clip`, type: 'AVATAR', startMs: 0, endMs: 4500 }],
  1018. },
  1019. { code: 'MEDIA', name: '画面素材', order: 1, clips: [] },
  1020. {
  1021. code: 'AUDIO',
  1022. name: '本地配音',
  1023. order: 2,
  1024. clips: [{ id: `${runPrefix}-audio-clip`, type: 'AUDIO', capabilityId: voiceId, startMs: 0, endMs: 4500 }],
  1025. },
  1026. {
  1027. code: 'SUBTITLE',
  1028. name: '同步字幕',
  1029. order: 3,
  1030. clips: [{ id: `${runPrefix}-subtitle-clip`, type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 4500, style: { font: 'Microsoft YaHei', size: 42 } }],
  1031. },
  1032. ],
  1033. }
  1034. const timelineResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline })
  1035. const timelineSaved = await responseData<JsonRecord>(timelineResponse, 200, '保存四轨时间线')
  1036. project = versioned(timelineSaved)
  1037. const savedTimeline = asRecord(timelineSaved.timeline)
  1038. expect(asRecords(savedTimeline.tracks).map((track) => track.code)).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE'])
  1039. addPass(activities, module, '保存四轨时间线', timelineResponse, project.id)
  1040. const staleResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline })
  1041. assertStatus(staleResponse, 409, '过期时间线版本必须冲突')
  1042. addPass(activities, module, 'CAS 过期时间线拦截', staleResponse, project.id)
  1043. const detailResponse = await request.get(`/api/v1/video-projects/${project.id}`, { headers })
  1044. const detail = await responseData<JsonRecord>(detailResponse, 200, '读取视频工程详情')
  1045. project = versioned(detail)
  1046. expect(asRecords(asRecord(detail.timeline).tracks).length).toBe(4)
  1047. addPass(activities, module, '读取持久化时间线', detailResponse, project.id)
  1048. const projectId = project.id
  1049. const deleteResponse = await request.delete(`/api/v1/video-projects/${projectId}`, {
  1050. headers,
  1051. params: { dataVersion: String(project.dataVersion) },
  1052. })
  1053. await responseData(deleteResponse, 200, '删除视频工程')
  1054. project = null
  1055. addPass(activities, module, '删除工程', deleteResponse, projectId)
  1056. const absent = await request.get(`/api/v1/video-projects/${projectId}`, { headers })
  1057. assertStatus(absent, 404, '删除后的视频工程不可读取')
  1058. addPass(activities, module, '删除后不可读取', absent, projectId)
  1059. } catch (error) {
  1060. primaryError = error
  1061. } finally {
  1062. await cleanupVersioned(
  1063. request,
  1064. headers,
  1065. project,
  1066. module,
  1067. (id) => `/api/v1/video-projects/${id}`,
  1068. (id) => `/api/v1/video-projects/${id}`,
  1069. activities,
  1070. cleanupErrors,
  1071. )
  1072. await cleanupVersioned(
  1073. request,
  1074. headers,
  1075. generatedAvatar,
  1076. '视频工程依赖形象',
  1077. (id) => `/api/v1/avatars/${id}`,
  1078. (id) => `/api/v1/avatars/${id}`,
  1079. activities,
  1080. cleanupErrors,
  1081. )
  1082. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  1083. }
  1084. if (primaryError) throw primaryError
  1085. expect(cleanupErrors, '视频工程测试数据必须清理干净').toEqual([])
  1086. })
  1087. })