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(), } const longVideoDescription = Array.from({ length: 24 }, (_, index) => ( `第 ${index + 1} 段:检修前需要逐项确认停机、断电、验电、卸压与上锁挂牌,并完整记录复核结果。` )).join('\n') const longDetailVideo = { ...video, id: 'video-long-description', title: '长简介成片详情回归', description: longVideoDescription, } const shortDetailVideo = { ...video, id: 'video-short-description', title: '短简介成片详情回归', description: '简短的设备培训说明。', } async function installMocks(page: Page, videos: Array = [video]) { 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: videos, total: videos.length, 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 }, testInfo) => { const consoleErrors: string[] = [] const pageErrors: string[] = [] page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) }) page.on('pageerror', (error) => pageErrors.push(error.message)) await installMocks(page, [longDetailVideo, shortDetailVideo]) await page.setViewportSize({ width: 1280, height: 640 }) await page.goto('/videos/manage') const longCard = page.locator('.video-library-card').filter({ hasText: longDetailVideo.title }) await longCard.getByRole('button', { name: '查看详情', exact: true }).click() const dialog = page.locator('.video-detail-dialog:visible') const header = dialog.locator(':scope > .el-dialog__header') const body = dialog.locator(':scope > .el-dialog__body') const footer = dialog.locator(':scope > .el-dialog__footer') const player = dialog.locator('.video-detail-player') const description = dialog.locator('.video-detail-description') const descriptionText = description.locator('p') const expandButton = dialog.getByRole('button', { name: '展开简介', exact: true }) await expect(dialog).toBeVisible() await expect(page.locator('.video-detail-overlay:visible')).toBeVisible() await expect(header).toBeVisible() await expect(body.locator('.video-detail-layout')).toBeVisible() await expect(footer).toBeVisible() await expect(descriptionText).toHaveText(longVideoDescription) await expect(expandButton).toBeVisible() await expect(expandButton).toHaveAttribute('aria-expanded', 'false') const collapsedDescription = await descriptionText.evaluate((element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) expect(collapsedDescription.scrollHeight).toBeGreaterThan(collapsedDescription.clientHeight) const scrollState = await body.evaluate((element) => ({ overflowY: getComputedStyle(element).overflowY, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) expect(['auto', 'scroll']).toContain(scrollState.overflowY) expect(scrollState.scrollHeight).toBeGreaterThan(scrollState.clientHeight) await captureScreenshot(page, testInfo, 'video-detail-long-collapsed-fixed-chrome') const playerBeforeExpand = await player.boundingBox() expect(playerBeforeExpand).not.toBeNull() await expandButton.click() const collapseButton = dialog.getByRole('button', { name: '收起简介', exact: true }) await expect(collapseButton).toBeVisible() await expect(collapseButton).toHaveAttribute('aria-expanded', 'true') const expandedDescription = await descriptionText.evaluate((element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) expect(expandedDescription.clientHeight).toBeGreaterThan(collapsedDescription.clientHeight) expect(expandedDescription.clientHeight).toBe(expandedDescription.scrollHeight) const playerAfterExpand = await player.boundingBox() expect(playerAfterExpand).not.toBeNull() expect(Math.abs(playerAfterExpand!.y - playerBeforeExpand!.y)).toBeLessThanOrEqual(1) const [dialogBox, headerBefore, footerBefore] = await Promise.all([ dialog.boundingBox(), header.boundingBox(), footer.boundingBox(), ]) expect(dialogBox).not.toBeNull() expect(headerBefore).not.toBeNull() expect(footerBefore).not.toBeNull() await body.evaluate((element) => { element.scrollTop = element.scrollHeight }) await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) const [headerAfter, bodyBox, footerAfter] = await Promise.all([ header.boundingBox(), body.boundingBox(), footer.boundingBox(), ]) expect(headerAfter).not.toBeNull() expect(bodyBox).not.toBeNull() expect(footerAfter).not.toBeNull() expect(Math.abs(headerAfter!.y - headerBefore!.y)).toBeLessThanOrEqual(1) expect(Math.abs(footerAfter!.y - footerBefore!.y)).toBeLessThanOrEqual(1) expect(headerAfter!.y).toBeGreaterThanOrEqual(dialogBox!.y) expect(bodyBox!.y).toBeGreaterThanOrEqual(headerAfter!.y + headerAfter!.height - 2) expect(bodyBox!.y + bodyBox!.height).toBeLessThanOrEqual(footerAfter!.y + 2) expect(footerAfter!.y + footerAfter!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height) await collapseButton.scrollIntoViewIfNeeded() await expect(collapseButton).toBeInViewport() await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) await captureScreenshot(page, testInfo, 'video-detail-long-expanded-middle-scrolled') await collapseButton.click() await expect(dialog.getByRole('button', { name: '展开简介', exact: true })).toHaveAttribute('aria-expanded', 'false') const recollapsedHeight = await descriptionText.evaluate((element) => element.clientHeight) expect(recollapsedHeight).toBeLessThanOrEqual(collapsedDescription.clientHeight + 1) await page.keyboard.press('Escape') await expect(dialog).toBeHidden() const shortCard = page.locator('.video-library-card').filter({ hasText: shortDetailVideo.title }) await shortCard.getByRole('button', { name: '查看详情', exact: true }).click() const shortDialog = page.locator('.video-detail-dialog:visible') const shortDescription = shortDialog.locator('.video-detail-description') const shortDescriptionText = shortDescription.locator('p') await expect(shortDialog).toBeVisible() await expect(shortDescriptionText).toHaveText(shortDetailVideo.description) await shortDescriptionText.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve())) })) const shortDescriptionMetrics = await shortDescriptionText.evaluate((element) => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) expect(shortDescriptionMetrics.scrollHeight).toBeLessThanOrEqual(shortDescriptionMetrics.clientHeight + 1) await expect(shortDialog.getByRole('button', { name: /^(展开|收起)简介$/ })).toHaveCount(0) await captureScreenshot(page, testInfo, 'video-detail-short-no-toggle') expect(consoleErrors, '定向回归不应产生 console.error').toEqual([]) expect(pageErrors, '定向回归不应产生 pageerror').toEqual([]) }) 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.route('**/widget-protocol-host', (route) => route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: `