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.
 
 
 
 

381 lines
19 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. const longVideoDescription = Array.from({ length: 24 }, (_, index) => (
  59. `第 ${index + 1} 段:检修前需要逐项确认停机、断电、验电、卸压与上锁挂牌,并完整记录复核结果。`
  60. )).join('\n')
  61. const longDetailVideo = {
  62. ...video,
  63. id: 'video-long-description',
  64. title: '长简介成片详情回归',
  65. description: longVideoDescription,
  66. }
  67. const shortDetailVideo = {
  68. ...video,
  69. id: 'video-short-description',
  70. title: '短简介成片详情回归',
  71. description: '简短的设备培训说明。',
  72. }
  73. async function installMocks(page: Page, videos: Array<typeof video> = [video]) {
  74. const videoQueries: Array<Record<string, string>> = []
  75. await page.addInitScript(() => {
  76. window.sessionStorage.setItem('ai-person:web:access-token:v1', 'prototype-sync-token')
  77. class FakeUtterance {
  78. text: string
  79. lang = ''
  80. rate = 1
  81. pitch = 1
  82. onstart: null | (() => void) = null
  83. onend: null | (() => void) = null
  84. onerror: null | (() => void) = null
  85. constructor(text: string) {
  86. this.text = text
  87. const state = window as unknown as { __speechSynthesisUtteranceCalls: number }
  88. state.__speechSynthesisUtteranceCalls += 1
  89. }
  90. }
  91. ;(window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls = 0
  92. const fakeSpeech = {
  93. speaking: false,
  94. paused: false,
  95. current: null as FakeUtterance | null,
  96. speak(utterance: FakeUtterance) {
  97. this.current = utterance
  98. this.speaking = true
  99. this.paused = false
  100. utterance.onstart?.()
  101. },
  102. pause() { this.paused = true; this.speaking = true },
  103. resume() { this.paused = false; this.speaking = true },
  104. cancel() { this.speaking = false; this.paused = false; this.current = null },
  105. }
  106. Object.defineProperty(window, 'SpeechSynthesisUtterance', { configurable: true, value: FakeUtterance })
  107. Object.defineProperty(window, 'speechSynthesis', { configurable: true, value: fakeSpeech })
  108. })
  109. await page.route('**/api/auth/v1/**', async (route) => {
  110. const pathname = new URL(route.request().url()).pathname
  111. let data: unknown = {}
  112. if (pathname.endsWith('/auth/me')) data = profile
  113. else if (pathname.endsWith('/menus/navigation')) data = []
  114. else if (pathname.endsWith('/system-config/public')) data = { systemName: '虚拟教员系统', shortName: '数字人平台' }
  115. await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  116. })
  117. await page.route('**/api/v1/**', async (route) => {
  118. const url = new URL(route.request().url())
  119. let data: unknown = {}
  120. if (url.pathname.endsWith('/videos')) {
  121. videoQueries.push(Object.fromEntries(url.searchParams.entries()))
  122. data = { items: videos, total: videos.length, page: 1, pageSize: 12, pages: 1, categories: ['设备培训'] }
  123. } else if (url.pathname.endsWith('/video-folders')) {
  124. data = { items: [], total: 0, page: 1, pageSize: 100, pages: 1, unfiledCount: 1 }
  125. } else if (url.pathname.endsWith('/realtime-agents')) {
  126. data = { items: [publishedAgent, draftAgent], total: 2, page: 1, pageSize: 10, pages: 1 }
  127. } else if (url.pathname.endsWith('/avatars')) {
  128. data = { items: [], total: 0, page: 1, pageSize: 200, pages: 1 }
  129. } else if (url.pathname.endsWith('/health')) {
  130. data = { status: 'ok' }
  131. }
  132. await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  133. })
  134. await page.route('**/open/v1/**', async (route) => {
  135. const url = new URL(route.request().url())
  136. let data: unknown = publishedAgent
  137. let status = 200
  138. if (url.pathname.endsWith('/sessions')) {
  139. data = { sessionId: 'session-1', sessionToken: 'session-token', expiresAt: new Date(Date.now() + 60_000).toISOString() }
  140. status = 201
  141. } else if (url.pathname.endsWith('/chat')) {
  142. data = { reply: '设备点检用于及时发现异常并降低故障风险。', citations: [], audioUrl: null, provider: 'local', blocked: false }
  143. }
  144. await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  145. })
  146. return { videoQueries }
  147. }
  148. async function expectOpenedPage(context: BrowserContext, action: () => Promise<void>, path: RegExp) {
  149. const openedPromise = context.waitForEvent('page')
  150. await action()
  151. const opened = await openedPromise
  152. await expect(opened).toHaveURL(path)
  153. await opened.close()
  154. }
  155. test('成片筛选在搜索前且排序方向合并到排序项', async ({ page }, testInfo) => {
  156. const { videoQueries } = await installMocks(page)
  157. await page.goto('/videos/manage')
  158. await expect(page.getByText('液压系统点检口播')).toBeVisible()
  159. const row = page.locator('.video-filter-row')
  160. const searchButton = row.getByRole('button', { name: '搜索', exact: true })
  161. const sortSelect = row.locator('label').filter({ hasText: '排序' }).locator('select')
  162. const filterSelects = row.locator('label select')
  163. await expect(filterSelects).toHaveCount(4)
  164. await expect(sortSelect.locator('option')).toContainText(['更新时间降序', '更新时间升序', '创建时间降序', '创建时间升序', '标题升序', '标题降序', '时长升序', '时长降序'])
  165. await expect(row.locator('label:last-of-type + button')).toHaveCount(1)
  166. await row.locator('label').filter({ hasText: '分类' }).locator('select').selectOption('设备培训')
  167. await row.locator('label').filter({ hasText: '可见范围' }).locator('select').selectOption('internal')
  168. await row.locator('label').filter({ hasText: '归属' }).locator('select').selectOption('mine')
  169. await sortSelect.selectOption('duration_desc')
  170. await searchButton.click()
  171. await expect.poll(() => videoQueries.length).toBeGreaterThan(1)
  172. expect(videoQueries.at(-1)).toMatchObject({ category: '设备培训', visibility: 'internal', owner: 'mine', sort: 'duration', order: 'desc' })
  173. await captureScreenshot(page, testInfo, 'video-filters-before-search-combined-sort')
  174. })
  175. test('成片详情固定首尾、中部滚动且简介仅在溢出时展开收起', async ({ page }, testInfo) => {
  176. const consoleErrors: string[] = []
  177. const pageErrors: string[] = []
  178. page.on('console', (message) => {
  179. if (message.type() === 'error') consoleErrors.push(message.text())
  180. })
  181. page.on('pageerror', (error) => pageErrors.push(error.message))
  182. await installMocks(page, [longDetailVideo, shortDetailVideo])
  183. await page.setViewportSize({ width: 1280, height: 640 })
  184. await page.goto('/videos/manage')
  185. const longCard = page.locator('.video-library-card').filter({ hasText: longDetailVideo.title })
  186. await longCard.getByRole('button', { name: '查看详情', exact: true }).click()
  187. const dialog = page.locator('.video-detail-dialog:visible')
  188. const header = dialog.locator(':scope > .el-dialog__header')
  189. const body = dialog.locator(':scope > .el-dialog__body')
  190. const footer = dialog.locator(':scope > .el-dialog__footer')
  191. const player = dialog.locator('.video-detail-player')
  192. const description = dialog.locator('.video-detail-description')
  193. const descriptionText = description.locator('p')
  194. const expandButton = dialog.getByRole('button', { name: '展开简介', exact: true })
  195. await expect(dialog).toBeVisible()
  196. await expect(page.locator('.video-detail-overlay:visible')).toBeVisible()
  197. await expect(header).toBeVisible()
  198. await expect(body.locator('.video-detail-layout')).toBeVisible()
  199. await expect(footer).toBeVisible()
  200. await expect(descriptionText).toHaveText(longVideoDescription)
  201. await expect(expandButton).toBeVisible()
  202. await expect(expandButton).toHaveAttribute('aria-expanded', 'false')
  203. const collapsedDescription = await descriptionText.evaluate((element) => ({
  204. clientHeight: element.clientHeight,
  205. scrollHeight: element.scrollHeight,
  206. }))
  207. expect(collapsedDescription.scrollHeight).toBeGreaterThan(collapsedDescription.clientHeight)
  208. const scrollState = await body.evaluate((element) => ({
  209. overflowY: getComputedStyle(element).overflowY,
  210. clientHeight: element.clientHeight,
  211. scrollHeight: element.scrollHeight,
  212. }))
  213. expect(['auto', 'scroll']).toContain(scrollState.overflowY)
  214. expect(scrollState.scrollHeight).toBeGreaterThan(scrollState.clientHeight)
  215. await captureScreenshot(page, testInfo, 'video-detail-long-collapsed-fixed-chrome')
  216. const playerBeforeExpand = await player.boundingBox()
  217. expect(playerBeforeExpand).not.toBeNull()
  218. await expandButton.click()
  219. const collapseButton = dialog.getByRole('button', { name: '收起简介', exact: true })
  220. await expect(collapseButton).toBeVisible()
  221. await expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
  222. const expandedDescription = await descriptionText.evaluate((element) => ({
  223. clientHeight: element.clientHeight,
  224. scrollHeight: element.scrollHeight,
  225. }))
  226. expect(expandedDescription.clientHeight).toBeGreaterThan(collapsedDescription.clientHeight)
  227. expect(expandedDescription.clientHeight).toBe(expandedDescription.scrollHeight)
  228. const playerAfterExpand = await player.boundingBox()
  229. expect(playerAfterExpand).not.toBeNull()
  230. expect(Math.abs(playerAfterExpand!.y - playerBeforeExpand!.y)).toBeLessThanOrEqual(1)
  231. const [dialogBox, headerBefore, footerBefore] = await Promise.all([
  232. dialog.boundingBox(),
  233. header.boundingBox(),
  234. footer.boundingBox(),
  235. ])
  236. expect(dialogBox).not.toBeNull()
  237. expect(headerBefore).not.toBeNull()
  238. expect(footerBefore).not.toBeNull()
  239. await body.evaluate((element) => { element.scrollTop = element.scrollHeight })
  240. await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
  241. const [headerAfter, bodyBox, footerAfter] = await Promise.all([
  242. header.boundingBox(),
  243. body.boundingBox(),
  244. footer.boundingBox(),
  245. ])
  246. expect(headerAfter).not.toBeNull()
  247. expect(bodyBox).not.toBeNull()
  248. expect(footerAfter).not.toBeNull()
  249. expect(Math.abs(headerAfter!.y - headerBefore!.y)).toBeLessThanOrEqual(1)
  250. expect(Math.abs(footerAfter!.y - footerBefore!.y)).toBeLessThanOrEqual(1)
  251. expect(headerAfter!.y).toBeGreaterThanOrEqual(dialogBox!.y)
  252. expect(bodyBox!.y).toBeGreaterThanOrEqual(headerAfter!.y + headerAfter!.height - 2)
  253. expect(bodyBox!.y + bodyBox!.height).toBeLessThanOrEqual(footerAfter!.y + 2)
  254. expect(footerAfter!.y + footerAfter!.height).toBeLessThanOrEqual(dialogBox!.y + dialogBox!.height)
  255. await collapseButton.scrollIntoViewIfNeeded()
  256. await expect(collapseButton).toBeInViewport()
  257. await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
  258. await captureScreenshot(page, testInfo, 'video-detail-long-expanded-middle-scrolled')
  259. await collapseButton.click()
  260. await expect(dialog.getByRole('button', { name: '展开简介', exact: true })).toHaveAttribute('aria-expanded', 'false')
  261. const recollapsedHeight = await descriptionText.evaluate((element) => element.clientHeight)
  262. expect(recollapsedHeight).toBeLessThanOrEqual(collapsedDescription.clientHeight + 1)
  263. await page.keyboard.press('Escape')
  264. await expect(dialog).toBeHidden()
  265. const shortCard = page.locator('.video-library-card').filter({ hasText: shortDetailVideo.title })
  266. await shortCard.getByRole('button', { name: '查看详情', exact: true }).click()
  267. const shortDialog = page.locator('.video-detail-dialog:visible')
  268. const shortDescription = shortDialog.locator('.video-detail-description')
  269. const shortDescriptionText = shortDescription.locator('p')
  270. await expect(shortDialog).toBeVisible()
  271. await expect(shortDescriptionText).toHaveText(shortDetailVideo.description)
  272. await shortDescriptionText.evaluate(() => new Promise<void>((resolve) => {
  273. requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
  274. }))
  275. const shortDescriptionMetrics = await shortDescriptionText.evaluate((element) => ({
  276. clientHeight: element.clientHeight,
  277. scrollHeight: element.scrollHeight,
  278. }))
  279. expect(shortDescriptionMetrics.scrollHeight).toBeLessThanOrEqual(shortDescriptionMetrics.clientHeight + 1)
  280. await expect(shortDialog.getByRole('button', { name: /^(展开|收起)简介$/ })).toHaveCount(0)
  281. await captureScreenshot(page, testInfo, 'video-detail-short-no-toggle')
  282. expect(consoleErrors, '定向回归不应产生 console.error').toEqual([])
  283. expect(pageErrors, '定向回归不应产生 pageerror').toEqual([])
  284. })
  285. test('智能体预览入口可点击并打开独立页与悬浮图标演示', async ({ page, context }, testInfo) => {
  286. await installMocks(page)
  287. await page.goto('/agents/manage')
  288. const publishedRow = page.locator('.agent-table .el-table__row').filter({ hasText: '设备点检数字教员' })
  289. const draftRow = page.locator('.agent-table .el-table__row').filter({ hasText: '草稿数字教员' })
  290. await expect(publishedRow.getByRole('button', { name: '独立页面' })).toBeEnabled()
  291. await expect(draftRow.getByRole('button', { name: '独立页面' })).toBeEnabled()
  292. await draftRow.getByRole('button', { name: '独立页面' }).click()
  293. await expect(page.getByText('智能体发布后才能打开体验页')).toBeVisible()
  294. await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '独立页面' }).click(), /\/live\/published-agent$/)
  295. await expectOpenedPage(context, () => publishedRow.getByRole('button', { name: '悬浮图标' }).click(), /\/embed-demo\?agent=published-agent$/)
  296. await captureScreenshot(page, testInfo, 'agent-preview-actions-enabled')
  297. })
  298. test('公开回答只播放服务端音频,缺失时保留文字且不调用浏览器 TTS', async ({ page }, testInfo) => {
  299. await installMocks(page)
  300. await page.goto('/live/published-agent')
  301. await expect(page.getByText('设备点检数字教员')).toBeVisible()
  302. await page.getByRole('textbox', { name: '维修问题' }).fill('设备点检的主要目的是什么?')
  303. await page.getByRole('button', { name: '发送问题' }).click()
  304. const answer = page.locator('.live-message-list article.assistant').filter({ hasText: '设备点检用于及时发现异常并降低故障风险。' })
  305. await expect(answer).toBeVisible()
  306. await expect(answer.locator('.live-degradation[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。')
  307. const speechButton = answer.locator('.live-speech-toggle')
  308. await expect(speechButton).not.toHaveClass(/is-speaking/)
  309. await speechButton.click()
  310. await expect(answer.locator('.live-degradation[role="status"]')).toBeVisible()
  311. await expect(speechButton).not.toHaveClass(/is-speaking/)
  312. await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0)
  313. await captureScreenshot(page, testInfo, 'public-answer-speech-control')
  314. await page.goto('/embed/published-agent?open=1')
  315. const widgetAnswer = page.locator('.embed-messages article.assistant').first()
  316. const widgetSpeechButton = widgetAnswer.locator('.embed-speech-toggle')
  317. await expect(widgetSpeechButton).toBeVisible()
  318. await widgetSpeechButton.click()
  319. await expect(widgetAnswer.locator('.embed-audio-unavailable[role="status"]')).toContainText('语音暂不可用,文字回答仍可查看。')
  320. await expect(widgetSpeechButton).not.toHaveClass(/is-speaking/)
  321. await expect.poll(() => page.evaluate(() => (window as unknown as { __speechSynthesisUtteranceCalls: number }).__speechSynthesisUtteranceCalls)).toBe(0)
  322. })
  323. test('标签达到上限时静默淘汰最久未访问项', async ({ page }) => {
  324. await installMocks(page)
  325. await page.addInitScript(() => {
  326. const paths = [
  327. '/overview', '/agents/manage', '/agents/projects', '/agents/chat-history', '/agents/wake-words', '/agents/remote-control',
  328. '/videos/manage', '/assets/avatars', '/assets/ai-models', '/organization/users', '/organization/departments', '/organization/roles',
  329. ]
  330. const tabs = paths.map((path, index) => ({
  331. key: path, fullPath: path, name: `seed-${index}`, title: `种子标签${index + 1}`,
  332. permissions: [], locked: false,
  333. }))
  334. window.localStorage.setItem('ai-person:web:page-tabs:v1', JSON.stringify({ 'prototype-admin': { tabs } }))
  335. })
  336. await page.goto('/settings/audit')
  337. await expect(page.locator('.page-tab')).toHaveCount(12)
  338. await expect(page.getByText(/页面标签数量已达上限/)).toHaveCount(0)
  339. await expect(page.locator('.page-tab').filter({ hasText: '审计日志' })).toHaveCount(1)
  340. })