|
- import type { APIRequestContext, Locator, Page } from '@playwright/test'
-
- import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures'
- import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
-
- type JsonRecord = Record<string, unknown>
- type Headers = Record<string, string>
-
- const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object'
- ? value as JsonRecord
- : {}
-
- async function getAgent(request: APIRequestContext, headers: Headers, agentId: string) {
- const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
- expect(response.status(), '智能体详情接口应返回成功').toBe(200)
- return asRecord(envelopeData(await response.json()))
- }
-
- async function saveThroughPage(page: Page, agentId: string) {
- const responsePromise = page.waitForResponse((response) => (
- response.request().method() === 'PUT'
- && new URL(response.url()).pathname === `/api/v1/realtime-agents/${agentId}`
- ))
- const saveButton = page.getByRole('button', { name: '保存配置' })
- await expect(saveButton).toBeVisible()
- await saveButton.click()
- const response = await responsePromise
- expect(response.status(), '页面保存智能体配置应返回成功').toBe(200)
- await expect(page.locator('.el-message').filter({ hasText: /配置已保存/ }).last()).toBeVisible()
- }
-
- async function reloadSectionAndCapture(
- page: Page,
- testInfo: Parameters<typeof captureScreenshot>[1],
- tabName: string,
- screenshotName: string,
- ) {
- await page.reload()
- const tabs = page.getByRole('navigation', { name: '配置分区' })
- await expect(tabs).toBeVisible()
- await expect(tabs.getByRole('button', { name: new RegExp(tabName) })).toHaveClass(/is-active/)
- await captureScreenshot(page, testInfo, screenshotName)
- }
-
- async function cleanupAgent(request: APIRequestContext, headers: Headers, agentId: string) {
- const detailResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
- if (detailResponse.status() === 404) return
- if (detailResponse.status() !== 200) return
- let agent = asRecord(envelopeData(await detailResponse.json()))
- if (String(agent.status) === 'published') {
- const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, {
- headers,
- data: { dataVersion: Number(agent.dataVersion) },
- })
- if (disabled.status() === 200) agent = asRecord(envelopeData(await disabled.json()))
- }
- await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
- headers,
- params: { dataVersion: String(Number(agent.dataVersion)) },
- })
- }
-
- function visibleDetail(page: Page) {
- return page.locator('.detail-body:visible')
- }
-
- async function selectFirstRadio(card: Locator) {
- const label = card.locator('label:has(input[type="radio"])').first()
- const radio = label.locator('input[type="radio"]')
- await expect(radio).toBeVisible()
- await radio.check()
- return (await label.locator('b').first().textContent())?.trim() || ''
- }
-
- test('智能体管理与六区配置全部通过页面提交、刷新持久化并完成发布生命周期', async ({ page, request }, testInfo) => {
- test.setTimeout(360_000)
- const headers = await authHeaders(request)
- const name = `${runPrefix}-页面配置教员`
- const identityPrompt = `你是${name},只依据装备检修知识回答,并在操作前提示停机、断电、验电和卸压。`
- const description = '用于液压与电气检修教学的页面提交验证智能体'
- const welcome = '您好,我是页面配置验证教员,请选择需要学习的检修项目。'
- const fallback = '当前资料未覆盖该问题,请换一种问法或联系现场教员。'
- const sensitiveReply = '该输入不符合教学规范,请重新描述装备检修问题。'
- const suggestion = '液压系统卸压后应检查哪些项目?'
- let agentId = ''
- let deleted = false
-
- try {
- await loginAsAdmin(page)
- await page.goto('/agents/manage')
- await expect(page.getByRole('heading', { name: '虚拟教员智能体' })).toBeVisible()
-
- await page.getByRole('button', { name: '新建智能体' }).click()
- const createDialog = page.locator('.el-dialog:visible').filter({ hasText: '新建智能体' })
- await expect(createDialog).toBeVisible()
- await createDialog.locator('input[placeholder*="装备维修"]').fill(name)
- await createDialog.getByRole('button', { name: '90 天' }).click()
- const expiryInput = createDialog.locator('input[type="date"]')
- await expect(expiryInput).not.toHaveValue('')
- await captureScreenshot(page, testInfo, 'agent-create-real-form-filled')
- await createDialog.getByRole('button', { name: '创建', exact: true }).click()
-
- await page.waitForURL((url) => /^\/agents\/(?!manage(?:\/|$))[^/]+$/.test(url.pathname))
- agentId = new URL(page.url()).pathname.split('/').filter(Boolean).at(-1) || ''
- expect(agentId, '页面新建成功后 URL 应包含智能体 ID').not.toBe('')
- let persisted = await getAgent(request, headers, agentId)
- expect(persisted.name).toBe(name)
- expect(persisted.status).toBe('draft')
- expect(persisted.expiresAt).toBeTruthy()
-
- // 数字人:必须从页面真实选择,保存后通过 API 和刷新后的 aria 状态双重核验。
- const tabs = page.getByRole('navigation', { name: '配置分区' })
- await tabs.getByRole('button', { name: /^数字人/ }).click()
- const avatarOptions = visibleDetail(page).locator('.avatar-option')
- await expect(avatarOptions.first(), '持久 DEMO 数据应至少提供一个可用数字人').toBeVisible()
- const selectedAvatar = avatarOptions.nth(Math.min(1, await avatarOptions.count() - 1))
- const avatarName = (await selectedAvatar.locator('b').textContent())?.trim() || ''
- if (await selectedAvatar.getAttribute('aria-pressed') !== 'true') await selectedAvatar.click()
- await saveThroughPage(page, agentId)
- persisted = await getAgent(request, headers, agentId)
- expect((persisted.avatarIds as unknown[]).length).toBeGreaterThan(0)
- expect(persisted.avatarName).toBe(avatarName)
- await reloadSectionAndCapture(page, testInfo, '数字人', 'agent-avatar-saved-and-reloaded')
- await expect(visibleDetail(page).locator('.avatar-option.is-selected').filter({ hasText: avatarName })).toBeVisible()
-
- // AI 内核:知识库、LLM、TTS、ASR、播报音色与速度全部经页面控件提交。
- await tabs.getByRole('button', { name: /^AI内核/ }).click()
- const core = visibleDetail(page)
- const coreCards = core.locator('.core-grid > .detail-card')
- await expect(coreCards).toHaveCount(4)
- const knowledgeCheckbox = coreCards.nth(0).locator('input[type="checkbox"]').first()
- await expect(knowledgeCheckbox, '持久 DEMO 数据应至少提供一个知识库').toBeVisible()
- await knowledgeCheckbox.check()
- const llmName = await selectFirstRadio(coreCards.nth(1))
- const ttsName = await selectFirstRadio(coreCards.nth(2))
- const asrName = await selectFirstRadio(coreCards.nth(3))
- // 选择带真实样音的能力资产音色。新建草稿会回落显示离线引擎 speaker,
- // 但只有能力资产音色才是当前正式持久化模型可引用的资源。
- const voiceOptions = core.locator('.voice-option')
- const capabilityVoice = core.locator('.voice-option:has(.voice-preview-button:not(:disabled))').first()
- await expect(capabilityVoice, '持久 DEMO 数据应至少提供一个带样音的可用播报音色').toBeVisible()
- const voiceCheckbox = capabilityVoice.locator('input[type="checkbox"]')
- await voiceCheckbox.check()
- for (let index = 0; index < await voiceOptions.count(); index += 1) {
- const option = voiceOptions.nth(index)
- const checkbox = option.locator('input[type="checkbox"]')
- const preview = option.locator('.voice-preview-button')
- if (await preview.isDisabled() && await checkbox.isChecked()) await checkbox.uncheck()
- }
- await core.locator('.voice-speed-field input[type="range"]').fill('1.15')
- await saveThroughPage(page, agentId)
- persisted = await getAgent(request, headers, agentId)
- expect((persisted.knowledgeBaseIds as unknown[]).length).toBeGreaterThan(0)
- expect(persisted.llmModel).toBe(llmName)
- expect(persisted.ttsModel).toBe(ttsName)
- expect(persisted.asrModel).toBe(asrName)
- expect((persisted.voiceSpeakers as unknown[]).length).toBeGreaterThan(0)
- expect(Number(persisted.voiceSpeed)).toBeCloseTo(1.15)
- await reloadSectionAndCapture(page, testInfo, 'AI内核', 'agent-core-saved-and-reloaded')
- await expect(visibleDetail(page).locator('input[name="agent-llm"]:checked')).toHaveCount(1)
-
- // 身份设置。
- await tabs.getByRole('button', { name: /^身份设置/ }).click()
- const identity = visibleDetail(page)
- await identity.locator('.identity-editor textarea').fill(identityPrompt)
- await identity.locator('input[placeholder*="一句话说明用途"]').fill(description)
- await saveThroughPage(page, agentId)
- persisted = await getAgent(request, headers, agentId)
- expect(persisted.systemPrompt).toBe(identityPrompt)
- expect(persisted.description).toBe(description)
- await reloadSectionAndCapture(page, testInfo, '身份设置', 'agent-identity-saved-and-reloaded')
- await expect(visibleDetail(page).locator('.identity-editor textarea')).toHaveValue(identityPrompt)
-
- // 场景外观:真实选择一项已入库场景素材,同时修改互动按钮与挂件位置。
- await tabs.getByRole('button', { name: /^场景外观/ }).click()
- const scene = visibleDetail(page)
- let sceneType = ''
- for (const candidate of [
- { label: '背景图片', value: 'image' },
- { label: '背景视频', value: 'video' },
- { label: '网页背景', value: 'page' },
- ]) {
- await scene.getByRole('button', { name: candidate.label }).click()
- if (await scene.locator('.scene-asset').count()) {
- sceneType = candidate.value
- break
- }
- }
- expect(sceneType, '持久 DEMO 数据应至少提供一项背景图片、视频或网页素材').not.toBe('')
- await scene.locator('.scene-asset input[type="radio"]').first().check()
- await scene.locator('.detail-card').filter({ hasText: '显示互动按钮' }).getByLabel('否', { exact: true }).check()
- await scene.getByLabel('左上', { exact: true }).check()
- await saveThroughPage(page, agentId)
- persisted = await getAgent(request, headers, agentId)
- expect(persisted.sceneBackgroundType).toBe(sceneType)
- expect(persisted.sceneAssetId).toBeTruthy()
- expect(persisted.showInteractionButtons).toBe(false)
- expect(persisted.uiPosition).toBe('top-left')
- await reloadSectionAndCapture(page, testInfo, '场景外观', 'agent-scene-saved-and-reloaded')
- await expect(visibleDetail(page).locator('.scene-asset.is-selected')).toHaveCount(1)
-
- // 交互设置:三段话术、交互方式、屏保开关、时长和持久素材。
- await tabs.getByRole('button', { name: /^交互设置/ }).click()
- const interaction = visibleDetail(page)
- await interaction.locator('textarea[placeholder*="您好,我是装备维修"]').fill(welcome)
- await interaction.locator('textarea[placeholder*="暂未找到相关资料"]').fill(fallback)
- await interaction.locator('textarea[placeholder*="不适宜内容"]').fill(sensitiveReply)
- const voiceMode = interaction.getByRole('button', { name: '语音提问' })
- if ((await voiceMode.getAttribute('class') || '').includes('is-active')) await voiceMode.click()
- const screensaverSwitch = interaction.locator('.switch-row .el-switch')
- if (await screensaverSwitch.getAttribute('aria-checked') !== 'true') await screensaverSwitch.click()
- await interaction.locator('input[type="number"][min="10"]').fill('45')
- const screensaver = interaction.locator('.screensaver-option').first()
- await expect(screensaver, '持久 DEMO 场景素材应可直接用于屏保').toBeVisible()
- await screensaver.click()
- await saveThroughPage(page, agentId)
- persisted = await getAgent(request, headers, agentId)
- expect(persisted.welcomeMessage).toBe(welcome)
- expect(persisted.fallbackMessage).toBe(fallback)
- expect(persisted.sensitiveReply).toBe(sensitiveReply)
- expect(persisted.interactionModes).toEqual(['text'])
- expect(persisted.screensaverEnabled).toBe(true)
- expect(Number(persisted.screensaverIdleSeconds)).toBe(45)
- expect(persisted.screensaverUrl).toBeTruthy()
- await reloadSectionAndCapture(page, testInfo, '交互设置', 'agent-interaction-saved-and-reloaded')
- await expect(visibleDetail(page).locator('textarea[placeholder*="您好,我是装备维修"]')).toHaveValue(welcome)
-
- // UI 自定义:开关、颜色、尺寸、位置、推荐问题均通过页面控件修改。
- await tabs.getByRole('button', { name: /^UI自定义/ }).click()
- const ui = visibleDetail(page)
- const waveformRow = ui.locator('.ui-component-row').filter({ hasText: '语音波形' })
- await waveformRow.locator('input[type="checkbox"]').check()
- const suggestionsRow = ui.locator('.ui-component-row').filter({ hasText: '推荐问题' })
- await suggestionsRow.locator('input[type="checkbox"]').check()
- await ui.locator('.color-grid label').filter({ hasText: '主题色' }).locator('input[type="color"]').fill('#155e75')
- await ui.locator('.size-grid label').filter({ hasText: '面板宽度' }).locator('input[type="number"]').fill('438')
- await ui.locator('.size-grid label').filter({ hasText: '面板高度' }).locator('input[type="number"]').fill('688')
- await ui.locator('.size-grid select').selectOption('top-left')
- if (!await ui.locator('.suggestion-row input').count()) {
- await ui.getByRole('button', { name: '添加推荐问题' }).click()
- }
- await ui.locator('.suggestion-row input').first().fill(suggestion)
- await saveThroughPage(page, agentId)
- persisted = await getAgent(request, headers, agentId)
- const uiConfig = asRecord(persisted.uiConfig)
- expect(uiConfig.themeColor).toBe('#155e75')
- expect(uiConfig.panelWidth).toBe(438)
- expect(uiConfig.panelHeight).toBe(688)
- expect(uiConfig.panelPosition).toBe('top-left')
- expect(uiConfig.suggestions).toContain(suggestion)
- expect((uiConfig.components as JsonRecord[]).some((item) => item.key === 'waveform' && item.enabled === true)).toBeTruthy()
- await reloadSectionAndCapture(page, testInfo, 'UI自定义', 'agent-ui-saved-and-reloaded')
- await expect(visibleDetail(page).locator('.color-grid label').filter({ hasText: '主题色' }).locator('input[type="color"]')).toHaveValue('#155e75')
-
- // 回到管理页:真实筛选、延期、接入信息、发布、停用、换密钥、删除。
- await page.getByRole('button', { name: '返回智能体列表' }).click()
- await expect(page).toHaveURL(/\/agents\/manage/)
- const keyword = page.locator('.agent-search input')
- await keyword.fill(name)
- await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('draft')
- const row = page.locator(`[data-testid="agent-row-${agentId}"]`)
- await expect(row).toBeVisible()
- await captureScreenshot(page, testInfo, 'agent-list-filtered-draft-with-real-data')
-
- const beforeExtension = new Date(String((await getAgent(request, headers, agentId)).expiresAt)).getTime()
- await row.getByRole('button', { name: '设置有效期' }).click()
- await page.locator('.el-dropdown-menu:visible').getByText('+30 天', { exact: true }).click()
- await expect(page.locator('.el-message').filter({ hasText: '有效期已延长' }).last()).toBeVisible()
- const afterExtension = new Date(String((await getAgent(request, headers, agentId)).expiresAt)).getTime()
- expect(afterExtension - beforeExtension).toBeGreaterThan(29 * 86_400_000)
-
- await row.getByRole('button', { name: '接入信息' }).click()
- const accessDialog = page.locator('.el-dialog:visible').filter({ hasText: `接入信息 · ${name}` })
- await expect(accessDialog).toBeVisible()
- await expect(accessDialog).toContainText('独立页面地址')
- const accessCodeBlocks = accessDialog.locator('pre code')
- await expect(accessCodeBlocks).toHaveCount(3)
- await expect(accessCodeBlocks.first()).not.toHaveText('')
- await captureScreenshot(page, testInfo, 'agent-access-information-nonempty')
- await accessDialog.locator('.el-dialog__headerbtn').click()
-
- await row.getByRole('button', { name: '发布', exact: true }).click()
- await expect(row).toHaveCount(0)
- expect((await getAgent(request, headers, agentId)).status).toBe('published')
- await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('published')
- await expect(row).toBeVisible()
- await captureScreenshot(page, testInfo, 'agent-published-through-list-ui')
-
- await row.getByRole('button', { name: '停用', exact: true }).click()
- await expect(row).toHaveCount(0)
- expect((await getAgent(request, headers, agentId)).status).toBe('disabled')
- await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('disabled')
- await expect(row).toBeVisible()
-
- const oldPreview = String((await getAgent(request, headers, agentId)).apiKeyPreview)
- await row.getByRole('button', { name: '换密钥' }).click()
- const confirm = page.locator('.el-message-box:visible')
- await expect(confirm).toBeVisible()
- await confirm.getByRole('button', { name: '确认轮换' }).click()
- const keyDialog = page.locator('.el-dialog:visible').filter({ hasText: '新的接入密钥' })
- await expect(keyDialog).toBeVisible()
- const keySecret = keyDialog.locator('.key-reveal code')
- await expect(keySecret).not.toHaveText('')
- const newPreview = String((await getAgent(request, headers, agentId)).apiKeyPreview)
- expect(newPreview).not.toBe(oldPreview)
- await captureScreenshot(page, testInfo, 'agent-key-rotated-secret-masked', [keySecret])
- await keyDialog.locator('.el-dialog__headerbtn').click()
-
- await row.getByRole('button', { name: '删除' }).click()
- const deleteConfirm = page.locator('.el-message-box:visible')
- await expect(deleteConfirm).toBeVisible()
- await deleteConfirm.getByRole('button', { name: '确认删除' }).click()
- await expect(row).toHaveCount(0)
- const absent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
- expect(absent.status()).toBe(404)
- deleted = true
-
- await keyword.fill('')
- await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('all')
-
- await attachJson(testInfo, '智能体页面提交验收', {
- name,
- agentId,
- pageSubmittedSections: ['数字人', 'AI内核', '身份设置', '场景外观', '交互设置', 'UI自定义'],
- managementActions: ['新建', '关键词筛选', '状态筛选', '设置有效期', '接入信息', '发布', '停用', '换密钥', '删除'],
- persistenceChecks: '每个分区保存后均通过正式详情 API 核验,并刷新页面截图复查。',
- cleanupComplete: true,
- })
- } finally {
- if (agentId && !deleted) await cleanupAgent(request, headers, agentId)
- }
- })
|