Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

231 linhas
12 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import type { Page } from '@playwright/test'
  4. import { expect } from './fixtures'
  5. interface DemoSeedRoute {
  6. path: string
  7. markerTexts?: string[]
  8. codes?: string[]
  9. }
  10. interface DemoSeedManifest {
  11. schemaVersion: number
  12. seedId: string
  13. entities: Record<string, Array<{ code?: string; slug?: string; name?: string; category?: string }>>
  14. e2eRoutes: Record<string, DemoSeedRoute>
  15. }
  16. interface DatasetRequirement {
  17. label: string
  18. selector: string
  19. minItems?: number
  20. manifestEntityKey?: string
  21. entityCategory?: string
  22. markerScopeSelector?: string
  23. }
  24. interface ValueRequirement {
  25. label: string
  26. selector: string
  27. minimumLength?: number
  28. }
  29. interface RouteAcceptance {
  30. datasets?: DatasetRequirement[]
  31. values?: ValueRequirement[]
  32. }
  33. export interface NonEmptyEvidence {
  34. route: string
  35. seedId: string
  36. datasets: Array<{
  37. label: string
  38. selector: string
  39. count: number
  40. manifestEntityKey?: string
  41. matchedMarker?: string
  42. }>
  43. values: Array<{ label: string; selector: string; valueLength: number }>
  44. }
  45. const manifestFilename = () => path.resolve(
  46. process.env.E2E_DEMO_SEED_MANIFEST?.trim() || '../ai_person_api/scripts/demo_seed_manifest.json',
  47. )
  48. let cachedManifest: DemoSeedManifest | undefined
  49. function demoSeedManifest() {
  50. if (cachedManifest) return cachedManifest
  51. const filename = manifestFilename()
  52. if (!fs.existsSync(filename)) {
  53. throw new Error(`演示数据清单不存在:${filename}。非空页面验收不允许退回到空数据截图。`)
  54. }
  55. const parsed = JSON.parse(fs.readFileSync(filename, 'utf8')) as Partial<DemoSeedManifest>
  56. if (!parsed.seedId || !parsed.entities || typeof parsed.entities !== 'object'
  57. || !parsed.e2eRoutes || typeof parsed.e2eRoutes !== 'object') {
  58. throw new Error(`演示数据清单格式无效:${filename}`)
  59. }
  60. cachedManifest = parsed as DemoSeedManifest
  61. return cachedManifest
  62. }
  63. const tableRows = '.el-table__body-wrapper tbody tr.el-table__row'
  64. /**
  65. * 每个正式路由的“有数据”定义。这里只使用页面真实渲染出来的行、卡片和表单值;
  66. * 不读取前端 store,也不拦截或伪造网络响应。
  67. */
  68. const routeAcceptance: Record<string, RouteAcceptance> = {
  69. overview: {
  70. datasets: [
  71. { label: '最近智能体', selector: '.overview-agent-list > button', manifestEntityKey: 'agents', markerScopeSelector: '.overview-agent-list' },
  72. { label: '最近口播视频', selector: '.overview-video-grid > button', manifestEntityKey: 'videos', markerScopeSelector: '.overview-video-grid' },
  73. // Recent feeds are intentionally ordered by time. Earlier lifecycle
  74. // tests create real jobs, so durable DEMO jobs may legitimately fall
  75. // outside the five visible rows; require non-empty real rows here and
  76. // rely on the run's seed summary for durable-job existence.
  77. { label: '最近制作任务', selector: '.overview-job-queue > button' },
  78. ],
  79. },
  80. agents: { datasets: [{ label: '智能体列表', selector: 'tbody tr[data-testid^="agent-row-"]', manifestEntityKey: 'agents', markerScopeSelector: '.agent-table-panel' }] },
  81. 'agent-projects': { datasets: [{ label: '智能体组别', selector: tableRows, manifestEntityKey: 'agentProjects', markerScopeSelector: '.projects-panel' }] },
  82. 'chat-history': { datasets: [{ label: '会话记录', selector: tableRows, manifestEntityKey: 'chatSessions', markerScopeSelector: '.history-panel' }] },
  83. 'wake-words': { datasets: [{ label: '唤醒词', selector: tableRows, manifestEntityKey: 'wakeWords', markerScopeSelector: '.wake-panel' }] },
  84. 'remote-control': { datasets: [{ label: '远程终端', selector: '.terminal-grid > .terminal-card', manifestEntityKey: 'remoteTerminals', markerScopeSelector: '.terminal-panel' }] },
  85. 'video-create': { datasets: [{ label: '视频制作可选数字形象', selector: '.studio-avatar-list > button' }] },
  86. videos: {
  87. datasets: [
  88. { label: '成片文件夹', selector: '.video-folder-strip > article', manifestEntityKey: 'videoFolders', markerScopeSelector: '.video-folder-strip' },
  89. { label: '成片视频', selector: '.video-library-grid > .video-library-card', manifestEntityKey: 'videos', markerScopeSelector: '.video-library-grid' },
  90. ],
  91. },
  92. avatars: { datasets: [{ label: '数字形象', selector: '.avatar-grid > .avatar-card', manifestEntityKey: 'avatars', markerScopeSelector: '.avatar-grid' }] },
  93. 'voice-clones': { datasets: [{ label: '声音克隆能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'VOICE_CLONE', markerScopeSelector: '.capability-grid' }] },
  94. 'ai-models': { datasets: [{ label: 'AI 模型能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'LLM', markerScopeSelector: '.capability-grid' }] },
  95. 'voice-models': { datasets: [{ label: '语音模型能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'TTS', markerScopeSelector: '.capability-grid' }] },
  96. 'asr-models': { datasets: [{ label: 'ASR 模型能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'ASR', markerScopeSelector: '.capability-grid' }] },
  97. 'scene-materials': { datasets: [{ label: '场景素材', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'SCENE', markerScopeSelector: '.capability-grid' }] },
  98. knowledge: { datasets: [{ label: '知识库', selector: '.knowledge-base-card', manifestEntityKey: 'knowledgeBases', markerScopeSelector: '.knowledge-library-page' }] },
  99. 'sensitive-words': { datasets: [{ label: '敏感词', selector: tableRows, manifestEntityKey: 'sensitiveWords', markerScopeSelector: '.words-panel' }] },
  100. 'hot-words': { datasets: [{ label: '热词', selector: tableRows, manifestEntityKey: 'hotWords', markerScopeSelector: '.hot-word-panel' }] },
  101. // This table is server-paginated by newest first for the same reason as the
  102. // overview queue. The dedicated jobs lifecycle test separately exercises
  103. // DEMO/created-job search, paging and detail persistence.
  104. jobs: { datasets: [{ label: '制作任务', selector: '.task-table-v2 tbody > tr' }] },
  105. tools: { datasets: [{ label: '业务工具', selector: '.tool-grid > .tool-card', manifestEntityKey: 'integrationTools', markerScopeSelector: '.tool-grid' }] },
  106. users: { datasets: [{ label: '用户', selector: '.users-table .el-table__body-wrapper tbody tr.el-table__row' }] },
  107. departments: { datasets: [{ label: '部门', selector: '.department-panel .el-table__body-wrapper tbody tr.el-table__row' }] },
  108. roles: { datasets: [{ label: '角色', selector: '.roles-table .el-table__body-wrapper tbody tr.el-table__row' }] },
  109. permissions: {
  110. datasets: [
  111. { label: '可配置角色', selector: '.role-selector > button' },
  112. { label: '权限项', selector: '.permission-list > .permission-item' },
  113. ],
  114. },
  115. 'settings-general': {
  116. datasets: [{ label: '基础设置分区', selector: '.system-config-section', minItems: 3 }],
  117. values: [
  118. { label: '平台名称', selector: 'input[placeholder="请输入系统名称"]', minimumLength: 2 },
  119. { label: '平台简称', selector: 'input[placeholder="请输入系统简称"]', minimumLength: 2 },
  120. ],
  121. },
  122. 'settings-integrations': {
  123. datasets: [
  124. { label: '三方配置状态', selector: '.integration-status-grid > article', minItems: 3 },
  125. { label: '三方配置业务分区', selector: '.settings-section-card', minItems: 3 },
  126. ],
  127. },
  128. 'settings-menus': { datasets: [{ label: '菜单节点', selector: '.menu-name-cell' }] },
  129. profile: {
  130. datasets: [{ label: '当前账号角色', selector: '.profile-role-list > span' }],
  131. values: [{ label: '个人中心账号信息', selector: '.profile-readonly-grid dd', minimumLength: 1 }],
  132. },
  133. }
  134. function markerCandidates(manifest: DemoSeedManifest, entityKey: string, category?: string) {
  135. const entities = manifest.entities?.[entityKey]
  136. if (!Array.isArray(entities)) throw new Error(`演示数据清单缺少 entities.${entityKey}`)
  137. const selected = category ? entities.filter((item) => item.category === category) : entities
  138. const candidates = selected.flatMap((item) => [item.name, item.code, item.slug])
  139. .map((item) => String(item || '').trim())
  140. .filter(Boolean)
  141. if (!candidates.length) throw new Error(`演示数据清单 entities.${entityKey}${category ? `(${category})` : ''} 没有可用 name/code/slug`)
  142. return candidates
  143. }
  144. async function renderedCount(page: Page, selector: string) {
  145. return page.locator(selector).evaluateAll((elements) => elements.filter((element) => {
  146. const style = getComputedStyle(element)
  147. const bounds = element.getBoundingClientRect()
  148. return style.display !== 'none' && style.visibility !== 'hidden'
  149. && bounds.width > 0 && bounds.height > 0 && element.getClientRects().length > 0
  150. }).length)
  151. }
  152. export function hasNonEmptyAcceptance(route: string) {
  153. return Boolean(routeAcceptance[route])
  154. }
  155. export async function expectRouteHasNonEmptyData(page: Page, route: string): Promise<NonEmptyEvidence> {
  156. const acceptance = routeAcceptance[route]
  157. if (!acceptance) throw new Error(`正式路由 ${route} 没有配置非空页面验收规则`)
  158. const needsManifest = Boolean(acceptance.datasets?.some((item) => item.manifestEntityKey))
  159. const manifest = needsManifest ? demoSeedManifest() : undefined
  160. const evidence: NonEmptyEvidence = { route, seedId: manifest?.seedId || 'iam-or-static-config', datasets: [], values: [] }
  161. for (const requirement of acceptance.datasets || []) {
  162. const minimum = requirement.minItems ?? 1
  163. await expect.poll(
  164. () => renderedCount(page, requirement.selector),
  165. { timeout: 30_000, message: `${route} / ${requirement.label} 必须显示至少 ${minimum} 条真实数据` },
  166. ).toBeGreaterThanOrEqual(minimum)
  167. const count = await renderedCount(page, requirement.selector)
  168. let matchedMarker: string | undefined
  169. if (requirement.manifestEntityKey && manifest) {
  170. const candidates = markerCandidates(manifest, requirement.manifestEntityKey, requirement.entityCategory)
  171. const scope = page.locator(requirement.markerScopeSelector || 'main').first()
  172. await expect(scope, `${route} / ${requirement.label} 的数据容器没有渲染`).toBeVisible()
  173. await expect.poll(async () => {
  174. const content = await scope.innerText()
  175. matchedMarker = candidates.find((candidate) => content.includes(candidate))
  176. return matchedMarker || ''
  177. }, {
  178. timeout: 30_000,
  179. message: `${route} / ${requirement.label} 必须展示 manifest 中的持久化演示数据,不能只依赖临时测试数据`,
  180. }).not.toBe('')
  181. }
  182. evidence.datasets.push({
  183. label: requirement.label,
  184. selector: requirement.selector,
  185. count,
  186. manifestEntityKey: requirement.manifestEntityKey,
  187. matchedMarker,
  188. })
  189. }
  190. for (const requirement of acceptance.values || []) {
  191. const locator = page.locator(requirement.selector).first()
  192. await expect(locator, `${route} / ${requirement.label} 控件没有渲染`).toBeVisible({ timeout: 30_000 })
  193. await expect.poll(async () => {
  194. const raw = await locator.evaluate((element) => element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
  195. ? element.value
  196. : element.textContent || '')
  197. return raw.trim().length
  198. }, {
  199. timeout: 30_000,
  200. message: `${route} / ${requirement.label} 不允许为空`,
  201. }).toBeGreaterThanOrEqual(requirement.minimumLength ?? 1)
  202. const raw = await locator.evaluate((element) => element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
  203. ? element.value
  204. : element.textContent || '')
  205. evidence.values.push({ label: requirement.label, selector: requirement.selector, valueLength: raw.trim().length })
  206. }
  207. return evidence
  208. }