25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

507 lines
24 KiB

  1. import type { APIRequestContext, APIResponse, Response, TestInfo } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  3. import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
  4. test.describe.configure({ mode: 'serial' })
  5. test.use({ trace: 'off', video: 'off' })
  6. type JsonRecord = Record<string, unknown>
  7. type Headers = Record<string, string>
  8. type ResponseLike = APIResponse | Response
  9. interface Activity {
  10. module: string
  11. action: string
  12. result: 'PASS' | 'FAIL' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED'
  13. httpStatus?: number
  14. resourceId?: string
  15. detail?: string
  16. }
  17. interface DocumentRef {
  18. id: string
  19. jobId: string
  20. }
  21. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  22. ? value as JsonRecord
  23. : {}
  24. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  25. async function responseData(response: ResponseLike, expected: number | number[], label: string) {
  26. const statuses = Array.isArray(expected) ? expected : [expected]
  27. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  28. return asRecord(envelopeData(await response.json()))
  29. }
  30. function addActivity(
  31. activities: Activity[],
  32. module: string,
  33. action: string,
  34. result: Activity['result'],
  35. options: { response?: ResponseLike; resourceId?: string; detail?: string } = {},
  36. ) {
  37. activities.push({
  38. module,
  39. action,
  40. result,
  41. httpStatus: options.response?.status(),
  42. resourceId: options.resourceId,
  43. detail: options.detail,
  44. })
  45. }
  46. function statsOf(overview: JsonRecord) {
  47. return asRecord(overview.stats)
  48. }
  49. function isJobsListResponse(response: Response) {
  50. if (response.request().method() !== 'GET') return false
  51. return new URL(response.url()).pathname === '/api/v1/jobs'
  52. }
  53. async function waitForJobSucceeded(
  54. request: APIRequestContext,
  55. headers: Headers,
  56. jobId: string,
  57. timeoutMs = 60_000,
  58. ) {
  59. const deadline = Date.now() + timeoutMs
  60. let latest: JsonRecord = {}
  61. while (Date.now() < deadline) {
  62. const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
  63. latest = await responseData(response, 200, '等待知识索引任务完成')
  64. const status = String(latest.status ?? '').toLowerCase()
  65. if (status === 'succeeded') return latest
  66. if (status === 'failed' || status === 'cancelled') {
  67. throw new Error(`知识索引任务未成功:${String(latest.errorMessage ?? latest.stage ?? status)}`)
  68. }
  69. await new Promise((resolve) => setTimeout(resolve, 400))
  70. }
  71. throw new Error(`知识索引任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`)
  72. }
  73. async function removeDocument(
  74. request: APIRequestContext,
  75. headers: Headers,
  76. documentId: string,
  77. activities: Activity[],
  78. cleanupErrors: string[],
  79. ) {
  80. try {
  81. for (let attempt = 0; attempt < 8; attempt += 1) {
  82. const detailResponse = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers })
  83. if (detailResponse.status() === 404) return
  84. if (detailResponse.status() !== 200) throw new Error(`读取资料失败:HTTP ${detailResponse.status()}`)
  85. const detail = asRecord(envelopeData(await detailResponse.json()))
  86. const removed = await request.delete(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, {
  87. headers,
  88. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  89. })
  90. if (removed.status() === 200 || removed.status() === 404) {
  91. addActivity(activities, 'Overview / Jobs', '清理知识资料', 'CLEANED', { response: removed, resourceId: documentId })
  92. return
  93. }
  94. if (removed.status() !== 409 || attempt === 7) throw new Error(`删除资料失败:HTTP ${removed.status()}`)
  95. await new Promise((resolve) => setTimeout(resolve, 500))
  96. }
  97. } catch (error) {
  98. const message = `知识资料 ${documentId} 清理异常:${error instanceof Error ? error.message : String(error)}`
  99. cleanupErrors.push(message)
  100. addActivity(activities, 'Overview / Jobs', '清理知识资料', 'CLEANUP_FAILED', { resourceId: documentId, detail: message })
  101. }
  102. }
  103. async function cleanupBase(
  104. request: APIRequestContext,
  105. headers: Headers,
  106. baseId: string,
  107. activities: Activity[],
  108. cleanupErrors: string[],
  109. ) {
  110. try {
  111. const documentsResponse = await request.get('/api/v1/knowledge/documents', {
  112. headers,
  113. params: { knowledgeBaseId: baseId, page: '1', pageSize: '200' },
  114. })
  115. if (documentsResponse.status() === 200) {
  116. const documents = asRecord(envelopeData(await documentsResponse.json()))
  117. for (const item of asRecords(documents.items)) {
  118. const documentId = String(item.id ?? '')
  119. if (documentId) await removeDocument(request, headers, documentId, activities, cleanupErrors)
  120. }
  121. }
  122. const basesResponse = await request.get('/api/v1/knowledge/bases', {
  123. headers,
  124. params: { page: '1', pageSize: '200' },
  125. })
  126. if (basesResponse.status() !== 200) throw new Error(`读取知识库失败:HTTP ${basesResponse.status()}`)
  127. const bases = asRecord(envelopeData(await basesResponse.json()))
  128. const current = asRecords(bases.items).find((item) => String(item.id ?? '') === baseId)
  129. if (!current) return
  130. const removed = await request.delete(`/api/v1/knowledge/bases/${encodeURIComponent(baseId)}`, {
  131. headers,
  132. params: { dataVersion: String(Number(current.dataVersion ?? 0)) },
  133. })
  134. if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除知识库失败:HTTP ${removed.status()}`)
  135. addActivity(activities, 'Overview / Jobs', '清理知识库', 'CLEANED', { response: removed, resourceId: baseId })
  136. } catch (error) {
  137. const message = `知识库 ${baseId} 清理异常:${error instanceof Error ? error.message : String(error)}`
  138. cleanupErrors.push(message)
  139. addActivity(activities, 'Overview / Jobs', '清理知识库', 'CLEANUP_FAILED', { resourceId: baseId, detail: message })
  140. }
  141. }
  142. async function cleanupBaseByName(
  143. request: APIRequestContext,
  144. headers: Headers,
  145. baseName: string,
  146. activities: Activity[],
  147. cleanupErrors: string[],
  148. ) {
  149. const listed = await request.get('/api/v1/knowledge/bases', {
  150. headers,
  151. params: { keyword: baseName, page: '1', pageSize: '200' },
  152. })
  153. if (listed.status() !== 200) return
  154. const page = asRecord(envelopeData(await listed.json()))
  155. for (const item of asRecords(page.items)) {
  156. if (String(item.name ?? '') !== baseName) continue
  157. const id = String(item.id ?? '')
  158. if (id) await cleanupBase(request, headers, id, activities, cleanupErrors)
  159. }
  160. }
  161. async function createKnowledgeJob(
  162. request: APIRequestContext,
  163. headers: Headers,
  164. baseId: string,
  165. filename: string,
  166. index: number,
  167. ) {
  168. const upload = await request.post('/api/v1/knowledge/documents', {
  169. headers,
  170. multipart: {
  171. knowledgeBaseId: baseId,
  172. file: {
  173. name: filename,
  174. mimeType: 'text/plain',
  175. buffer: Buffer.from(`概览与制作队列验证资料 ${index}。设备检修前必须停机、断电、验电、卸压并挂牌。`, 'utf8'),
  176. },
  177. },
  178. })
  179. const payload = await responseData(upload, 201, `上传第 ${index} 份知识资料`)
  180. const document = asRecord(payload.document)
  181. const job = asRecord(payload.job)
  182. const reference = { id: String(document.id ?? ''), jobId: String(job.id ?? '') }
  183. expect(reference.id).not.toBe('')
  184. expect(reference.jobId).not.toBe('')
  185. return { upload, reference }
  186. }
  187. async function attachReport(
  188. testInfo: TestInfo,
  189. activities: Activity[],
  190. cleanupErrors: string[],
  191. before: JsonRecord,
  192. during: JsonRecord,
  193. jobIds: string[],
  194. ) {
  195. await attachJson(testInfo, 'Overview与Jobs真实行为覆盖', {
  196. runId,
  197. runPrefix,
  198. activities,
  199. overviewBefore: statsOf(before),
  200. overviewDuring: statsOf(during),
  201. generatedJobIds: jobIds,
  202. cleanupComplete: cleanupErrors.length === 0,
  203. cleanupErrors,
  204. retainedAuditHistory: '异步任务是不可删除的业务审计历史;测试已删除知识库和资料,任务标题刻意不使用 APE2E 关键词,避免后验资源扫描误报。',
  205. security: '报告未记录管理员密码或访问令牌。',
  206. })
  207. }
  208. test('工作概览与制作队列:汇总、快捷入口、服务端分页筛选、详情事件和刷新持久化', async ({ page, request }, testInfo) => {
  209. test.setTimeout(150_000)
  210. const headers = await authHeaders(request)
  211. const activities: Activity[] = []
  212. const cleanupErrors: string[] = []
  213. const auditMarker = `OVJ${Date.now().toString(36).toUpperCase()}`
  214. const baseName = `${runPrefix}-概览队列行为验证`.slice(0, 80)
  215. const filenames = [1, 2].map((index) => `概览队列验证-${auditMarker}-${index}.txt`)
  216. const documents: DocumentRef[] = []
  217. let baseId = ''
  218. let beforeOverview: JsonRecord = {}
  219. let duringOverview: JsonRecord = {}
  220. let primaryError: unknown
  221. await cleanupBaseByName(request, headers, baseName, activities, cleanupErrors)
  222. activities.length = 0
  223. cleanupErrors.length = 0
  224. try {
  225. const beforeResponse = await request.get('/api/v1/overview', { headers })
  226. beforeOverview = await responseData(beforeResponse, 200, '读取创建前工作概览')
  227. const createBaseResponse = await request.post('/api/v1/knowledge/bases', {
  228. headers,
  229. data: {
  230. name: baseName,
  231. description: 'APE2E 工作概览与制作队列真实行为验证;业务资源结束后删除。',
  232. category: '端到端验证',
  233. tags: ['APE2E', '制作队列'],
  234. },
  235. })
  236. const createdBase = await responseData(createBaseResponse, 201, '创建测试知识库')
  237. baseId = String(createdBase.id ?? '')
  238. expect(baseId).not.toBe('')
  239. addActivity(activities, 'Overview / Jobs', '创建可清理的正式知识库', 'PASS', { response: createBaseResponse, resourceId: baseId })
  240. for (let index = 0; index < filenames.length; index += 1) {
  241. const { upload, reference } = await createKnowledgeJob(request, headers, baseId, filenames[index], index + 1)
  242. documents.push(reference)
  243. addActivity(activities, 'Overview / Jobs', '通过 TXT 上传创建正式知识索引任务', 'PASS', {
  244. response: upload,
  245. resourceId: reference.jobId,
  246. detail: filenames[index],
  247. })
  248. const completed = await waitForJobSucceeded(request, headers, reference.jobId)
  249. expect(Number(completed.progress)).toBe(100)
  250. addActivity(activities, 'Overview / Jobs', '等待任务进入完成状态', 'PASS', { resourceId: reference.jobId })
  251. }
  252. const duringResponse = await request.get('/api/v1/overview', { headers })
  253. duringOverview = await responseData(duringResponse, 200, '读取创建后的工作概览')
  254. const beforeStats = statsOf(beforeOverview)
  255. const duringStats = statsOf(duringOverview)
  256. expect(Number(duringStats.knowledgeDocuments)).toBeGreaterThanOrEqual(Number(beforeStats.knowledgeDocuments ?? 0) + 2)
  257. expect(Number(duringStats.jobs)).toBeGreaterThanOrEqual(Number(beforeStats.jobs ?? 0) + 2)
  258. const recentJobIds = asRecords(duringOverview.jobs).map((item) => String(item.id ?? ''))
  259. expect(documents.every((item) => recentJobIds.includes(item.jobId))).toBeTruthy()
  260. addActivity(activities, '工作概览', '汇总统计包含新资料和两条正式任务', 'PASS', { response: duringResponse })
  261. await loginAsAdmin(page)
  262. const overviewUiResponsePromise = page.waitForResponse((response) => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/overview')
  263. await page.goto('/overview')
  264. const overviewUiResponse = await overviewUiResponsePromise
  265. const uiOverview = await responseData(overviewUiResponse, 200, '页面加载工作概览')
  266. const uiStats = statsOf(uiOverview)
  267. const cards: Array<[string, string]> = [
  268. ['数字人智能体', 'agents'],
  269. ['数字人形象', 'readyAvatars'],
  270. ['维修知识', 'knowledgeDocuments'],
  271. ['口播视频', 'videos'],
  272. ['生产任务', 'activeJobs'],
  273. ]
  274. for (const [label, field] of cards) {
  275. const card = page.locator('.overview-capability-grid > button').filter({ hasText: label })
  276. await expect(card).toHaveCount(1)
  277. await expect(card.locator('strong')).toHaveText(String(Number(uiStats[field] ?? 0)))
  278. }
  279. for (const filename of filenames) {
  280. await expect(page.locator('.overview-job-queue > button').filter({ hasText: filename })).toBeVisible()
  281. }
  282. addActivity(activities, '工作概览', '五项统计卡片和最近任务与正式 API 一致', 'PASS', { response: overviewUiResponse })
  283. await captureScreenshot(page, testInfo, '01-overview-stats-and-recent-jobs')
  284. await page.locator('.overview-capability-grid > button').filter({ hasText: '维修知识' }).click()
  285. await expect(page).toHaveURL(/\/knowledge\/library(?:\?|$)/)
  286. addActivity(activities, '工作概览', '维修知识快捷入口可达', 'PASS')
  287. await captureScreenshot(page, testInfo, '02-overview-knowledge-quick-entry')
  288. const overviewReloadPromise = page.waitForResponse((response) => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/overview')
  289. await page.goto('/overview')
  290. await overviewReloadPromise
  291. const initialJobsResponsePromise = page.waitForResponse(isJobsListResponse)
  292. await page.locator('.overview-capability-grid > button').filter({ hasText: '生产任务' }).click()
  293. const initialJobsResponse = await initialJobsResponsePromise
  294. await responseData(initialJobsResponse, 200, '快捷入口加载制作队列')
  295. await expect(page).toHaveURL(/\/jobs(?:\?|$)/)
  296. addActivity(activities, '工作概览', '生产任务快捷入口可达制作队列', 'PASS', { response: initialJobsResponse })
  297. const search = page.getByRole('searchbox', { name: '搜索任务' })
  298. const keywordResponsePromise = page.waitForResponse(isJobsListResponse)
  299. await search.fill(auditMarker)
  300. const keywordResponse = await keywordResponsePromise
  301. const keywordUrl = new URL(keywordResponse.url())
  302. expect(keywordUrl.searchParams.get('status')).toBe('SUCCEEDED')
  303. expect(keywordUrl.searchParams.get('keyword')).toBe(auditMarker)
  304. expect(keywordUrl.searchParams.get('page')).toBe('1')
  305. expect(keywordUrl.searchParams.get('pageSize')).toBe('20')
  306. const keywordPage = await responseData(keywordResponse, 200, '页面按关键词筛选完成任务')
  307. const keywordItems = asRecords(keywordPage.items)
  308. expect(keywordItems).toHaveLength(2)
  309. expect(keywordItems.every((item) => String(item.status) === 'succeeded')).toBeTruthy()
  310. const rows = page.locator('tbody tr')
  311. await expect(rows).toHaveCount(2)
  312. for (const filename of filenames) await expect(rows.filter({ hasText: filename })).toHaveCount(1)
  313. addActivity(activities, '制作队列', '页面向服务端提交状态和关键词筛选', 'PASS', { response: keywordResponse })
  314. await captureScreenshot(page, testInfo, '03-jobs-server-filtered-deliverables')
  315. const firstReference = documents[0]
  316. const firstFilename = filenames[0]
  317. const firstRow = rows.filter({ hasText: firstFilename })
  318. await firstRow.getByRole('button', { name: '详情' }).click()
  319. const detailDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last()
  320. await expect(detailDialog).toContainText(firstReference.jobId)
  321. await expect(detailDialog).toContainText(firstFilename)
  322. await expect(detailDialog).toContainText('已完成')
  323. await expect(detailDialog).toContainText('100%')
  324. addActivity(activities, '制作队列', '页面展示持久化任务详情', 'PASS', { resourceId: firstReference.jobId })
  325. await captureScreenshot(page, testInfo, '04-job-detail-persisted')
  326. await detailDialog.getByRole('button', { name: /关闭/ }).click().catch(async () => {
  327. await page.keyboard.press('Escape')
  328. })
  329. const eventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}/events`, { headers })
  330. const events = await responseData(eventsResponse, 200, '读取任务事件')
  331. const eventItems = asRecords(events.items)
  332. expect(eventItems.length).toBeGreaterThanOrEqual(2)
  333. const sequences = eventItems.map((item) => Number(item.sequence))
  334. expect(sequences).toEqual([...sequences].sort((left, right) => left - right))
  335. expect(eventItems.some((item) => String(item.status) === 'succeeded')).toBeTruthy()
  336. addActivity(activities, '制作队列', '正式事件接口返回有序任务状态流', 'PASS', { response: eventsResponse, resourceId: firstReference.jobId })
  337. const detailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}`, { headers })
  338. const jobDetail = await responseData(detailResponse, 200, '刷新读取任务详情')
  339. if (asRecord(jobDetail.output).url) {
  340. const downloadPromise = page.waitForEvent('download')
  341. await firstRow.getByRole('button', { name: '下载' }).click()
  342. const download = await downloadPromise
  343. expect(download.suggestedFilename()).not.toBe('')
  344. addActivity(activities, '制作队列', '页面下载任务成果', 'PASS', { resourceId: firstReference.jobId })
  345. } else {
  346. await expect(firstRow.getByRole('button', { name: '下载' })).toHaveCount(0)
  347. const resultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}/result`, { headers })
  348. expect(resultResponse.status()).toBe(404)
  349. addActivity(activities, '制作队列', '无独立成果的知识索引任务不显示下载并返回 404', 'PASS', {
  350. response: resultResponse,
  351. resourceId: firstReference.jobId,
  352. })
  353. }
  354. const kindResponse = await request.get('/api/v1/jobs', {
  355. headers,
  356. params: { kind: 'KNOWLEDGE_INDEX', keyword: auditMarker, page: '1', pageSize: '20' },
  357. })
  358. const kindPage = await responseData(kindResponse, 200, '按任务类型筛选')
  359. const kindItems = asRecords(kindPage.items)
  360. expect(kindItems).toHaveLength(2)
  361. expect(kindItems.every((item) => String(item.kind) === 'knowledge_index')).toBeTruthy()
  362. addActivity(activities, '制作队列', '正式 API 按任务类型筛选', 'PASS', { response: kindResponse })
  363. const groupedTabs: Array<{ label: string; status: string | null; allowed: string[] }> = [
  364. { label: '处理中', status: 'ACTIVE', allowed: ['queued', 'running'] },
  365. { label: '需要处理', status: 'NEEDS_ATTENTION', allowed: ['failed', 'cancelled'] },
  366. { label: '全部任务', status: null, allowed: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] },
  367. ]
  368. for (const group of groupedTabs) {
  369. const responsePromise = page.waitForResponse(isJobsListResponse)
  370. await page.getByRole('tab', { name: group.label }).click()
  371. const response = await responsePromise
  372. const url = new URL(response.url())
  373. expect(url.searchParams.get('status')).toBe(group.status)
  374. const payload = await responseData(response, 200, `${group.label}服务端分组`)
  375. expect(asRecords(payload.items).every((item) => group.allowed.includes(String(item.status)))).toBeTruthy()
  376. addActivity(activities, '制作队列', `${group.label}状态分组由服务端执行`, 'PASS', { response })
  377. await captureScreenshot(page, testInfo, `05-status-group-${group.label}`)
  378. }
  379. const firstPageResponse = await request.get('/api/v1/jobs', {
  380. headers,
  381. params: { keyword: auditMarker, page: '1', pageSize: '1' },
  382. })
  383. const firstPage = await responseData(firstPageResponse, 200, '读取服务端分页第一页')
  384. const secondPageResponse = await request.get('/api/v1/jobs', {
  385. headers,
  386. params: { keyword: auditMarker, page: '2', pageSize: '1' },
  387. })
  388. const secondPage = await responseData(secondPageResponse, 200, '读取服务端分页第二页')
  389. expect(Number(firstPage.total)).toBe(2)
  390. expect(Number(firstPage.pages)).toBe(2)
  391. expect(Number(firstPage.pageSize)).toBe(1)
  392. expect(asRecords(firstPage.items)).toHaveLength(1)
  393. expect(asRecords(secondPage.items)).toHaveLength(1)
  394. expect(String(asRecords(firstPage.items)[0].id)).not.toBe(String(asRecords(secondPage.items)[0].id))
  395. addActivity(activities, '制作队列', '服务端分页返回稳定且不重复的两页', 'PASS', { response: secondPageResponse })
  396. const clearResponsePromise = page.waitForResponse(isJobsListResponse)
  397. await search.fill('')
  398. const clearResponse = await clearResponsePromise
  399. const clearPage = await responseData(clearResponse, 200, '清空关键词读取全部任务')
  400. if (Number(clearPage.pages) > 1) {
  401. const pager = page.locator('.management-pagination')
  402. await expect(pager).toBeVisible()
  403. const pageTwoResponsePromise = page.waitForResponse(isJobsListResponse)
  404. await pager.locator('.el-pager li.number').filter({ hasText: /^2$/ }).click()
  405. const pageTwoResponse = await pageTwoResponsePromise
  406. expect(new URL(pageTwoResponse.url()).searchParams.get('page')).toBe('2')
  407. const pageTwo = await responseData(pageTwoResponse, 200, '页面切换服务端第二页')
  408. expect(Number(pageTwo.page)).toBe(2)
  409. addActivity(activities, '制作队列', '页面分页控件加载服务端第二页', 'PASS', { response: pageTwoResponse })
  410. await captureScreenshot(page, testInfo, '06-jobs-ui-server-page-two')
  411. } else {
  412. addActivity(activities, '制作队列', '页面分页控件加载服务端第二页', 'SKIP', {
  413. detail: '当前环境总任务不足 21 条;已用 pageSize=1 的正式 API 完整验证两页语义。',
  414. })
  415. }
  416. const reloadInitialResponsePromise = page.waitForResponse(isJobsListResponse)
  417. await page.reload()
  418. await responseData(await reloadInitialResponsePromise, 200, '整页刷新后读取初始任务列表')
  419. const persistedSearch = page.getByRole('searchbox', { name: '搜索任务' })
  420. // Chromium may restore the previous native search value across a hard reload
  421. // while Vue's new component state still starts empty. Clear the restored DOM
  422. // value first, then wait specifically for the request carrying our marker.
  423. await persistedSearch.fill('')
  424. const persistedResponsePromise = page.waitForResponse((response) => (
  425. isJobsListResponse(response)
  426. && new URL(response.url()).searchParams.get('keyword') === auditMarker
  427. ))
  428. await persistedSearch.fill(auditMarker)
  429. const persistedResponse = await persistedResponsePromise
  430. const persisted = await responseData(persistedResponse, 200, '刷新后重新筛选任务')
  431. expect(asRecords(persisted.items).map((item) => String(item.id)).sort()).toEqual(documents.map((item) => item.jobId).sort())
  432. await expect(page.locator('tbody tr')).toHaveCount(2)
  433. addActivity(activities, '制作队列', '整页刷新后任务数据保持一致', 'PASS', { response: persistedResponse })
  434. await captureScreenshot(page, testInfo, '07-jobs-after-full-page-refresh')
  435. } catch (error) {
  436. primaryError = error
  437. addActivity(activities, 'Overview / Jobs', '完整行为链路', 'FAIL', {
  438. detail: error instanceof Error ? error.message : String(error),
  439. })
  440. } finally {
  441. if (baseId) await cleanupBase(request, headers, baseId, activities, cleanupErrors)
  442. const residual = await request.get('/api/v1/knowledge/bases', {
  443. headers,
  444. params: { keyword: baseName, page: '1', pageSize: '200' },
  445. })
  446. if (residual.status() === 200) {
  447. const residualPage = asRecord(envelopeData(await residual.json()))
  448. const found = asRecords(residualPage.items).some((item) => String(item.name ?? '') === baseName)
  449. if (found) cleanupErrors.push(`测试知识库仍存在:${baseName}`)
  450. }
  451. if (documents.length) {
  452. const history = await request.get('/api/v1/jobs', {
  453. headers,
  454. params: { keyword: auditMarker, page: '1', pageSize: '20' },
  455. })
  456. if (history.status() === 200) {
  457. const historyPage = asRecord(await responseData(history, 200, '复核任务审计历史'))
  458. expect(asRecords(historyPage.items).length).toBe(documents.length)
  459. addActivity(activities, '制作队列', '不可删除任务按业务审计历史保留', 'PASS', {
  460. response: history,
  461. detail: '任务标题未使用 APE2E 关键词,不计为可清理业务资源残留。',
  462. })
  463. }
  464. }
  465. await attachReport(testInfo, activities, cleanupErrors, beforeOverview, duringOverview, documents.map((item) => item.jobId))
  466. }
  467. if (primaryError) throw primaryError
  468. expect(cleanupErrors, 'Overview / Jobs 测试业务资源必须清理完成').toEqual([])
  469. })