Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

256 wiersze
14 KiB

  1. import type { APIRequestContext, TestInfo } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  3. import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin, visibleDialog } from './helpers'
  4. type JsonRecord = Record<string, unknown>
  5. type Headers = Record<string, string>
  6. interface Activity {
  7. module: string
  8. action: string
  9. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  10. httpStatus?: number
  11. resourceId?: string
  12. detail?: string
  13. }
  14. interface ResponseLike {
  15. status(): number
  16. json(): Promise<unknown>
  17. }
  18. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  19. ? value as JsonRecord
  20. : {}
  21. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  22. async function responseData(response: ResponseLike, expected: number | number[], label: string) {
  23. const statuses = Array.isArray(expected) ? expected : [expected]
  24. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  25. return asRecord(envelopeData(await response.json()))
  26. }
  27. function addPass(
  28. activities: Activity[],
  29. action: string,
  30. response?: ResponseLike,
  31. resourceId?: string,
  32. detail?: string,
  33. ) {
  34. activities.push({ module: 'AI模型', action, result: 'PASS', httpStatus: response?.status(), resourceId, detail })
  35. }
  36. async function listTestModels(request: APIRequestContext, headers: Headers) {
  37. const response = await request.get('/api/v1/capabilities', {
  38. headers,
  39. params: { capabilityType: 'LLM', keyword: runPrefix, page: '1', pageSize: '200' },
  40. })
  41. const payload = await responseData(response, 200, '读取测试 AI 模型')
  42. return asRecords(payload.items ?? payload.records)
  43. }
  44. async function cleanupModels(
  45. request: APIRequestContext,
  46. headers: Headers,
  47. activities: Activity[],
  48. cleanupErrors: string[],
  49. ) {
  50. try {
  51. for (const item of await listTestModels(request, headers)) {
  52. if (!String(item.name ?? '').startsWith(runPrefix)) continue
  53. const id = String(item.id ?? '')
  54. if (!id) continue
  55. const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers })
  56. if (detailResponse.status() === 404) continue
  57. const detail = await responseData(detailResponse, 200, '刷新待清理 AI 模型版本')
  58. const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, {
  59. headers,
  60. params: { dataVersion: String(Number(detail.dataVersion ?? 0)) },
  61. })
  62. if (![200, 404].includes(removed.status())) throw new Error(`AI 模型 ${id} 删除失败:HTTP ${removed.status()}`)
  63. activities.push({ module: 'AI模型', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  64. }
  65. } catch (error) {
  66. const message = `AI 模型兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  67. cleanupErrors.push(message)
  68. activities.push({ module: 'AI模型', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  69. }
  70. }
  71. async function attachLifecycleReport(
  72. testInfo: TestInfo,
  73. activities: Activity[],
  74. cleanupErrors: string[],
  75. ) {
  76. await attachJson(testInfo, 'AI模型UI-页面生命周期与清理', {
  77. runId,
  78. runPrefix,
  79. activities,
  80. cleanupComplete: cleanupErrors.length === 0,
  81. cleanupErrors,
  82. security: '报告未记录管理员密码、访问令牌或外部模型服务密钥。测试模型只使用不可解析的本地配置标识。',
  83. })
  84. }
  85. test('AI模型页面:禁用安全模型新增、参数编辑、可解释预览、校验、启停、筛选与删除', async ({ page, request }, testInfo) => {
  86. test.setTimeout(120_000)
  87. const headers = await authHeaders(request)
  88. const activities: Activity[] = []
  89. const cleanupErrors: string[] = []
  90. const modelName = `${runPrefix}-离线内容安全模型`.slice(0, 50)
  91. const initialDescription = `${runPrefix} 维修问答输入输出安全审查`.slice(0, 200)
  92. const editedDescription = `${runPrefix} 维修问答输入输出安全审查,返回命中规则与处置依据`.slice(0, 200)
  93. const initialReference = `${runPrefix.toLowerCase()}-safety-guard:v1`
  94. const editedReference = `${runPrefix.toLowerCase()}-safety-guard:v2?threshold=0.12&explain=true`
  95. let modelId = ''
  96. let primaryError: unknown
  97. await cleanupModels(request, headers, activities, cleanupErrors)
  98. activities.length = 0
  99. cleanupErrors.length = 0
  100. try {
  101. await loginAsAdmin(page)
  102. await page.goto('/assets/ai-models')
  103. await expect(page.getByText('AI模型', { exact: true }).first()).toBeVisible()
  104. await expect(page.getByText('正在加载配置')).toHaveCount(0)
  105. await page.getByRole('button', { name: /新增AI模型/ }).click()
  106. let dialog = visibleDialog(page)
  107. await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill(modelName)
  108. await dialog.locator('.capability-form > label').filter({ hasText: '分类' }).locator('select').selectOption({ label: '离线问答引擎' })
  109. await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(initialDescription)
  110. await dialog.locator('.capability-form-technical summary').click()
  111. await dialog.locator('.capability-form-technical input').fill(initialReference)
  112. const enabledSwitch = dialog.locator('.switch-field .el-switch')
  113. if ((await enabledSwitch.getAttribute('class'))?.includes('is-checked')) await enabledSwitch.click()
  114. const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/capabilities(?:\?|$)/.test(response.url()))
  115. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  116. const createResponse = await createResponsePromise
  117. const created = await responseData(createResponse, 201, '页面新增禁用安全模型')
  118. modelId = String(created.id ?? '')
  119. expect(modelId).not.toBe('')
  120. let card = page.locator('.capability-card').filter({ hasText: modelName })
  121. await expect(card).toBeVisible()
  122. await expect(card.locator('.status-pill')).toHaveText('已停用')
  123. const createdDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers })
  124. const createdDetail = await responseData(createdDetailResponse, 200, 'API 后验新增安全模型')
  125. expect(createdDetail).toMatchObject({
  126. name: modelName,
  127. type: '离线问答引擎',
  128. description: initialDescription,
  129. reference: initialReference,
  130. enabled: false,
  131. })
  132. addPass(activities, '通过页面新增禁用状态的离线内容安全模型并完成 API 后验', createResponse, modelId)
  133. await captureScreenshot(page, testInfo, 'AI模型-01-禁用安全模型页面新增')
  134. await card.getByRole('button', { name: '编辑', exact: true }).click()
  135. dialog = visibleDialog(page)
  136. await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(editedDescription)
  137. await dialog.locator('.capability-form-technical summary').click()
  138. await dialog.locator('.capability-form-technical input').fill(editedReference)
  139. const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  140. && response.url().includes(`/api/v1/capabilities/${modelId}`)
  141. && !response.url().includes('/status'))
  142. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  143. const editResponse = await editResponsePromise
  144. await responseData(editResponse, 200, '页面编辑安全模型参数')
  145. await page.reload()
  146. const keyword = page.getByPlaceholder('搜索AI模型名称、分类或说明')
  147. await keyword.fill(modelName)
  148. card = page.locator('.capability-card').filter({ hasText: modelName })
  149. await expect(card).toContainText(editedDescription)
  150. await card.locator('.capability-technical-info summary').click()
  151. await expect(card).toContainText(editedReference)
  152. const editedDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers })
  153. const editedDetail = await responseData(editedDetailResponse, 200, 'API 后验编辑安全模型参数')
  154. expect(editedDetail).toMatchObject({ description: editedDescription, reference: editedReference, enabled: false })
  155. addPass(activities, '页面编辑模型用途与配置参数,刷新和 API 均保持', editResponse, modelId)
  156. await captureScreenshot(page, testInfo, 'AI模型-02-参数编辑刷新持久化')
  157. await card.getByRole('button', { name: '预览', exact: true }).click()
  158. const previewMessage = page.locator('.el-message:visible').last()
  159. await expect(previewMessage).toContainText(modelName)
  160. await expect(previewMessage).toContainText('离线问答引擎')
  161. await expect(previewMessage).toContainText('用途')
  162. await expect(previewMessage).toContainText(editedDescription)
  163. await expect(previewMessage).toContainText(editedReference)
  164. addPass(activities, '页面预览展示模型分类、用途和配置标识的可解释结果', undefined, modelId)
  165. await captureScreenshot(page, testInfo, 'AI模型-03-可解释预览结果')
  166. const verifyResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  167. && response.url().includes(`/api/v1/capabilities/${modelId}/verify`))
  168. await card.getByRole('button', { name: '校验本地配置' }).click()
  169. const verifyResponse = await verifyResponsePromise
  170. const verified = await responseData(verifyResponse, 200, '页面校验安全模型配置')
  171. expect(verified).toMatchObject({ valid: true, message: '配置可用' })
  172. await expect(page.getByText('配置可用').last()).toBeVisible()
  173. const verifiedDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers })
  174. const verifiedDetail = await responseData(verifiedDetailResponse, 200, 'API 后验模型校验状态')
  175. expect(verifiedDetail.validationStatus).toBe('AVAILABLE')
  176. addPass(activities, '页面校验配置并以 API 确认 AVAILABLE 状态', verifyResponse, modelId)
  177. await captureScreenshot(page, testInfo, 'AI模型-04-本地配置校验通过')
  178. const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  179. && response.url().includes(`/api/v1/capabilities/${modelId}/status`))
  180. await card.getByRole('button', { name: '启用', exact: true }).click()
  181. const enableResponse = await enableResponsePromise
  182. await responseData(enableResponse, 200, '页面启用安全模型')
  183. await expect(card.locator('.status-pill')).toHaveText('可用')
  184. const disableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT'
  185. && response.url().includes(`/api/v1/capabilities/${modelId}/status`))
  186. await card.getByRole('button', { name: '停用', exact: true }).click()
  187. const disableResponse = await disableResponsePromise
  188. await responseData(disableResponse, 200, '页面停用安全模型')
  189. await expect(card.locator('.status-pill')).toHaveText('已停用')
  190. await page.reload()
  191. await keyword.fill(modelName)
  192. card = page.locator('.capability-card').filter({ hasText: modelName })
  193. await expect(card.locator('.status-pill')).toHaveText('已停用')
  194. const statusFilter = page.getByLabel('状态筛选')
  195. await statusFilter.selectOption('enabled')
  196. await expect(card).toHaveCount(0)
  197. await expect(page.getByText('没有匹配的配置')).toBeVisible()
  198. await statusFilter.selectOption('disabled')
  199. await expect(page.locator('.capability-card').filter({ hasText: modelName })).toBeVisible()
  200. const disabledDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers })
  201. const disabledDetail = await responseData(disabledDetailResponse, 200, 'API 后验最终停用状态')
  202. expect(disabledDetail.enabled).toBe(false)
  203. addPass(activities, '页面启用后再停用,刷新、状态筛选和 API 状态一致', disableResponse, modelId)
  204. await captureScreenshot(page, testInfo, 'AI模型-05-停用筛选刷新持久化')
  205. await statusFilter.selectOption('all')
  206. card = page.locator('.capability-card').filter({ hasText: modelName })
  207. await card.getByRole('button', { name: '删除', exact: true }).click()
  208. const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE'
  209. && response.url().includes(`/api/v1/capabilities/${modelId}`))
  210. await confirmMessageBox(page, /确定/)
  211. const deleteResponse = await deleteResponsePromise
  212. await responseData(deleteResponse, 200, '页面删除安全模型')
  213. const deletedModelId = modelId
  214. modelId = ''
  215. await expect(page.locator('.capability-card').filter({ hasText: modelName })).toHaveCount(0)
  216. const absent = await request.get(`/api/v1/capabilities/${encodeURIComponent(deletedModelId)}`, { headers })
  217. expect(absent.status()).toBe(404)
  218. addPass(activities, '页面删除并确认 API 不可读取', deleteResponse, deletedModelId)
  219. await captureScreenshot(page, testInfo, 'AI模型-06-删除后无残留')
  220. } catch (error) {
  221. primaryError = error
  222. activities.push({ module: 'AI模型', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  223. } finally {
  224. if (modelId) await cleanupModels(request, headers, activities, cleanupErrors)
  225. await attachLifecycleReport(testInfo, activities, cleanupErrors)
  226. }
  227. if (primaryError) throw primaryError
  228. expect(cleanupErrors, 'AI 模型测试资源必须清理干净').toEqual([])
  229. })