25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 

333 satır
19 KiB

  1. import type { APIRequestContext, Locator, Page } from '@playwright/test'
  2. import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures'
  3. import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
  4. type JsonRecord = Record<string, unknown>
  5. type Headers = Record<string, string>
  6. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object'
  7. ? value as JsonRecord
  8. : {}
  9. async function getAgent(request: APIRequestContext, headers: Headers, agentId: string) {
  10. const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  11. expect(response.status(), '智能体详情接口应返回成功').toBe(200)
  12. return asRecord(envelopeData(await response.json()))
  13. }
  14. async function saveThroughPage(page: Page, agentId: string) {
  15. const responsePromise = page.waitForResponse((response) => (
  16. response.request().method() === 'PUT'
  17. && new URL(response.url()).pathname === `/api/v1/realtime-agents/${agentId}`
  18. ))
  19. const saveButton = page.getByRole('button', { name: '保存配置' })
  20. await expect(saveButton).toBeVisible()
  21. await saveButton.click()
  22. const response = await responsePromise
  23. expect(response.status(), '页面保存智能体配置应返回成功').toBe(200)
  24. await expect(page.locator('.el-message').filter({ hasText: /配置已保存/ }).last()).toBeVisible()
  25. }
  26. async function reloadSectionAndCapture(
  27. page: Page,
  28. testInfo: Parameters<typeof captureScreenshot>[1],
  29. tabName: string,
  30. screenshotName: string,
  31. ) {
  32. await page.reload()
  33. const tabs = page.getByRole('navigation', { name: '配置分区' })
  34. await expect(tabs).toBeVisible()
  35. await expect(tabs.getByRole('button', { name: new RegExp(tabName) })).toHaveClass(/is-active/)
  36. await captureScreenshot(page, testInfo, screenshotName)
  37. }
  38. async function cleanupAgent(request: APIRequestContext, headers: Headers, agentId: string) {
  39. const detailResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  40. if (detailResponse.status() === 404) return
  41. if (detailResponse.status() !== 200) return
  42. let agent = asRecord(envelopeData(await detailResponse.json()))
  43. if (String(agent.status) === 'published') {
  44. const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, {
  45. headers,
  46. data: { dataVersion: Number(agent.dataVersion) },
  47. })
  48. if (disabled.status() === 200) agent = asRecord(envelopeData(await disabled.json()))
  49. }
  50. await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
  51. headers,
  52. params: { dataVersion: String(Number(agent.dataVersion)) },
  53. })
  54. }
  55. function visibleDetail(page: Page) {
  56. return page.locator('.detail-body:visible')
  57. }
  58. async function selectFirstRadio(card: Locator) {
  59. const label = card.locator('label:has(input[type="radio"])').first()
  60. const radio = label.locator('input[type="radio"]')
  61. await expect(radio).toBeVisible()
  62. await radio.check()
  63. return (await label.locator('b').first().textContent())?.trim() || ''
  64. }
  65. test('智能体管理与六区配置全部通过页面提交、刷新持久化并完成发布生命周期', async ({ page, request }, testInfo) => {
  66. test.setTimeout(360_000)
  67. const headers = await authHeaders(request)
  68. const name = `${runPrefix}-页面配置教员`
  69. const identityPrompt = `你是${name},只依据装备检修知识回答,并在操作前提示停机、断电、验电和卸压。`
  70. const description = '用于液压与电气检修教学的页面提交验证智能体'
  71. const welcome = '您好,我是页面配置验证教员,请选择需要学习的检修项目。'
  72. const fallback = '当前资料未覆盖该问题,请换一种问法或联系现场教员。'
  73. const sensitiveReply = '该输入不符合教学规范,请重新描述装备检修问题。'
  74. const suggestion = '液压系统卸压后应检查哪些项目?'
  75. let agentId = ''
  76. let deleted = false
  77. try {
  78. await loginAsAdmin(page)
  79. await page.goto('/agents/manage')
  80. await expect(page.getByRole('heading', { name: '虚拟教员智能体' })).toBeVisible()
  81. await page.getByRole('button', { name: '新建智能体' }).click()
  82. const createDialog = page.locator('.el-dialog:visible').filter({ hasText: '新建智能体' })
  83. await expect(createDialog).toBeVisible()
  84. await createDialog.locator('input[placeholder*="装备维修"]').fill(name)
  85. await createDialog.getByRole('button', { name: '90 天' }).click()
  86. const expiryInput = createDialog.locator('input[type="date"]')
  87. await expect(expiryInput).not.toHaveValue('')
  88. await captureScreenshot(page, testInfo, 'agent-create-real-form-filled')
  89. await createDialog.getByRole('button', { name: '创建', exact: true }).click()
  90. await page.waitForURL((url) => /^\/agents\/(?!manage(?:\/|$))[^/]+$/.test(url.pathname))
  91. agentId = new URL(page.url()).pathname.split('/').filter(Boolean).at(-1) || ''
  92. expect(agentId, '页面新建成功后 URL 应包含智能体 ID').not.toBe('')
  93. let persisted = await getAgent(request, headers, agentId)
  94. expect(persisted.name).toBe(name)
  95. expect(persisted.status).toBe('draft')
  96. expect(persisted.expiresAt).toBeTruthy()
  97. // 数字人:必须从页面真实选择,保存后通过 API 和刷新后的 aria 状态双重核验。
  98. const tabs = page.getByRole('navigation', { name: '配置分区' })
  99. await tabs.getByRole('button', { name: /^数字人/ }).click()
  100. const avatarOptions = visibleDetail(page).locator('.avatar-option')
  101. await expect(avatarOptions.first(), '持久 DEMO 数据应至少提供一个可用数字人').toBeVisible()
  102. const selectedAvatar = avatarOptions.nth(Math.min(1, await avatarOptions.count() - 1))
  103. const avatarName = (await selectedAvatar.locator('b').textContent())?.trim() || ''
  104. if (await selectedAvatar.getAttribute('aria-pressed') !== 'true') await selectedAvatar.click()
  105. await saveThroughPage(page, agentId)
  106. persisted = await getAgent(request, headers, agentId)
  107. expect((persisted.avatarIds as unknown[]).length).toBeGreaterThan(0)
  108. expect(persisted.avatarName).toBe(avatarName)
  109. await reloadSectionAndCapture(page, testInfo, '数字人', 'agent-avatar-saved-and-reloaded')
  110. await expect(visibleDetail(page).locator('.avatar-option.is-selected').filter({ hasText: avatarName })).toBeVisible()
  111. // AI 内核:知识库、LLM、TTS、ASR、播报音色与速度全部经页面控件提交。
  112. await tabs.getByRole('button', { name: /^AI内核/ }).click()
  113. const core = visibleDetail(page)
  114. const coreCards = core.locator('.core-grid > .detail-card')
  115. await expect(coreCards).toHaveCount(4)
  116. const knowledgeCheckbox = coreCards.nth(0).locator('input[type="checkbox"]').first()
  117. await expect(knowledgeCheckbox, '持久 DEMO 数据应至少提供一个知识库').toBeVisible()
  118. await knowledgeCheckbox.check()
  119. const llmName = await selectFirstRadio(coreCards.nth(1))
  120. const ttsName = await selectFirstRadio(coreCards.nth(2))
  121. const asrName = await selectFirstRadio(coreCards.nth(3))
  122. // 选择带真实样音的能力资产音色。新建草稿会回落显示离线引擎 speaker,
  123. // 但只有能力资产音色才是当前正式持久化模型可引用的资源。
  124. const voiceOptions = core.locator('.voice-option')
  125. const capabilityVoice = core.locator('.voice-option:has(.voice-preview-button:not(:disabled))').first()
  126. await expect(capabilityVoice, '持久 DEMO 数据应至少提供一个带样音的可用播报音色').toBeVisible()
  127. const voiceCheckbox = capabilityVoice.locator('input[type="checkbox"]')
  128. await voiceCheckbox.check()
  129. for (let index = 0; index < await voiceOptions.count(); index += 1) {
  130. const option = voiceOptions.nth(index)
  131. const checkbox = option.locator('input[type="checkbox"]')
  132. const preview = option.locator('.voice-preview-button')
  133. if (await preview.isDisabled() && await checkbox.isChecked()) await checkbox.uncheck()
  134. }
  135. await core.locator('.voice-speed-field input[type="range"]').fill('1.15')
  136. await saveThroughPage(page, agentId)
  137. persisted = await getAgent(request, headers, agentId)
  138. expect((persisted.knowledgeBaseIds as unknown[]).length).toBeGreaterThan(0)
  139. expect(persisted.llmModel).toBe(llmName)
  140. expect(persisted.ttsModel).toBe(ttsName)
  141. expect(persisted.asrModel).toBe(asrName)
  142. expect((persisted.voiceSpeakers as unknown[]).length).toBeGreaterThan(0)
  143. expect(Number(persisted.voiceSpeed)).toBeCloseTo(1.15)
  144. await reloadSectionAndCapture(page, testInfo, 'AI内核', 'agent-core-saved-and-reloaded')
  145. await expect(visibleDetail(page).locator('input[name="agent-llm"]:checked')).toHaveCount(1)
  146. // 身份设置。
  147. await tabs.getByRole('button', { name: /^身份设置/ }).click()
  148. const identity = visibleDetail(page)
  149. await identity.locator('.identity-editor textarea').fill(identityPrompt)
  150. await identity.locator('input[placeholder*="一句话说明用途"]').fill(description)
  151. await saveThroughPage(page, agentId)
  152. persisted = await getAgent(request, headers, agentId)
  153. expect(persisted.systemPrompt).toBe(identityPrompt)
  154. expect(persisted.description).toBe(description)
  155. await reloadSectionAndCapture(page, testInfo, '身份设置', 'agent-identity-saved-and-reloaded')
  156. await expect(visibleDetail(page).locator('.identity-editor textarea')).toHaveValue(identityPrompt)
  157. // 场景外观:真实选择一项已入库场景素材,同时修改互动按钮与挂件位置。
  158. await tabs.getByRole('button', { name: /^场景外观/ }).click()
  159. const scene = visibleDetail(page)
  160. let sceneType = ''
  161. for (const candidate of [
  162. { label: '背景图片', value: 'image' },
  163. { label: '背景视频', value: 'video' },
  164. { label: '网页背景', value: 'page' },
  165. ]) {
  166. await scene.getByRole('button', { name: candidate.label }).click()
  167. if (await scene.locator('.scene-asset').count()) {
  168. sceneType = candidate.value
  169. break
  170. }
  171. }
  172. expect(sceneType, '持久 DEMO 数据应至少提供一项背景图片、视频或网页素材').not.toBe('')
  173. await scene.locator('.scene-asset input[type="radio"]').first().check()
  174. await scene.locator('.detail-card').filter({ hasText: '显示互动按钮' }).getByLabel('否', { exact: true }).check()
  175. await scene.getByLabel('左上', { exact: true }).check()
  176. await saveThroughPage(page, agentId)
  177. persisted = await getAgent(request, headers, agentId)
  178. expect(persisted.sceneBackgroundType).toBe(sceneType)
  179. expect(persisted.sceneAssetId).toBeTruthy()
  180. expect(persisted.showInteractionButtons).toBe(false)
  181. expect(persisted.uiPosition).toBe('top-left')
  182. await reloadSectionAndCapture(page, testInfo, '场景外观', 'agent-scene-saved-and-reloaded')
  183. await expect(visibleDetail(page).locator('.scene-asset.is-selected')).toHaveCount(1)
  184. // 交互设置:三段话术、交互方式、屏保开关、时长和持久素材。
  185. await tabs.getByRole('button', { name: /^交互设置/ }).click()
  186. const interaction = visibleDetail(page)
  187. await interaction.locator('textarea[placeholder*="您好,我是装备维修"]').fill(welcome)
  188. await interaction.locator('textarea[placeholder*="暂未找到相关资料"]').fill(fallback)
  189. await interaction.locator('textarea[placeholder*="不适宜内容"]').fill(sensitiveReply)
  190. const voiceMode = interaction.getByRole('button', { name: '语音提问' })
  191. if ((await voiceMode.getAttribute('class') || '').includes('is-active')) await voiceMode.click()
  192. const screensaverSwitch = interaction.locator('.switch-row .el-switch')
  193. if (await screensaverSwitch.getAttribute('aria-checked') !== 'true') await screensaverSwitch.click()
  194. await interaction.locator('input[type="number"][min="10"]').fill('45')
  195. const screensaver = interaction.locator('.screensaver-option').first()
  196. await expect(screensaver, '持久 DEMO 场景素材应可直接用于屏保').toBeVisible()
  197. await screensaver.click()
  198. await saveThroughPage(page, agentId)
  199. persisted = await getAgent(request, headers, agentId)
  200. expect(persisted.welcomeMessage).toBe(welcome)
  201. expect(persisted.fallbackMessage).toBe(fallback)
  202. expect(persisted.sensitiveReply).toBe(sensitiveReply)
  203. expect(persisted.interactionModes).toEqual(['text'])
  204. expect(persisted.screensaverEnabled).toBe(true)
  205. expect(Number(persisted.screensaverIdleSeconds)).toBe(45)
  206. expect(persisted.screensaverUrl).toBeTruthy()
  207. await reloadSectionAndCapture(page, testInfo, '交互设置', 'agent-interaction-saved-and-reloaded')
  208. await expect(visibleDetail(page).locator('textarea[placeholder*="您好,我是装备维修"]')).toHaveValue(welcome)
  209. // UI 自定义:开关、颜色、尺寸、位置、推荐问题均通过页面控件修改。
  210. await tabs.getByRole('button', { name: /^UI自定义/ }).click()
  211. const ui = visibleDetail(page)
  212. const waveformRow = ui.locator('.ui-component-row').filter({ hasText: '语音波形' })
  213. await waveformRow.locator('input[type="checkbox"]').check()
  214. const suggestionsRow = ui.locator('.ui-component-row').filter({ hasText: '推荐问题' })
  215. await suggestionsRow.locator('input[type="checkbox"]').check()
  216. await ui.locator('.color-grid label').filter({ hasText: '主题色' }).locator('input[type="color"]').fill('#155e75')
  217. await ui.locator('.size-grid label').filter({ hasText: '面板宽度' }).locator('input[type="number"]').fill('438')
  218. await ui.locator('.size-grid label').filter({ hasText: '面板高度' }).locator('input[type="number"]').fill('688')
  219. await ui.locator('.size-grid select').selectOption('top-left')
  220. if (!await ui.locator('.suggestion-row input').count()) {
  221. await ui.getByRole('button', { name: '添加推荐问题' }).click()
  222. }
  223. await ui.locator('.suggestion-row input').first().fill(suggestion)
  224. await saveThroughPage(page, agentId)
  225. persisted = await getAgent(request, headers, agentId)
  226. const uiConfig = asRecord(persisted.uiConfig)
  227. expect(uiConfig.themeColor).toBe('#155e75')
  228. expect(uiConfig.panelWidth).toBe(438)
  229. expect(uiConfig.panelHeight).toBe(688)
  230. expect(uiConfig.panelPosition).toBe('top-left')
  231. expect(uiConfig.suggestions).toContain(suggestion)
  232. expect((uiConfig.components as JsonRecord[]).some((item) => item.key === 'waveform' && item.enabled === true)).toBeTruthy()
  233. await reloadSectionAndCapture(page, testInfo, 'UI自定义', 'agent-ui-saved-and-reloaded')
  234. await expect(visibleDetail(page).locator('.color-grid label').filter({ hasText: '主题色' }).locator('input[type="color"]')).toHaveValue('#155e75')
  235. // 回到管理页:真实筛选、延期、接入信息、发布、停用、换密钥、删除。
  236. await page.getByRole('button', { name: '返回智能体列表' }).click()
  237. await expect(page).toHaveURL(/\/agents\/manage/)
  238. const keyword = page.locator('.agent-search input')
  239. await keyword.fill(name)
  240. await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('draft')
  241. const row = page.locator(`[data-testid="agent-row-${agentId}"]`)
  242. await expect(row).toBeVisible()
  243. await captureScreenshot(page, testInfo, 'agent-list-filtered-draft-with-real-data')
  244. const beforeExtension = new Date(String((await getAgent(request, headers, agentId)).expiresAt)).getTime()
  245. await row.getByRole('button', { name: '设置有效期' }).click()
  246. await page.locator('.el-dropdown-menu:visible').getByText('+30 天', { exact: true }).click()
  247. await expect(page.locator('.el-message').filter({ hasText: '有效期已延长' }).last()).toBeVisible()
  248. const afterExtension = new Date(String((await getAgent(request, headers, agentId)).expiresAt)).getTime()
  249. expect(afterExtension - beforeExtension).toBeGreaterThan(29 * 86_400_000)
  250. await row.getByRole('button', { name: '接入信息' }).click()
  251. const accessDialog = page.locator('.el-dialog:visible').filter({ hasText: `接入信息 · ${name}` })
  252. await expect(accessDialog).toBeVisible()
  253. await expect(accessDialog).toContainText('独立页面地址')
  254. const accessCodeBlocks = accessDialog.locator('pre code')
  255. await expect(accessCodeBlocks).toHaveCount(3)
  256. await expect(accessCodeBlocks.first()).not.toHaveText('')
  257. await captureScreenshot(page, testInfo, 'agent-access-information-nonempty')
  258. await accessDialog.locator('.el-dialog__headerbtn').click()
  259. await row.getByRole('button', { name: '发布', exact: true }).click()
  260. await expect(row).toHaveCount(0)
  261. expect((await getAgent(request, headers, agentId)).status).toBe('published')
  262. await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('published')
  263. await expect(row).toBeVisible()
  264. await captureScreenshot(page, testInfo, 'agent-published-through-list-ui')
  265. await row.getByRole('button', { name: '停用', exact: true }).click()
  266. await expect(row).toHaveCount(0)
  267. expect((await getAgent(request, headers, agentId)).status).toBe('disabled')
  268. await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('disabled')
  269. await expect(row).toBeVisible()
  270. const oldPreview = String((await getAgent(request, headers, agentId)).apiKeyPreview)
  271. await row.getByRole('button', { name: '换密钥' }).click()
  272. const confirm = page.locator('.el-message-box:visible')
  273. await expect(confirm).toBeVisible()
  274. await confirm.getByRole('button', { name: '确认轮换' }).click()
  275. const keyDialog = page.locator('.el-dialog:visible').filter({ hasText: '新的接入密钥' })
  276. await expect(keyDialog).toBeVisible()
  277. const keySecret = keyDialog.locator('.key-reveal code')
  278. await expect(keySecret).not.toHaveText('')
  279. const newPreview = String((await getAgent(request, headers, agentId)).apiKeyPreview)
  280. expect(newPreview).not.toBe(oldPreview)
  281. await captureScreenshot(page, testInfo, 'agent-key-rotated-secret-masked', [keySecret])
  282. await keyDialog.locator('.el-dialog__headerbtn').click()
  283. await row.getByRole('button', { name: '删除' }).click()
  284. const deleteConfirm = page.locator('.el-message-box:visible')
  285. await expect(deleteConfirm).toBeVisible()
  286. await deleteConfirm.getByRole('button', { name: '确认删除' }).click()
  287. await expect(row).toHaveCount(0)
  288. const absent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  289. expect(absent.status()).toBe(404)
  290. deleted = true
  291. await keyword.fill('')
  292. await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('all')
  293. await attachJson(testInfo, '智能体页面提交验收', {
  294. name,
  295. agentId,
  296. pageSubmittedSections: ['数字人', 'AI内核', '身份设置', '场景外观', '交互设置', 'UI自定义'],
  297. managementActions: ['新建', '关键词筛选', '状态筛选', '设置有效期', '接入信息', '发布', '停用', '换密钥', '删除'],
  298. persistenceChecks: '每个分区保存后均通过正式详情 API 核验,并刷新页面截图复查。',
  299. cleanupComplete: true,
  300. })
  301. } finally {
  302. if (agentId && !deleted) await cleanupAgent(request, headers, agentId)
  303. }
  304. })