|
- 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<string, unknown>
- type Headers = Record<string, string>
- 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([])
- })
|