import type { APIRequestContext, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin, visibleDialog } from './helpers' type JsonRecord = Record type Headers = Record interface Activity { module: string action: string result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number resourceId?: string detail?: string } interface ResponseLike { status(): number json(): Promise } 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 addPass( activities: Activity[], action: string, response?: ResponseLike, resourceId?: string, detail?: string, ) { activities.push({ module: 'AI模型', action, result: 'PASS', httpStatus: response?.status(), resourceId, detail }) } async function listTestModels(request: APIRequestContext, headers: Headers) { const response = await request.get('/api/v1/capabilities', { headers, params: { capabilityType: 'LLM', keyword: runPrefix, page: '1', pageSize: '200' }, }) const payload = await responseData(response, 200, '读取测试 AI 模型') return asRecords(payload.items ?? payload.records) } async function cleanupModels( request: APIRequestContext, headers: Headers, activities: Activity[], cleanupErrors: string[], ) { try { for (const item of await listTestModels(request, headers)) { if (!String(item.name ?? '').startsWith(runPrefix)) continue const id = String(item.id ?? '') if (!id) continue const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers }) if (detailResponse.status() === 404) continue const detail = await responseData(detailResponse, 200, '刷新待清理 AI 模型版本') const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers, params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, }) if (![200, 404].includes(removed.status())) throw new Error(`AI 模型 ${id} 删除失败:HTTP ${removed.status()}`) activities.push({ module: 'AI模型', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) } } catch (error) { const message = `AI 模型兜底清理异常:${error instanceof Error ? error.message : String(error)}` cleanupErrors.push(message) activities.push({ module: 'AI模型', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) } } async function attachLifecycleReport( testInfo: TestInfo, activities: Activity[], cleanupErrors: string[], ) { await attachJson(testInfo, 'AI模型UI-页面生命周期与清理', { runId, runPrefix, activities, cleanupComplete: cleanupErrors.length === 0, cleanupErrors, security: '报告未记录管理员密码、访问令牌或外部模型服务密钥。测试模型只使用不可解析的本地配置标识。', }) } test('AI模型页面:禁用安全模型新增、参数编辑、可解释预览、校验、启停、筛选与删除', async ({ page, request }, testInfo) => { test.setTimeout(120_000) const headers = await authHeaders(request) const activities: Activity[] = [] const cleanupErrors: string[] = [] const modelName = `${runPrefix}-离线内容安全模型`.slice(0, 50) const initialDescription = `${runPrefix} 维修问答输入输出安全审查`.slice(0, 200) const editedDescription = `${runPrefix} 维修问答输入输出安全审查,返回命中规则与处置依据`.slice(0, 200) const initialReference = `${runPrefix.toLowerCase()}-safety-guard:v1` const editedReference = `${runPrefix.toLowerCase()}-safety-guard:v2?threshold=0.12&explain=true` let modelId = '' let primaryError: unknown await cleanupModels(request, headers, activities, cleanupErrors) activities.length = 0 cleanupErrors.length = 0 try { await loginAsAdmin(page) await page.goto('/assets/ai-models') await expect(page.getByText('AI模型', { exact: true }).first()).toBeVisible() await expect(page.getByText('正在加载配置')).toHaveCount(0) await page.getByRole('button', { name: /新增AI模型/ }).click() let dialog = visibleDialog(page) await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill(modelName) await dialog.locator('.capability-form > label').filter({ hasText: '分类' }).locator('select').selectOption({ label: '离线问答引擎' }) await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(initialDescription) await dialog.locator('.capability-form-technical summary').click() await dialog.locator('.capability-form-technical input').fill(initialReference) const enabledSwitch = dialog.locator('.switch-field .el-switch') if ((await enabledSwitch.getAttribute('class'))?.includes('is-checked')) await enabledSwitch.click() const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/capabilities(?:\?|$)/.test(response.url())) await dialog.getByRole('button', { name: '保存', exact: true }).click() const createResponse = await createResponsePromise const created = await responseData(createResponse, 201, '页面新增禁用安全模型') modelId = String(created.id ?? '') expect(modelId).not.toBe('') let card = page.locator('.capability-card').filter({ hasText: modelName }) await expect(card).toBeVisible() await expect(card.locator('.status-pill')).toHaveText('已停用') const createdDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) const createdDetail = await responseData(createdDetailResponse, 200, 'API 后验新增安全模型') expect(createdDetail).toMatchObject({ name: modelName, type: '离线问答引擎', description: initialDescription, reference: initialReference, enabled: false, }) addPass(activities, '通过页面新增禁用状态的离线内容安全模型并完成 API 后验', createResponse, modelId) await captureScreenshot(page, testInfo, 'AI模型-01-禁用安全模型页面新增') await card.getByRole('button', { name: '编辑', exact: true }).click() dialog = visibleDialog(page) await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(editedDescription) await dialog.locator('.capability-form-technical summary').click() await dialog.locator('.capability-form-technical input').fill(editedReference) const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${modelId}`) && !response.url().includes('/status')) await dialog.getByRole('button', { name: '保存', exact: true }).click() const editResponse = await editResponsePromise await responseData(editResponse, 200, '页面编辑安全模型参数') await page.reload() const keyword = page.getByPlaceholder('搜索AI模型名称、分类或说明') await keyword.fill(modelName) card = page.locator('.capability-card').filter({ hasText: modelName }) await expect(card).toContainText(editedDescription) await card.locator('.capability-technical-info summary').click() await expect(card).toContainText(editedReference) const editedDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) const editedDetail = await responseData(editedDetailResponse, 200, 'API 后验编辑安全模型参数') expect(editedDetail).toMatchObject({ description: editedDescription, reference: editedReference, enabled: false }) addPass(activities, '页面编辑模型用途与配置参数,刷新和 API 均保持', editResponse, modelId) await captureScreenshot(page, testInfo, 'AI模型-02-参数编辑刷新持久化') await card.getByRole('button', { name: '预览', exact: true }).click() const previewMessage = page.locator('.el-message:visible').last() await expect(previewMessage).toContainText(modelName) await expect(previewMessage).toContainText('离线问答引擎') await expect(previewMessage).toContainText('用途') await expect(previewMessage).toContainText(editedDescription) await expect(previewMessage).toContainText(editedReference) addPass(activities, '页面预览展示模型分类、用途和配置标识的可解释结果', undefined, modelId) await captureScreenshot(page, testInfo, 'AI模型-03-可解释预览结果') const verifyResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes(`/api/v1/capabilities/${modelId}/verify`)) await card.getByRole('button', { name: '校验本地配置' }).click() const verifyResponse = await verifyResponsePromise const verified = await responseData(verifyResponse, 200, '页面校验安全模型配置') expect(verified).toMatchObject({ valid: true, message: '配置可用' }) await expect(page.getByText('配置可用').last()).toBeVisible() const verifiedDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) const verifiedDetail = await responseData(verifiedDetailResponse, 200, 'API 后验模型校验状态') expect(verifiedDetail.validationStatus).toBe('AVAILABLE') addPass(activities, '页面校验配置并以 API 确认 AVAILABLE 状态', verifyResponse, modelId) await captureScreenshot(page, testInfo, 'AI模型-04-本地配置校验通过') const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${modelId}/status`)) await card.getByRole('button', { name: '启用', exact: true }).click() const enableResponse = await enableResponsePromise await responseData(enableResponse, 200, '页面启用安全模型') await expect(card.locator('.status-pill')).toHaveText('可用') const disableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${modelId}/status`)) await card.getByRole('button', { name: '停用', exact: true }).click() const disableResponse = await disableResponsePromise await responseData(disableResponse, 200, '页面停用安全模型') await expect(card.locator('.status-pill')).toHaveText('已停用') await page.reload() await keyword.fill(modelName) card = page.locator('.capability-card').filter({ hasText: modelName }) await expect(card.locator('.status-pill')).toHaveText('已停用') const statusFilter = page.getByLabel('状态筛选') await statusFilter.selectOption('enabled') await expect(card).toHaveCount(0) await expect(page.getByText('没有匹配的配置')).toBeVisible() await statusFilter.selectOption('disabled') await expect(page.locator('.capability-card').filter({ hasText: modelName })).toBeVisible() const disabledDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) const disabledDetail = await responseData(disabledDetailResponse, 200, 'API 后验最终停用状态') expect(disabledDetail.enabled).toBe(false) addPass(activities, '页面启用后再停用,刷新、状态筛选和 API 状态一致', disableResponse, modelId) await captureScreenshot(page, testInfo, 'AI模型-05-停用筛选刷新持久化') await statusFilter.selectOption('all') card = page.locator('.capability-card').filter({ hasText: modelName }) await card.getByRole('button', { name: '删除', exact: true }).click() const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/capabilities/${modelId}`)) await confirmMessageBox(page, /确定/) const deleteResponse = await deleteResponsePromise await responseData(deleteResponse, 200, '页面删除安全模型') const deletedModelId = modelId modelId = '' await expect(page.locator('.capability-card').filter({ hasText: modelName })).toHaveCount(0) const absent = await request.get(`/api/v1/capabilities/${encodeURIComponent(deletedModelId)}`, { headers }) expect(absent.status()).toBe(404) addPass(activities, '页面删除并确认 API 不可读取', deleteResponse, deletedModelId) await captureScreenshot(page, testInfo, 'AI模型-06-删除后无残留') } catch (error) { primaryError = error activities.push({ module: 'AI模型', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) } finally { if (modelId) await cleanupModels(request, headers, activities, cleanupErrors) await attachLifecycleReport(testInfo, activities, cleanupErrors) } if (primaryError) throw primaryError expect(cleanupErrors, 'AI 模型测试资源必须清理干净').toEqual([]) })