25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

1183 satır
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`, {
  723. headers,
  724. data: { utterance: `${runPrefix}你好教员` },
  725. })
  726. if (wakeTestResponse.status() === 200) {
  727. const wakeTest = await responseData<JsonRecord>(wakeTestResponse, 200, '验证唤醒词文本规则')
  728. expect(wakeTest.matched).toBe(true)
  729. expect(wakeTest.testMode).toBe('TEXT_EXACT')
  730. expect(wakeTest.utterance).toBe(`${runPrefix}你好教员`)
  731. addPass(activities, '唤醒词', '文本规则验证', wakeTestResponse, wake.id)
  732. } else {
  733. activities.push({
  734. module: '唤醒词',
  735. action: '文本规则验证',
  736. result: 'FAIL',
  737. httpStatus: wakeTestResponse.status(),
  738. resourceId: wake.id,
  739. detail: '已启用且刚创建的唤醒词文本规则验证接口返回非 200;继续执行后续 CRUD 并在报告中保留该真实缺陷。',
  740. })
  741. expect.soft(wakeTestResponse.status(), '已启用唤醒词的文本规则验证接口应返回 HTTP 200').toBe(200)
  742. }
  743. const wakeUpdateResponse = await request.put(`/api/v1/wake-words/${wake.id}`, {
  744. headers,
  745. data: { reply: 'APE2E 已编辑唤醒回复。', sensitivity: 82, activePeriod: '06:30-21:30', dataVersion: wake.dataVersion },
  746. })
  747. if (wakeUpdateResponse.status() === 200) {
  748. const wakeUpdated = await responseData<JsonRecord>(wakeUpdateResponse, 200, '编辑唤醒词')
  749. expect(wakeUpdated.activePeriod).toBe('06:30-21:30')
  750. wake = versioned(wakeUpdated)
  751. addPass(activities, '唤醒词', '编辑并更新自定义时段', wakeUpdateResponse, wake.id, 'activePeriod=06:30-21:30')
  752. } else {
  753. activities.push({ module: '唤醒词', action: '编辑', result: 'FAIL', httpStatus: wakeUpdateResponse.status(), resourceId: wake.id, detail: '真实编辑请求失败;继续验证其他模块。' })
  754. expect.soft(wakeUpdateResponse.status(), '唤醒词编辑接口应返回 HTTP 200').toBe(200)
  755. }
  756. const wakeStatusResponse = await request.put(`/api/v1/wake-words/${wake.id}/status`, {
  757. headers,
  758. data: { enabled: false, dataVersion: wake.dataVersion },
  759. })
  760. if (wakeStatusResponse.status() === 200) {
  761. const wakeDisabled = await responseData<JsonRecord>(wakeStatusResponse, 200, '停用唤醒词')
  762. expect(wakeDisabled.activePeriod).toBe('06:30-21:30')
  763. wake = versioned(wakeDisabled)
  764. addPass(activities, '唤醒词', '停用', wakeStatusResponse, wake.id)
  765. } else {
  766. activities.push({ module: '唤醒词', action: '停用', result: 'FAIL', httpStatus: wakeStatusResponse.status(), resourceId: wake.id, detail: '真实状态提交失败;继续验证其他模块。' })
  767. expect.soft(wakeStatusResponse.status(), '唤醒词状态接口应返回 HTTP 200').toBe(200)
  768. }
  769. const wakeId = wake.id
  770. const wakeDeleteResponse = await request.delete(`/api/v1/wake-words/${wakeId}`, {
  771. headers,
  772. params: { dataVersion: String(wake.dataVersion) },
  773. })
  774. if (wakeDeleteResponse.status() === 200) {
  775. await responseData(wakeDeleteResponse, 200, '删除唤醒词')
  776. wake = null
  777. addPass(activities, '唤醒词', '删除', wakeDeleteResponse, wakeId)
  778. const wakeAbsent = await request.get(`/api/v1/wake-words/${wakeId}`, { headers })
  779. assertStatus(wakeAbsent, 404, '删除后的唤醒词不可读取')
  780. } else {
  781. activities.push({ module: '唤醒词', action: '删除', result: 'FAIL', httpStatus: wakeDeleteResponse.status(), resourceId: wake.id, detail: 'API 删除失败,finally 将再次按当前版本尝试兜底清理。' })
  782. expect.soft(wakeDeleteResponse.status(), '唤醒词删除接口应返回 HTTP 200').toBe(200)
  783. }
  784. const hotResponse = await request.post('/api/v1/hot-words', {
  785. headers,
  786. data: {
  787. term: `${runPrefix}-液压泵`,
  788. aliases: [`${runPrefix}-液压蹦`, `${runPrefix}-液压蹦`, `${runPrefix}-液压崩`],
  789. category: 'APE2E液压系统',
  790. description: '端到端热词。',
  791. enabled: true,
  792. },
  793. })
  794. const hotCreated = await responseData<JsonRecord>(hotResponse, 201, '创建热词')
  795. hot = versioned(hotCreated)
  796. expect(Array.isArray(hotCreated.aliases) ? hotCreated.aliases.length : 0).toBe(2)
  797. addPass(activities, '热词', '创建并去重别名', hotResponse, hot.id)
  798. const hotListResponse = await request.get('/api/v1/hot-words', {
  799. headers,
  800. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  801. })
  802. const hotPage = asRecord(await responseData(hotListResponse, 200, '筛选热词'))
  803. expect(asRecords(hotPage.items).some((item) => String(item.id) === hot?.id)).toBeTruthy()
  804. addPass(activities, '热词', '列表筛选', hotListResponse, hot.id)
  805. const hotUpdateResponse = await request.put(`/api/v1/hot-words/${hot.id}`, {
  806. headers,
  807. data: { aliases: [`${runPrefix}-液压棒`], description: 'APE2E 已编辑热词。', dataVersion: hot.dataVersion },
  808. })
  809. const hotUpdated = await responseData<JsonRecord>(hotUpdateResponse, 200, '编辑热词')
  810. hot = versioned(hotUpdated)
  811. expect(hotUpdated.aliases).toEqual([`${runPrefix}-液压棒`])
  812. addPass(activities, '热词', '编辑', hotUpdateResponse, hot.id)
  813. const hotStatusResponse = await request.put(`/api/v1/hot-words/${hot.id}/status`, {
  814. headers,
  815. data: { enabled: false, dataVersion: hot.dataVersion },
  816. })
  817. hot = versioned(await responseData(hotStatusResponse, 200, '停用热词'))
  818. addPass(activities, '热词', '停用', hotStatusResponse, hot.id)
  819. const hotId = hot.id
  820. const hotDeleteResponse = await request.delete(`/api/v1/hot-words/${hotId}`, {
  821. headers,
  822. params: { dataVersion: String(hot.dataVersion) },
  823. })
  824. await responseData(hotDeleteResponse, 200, '删除热词')
  825. hot = null
  826. addPass(activities, '热词', '删除', hotDeleteResponse, hotId)
  827. const hotAbsent = await request.get(`/api/v1/hot-words/${hotId}`, { headers })
  828. assertStatus(hotAbsent, 404, '删除后的热词不可读取')
  829. const toolResponse = await request.post('/api/v1/tools', {
  830. headers,
  831. data: {
  832. name: `${runPrefix}-检修工具入口`,
  833. domain: 'https://example.com',
  834. entryPath: '/ape2e/start',
  835. category: 'APE2E业务协同',
  836. description: '仅验证平台工具配置,不探测外部网络。',
  837. openMode: '新窗口打开',
  838. enabled: false,
  839. sortOrder: 9900,
  840. allowedRoleCodes: [],
  841. },
  842. })
  843. const toolCreated = await responseData<JsonRecord>(toolResponse, 201, '创建工具')
  844. tool = versioned(toolCreated)
  845. expect(toolCreated.connectionStatus).toBe('untested')
  846. addPass(activities, '工具', '创建禁用工具', toolResponse, tool.id)
  847. const toolListResponse = await request.get('/api/v1/tools', {
  848. headers,
  849. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  850. })
  851. const toolPage = asRecord(await responseData(toolListResponse, 200, '筛选工具'))
  852. expect(asRecords(toolPage.items).some((item) => String(item.id) === tool?.id)).toBeTruthy()
  853. addPass(activities, '工具', '列表筛选', toolListResponse, tool.id)
  854. const toolUpdateResponse = await request.put(`/api/v1/tools/${tool.id}`, {
  855. headers,
  856. data: {
  857. entryPath: '/ape2e/updated',
  858. description: 'APE2E 已提交工具编辑。',
  859. dataVersion: tool.dataVersion,
  860. },
  861. })
  862. tool = versioned(await responseData(toolUpdateResponse, 200, '编辑工具'))
  863. addPass(activities, '工具', '编辑', toolUpdateResponse, tool.id)
  864. const toolStatusResponse = await request.put(`/api/v1/tools/${tool.id}/status`, {
  865. headers,
  866. data: { enabled: true, dataVersion: tool.dataVersion },
  867. })
  868. tool = versioned(await responseData(toolStatusResponse, 200, '启用工具'))
  869. addPass(activities, '工具', '启用', toolStatusResponse, tool.id)
  870. const launchResponse = await request.get(`/api/v1/tools/${tool.id}/launch`, { headers })
  871. const launch = await responseData<JsonRecord>(launchResponse, 200, '读取工具启动参数')
  872. expect(launch.url).toBe('https://example.com/ape2e/updated')
  873. addPass(activities, '工具', '生成启动地址(未发起外部请求)', launchResponse, tool.id)
  874. activities.push({
  875. module: '工具',
  876. action: '外部连接探测',
  877. result: 'SKIP',
  878. resourceId: tool.id,
  879. detail: '工具配置 CRUD 与启动地址已验证;/check 会访问外部域名,按能力边界不执行且不使用 Mock。',
  880. })
  881. const toolId = tool.id
  882. const toolDeleteResponse = await request.delete(`/api/v1/tools/${toolId}`, {
  883. headers,
  884. params: { dataVersion: String(tool.dataVersion) },
  885. })
  886. await responseData(toolDeleteResponse, 200, '删除工具')
  887. tool = null
  888. addPass(activities, '工具', '删除', toolDeleteResponse, toolId)
  889. const toolAbsent = await request.get(`/api/v1/tools/${toolId}`, { headers })
  890. assertStatus(toolAbsent, 404, '删除后的工具不可读取')
  891. } catch (error) {
  892. primaryError = error
  893. } finally {
  894. await cleanupVersioned(request, headers, tool, '工具', (id) => `/api/v1/tools/${id}`, (id) => `/api/v1/tools/${id}`, activities, cleanupErrors)
  895. await cleanupVersioned(request, headers, hot, '热词', (id) => `/api/v1/hot-words/${id}`, (id) => `/api/v1/hot-words/${id}`, activities, cleanupErrors)
  896. await cleanupVersioned(request, headers, wake, '唤醒词', (id) => `/api/v1/wake-words/${id}`, (id) => `/api/v1/wake-words/${id}`, activities, cleanupErrors)
  897. await cleanupVersioned(request, headers, sensitive, '敏感词', (id) => `/api/v1/sensitive-words/${id}`, (id) => `/api/v1/sensitive-words/${id}`, activities, cleanupErrors)
  898. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  899. }
  900. if (primaryError) throw primaryError
  901. expect(cleanupErrors, '运营配置测试数据必须清理干净').toEqual([])
  902. })
  903. test('视频工程:创建、编辑、四轨时间线、CAS 与删除', async ({ request }, testInfo) => {
  904. test.setTimeout(240_000)
  905. const module = '视频工程'
  906. const activities: Activity[] = []
  907. const cleanupErrors: string[] = []
  908. const headers = await authHeaders(request)
  909. let project: VersionedRef | null = null
  910. let generatedAvatar: VersionedRef | null = null
  911. let primaryError: unknown
  912. try {
  913. const avatarsResponse = await request.get('/api/v1/avatars', {
  914. headers,
  915. params: { page: '1', pageSize: '100', status: 'ready' },
  916. })
  917. const avatarsPage = asRecord(await responseData(avatarsResponse, 200, '读取可用数字形象'))
  918. let avatarId = String(asRecords(avatarsPage.items)[0]?.id ?? '')
  919. if (!avatarId) {
  920. const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
  921. expect(fs.existsSync(fixturePath), `视频工程依赖的真实形象素材不存在:${fixturePath}`).toBeTruthy()
  922. const createAvatarResponse = await request.post('/api/v1/avatars', {
  923. headers,
  924. multipart: {
  925. name: `${runPrefix}-视频工程依赖形象`,
  926. specialty: '视频口播教学',
  927. gender: '男',
  928. resize: 'true',
  929. matting: 'off',
  930. file: {
  931. name: `${runPrefix}-video-project-avatar.mp4`,
  932. mimeType: 'video/mp4',
  933. buffer: fs.readFileSync(fixturePath),
  934. },
  935. },
  936. timeout: 120_000,
  937. })
  938. const createdPayload = asRecord(await responseData(createAvatarResponse, 201, '创建视频工程依赖形象'))
  939. generatedAvatar = versioned(createdPayload.avatar)
  940. avatarId = generatedAvatar.id
  941. const jobId = String(asRecord(createdPayload.job).id ?? '')
  942. expect(jobId).not.toBe('')
  943. const deadline = Date.now() + 150_000
  944. let ready = false
  945. while (Date.now() < deadline) {
  946. const jobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  947. const job = asRecord(await responseData(jobResponse, 200, '等待视频工程依赖形象'))
  948. const status = String(job.status ?? '').toLowerCase()
  949. if (status === 'succeeded') {
  950. ready = true
  951. break
  952. }
  953. if (['failed', 'cancelled'].includes(status)) throw new Error(`视频工程依赖形象生成失败:${status}`)
  954. await new Promise((resolve) => setTimeout(resolve, 750))
  955. }
  956. expect(ready, '视频工程依赖形象应在 150 秒内生成完成').toBeTruthy()
  957. const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
  958. const avatarDetail = await responseData<JsonRecord>(avatarDetailResponse, 200, '刷新视频工程依赖形象')
  959. generatedAvatar = versioned(avatarDetail)
  960. expect(String(avatarDetail.status).toLowerCase()).toBe('ready')
  961. addPass(activities, module, '通过真实上传生成工程依赖形象', createAvatarResponse, avatarId)
  962. }
  963. const voicesResponse = await request.get('/api/v1/capabilities', {
  964. headers,
  965. params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' },
  966. })
  967. const voicesPage = asRecord(await responseData(voicesResponse, 200, '读取可用 TTS 能力'))
  968. const voice = asRecords(voicesPage.items)[0]
  969. const voiceId = String(voice?.id ?? '')
  970. expect(voiceId, '视频工程必须选择平台已启用的正式 TTS 能力').not.toBe('')
  971. activities.push({ module, action: '依赖能力探测', result: 'PASS', detail: '已选择平台现有 ready 数字形象和已启用音色;报告不复制资源配置。' })
  972. const createResponse = await request.post('/api/v1/video-projects', {
  973. headers,
  974. data: {
  975. name: `${runPrefix}-设备检修口播工程`,
  976. scriptText: '停机、断电、卸压后方可拆卸液压管路。',
  977. avatarId,
  978. voiceCapabilityId: voiceId,
  979. voiceSpeed: 1,
  980. orientation: 'portrait',
  981. backgroundType: 'workshop',
  982. subtitleEnabled: true,
  983. subtitleFont: 'Microsoft YaHei',
  984. subtitleFontSize: 42,
  985. subtitlePosition: 'bottom',
  986. durationMs: 4500,
  987. },
  988. })
  989. const created = await responseData<JsonRecord>(createResponse, 201, '创建视频工程')
  990. project = versioned(created)
  991. expect(created.outputWidth).toBe(1080)
  992. expect(created.outputHeight).toBe(1920)
  993. addPass(activities, module, '创建竖屏工程', createResponse, project.id)
  994. const listResponse = await request.get('/api/v1/video-projects', {
  995. headers,
  996. params: { keyword: runPrefix, page: '1', pageSize: '20' },
  997. })
  998. const page = asRecord(await responseData(listResponse, 200, '筛选视频工程'))
  999. expect(asRecords(page.items).some((item) => String(item.id) === project?.id)).toBeTruthy()
  1000. addPass(activities, module, '列表筛选', listResponse, project.id)
  1001. const updateResponse = await request.put(`/api/v1/video-projects/${project.id}`, {
  1002. headers,
  1003. data: {
  1004. name: `${runPrefix}-设备检修口播工程-修订`,
  1005. scriptText: '停机、断电、验电、卸压后,确认压力表回零。',
  1006. voiceSpeed: 1.1,
  1007. dataVersion: project.dataVersion,
  1008. },
  1009. })
  1010. const updated = await responseData<JsonRecord>(updateResponse, 200, '编辑视频工程')
  1011. project = versioned(updated)
  1012. expect(updated.voiceSpeed).toBe(1.1)
  1013. addPass(activities, module, '编辑工程参数', updateResponse, project.id)
  1014. const timeline = {
  1015. dataVersion: project.dataVersion,
  1016. durationMs: 4500,
  1017. tracks: [
  1018. {
  1019. code: 'AVATAR',
  1020. name: '数字人',
  1021. order: 0,
  1022. clips: [{ id: `${runPrefix}-avatar-clip`, type: 'AVATAR', startMs: 0, endMs: 4500 }],
  1023. },
  1024. { code: 'MEDIA', name: '画面素材', order: 1, clips: [] },
  1025. {
  1026. code: 'AUDIO',
  1027. name: '本地配音',
  1028. order: 2,
  1029. clips: [{ id: `${runPrefix}-audio-clip`, type: 'AUDIO', capabilityId: voiceId, startMs: 0, endMs: 4500 }],
  1030. },
  1031. {
  1032. code: 'SUBTITLE',
  1033. name: '同步字幕',
  1034. order: 3,
  1035. clips: [{ id: `${runPrefix}-subtitle-clip`, type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 4500, style: { font: 'Microsoft YaHei', size: 42 } }],
  1036. },
  1037. ],
  1038. }
  1039. const timelineResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline })
  1040. const timelineSaved = await responseData<JsonRecord>(timelineResponse, 200, '保存四轨时间线')
  1041. project = versioned(timelineSaved)
  1042. const savedTimeline = asRecord(timelineSaved.timeline)
  1043. expect(asRecords(savedTimeline.tracks).map((track) => track.code)).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE'])
  1044. addPass(activities, module, '保存四轨时间线', timelineResponse, project.id)
  1045. const staleResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline })
  1046. assertStatus(staleResponse, 409, '过期时间线版本必须冲突')
  1047. addPass(activities, module, 'CAS 过期时间线拦截', staleResponse, project.id)
  1048. const detailResponse = await request.get(`/api/v1/video-projects/${project.id}`, { headers })
  1049. const detail = await responseData<JsonRecord>(detailResponse, 200, '读取视频工程详情')
  1050. project = versioned(detail)
  1051. expect(asRecords(asRecord(detail.timeline).tracks).length).toBe(4)
  1052. addPass(activities, module, '读取持久化时间线', detailResponse, project.id)
  1053. const projectId = project.id
  1054. const deleteResponse = await request.delete(`/api/v1/video-projects/${projectId}`, {
  1055. headers,
  1056. params: { dataVersion: String(project.dataVersion) },
  1057. })
  1058. await responseData(deleteResponse, 200, '删除视频工程')
  1059. project = null
  1060. addPass(activities, module, '删除工程', deleteResponse, projectId)
  1061. const absent = await request.get(`/api/v1/video-projects/${projectId}`, { headers })
  1062. assertStatus(absent, 404, '删除后的视频工程不可读取')
  1063. addPass(activities, module, '删除后不可读取', absent, projectId)
  1064. } catch (error) {
  1065. primaryError = error
  1066. } finally {
  1067. await cleanupVersioned(
  1068. request,
  1069. headers,
  1070. project,
  1071. module,
  1072. (id) => `/api/v1/video-projects/${id}`,
  1073. (id) => `/api/v1/video-projects/${id}`,
  1074. activities,
  1075. cleanupErrors,
  1076. )
  1077. await cleanupVersioned(
  1078. request,
  1079. headers,
  1080. generatedAvatar,
  1081. '视频工程依赖形象',
  1082. (id) => `/api/v1/avatars/${id}`,
  1083. (id) => `/api/v1/avatars/${id}`,
  1084. activities,
  1085. cleanupErrors,
  1086. )
  1087. await attachCaseReport(testInfo, module, activities, cleanupErrors)
  1088. }
  1089. if (primaryError) throw primaryError
  1090. expect(cleanupErrors, '视频工程测试数据必须清理干净').toEqual([])
  1091. })
  1092. })