import type { APIRequestContext, APIResponse, Response, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, envelopeData, loginAsAdmin } from './helpers' test.describe.configure({ mode: 'serial' }) test.use({ trace: 'off', video: 'off' }) type JsonRecord = Record type Headers = Record type ResponseLike = APIResponse | Response interface Activity { module: string action: string result: 'PASS' | 'FAIL' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } interface DocumentRef { id: string jobId: string } const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] async function responseData(response: ResponseLike, expected: number | number[], label: string) { const statuses = Array.isArray(expected) ? expected : [expected] expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) return asRecord(envelopeData(await response.json())) } function addActivity( activities: Activity[], module: string, action: string, result: Activity['result'], options: { response?: ResponseLike; resourceId?: string; detail?: string } = {}, ) { activities.push({ module, action, result, httpStatus: options.response?.status(), resourceId: options.resourceId, detail: options.detail, }) } function statsOf(overview: JsonRecord) { return asRecord(overview.stats) } function isJobsListResponse(response: Response) { if (response.request().method() !== 'GET') return false return new URL(response.url()).pathname === '/api/v1/jobs' } async function waitForJobSucceeded( request: APIRequestContext, headers: Headers, jobId: string, timeoutMs = 60_000, ) { const deadline = Date.now() + timeoutMs let latest: JsonRecord = {} while (Date.now() < deadline) { const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) latest = await responseData(response, 200, '等待知识索引任务完成') const status = String(latest.status ?? '').toLowerCase() if (status === 'succeeded') return latest if (status === 'failed' || status === 'cancelled') { throw new Error(`知识索引任务未成功:${String(latest.errorMessage ?? latest.stage ?? status)}`) } await new Promise((resolve) => setTimeout(resolve, 400)) } throw new Error(`知识索引任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`) } async function removeDocument( request: APIRequestContext, headers: Headers, documentId: string, activities: Activity[], cleanupErrors: string[], ) { try { for (let attempt = 0; attempt < 8; attempt += 1) { const detailResponse = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers }) if (detailResponse.status() === 404) return if (detailResponse.status() !== 200) throw new Error(`读取资料失败:HTTP ${detailResponse.status()}`) const detail = asRecord(envelopeData(await detailResponse.json())) const removed = await request.delete(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers, params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, }) if (removed.status() === 200 || removed.status() === 404) { addActivity(activities, 'Overview / Jobs', '清理知识资料', 'CLEANED', { response: removed, resourceId: documentId }) return } if (removed.status() !== 409 || attempt === 7) throw new Error(`删除资料失败:HTTP ${removed.status()}`) await new Promise((resolve) => setTimeout(resolve, 500)) } } catch (error) { const message = `知识资料 ${documentId} 清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) addActivity(activities, 'Overview / Jobs', '清理知识资料', 'CLEANUP_FAILED', { resourceId: documentId, detail: message }) } } async function cleanupBase( request: APIRequestContext, headers: Headers, baseId: string, activities: Activity[], cleanupErrors: string[], ) { try { const documentsResponse = await request.get('/api/v1/knowledge/documents', { headers, params: { knowledgeBaseId: baseId, page: '1', pageSize: '200' }, }) if (documentsResponse.status() === 200) { const documents = asRecord(envelopeData(await documentsResponse.json())) for (const item of asRecords(documents.items)) { const documentId = String(item.id ?? '') if (documentId) await removeDocument(request, headers, documentId, activities, cleanupErrors) } } const basesResponse = await request.get('/api/v1/knowledge/bases', { headers, params: { page: '1', pageSize: '200' }, }) if (basesResponse.status() !== 200) throw new Error(`读取知识库失败:HTTP ${basesResponse.status()}`) const bases = asRecord(envelopeData(await basesResponse.json())) const current = asRecords(bases.items).find((item) => String(item.id ?? '') === baseId) if (!current) return const removed = await request.delete(`/api/v1/knowledge/bases/${encodeURIComponent(baseId)}`, { headers, params: { dataVersion: String(Number(current.dataVersion ?? 0)) }, }) if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除知识库失败:HTTP ${removed.status()}`) addActivity(activities, 'Overview / Jobs', '清理知识库', 'CLEANED', { response: removed, resourceId: baseId }) } catch (error) { const message = `知识库 ${baseId} 清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) addActivity(activities, 'Overview / Jobs', '清理知识库', 'CLEANUP_FAILED', { resourceId: baseId, detail: message }) } } async function cleanupBaseByName( request: APIRequestContext, headers: Headers, baseName: string, activities: Activity[], cleanupErrors: string[], ) { const listed = await request.get('/api/v1/knowledge/bases', { headers, params: { keyword: baseName, page: '1', pageSize: '200' }, }) if (listed.status() !== 200) return const page = asRecord(envelopeData(await listed.json())) for (const item of asRecords(page.items)) { if (String(item.name ?? '') !== baseName) continue const id = String(item.id ?? '') if (id) await cleanupBase(request, headers, id, activities, cleanupErrors) } } async function createKnowledgeJob( request: APIRequestContext, headers: Headers, baseId: string, filename: string, index: number, ) { const upload = await request.post('/api/v1/knowledge/documents', { headers, multipart: { knowledgeBaseId: baseId, file: { name: filename, mimeType: 'text/plain', buffer: Buffer.from(`概览与制作队列验证资料 ${index}。设备检修前必须停机、断电、验电、卸压并挂牌。`, 'utf8'), }, }, }) const payload = await responseData(upload, 201, `上传第 ${index} 份知识资料`) const document = asRecord(payload.document) const job = asRecord(payload.job) const reference = { id: String(document.id ?? ''), jobId: String(job.id ?? '') } expect(reference.id).not.toBe('') expect(reference.jobId).not.toBe('') return { upload, reference } } async function attachReport( testInfo: TestInfo, activities: Activity[], cleanupErrors: string[], before: JsonRecord, during: JsonRecord, jobIds: string[], ) { await attachJson(testInfo, 'Overview与Jobs真实行为覆盖', { runId, runPrefix, activities, overviewBefore: statsOf(before), overviewDuring: statsOf(during), generatedJobIds: jobIds, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, retainedAuditHistory: '异步任务是不可删除的业务审计历史;测试已删除知识库和资料,任务标题刻意不使用 APE2E 关键词,避免后验资源扫描误报。', security: '报告未记录管理员密码或访问令牌。', }) } test('工作概览与制作队列:汇总、快捷入口、服务端分页筛选、详情事件和刷新持久化', async ({ page, request }, testInfo) => { test.setTimeout(150_000) const headers = await authHeaders(request) const activities: Activity[] = [] const cleanupErrors: string[] = [] const auditMarker = `OVJ${Date.now().toString(36).toUpperCase()}` const baseName = `${runPrefix}-概览队列行为验证`.slice(0, 80) const filenames = [1, 2].map((index) => `概览队列验证-${auditMarker}-${index}.txt`) const documents: DocumentRef[] = [] let baseId = '' let beforeOverview: JsonRecord = {} let duringOverview: JsonRecord = {} let primaryError: unknown await cleanupBaseByName(request, headers, baseName, activities, cleanupErrors) activities.length = 0 cleanupErrors.length = 0 try { const beforeResponse = await request.get('/api/v1/overview', { headers }) beforeOverview = await responseData(beforeResponse, 200, '读取创建前工作概览') const createBaseResponse = await request.post('/api/v1/knowledge/bases', { headers, data: { name: baseName, description: 'APE2E 工作概览与制作队列真实行为验证;业务资源结束后删除。', category: '端到端验证', tags: ['APE2E', '制作队列'], }, }) const createdBase = await responseData(createBaseResponse, 201, '创建测试知识库') baseId = String(createdBase.id ?? '') expect(baseId).not.toBe('') addActivity(activities, 'Overview / Jobs', '创建可清理的正式知识库', 'PASS', { response: createBaseResponse, resourceId: baseId }) for (let index = 0; index < filenames.length; index += 1) { const { upload, reference } = await createKnowledgeJob(request, headers, baseId, filenames[index], index + 1) documents.push(reference) addActivity(activities, 'Overview / Jobs', '通过 TXT 上传创建正式知识索引任务', 'PASS', { response: upload, resourceId: reference.jobId, detail: filenames[index], }) const completed = await waitForJobSucceeded(request, headers, reference.jobId) expect(Number(completed.progress)).toBe(100) addActivity(activities, 'Overview / Jobs', '等待任务进入完成状态', 'PASS', { resourceId: reference.jobId }) } const duringResponse = await request.get('/api/v1/overview', { headers }) duringOverview = await responseData(duringResponse, 200, '读取创建后的工作概览') const beforeStats = statsOf(beforeOverview) const duringStats = statsOf(duringOverview) expect(Number(duringStats.knowledgeDocuments)).toBeGreaterThanOrEqual(Number(beforeStats.knowledgeDocuments ?? 0) + 2) expect(Number(duringStats.jobs)).toBeGreaterThanOrEqual(Number(beforeStats.jobs ?? 0) + 2) const recentJobIds = asRecords(duringOverview.jobs).map((item) => String(item.id ?? '')) expect(documents.every((item) => recentJobIds.includes(item.jobId))).toBeTruthy() addActivity(activities, '工作概览', '汇总统计包含新资料和两条正式任务', 'PASS', { response: duringResponse }) await loginAsAdmin(page) const overviewUiResponsePromise = page.waitForResponse((response) => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/overview') await page.goto('/overview') const overviewUiResponse = await overviewUiResponsePromise const uiOverview = await responseData(overviewUiResponse, 200, '页面加载工作概览') const uiStats = statsOf(uiOverview) const cards: Array<[string, string]> = [ ['数字人智能体', 'agents'], ['数字人形象', 'readyAvatars'], ['维修知识', 'knowledgeDocuments'], ['口播视频', 'videos'], ['生产任务', 'activeJobs'], ] for (const [label, field] of cards) { const card = page.locator('.overview-capability-grid > button').filter({ hasText: label }) await expect(card).toHaveCount(1) await expect(card.locator('strong')).toHaveText(String(Number(uiStats[field] ?? 0))) } for (const filename of filenames) { await expect(page.locator('.overview-job-queue > button').filter({ hasText: filename })).toBeVisible() } addActivity(activities, '工作概览', '五项统计卡片和最近任务与正式 API 一致', 'PASS', { response: overviewUiResponse }) await captureScreenshot(page, testInfo, '01-overview-stats-and-recent-jobs') await page.locator('.overview-capability-grid > button').filter({ hasText: '维修知识' }).click() await expect(page).toHaveURL(/\/knowledge\/library(?:\?|$)/) addActivity(activities, '工作概览', '维修知识快捷入口可达', 'PASS') await captureScreenshot(page, testInfo, '02-overview-knowledge-quick-entry') const overviewReloadPromise = page.waitForResponse((response) => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/overview') await page.goto('/overview') await overviewReloadPromise const initialJobsResponsePromise = page.waitForResponse(isJobsListResponse) await page.locator('.overview-capability-grid > button').filter({ hasText: '生产任务' }).click() const initialJobsResponse = await initialJobsResponsePromise await responseData(initialJobsResponse, 200, '快捷入口加载制作队列') await expect(page).toHaveURL(/\/jobs(?:\?|$)/) addActivity(activities, '工作概览', '生产任务快捷入口可达制作队列', 'PASS', { response: initialJobsResponse }) const search = page.getByRole('searchbox', { name: '搜索任务' }) const keywordResponsePromise = page.waitForResponse(isJobsListResponse) await search.fill(auditMarker) const keywordResponse = await keywordResponsePromise const keywordUrl = new URL(keywordResponse.url()) expect(keywordUrl.searchParams.get('status')).toBe('SUCCEEDED') expect(keywordUrl.searchParams.get('keyword')).toBe(auditMarker) expect(keywordUrl.searchParams.get('page')).toBe('1') expect(keywordUrl.searchParams.get('pageSize')).toBe('20') const keywordPage = await responseData(keywordResponse, 200, '页面按关键词筛选完成任务') const keywordItems = asRecords(keywordPage.items) expect(keywordItems).toHaveLength(2) expect(keywordItems.every((item) => String(item.status) === 'succeeded')).toBeTruthy() const rows = page.locator('tbody tr') await expect(rows).toHaveCount(2) for (const filename of filenames) await expect(rows.filter({ hasText: filename })).toHaveCount(1) addActivity(activities, '制作队列', '页面向服务端提交状态和关键词筛选', 'PASS', { response: keywordResponse }) await captureScreenshot(page, testInfo, '03-jobs-server-filtered-deliverables') const firstReference = documents[0] const firstFilename = filenames[0] const firstRow = rows.filter({ hasText: firstFilename }) await firstRow.getByRole('button', { name: '详情' }).click() const detailDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last() await expect(detailDialog).toContainText(firstReference.jobId) await expect(detailDialog).toContainText(firstFilename) await expect(detailDialog).toContainText('已完成') await expect(detailDialog).toContainText('100%') addActivity(activities, '制作队列', '页面展示持久化任务详情', 'PASS', { resourceId: firstReference.jobId }) await captureScreenshot(page, testInfo, '04-job-detail-persisted') await detailDialog.getByRole('button', { name: /关闭/ }).click().catch(async () => { await page.keyboard.press('Escape') }) const eventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}/events`, { headers }) const events = await responseData(eventsResponse, 200, '读取任务事件') const eventItems = asRecords(events.items) expect(eventItems.length).toBeGreaterThanOrEqual(2) const sequences = eventItems.map((item) => Number(item.sequence)) expect(sequences).toEqual([...sequences].sort((left, right) => left - right)) expect(eventItems.some((item) => String(item.status) === 'succeeded')).toBeTruthy() addActivity(activities, '制作队列', '正式事件接口返回有序任务状态流', 'PASS', { response: eventsResponse, resourceId: firstReference.jobId }) const detailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}`, { headers }) const jobDetail = await responseData(detailResponse, 200, '刷新读取任务详情') if (asRecord(jobDetail.output).url) { const downloadPromise = page.waitForEvent('download') await firstRow.getByRole('button', { name: '下载' }).click() const download = await downloadPromise expect(download.suggestedFilename()).not.toBe('') addActivity(activities, '制作队列', '页面下载任务成果', 'PASS', { resourceId: firstReference.jobId }) } else { await expect(firstRow.getByRole('button', { name: '下载' })).toHaveCount(0) const resultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}/result`, { headers }) expect(resultResponse.status()).toBe(404) addActivity(activities, '制作队列', '无独立成果的知识索引任务不显示下载并返回 404', 'PASS', { response: resultResponse, resourceId: firstReference.jobId, }) } const kindResponse = await request.get('/api/v1/jobs', { headers, params: { kind: 'KNOWLEDGE_INDEX', keyword: auditMarker, page: '1', pageSize: '20' }, }) const kindPage = await responseData(kindResponse, 200, '按任务类型筛选') const kindItems = asRecords(kindPage.items) expect(kindItems).toHaveLength(2) expect(kindItems.every((item) => String(item.kind) === 'knowledge_index')).toBeTruthy() addActivity(activities, '制作队列', '正式 API 按任务类型筛选', 'PASS', { response: kindResponse }) const groupedTabs: Array<{ label: string; status: string | null; allowed: string[] }> = [ { label: '处理中', status: 'ACTIVE', allowed: ['queued', 'running'] }, { label: '需要处理', status: 'NEEDS_ATTENTION', allowed: ['failed', 'cancelled'] }, { label: '全部任务', status: null, allowed: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] }, ] for (const group of groupedTabs) { const responsePromise = page.waitForResponse(isJobsListResponse) await page.getByRole('tab', { name: group.label }).click() const response = await responsePromise const url = new URL(response.url()) expect(url.searchParams.get('status')).toBe(group.status) const payload = await responseData(response, 200, `${group.label}服务端分组`) expect(asRecords(payload.items).every((item) => group.allowed.includes(String(item.status)))).toBeTruthy() addActivity(activities, '制作队列', `${group.label}状态分组由服务端执行`, 'PASS', { response }) await captureScreenshot(page, testInfo, `05-status-group-${group.label}`) } const firstPageResponse = await request.get('/api/v1/jobs', { headers, params: { keyword: auditMarker, page: '1', pageSize: '1' }, }) const firstPage = await responseData(firstPageResponse, 200, '读取服务端分页第一页') const secondPageResponse = await request.get('/api/v1/jobs', { headers, params: { keyword: auditMarker, page: '2', pageSize: '1' }, }) const secondPage = await responseData(secondPageResponse, 200, '读取服务端分页第二页') expect(Number(firstPage.total)).toBe(2) expect(Number(firstPage.pages)).toBe(2) expect(Number(firstPage.pageSize)).toBe(1) expect(asRecords(firstPage.items)).toHaveLength(1) expect(asRecords(secondPage.items)).toHaveLength(1) expect(String(asRecords(firstPage.items)[0].id)).not.toBe(String(asRecords(secondPage.items)[0].id)) addActivity(activities, '制作队列', '服务端分页返回稳定且不重复的两页', 'PASS', { response: secondPageResponse }) const clearResponsePromise = page.waitForResponse(isJobsListResponse) await search.fill('') const clearResponse = await clearResponsePromise const clearPage = await responseData(clearResponse, 200, '清空关键词读取全部任务') if (Number(clearPage.pages) > 1) { const pager = page.locator('.management-pagination') await expect(pager).toBeVisible() const pageTwoResponsePromise = page.waitForResponse(isJobsListResponse) await pager.locator('.el-pager li.number').filter({ hasText: /^2$/ }).click() const pageTwoResponse = await pageTwoResponsePromise expect(new URL(pageTwoResponse.url()).searchParams.get('page')).toBe('2') const pageTwo = await responseData(pageTwoResponse, 200, '页面切换服务端第二页') expect(Number(pageTwo.page)).toBe(2) addActivity(activities, '制作队列', '页面分页控件加载服务端第二页', 'PASS', { response: pageTwoResponse }) await captureScreenshot(page, testInfo, '06-jobs-ui-server-page-two') } else { addActivity(activities, '制作队列', '页面分页控件加载服务端第二页', 'SKIP', { detail: '当前环境总任务不足 21 条;已用 pageSize=1 的正式 API 完整验证两页语义。', }) } const reloadInitialResponsePromise = page.waitForResponse(isJobsListResponse) await page.reload() await responseData(await reloadInitialResponsePromise, 200, '整页刷新后读取初始任务列表') const persistedSearch = page.getByRole('searchbox', { name: '搜索任务' }) // Chromium may restore the previous native search value across a hard reload // while Vue's new component state still starts empty. Clear the restored DOM // value first, then wait specifically for the request carrying our marker. await persistedSearch.fill('') const persistedResponsePromise = page.waitForResponse((response) => ( isJobsListResponse(response) && new URL(response.url()).searchParams.get('keyword') === auditMarker )) await persistedSearch.fill(auditMarker) const persistedResponse = await persistedResponsePromise const persisted = await responseData(persistedResponse, 200, '刷新后重新筛选任务') expect(asRecords(persisted.items).map((item) => String(item.id)).sort()).toEqual(documents.map((item) => item.jobId).sort()) await expect(page.locator('tbody tr')).toHaveCount(2) addActivity(activities, '制作队列', '整页刷新后任务数据保持一致', 'PASS', { response: persistedResponse }) await captureScreenshot(page, testInfo, '07-jobs-after-full-page-refresh') } catch (error) { primaryError = error addActivity(activities, 'Overview / Jobs', '完整行为链路', 'FAIL', { detail: error instanceof Error ? error.message : String(error), }) } finally { if (baseId) await cleanupBase(request, headers, baseId, activities, cleanupErrors) const residual = await request.get('/api/v1/knowledge/bases', { headers, params: { keyword: baseName, page: '1', pageSize: '200' }, }) if (residual.status() === 200) { const residualPage = asRecord(envelopeData(await residual.json())) const found = asRecords(residualPage.items).some((item) => String(item.name ?? '') === baseName) if (found) cleanupErrors.push(`测试知识库仍存在:${baseName}`) } if (documents.length) { const history = await request.get('/api/v1/jobs', { headers, params: { keyword: auditMarker, page: '1', pageSize: '20' }, }) if (history.status() === 200) { const historyPage = asRecord(await responseData(history, 200, '复核任务审计历史')) expect(asRecords(historyPage.items).length).toBe(documents.length) addActivity(activities, '制作队列', '不可删除任务按业务审计历史保留', 'PASS', { response: history, detail: '任务标题未使用 APE2E 关键词,不计为可清理业务资源残留。', }) } } await attachReport(testInfo, activities, cleanupErrors, beforeOverview, duringOverview, documents.map((item) => item.jobId)) } if (primaryError) throw primaryError expect(cleanupErrors, 'Overview / Jobs 测试业务资源必须清理完成').toEqual([]) })