import type { BrowserContext, Page, Route } from '@playwright/test' import { captureScreenshot, expect, test } from './fixtures' const envelope = (data: unknown) => ({ code: 0, message: 'ok', data, timestamp: Date.now(), requestId: 'prototype-sync-regression', }) const profile = { user: { id: 'prototype-admin', username: 'prototype-admin', displayName: '系统管理员', departmentId: 'system', departmentName: '系统管理', enabled: true, mustChangePassword: false, version: 1, }, roles: [{ id: 'admin-role', code: 'ADMIN', name: '系统管理员', shortName: '系统', enabled: true, builtIn: true, isSuperAdmin: true, dataScope: 'ALL', }], activeRoleId: 'admin-role', permissions: ['*'], authorizationMode: 'SINGLE_ACTIVE', loginTime: new Date().toISOString(), } const baseAgent = { dataVersion: 1, name: '设备点检数字教员', description: '用于验证独立页面与悬浮图标入口', ownerId: 'prototype-admin', ownerName: '系统管理员', projectId: null, projectName: '', avatarId: '', avatarName: '', viewerUrl: '', avatars: [], voiceName: '标准教员音色', voiceSpeed: 1, voice: null, voiceNames: [], knowledgeBaseIds: [], knowledgeDocumentIds: [], interactionModes: ['text'], llmModel: '', asrModel: '', ttsModel: '', accessMode: 'internal', allowedOrigins: [], concurrencyLimit: 10, expiresAt: null, background: '', brandVisible: true, welcomeMessage: '您好,请问需要了解哪项设备点检知识?', fallbackMessage: '服务暂不可用', sensitiveReply: '请重新描述问题', showInteractionButtons: true, uiPosition: 'fullscreen', sceneBackgroundType: 'transparent', sceneAssetUrl: '', logoUrl: '', screensaverEnabled: false, screensaverUrl: '', screensaverIdleSeconds: 120, apiKey: '', apiKeyPreview: 'dhk_demo••••', callCount: 3, uiConfig: { components: [ { key: 'welcome', enabled: true, order: 1 }, { key: 'history', enabled: true, order: 2 }, { key: 'suggestions', enabled: true, order: 3 }, { key: 'input', enabled: true, order: 4 }, ], suggestions: ['设备点检的主要目的是什么?'], }, } const publishedAgent = { ...baseAgent, id: 'published-agent-id', slug: 'published-agent', status: 'published' } const draftAgent = { ...baseAgent, id: 'draft-agent-id', slug: 'draft-agent', name: '草稿数字教员', status: 'draft' } const video = { id: 'video-1', dataVersion: 1, title: '液压系统点检口播', description: '设备培训示例', category: '设备培训', tags: ['点检'], status: 'ready', visibility: 'internal', ownerId: 'prototype-admin', ownerName: '系统管理员', avatarId: null, duration: 42, width: 1920, height: 1080, size: 1024, coverUrl: '', fileUrl: null, coverFileCode: null, videoFileCode: null, folderId: null, shareStatus: 'inactive', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } async function installMocks(page: Page) { const videoQueries: Array> = [] await page.addInitScript(() => { window.sessionStorage.setItem('ai-person:web:access-token:v1', 'prototype-sync-token') class FakeUtterance { text: string lang = '' rate = 1 pitch = 1 onstart: null | (() => void) = null onend: null | (() => void) = null onerror: null | (() => void) = null constructor(text: string) { this.text = text const state = window as unknown as { __speechSynthesisUtteranceCalls: number } state.__speechSynthesisUtteranceCalls += 1 } } ;(window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls = 0 const fakeSpeech = { speaking: false, paused: false, current: null as FakeUtterance | null, speak(utterance: FakeUtterance) { this.current = utterance this.speaking = true this.paused = false utterance.onstart?.() }, pause() { this.paused = true; this.speaking = true }, resume() { this.paused = false; this.speaking = true }, cancel() { this.speaking = false; this.paused = false; this.current = null }, } Object.defineProperty(window, 'SpeechSynthesisUtterance', { configurable: true, value: FakeUtterance }) Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: fakeSpeech }) }) await page.route('**/api/auth/v1/**', async (route) => { const pathname = new URL(route.request().url()).pathname let data: unknown = {} if (pathname.endsWith('/auth/me')) data = profile else if (pathname.endsWith('/menus/navigation')) data = [] else if (pathname.endsWith('/system-config/public')) data = { systemName: '虚拟教员系统', shortName: '数字人平台' } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) }) await page.route('**/api/v1/**', async (route) => { const url = new URL(route.request().url()) let data: unknown = {} if (url.pathname.endsWith('/videos')) { videoQueries.push(Object.fromEntries(url.searchParams.entries())) data = { items: [video], total: 1, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] } } else if (url.pathname.endsWith('/video-folders')) { data = { items: [], total: 0, page: 1, pageSize: 100, pages: 1, unfiledCount: 1 } } else if (url.pathname.endsWith('/realtime-agents')) { data = { items: [publishedAgent, draftAgent], total: 2, page: 1, pageSize: 10, pages: 1 } } else if (url.pathname.endsWith('/avatars')) { data = { items: [], total: 0, page: 1, pageSize: 200, pages: 1 } } else if (url.pathname.endsWith('/health')) { data = { status: 'ok' } } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) }) await page.route('**/open/v1/**', async (route) => { const url = new URL(route.request().url()) let data: unknown = publishedAgent let status = 200 if (url.pathname.endsWith('/sessions')) { data = { sessionId: 'session-1', sessionToken: 'session-token', expiresAt: new Date(Date.now() + 60_000).toISOString() } status = 201 } else if (url.pathname.endsWith('/chat')) { data = { reply: '设备点检用于及时发现异常并降低故障风险。', citations: [], audioUrl: null, provider: 'local', blocked: false } } await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(envelope(data)) }) }) return { videoQueries } } async function expectOpenedPage(context: BrowserContext, action: () => Promise, path: RegExp) { const openedPromise = context.waitForEvent('page') await action() const opened = await openedPromise await expect(opened).toHaveURL(path) await opened.close() } test('成片筛选在搜索前且排序方向合并到排序项', async ({ page }, testInfo) => { const { videoQueries } = await installMocks(page) await page.goto('/videos/manage') await expect(page.getByText('液压系统点检口播')).toBeVisible() const row = page.locator('.video-filter-row') const searchButton = row.getByRole('button', { name: '搜索', exact: true }) const sortSelect = row.locator('label').filter({ hasText: '排序' }).locator('select') const filterSelects = row.locator('label select') await expect(filterSelects).toHaveCount(4) await expect(sortSelect.locator('option')).toContainText(['更新时间降序', '更新时间升序', '创建时间降序', '创建时间升序', '标题升序', '标题降序', '时长升序', '时长降序']) await expect(row.locator('label:last-of-type + button')).toHaveCount(1) await row.locator('label').filter({ hasText: '分类' }).locator('select').selectOption('设备培训') await row.locator('label').filter({ hasText: '可见范围' }).locator('select').selectOption('internal') await row.locator('label').filter({ hasText: '归属' }).locator('select').selectOption('mine') await sortSelect.selectOption('duration_desc') await searchButton.click() await expect.poll(() => videoQueries.length).toBeGreaterThan(1) expect(videoQueries.at(-1)).toMatchObject({ category: '设备培训', visibility: 'internal', owner: 'mine', sort: 'duration', order: 'desc' }) await captureScreenshot(page, testInfo, 'video-filters-before-search-combined-sort') }) test('智能体预览入口可点击并打开独立页与悬浮图标演示', async ({ page, context }, testInfo) => { await installMocks(page) await page.goto('/agents/manage') const publishedRow = page.locator('.agent-table .el-table__row').filter({ hasText: '设备点检数字教员' }) const draftRow = page.locator('.agent-table .el-table__row').filter({ hasText: '草稿数字教员' }) await expect(publishedRow.getByRole('button', { name: '独立页面' })).toBeEnabled() await expect(draftRow.getByRole('button', { name: '独立页面' })).toBeEnabled() await draftRow.getByRole('button', { name: '独立页面' }).click() await expect(page.getByText('智能体发布后才能打开体验页')).toBeVisible() await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '独立页面' }).click(), /\/live\/published-agent$/) await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '悬浮图标' }).click(), /\/embed-demo\?agent=published-agent$/) await captureScreenshot(page, testInfo, 'agent-preview-actions-enabled') }) test('公开回答只播放服务端音频,缺失时保留文字且不调用浏览器 TTS', async ({ page }, testInfo) => { await installMocks(page) await page.goto('/live/published-agent') await expect(page.getByText('设备点检数字教员')).toBeVisible() await page.getByRole('textbox', { name: '维修问题' }).fill('设备点检的主要目的是什么?') await page.getByRole('button', { name: '发送问题' }).click() const answer = page.locator('.live-message-list article.assistant').filter({ hasText: '设备点检用于及时发现异常并降低故障风险。' }) await expect(answer).toBeVisible() await expect(answer.locator('.live-degradation[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。') const speechButton = answer.locator('.live-speech-toggle') await expect(speechButton).not.toHaveClass(/is-speaking/) await speechButton.click() await expect(answer.locator('.live-degradation[role="status"]')).toBeVisible() await expect(speechButton).not.toHaveClass(/is-speaking/) await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0) await captureScreenshot(page, testInfo, 'public-answer-speech-control') await page.goto('/embed/published-agent?open=1') const widgetAnswer = page.locator('.embed-messages article.assistant').first() const widgetSpeechButton = widgetAnswer.locator('.embed-speech-toggle') await expect(widgetSpeechButton).toBeVisible() await widgetSpeechButton.click() await expect(widgetAnswer.locator('.embed-audio-unavailable[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。') await expect(widgetSpeechButton).not.toHaveClass(/is-speaking/) await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0) }) test('标签达到上限时静默淘汰最久未访问项', async ({ page }) => { await installMocks(page) await page.addInitScript(() => { const paths = [ '/overview', '/agents/manage', '/agents/projects', '/agents/chat-history', '/agents/wake-words', '/agents/remote-control', '/videos/manage', '/assets/avatars', '/assets/ai-models', '/organization/users', '/organization/departments', '/organization/roles', ] const tabs = paths.map((path, index) => ({ key: path, fullPath: path, name: `seed-${index}`, title: `种子标签${index + 1}`, permissions: [], locked: false, })) window.localStorage.setItem('ai-person:web:page-tabs:v1', JSON.stringify({ 'prototype-admin': { tabs } })) }) await page.goto('/settings/audit') await expect(page.locator('.page-tab')).toHaveCount(12) await expect(page.getByText(/页面标签数量已达上限/)).toHaveCount(0) await expect(page.locator('.page-tab').filter({ hasText: '审计日志' })).toHaveCount(1) })