import fs from 'node:fs' import path from 'node:path' import type { Page } from '@playwright/test' import { expect } from './fixtures' interface DemoSeedRoute { path: string markerTexts?: string[] codes?: string[] } interface DemoSeedManifest { schemaVersion: number seedId: string entities: Record> e2eRoutes: Record } interface DatasetRequirement { label: string selector: string minItems?: number manifestEntityKey?: string entityCategory?: string markerScopeSelector?: string } interface ValueRequirement { label: string selector: string minimumLength?: number } interface RouteAcceptance { datasets?: DatasetRequirement[] values?: ValueRequirement[] } export interface NonEmptyEvidence { route: string seedId: string datasets: Array<{ label: string selector: string count: number manifestEntityKey?: string matchedMarker?: string }> values: Array<{ label: string; selector: string; valueLength: number }> } const manifestFilename = () => path.resolve( process.env.E2E_DEMO_SEED_MANIFEST?.trim() || '../ai_person_api/scripts/demo_seed_manifest.json', ) let cachedManifest: DemoSeedManifest | undefined function demoSeedManifest() { if (cachedManifest) return cachedManifest const filename = manifestFilename() if (!fs.existsSync(filename)) { throw new Error(`演示数据清单不存在:${filename}。非空页面验收不允许退回到空数据截图。`) } const parsed = JSON.parse(fs.readFileSync(filename, 'utf8')) as Partial if (!parsed.seedId || !parsed.entities || typeof parsed.entities !== 'object' || !parsed.e2eRoutes || typeof parsed.e2eRoutes !== 'object') { throw new Error(`演示数据清单格式无效:${filename}`) } cachedManifest = parsed as DemoSeedManifest return cachedManifest } const tableRows = '.el-table__body-wrapper tbody tr.el-table__row' /** * 每个正式路由的“有数据”定义。这里只使用页面真实渲染出来的行、卡片和表单值; * 不读取前端 store,也不拦截或伪造网络响应。 */ const routeAcceptance: Record = { overview: { datasets: [ { label: '最近智能体', selector: '.overview-agent-list > button', manifestEntityKey: 'agents', markerScopeSelector: '.overview-agent-list' }, { label: '最近口播视频', selector: '.overview-video-grid > button', manifestEntityKey: 'videos', markerScopeSelector: '.overview-video-grid' }, // Recent feeds are intentionally ordered by time. Earlier lifecycle // tests create real jobs, so durable DEMO jobs may legitimately fall // outside the five visible rows; require non-empty real rows here and // rely on the run's seed summary for durable-job existence. { label: '最近制作任务', selector: '.overview-job-queue > button' }, ], }, agents: { datasets: [{ label: '智能体列表', selector: 'tbody tr[data-testid^="agent-row-"]', manifestEntityKey: 'agents', markerScopeSelector: '.agent-table-panel' }] }, 'agent-projects': { datasets: [{ label: '智能体组别', selector: tableRows, manifestEntityKey: 'agentProjects', markerScopeSelector: '.projects-panel' }] }, 'chat-history': { datasets: [{ label: '会话记录', selector: tableRows, manifestEntityKey: 'chatSessions', markerScopeSelector: '.history-panel' }] }, 'wake-words': { datasets: [{ label: '唤醒词', selector: tableRows, manifestEntityKey: 'wakeWords', markerScopeSelector: '.wake-panel' }] }, // 021 迁移会主动清除不可安全转换的旧终端;此页验收 V2 表格容器, // 真实终端数据由专用生命周期用例创建、激活并精确清理,不再依赖 demo manifest。 'remote-control': { datasets: [{ label: '远程终端 V2 表格', selector: '.desktop-terminal-table .el-table' }] }, 'video-create': { datasets: [{ label: '视频制作可选数字形象', selector: '.studio-avatar-list > button' }] }, videos: { datasets: [ { label: '成片文件夹', selector: '.video-folder-strip > article', manifestEntityKey: 'videoFolders', markerScopeSelector: '.video-folder-strip' }, { label: '成片视频', selector: '.video-library-grid > .video-library-card', manifestEntityKey: 'videos', markerScopeSelector: '.video-library-grid' }, ], }, avatars: { datasets: [{ label: '数字形象', selector: '.avatar-grid > .avatar-card', manifestEntityKey: 'avatars', markerScopeSelector: '.avatar-grid' }] }, 'voice-clones': { datasets: [{ label: '声音克隆能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'VOICE_CLONE', markerScopeSelector: '.capability-grid' }] }, 'ai-models': { datasets: [{ label: 'AI 模型能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'LLM', markerScopeSelector: '.capability-grid' }] }, 'voice-models': { datasets: [{ label: '语音模型能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'TTS', markerScopeSelector: '.capability-grid' }] }, 'asr-models': { datasets: [{ label: 'ASR 模型能力', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'ASR', markerScopeSelector: '.capability-grid' }] }, 'scene-materials': { datasets: [{ label: '场景素材', selector: '.capability-grid > .capability-card', manifestEntityKey: 'capabilities', entityCategory: 'SCENE', markerScopeSelector: '.capability-grid' }] }, knowledge: { datasets: [{ label: '知识库', selector: '.knowledge-base-card', manifestEntityKey: 'knowledgeBases', markerScopeSelector: '.knowledge-library-page' }] }, 'sensitive-words': { datasets: [{ label: '敏感词', selector: tableRows, manifestEntityKey: 'sensitiveWords', markerScopeSelector: '.words-panel' }] }, 'hot-words': { datasets: [{ label: '热词', selector: tableRows, manifestEntityKey: 'hotWords', markerScopeSelector: '.hot-word-panel' }] }, // This table is server-paginated by newest first for the same reason as the // overview queue. The dedicated jobs lifecycle test separately exercises // DEMO/created-job search, paging and detail persistence. jobs: { datasets: [{ label: '制作任务', selector: '.task-table-v2 tbody > tr' }] }, tools: { datasets: [{ label: '业务工具', selector: '.tool-grid > .tool-card', manifestEntityKey: 'integrationTools', markerScopeSelector: '.tool-grid' }] }, users: { datasets: [{ label: '用户', selector: '.users-table .el-table__body-wrapper tbody tr.el-table__row' }] }, departments: { datasets: [{ label: '部门', selector: '.department-panel .el-table__body-wrapper tbody tr.el-table__row' }] }, roles: { datasets: [{ label: '角色', selector: '.roles-table .el-table__body-wrapper tbody tr.el-table__row' }] }, permissions: { datasets: [ { label: '可配置角色', selector: '.role-selector > button' }, { label: '权限项', selector: '.permission-list > .permission-item' }, ], }, 'settings-general': { datasets: [{ label: '基础设置分区', selector: '.system-config-section', minItems: 3 }], values: [ { label: '平台名称', selector: 'input[placeholder="请输入系统名称"]', minimumLength: 2 }, { label: '平台简称', selector: 'input[placeholder="请输入系统简称"]', minimumLength: 2 }, ], }, 'settings-integrations': { datasets: [ { label: '三方配置状态', selector: '.integration-status-grid > article', minItems: 3 }, { label: '三方配置业务分区', selector: '.settings-section-card', minItems: 3 }, ], }, 'settings-menus': { datasets: [{ label: '菜单节点', selector: '.menu-name-cell' }] }, profile: { datasets: [{ label: '当前账号角色', selector: '.profile-role-list > span' }], values: [{ label: '个人中心账号信息', selector: '.profile-readonly-grid dd', minimumLength: 1 }], }, } function markerCandidates(manifest: DemoSeedManifest, entityKey: string, category?: string) { const entities = manifest.entities?.[entityKey] if (!Array.isArray(entities)) throw new Error(`演示数据清单缺少 entities.${entityKey}`) const selected = category ? entities.filter((item) => item.category === category) : entities const candidates = selected.flatMap((item) => [item.name, item.code, item.slug]) .map((item) => String(item || '').trim()) .filter(Boolean) if (!candidates.length) throw new Error(`演示数据清单 entities.${entityKey}${category ? `(${category})` : ''} 没有可用 name/code/slug`) return candidates } async function renderedCount(page: Page, selector: string) { return page.locator(selector).evaluateAll((elements) => elements.filter((element) => { const style = getComputedStyle(element) const bounds = element.getBoundingClientRect() return style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0 && element.getClientRects().length > 0 }).length) } export function hasNonEmptyAcceptance(route: string) { return Boolean(routeAcceptance[route]) } export async function expectRouteHasNonEmptyData(page: Page, route: string): Promise { const acceptance = routeAcceptance[route] if (!acceptance) throw new Error(`正式路由 ${route} 没有配置非空页面验收规则`) const needsManifest = Boolean(acceptance.datasets?.some((item) => item.manifestEntityKey)) const manifest = needsManifest ? demoSeedManifest() : undefined const evidence: NonEmptyEvidence = { route, seedId: manifest?.seedId || 'iam-or-static-config', datasets: [], values: [] } for (const requirement of acceptance.datasets || []) { const minimum = requirement.minItems ?? 1 await expect.poll( () => renderedCount(page, requirement.selector), { timeout: 30_000, message: `${route} / ${requirement.label} 必须显示至少 ${minimum} 条真实数据` }, ).toBeGreaterThanOrEqual(minimum) const count = await renderedCount(page, requirement.selector) let matchedMarker: string | undefined if (requirement.manifestEntityKey && manifest) { const candidates = markerCandidates(manifest, requirement.manifestEntityKey, requirement.entityCategory) const scope = page.locator(requirement.markerScopeSelector || 'main').first() await expect(scope, `${route} / ${requirement.label} 的数据容器没有渲染`).toBeVisible() await expect.poll(async () => { const content = await scope.innerText() matchedMarker = candidates.find((candidate) => content.includes(candidate)) return matchedMarker || '' }, { timeout: 30_000, message: `${route} / ${requirement.label} 必须展示 manifest 中的持久化演示数据,不能只依赖临时测试数据`, }).not.toBe('') } evidence.datasets.push({ label: requirement.label, selector: requirement.selector, count, manifestEntityKey: requirement.manifestEntityKey, matchedMarker, }) } for (const requirement of acceptance.values || []) { const locator = page.locator(requirement.selector).first() await expect(locator, `${route} / ${requirement.label} 控件没有渲染`).toBeVisible({ timeout: 30_000 }) await expect.poll(async () => { const raw = await locator.evaluate((element) => element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent || '') return raw.trim().length }, { timeout: 30_000, message: `${route} / ${requirement.label} 不允许为空`, }).toBeGreaterThanOrEqual(requirement.minimumLength ?? 1) const raw = await locator.evaluate((element) => element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.value : element.textContent || '') evidence.values.push({ label: requirement.label, selector: requirement.selector, valueLength: raw.trim().length }) } return evidence }