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 type Headers = Record 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[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) } })