|
- 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<typeof video> = [video]) {
- const videoQueries: Array<Record<string, string>> = []
-
- 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<void>, 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<void>((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.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)
- })
|