You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

242 rivejä
12 KiB

  1. import type { BrowserContext, Page, Route } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. const envelope = (data: unknown) => ({
  4. code: 0,
  5. message: 'ok',
  6. data,
  7. timestamp: Date.now(),
  8. requestId: 'prototype-sync-regression',
  9. })
  10. const profile = {
  11. user: {
  12. id: 'prototype-admin', username: 'prototype-admin', displayName: '系统管理员',
  13. departmentId: 'system', departmentName: '系统管理', enabled: true,
  14. mustChangePassword: false, version: 1,
  15. },
  16. roles: [{
  17. id: 'admin-role', code: 'ADMIN', name: '系统管理员', shortName: '系统',
  18. enabled: true, builtIn: true, isSuperAdmin: true, dataScope: 'ALL',
  19. }],
  20. activeRoleId: 'admin-role', permissions: ['*'], authorizationMode: 'SINGLE_ACTIVE',
  21. loginTime: new Date().toISOString(),
  22. }
  23. const baseAgent = {
  24. dataVersion: 1,
  25. name: '设备点检数字教员',
  26. description: '用于验证独立页面与悬浮图标入口',
  27. ownerId: 'prototype-admin', ownerName: '系统管理员',
  28. projectId: null, projectName: '', avatarId: '', avatarName: '', viewerUrl: '', avatars: [],
  29. voiceName: '标准教员音色', voiceSpeed: 1, voice: null, voiceNames: [],
  30. knowledgeBaseIds: [], knowledgeDocumentIds: [], interactionModes: ['text'],
  31. llmModel: '', asrModel: '', ttsModel: '', accessMode: 'internal', allowedOrigins: [],
  32. concurrencyLimit: 10, expiresAt: null, background: '', brandVisible: true,
  33. welcomeMessage: '您好,请问需要了解哪项设备点检知识?', fallbackMessage: '服务暂不可用',
  34. sensitiveReply: '请重新描述问题', showInteractionButtons: true, uiPosition: 'fullscreen',
  35. sceneBackgroundType: 'transparent', sceneAssetUrl: '', logoUrl: '',
  36. screensaverEnabled: false, screensaverUrl: '', screensaverIdleSeconds: 120,
  37. apiKey: '', apiKeyPreview: 'dhk_demo••••', callCount: 3,
  38. uiConfig: {
  39. components: [
  40. { key: 'welcome', enabled: true, order: 1 },
  41. { key: 'history', enabled: true, order: 2 },
  42. { key: 'suggestions', enabled: true, order: 3 },
  43. { key: 'input', enabled: true, order: 4 },
  44. ],
  45. suggestions: ['设备点检的主要目的是什么?'],
  46. },
  47. }
  48. const publishedAgent = { ...baseAgent, id: 'published-agent-id', slug: 'published-agent', status: 'published' }
  49. const draftAgent = { ...baseAgent, id: 'draft-agent-id', slug: 'draft-agent', name: '草稿数字教员', status: 'draft' }
  50. const video = {
  51. id: 'video-1', dataVersion: 1, title: '液压系统点检口播', description: '设备培训示例',
  52. category: '设备培训', tags: ['点检'], status: 'ready', visibility: 'internal',
  53. ownerId: 'prototype-admin', ownerName: '系统管理员', avatarId: null, duration: 42,
  54. width: 1920, height: 1080, size: 1024, coverUrl: '', fileUrl: null,
  55. coverFileCode: null, videoFileCode: null, folderId: null, shareStatus: 'inactive',
  56. createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
  57. }
  58. async function installMocks(page: Page) {
  59. const videoQueries: Array<Record<string, string>> = []
  60. await page.addInitScript(() => {
  61. window.sessionStorage.setItem('ai-person:web:access-token:v1', 'prototype-sync-token')
  62. class FakeUtterance {
  63. text: string
  64. lang = ''
  65. rate = 1
  66. pitch = 1
  67. onstart: null | (() => void) = null
  68. onend: null | (() => void) = null
  69. onerror: null | (() => void) = null
  70. constructor(text: string) {
  71. this.text = text
  72. const state = window as unknown as { __speechSynthesisUtteranceCalls: number }
  73. state.__speechSynthesisUtteranceCalls += 1
  74. }
  75. }
  76. ;(window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls = 0
  77. const fakeSpeech = {
  78. speaking: false,
  79. paused: false,
  80. current: null as FakeUtterance | null,
  81. speak(utterance: FakeUtterance) {
  82. this.current = utterance
  83. this.speaking = true
  84. this.paused = false
  85. utterance.onstart?.()
  86. },
  87. pause() { this.paused = true; this.speaking = true },
  88. resume() { this.paused = false; this.speaking = true },
  89. cancel() { this.speaking = false; this.paused = false; this.current = null },
  90. }
  91. Object.defineProperty(window, 'SpeechSynthesisUtterance', { configurable: true, value: FakeUtterance })
  92. Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: fakeSpeech })
  93. })
  94. await page.route('**/api/auth/v1/**', async (route) => {
  95. const pathname = new URL(route.request().url()).pathname
  96. let data: unknown = {}
  97. if (pathname.endsWith('/auth/me')) data = profile
  98. else if (pathname.endsWith('/menus/navigation')) data = []
  99. else if (pathname.endsWith('/system-config/public')) data = { systemName: '虚拟教员系统', shortName: '数字人平台' }
  100. await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  101. })
  102. await page.route('**/api/v1/**', async (route) => {
  103. const url = new URL(route.request().url())
  104. let data: unknown = {}
  105. if (url.pathname.endsWith('/videos')) {
  106. videoQueries.push(Object.fromEntries(url.searchParams.entries()))
  107. data = { items: [video], total: 1, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] }
  108. } else if (url.pathname.endsWith('/video-folders')) {
  109. data = { items: [], total: 0, page: 1, pageSize: 100, pages: 1, unfiledCount: 1 }
  110. } else if (url.pathname.endsWith('/realtime-agents')) {
  111. data = { items: [publishedAgent, draftAgent], total: 2, page: 1, pageSize: 10, pages: 1 }
  112. } else if (url.pathname.endsWith('/avatars')) {
  113. data = { items: [], total: 0, page: 1, pageSize: 200, pages: 1 }
  114. } else if (url.pathname.endsWith('/health')) {
  115. data = { status: 'ok' }
  116. }
  117. await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  118. })
  119. await page.route('**/open/v1/**', async (route) => {
  120. const url = new URL(route.request().url())
  121. let data: unknown = publishedAgent
  122. let status = 200
  123. if (url.pathname.endsWith('/sessions')) {
  124. data = { sessionId: 'session-1', sessionToken: 'session-token', expiresAt: new Date(Date.now() + 60_000).toISOString() }
  125. status = 201
  126. } else if (url.pathname.endsWith('/chat')) {
  127. data = { reply: '设备点检用于及时发现异常并降低故障风险。', citations: [], audioUrl: null, provider: 'local', blocked: false }
  128. }
  129. await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  130. })
  131. return { videoQueries }
  132. }
  133. async function expectOpenedPage(context: BrowserContext, action: () => Promise<void>, path: RegExp) {
  134. const openedPromise = context.waitForEvent('page')
  135. await action()
  136. const opened = await openedPromise
  137. await expect(opened).toHaveURL(path)
  138. await opened.close()
  139. }
  140. test('成片筛选在搜索前且排序方向合并到排序项', async ({ page }, testInfo) => {
  141. const { videoQueries } = await installMocks(page)
  142. await page.goto('/videos/manage')
  143. await expect(page.getByText('液压系统点检口播')).toBeVisible()
  144. const row = page.locator('.video-filter-row')
  145. const searchButton = row.getByRole('button', { name: '搜索', exact: true })
  146. const sortSelect = row.locator('label').filter({ hasText: '排序' }).locator('select')
  147. const filterSelects = row.locator('label select')
  148. await expect(filterSelects).toHaveCount(4)
  149. await expect(sortSelect.locator('option')).toContainText(['更新时间降序', '更新时间升序', '创建时间降序', '创建时间升序', '标题升序', '标题降序', '时长升序', '时长降序'])
  150. await expect(row.locator('label:last-of-type + button')).toHaveCount(1)
  151. await row.locator('label').filter({ hasText: '分类' }).locator('select').selectOption('设备培训')
  152. await row.locator('label').filter({ hasText: '可见范围' }).locator('select').selectOption('internal')
  153. await row.locator('label').filter({ hasText: '归属' }).locator('select').selectOption('mine')
  154. await sortSelect.selectOption('duration_desc')
  155. await searchButton.click()
  156. await expect.poll(() => videoQueries.length).toBeGreaterThan(1)
  157. expect(videoQueries.at(-1)).toMatchObject({ category: '设备培训', visibility: 'internal', owner: 'mine', sort: 'duration', order: 'desc' })
  158. await captureScreenshot(page, testInfo, 'video-filters-before-search-combined-sort')
  159. })
  160. test('智能体预览入口可点击并打开独立页与悬浮图标演示', async ({ page, context }, testInfo) => {
  161. await installMocks(page)
  162. await page.goto('/agents/manage')
  163. const publishedRow = page.locator('.agent-table .el-table__row').filter({ hasText: '设备点检数字教员' })
  164. const draftRow = page.locator('.agent-table .el-table__row').filter({ hasText: '草稿数字教员' })
  165. await expect(publishedRow.getByRole('button', { name: '独立页面' })).toBeEnabled()
  166. await expect(draftRow.getByRole('button', { name: '独立页面' })).toBeEnabled()
  167. await draftRow.getByRole('button', { name: '独立页面' }).click()
  168. await expect(page.getByText('智能体发布后才能打开体验页')).toBeVisible()
  169. await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '独立页面' }).click(), /\/live\/published-agent$/)
  170. await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '悬浮图标' }).click(), /\/embed-demo\?agent=published-agent$/)
  171. await captureScreenshot(page, testInfo, 'agent-preview-actions-enabled')
  172. })
  173. test('公开回答只播放服务端音频,缺失时保留文字且不调用浏览器 TTS', async ({ page }, testInfo) => {
  174. await installMocks(page)
  175. await page.goto('/live/published-agent')
  176. await expect(page.getByText('设备点检数字教员')).toBeVisible()
  177. await page.getByRole('textbox', { name: '维修问题' }).fill('设备点检的主要目的是什么?')
  178. await page.getByRole('button', { name: '发送问题' }).click()
  179. const answer = page.locator('.live-message-list article.assistant').filter({ hasText: '设备点检用于及时发现异常并降低故障风险。' })
  180. await expect(answer).toBeVisible()
  181. await expect(answer.locator('.live-degradation[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。')
  182. const speechButton = answer.locator('.live-speech-toggle')
  183. await expect(speechButton).not.toHaveClass(/is-speaking/)
  184. await speechButton.click()
  185. await expect(answer.locator('.live-degradation[role="status"]')).toBeVisible()
  186. await expect(speechButton).not.toHaveClass(/is-speaking/)
  187. await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0)
  188. await captureScreenshot(page, testInfo, 'public-answer-speech-control')
  189. await page.goto('/embed/published-agent?open=1')
  190. const widgetAnswer = page.locator('.embed-messages article.assistant').first()
  191. const widgetSpeechButton = widgetAnswer.locator('.embed-speech-toggle')
  192. await expect(widgetSpeechButton).toBeVisible()
  193. await widgetSpeechButton.click()
  194. await expect(widgetAnswer.locator('.embed-audio-unavailable[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。')
  195. await expect(widgetSpeechButton).not.toHaveClass(/is-speaking/)
  196. await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0)
  197. })
  198. test('标签达到上限时静默淘汰最久未访问项', async ({ page }) => {
  199. await installMocks(page)
  200. await page.addInitScript(() => {
  201. const paths = [
  202. '/overview', '/agents/manage', '/agents/projects', '/agents/chat-history', '/agents/wake-words', '/agents/remote-control',
  203. '/videos/manage', '/assets/avatars', '/assets/ai-models', '/organization/users', '/organization/departments', '/organization/roles',
  204. ]
  205. const tabs = paths.map((path, index) => ({
  206. key: path, fullPath: path, name: `seed-${index}`, title: `种子标签${index + 1}`,
  207. permissions: [], locked: false,
  208. }))
  209. window.localStorage.setItem('ai-person:web:page-tabs:v1', JSON.stringify({ 'prototype-admin': { tabs } }))
  210. })
  211. await page.goto('/settings/audit')
  212. await expect(page.locator('.page-tab')).toHaveCount(12)
  213. await expect(page.getByText(/页面标签数量已达上限/)).toHaveCount(0)
  214. await expect(page.locator('.page-tab').filter({ hasText: '审计日志' })).toHaveCount(1)
  215. })