commit 3aa0ea4443c86eeda8aaeaf8ec305dc7162e5b95 Author: leiyun Date: Sun Aug 16 01:27:54 2026 +0800 test: 建立数字人全量 E2E 测试方案 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d3ecbcb --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# 本地 Web 地址;默认 http://127.0.0.1:8003 +BASE_URL=http://127.0.0.1:8003 + +# 默认读取 ape2e 同级的账号.MD。仅能指向本机私密文件,不要填写密码。 +E2E_ACCOUNT_FILE=../账号.MD + +# 可选:覆盖持久演示数据清单位置;逐页非空验收默认读取 API 项目的正式清单。 +# E2E_DEMO_SEED_MANIFEST=../ai_person_api/scripts/demo_seed_manifest.json + +# 可选:显式指定已配置远控 pepper 的真实 API。留空时全量 runner 会先探测 +# BASE_URL/本机 8001,若均不可用则仅为本批次启动并回收一个临时真实 API 进程。 +# E2E_RUNTIME_API_URL=http://127.0.0.1:8001 +# E2E_PYTHON_EXECUTABLE=python.exe + +# 默认 test:full 启动一次性 API + Web 子进程,使远控与敏感词导入密钥 +# 只存在于本次测试进程。仅在明确验收已部署环境时才复用现有服务。 +# E2E_USE_EXISTING_STACK=true +# 非空页面验收默认先幂等补齐 DEMO- 持久演示数据;只在调用者已经用同一 manifest +# 完成初始化时才关闭。关闭后页面缺少演示数据仍会导致视觉用例失败。 +# E2E_SEED_DEMO_DATA=false +# 可选:用同一隔离全栈只复测指定文件,多个文件用英文逗号分隔。 +# APE2E_TEST_FILES=tests/operations-ui-lifecycle.spec.ts + +# 可选:允许执行依赖外部数字人、FFmpeg 或安全测试地址的条件用例。 +# E2E_SAFE_TOOL_URL=https://example.invalid +# E2E_ALLOW_EXTERNAL_MEDIA=false +# E2E_ALLOW_BRAND_MUTATION=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53449bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +artifacts/runs/ +playwright-report/ +test-results/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d71f95b --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# AI Person E2E + +这是独立的 Playwright 真实联调工程。`test:full` 默认会为当前批次自动启动并回收隔离的临时 API 与 Web,Web 使用随机本机端口并代理到临时 API;测试仍连接真实开发数据库和真实本地/上游能力,不使用业务 Mock,也不会把《账号.MD》中的凭据复制到工程、日志或报告。 + +## 执行 + +确认 `ai_person_web`、`ai_person_api` 的依赖已经安装后,在本目录执行;无需提前手工启动 8001/8003: + +```powershell +pnpm install --frozen-lockfile +pnpm run install:browsers +pnpm run test:full +``` + +临时进程使用仅存在于本次子进程环境中的随机远控 Pepper 和敏感词导入签名密钥,运行结束后会被精确回收。确需复用已启动的联调服务时,可设置 `E2E_USE_EXISTING_STACK=true`;其他可选项见 `.env.example`。 + +在执行 Playwright 前,runner 会调用 API 项目的 `scripts/seed_demo_business_data.py --summary-json`,按 manifest 幂等补齐持久演示数据并校验演示媒体文件;摘要保存在本批次的 `demo-seed-summary.json`。除非调用者已经完成同一份初始化,否则不要关闭这一步。 + +每次完整运行会生成独立目录: + +```text +artifacts/runs// + screenshots/ + test-results/ + html-report/ + results.json + verification.json + 全量E2E测试报告.md +``` + +环境变量可写入本地 `.env`(已忽略),模板见 `.env.example`。账号默认从 `../账号.MD` 读取,仅支持 UTF-8 的“账号:…”与“密码:…”两项。 + +所有真实写入统一使用 `APE2E-` 标记,并在用例 `finally` 中逆序清理。全局菜单、品牌与基础配置必须快照后恢复;外部数字人、ASR/TTS、FFmpeg、第三方知识、实际终端等条件不满足时必须明确跳过,不能用 Mock 冒充联调成功。 + +逐页视觉验收不再只检查路由和标题:每个正式数据页必须先显示至少一条真实数据库行/卡片,并命中 `../ai_person_api/scripts/demo_seed_manifest.json` 中对应 `DEMO-` 演示实体的名称、编码或 slug,随后才允许生成成功截图。IAM、基础设置、菜单和个人中心也分别校验真实表格行、配置值或角色数据;空列表会直接使本批次失败。`DEMO-` 数据是开发环境的持久演示数据,不属于测试临时数据,后验收只清理 `APE2E-` 资源。 + +由于用例会填写真实密码、API Key 和一次性密钥,工程全局关闭 trace/video;成功和失败均保留全页截图,敏感输入区域由用例显式遮罩或在截图前清空。 + +`test:full` 在 Playwright 结束后还会自动执行后验收:扫描文本制品中的真实密码、Bearer/JWT 和测试临时密码,确认没有 trace/video,并通过正式 API 查询 IAM、配置、菜单与各业务资源中是否还存在 `APE2E` 标记数据。结果写入 `verification.json` 并汇总到 Markdown 报告。 diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..7175f7b --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "22.19.0" diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f948d3 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "ai-person-e2e", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "install:browsers": "playwright install chromium", + "typecheck": "tsc --noEmit", + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "test:full": "node scripts/run-full.mjs", + "verify": "node scripts/verify-run.mjs", + "report": "node scripts/generate-report.mjs", + "report:html": "playwright show-report artifacts/runs/manual/html-report" + }, + "devDependencies": { + "@playwright/test": "1.55.0", + "@types/node": "22.18.1", + "typescript": "5.9.3" + }, + "packageManager": "pnpm@10.32.1" +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..22f9a32 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test' +import fs from 'node:fs' +import path from 'node:path' + +function loadLocalEnvironment() { + const filename = process.env.E2E_ENV_FILE?.trim() || path.resolve('.env') + if (!fs.existsSync(filename)) return + for (const line of fs.readFileSync(filename, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/) + if (!match || process.env[match[1]!]) continue + const raw = match[2]! + process.env[match[1]!] = raw.length >= 2 + && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) + ? raw.slice(1, -1) + : raw + } +} + +loadLocalEnvironment() + +const safeRunId = (process.env.APE2E_RUN_ID || 'manual') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .slice(0, 80) || 'manual' +const runDirectory = path.resolve('artifacts', 'runs', safeRunId) +const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:8003').replace(/\/$/, '') + +export default defineConfig({ + testDir: './tests', + outputDir: path.join(runDirectory, 'test-results'), + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + timeout: 90_000, + expect: { timeout: 12_000 }, + reporter: [ + ['list'], + ['html', { outputFolder: path.join(runDirectory, 'html-report'), open: 'never' }], + ['json', { outputFile: path.join(runDirectory, 'results.json') }], + ], + use: { + baseURL, + locale: 'zh-CN', + timezoneId: 'Asia/Shanghai', + actionTimeout: 12_000, + navigationTimeout: 30_000, + screenshot: 'off', + // 测试使用真实密码、API Key 与一次性密钥;不生成可能包含输入快照的 trace/video。 + // 成功与失败场景都由 fixtures.ts 生成经过遮罩控制的截图。 + trace: 'off', + video: 'off', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..ed10230 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,77 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.55.0 + version: 1.55.0 + '@types/node': + specifier: 22.18.1 + version: 22.18.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + +packages: + + '@playwright/test@1.55.0': + resolution: {integrity: sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==} + engines: {node: '>=18'} + hasBin: true + + '@types/node@22.18.1': + resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.55.0: + resolution: {integrity: sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.0: + resolution: {integrity: sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==} + engines: {node: '>=18'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@playwright/test@1.55.0': + dependencies: + playwright: 1.55.0 + + '@types/node@22.18.1': + dependencies: + undici-types: 6.21.0 + + fsevents@2.3.2: + optional: true + + playwright-core@1.55.0: {} + + playwright@1.55.0: + dependencies: + playwright-core: 1.55.0 + optionalDependencies: + fsevents: 2.3.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/scripts/generate-report.mjs b/scripts/generate-report.mjs new file mode 100644 index 0000000..39743ec --- /dev/null +++ b/scripts/generate-report.mjs @@ -0,0 +1,203 @@ +import fs from 'node:fs' +import path from 'node:path' + +const runId = (process.env.APE2E_RUN_ID || 'manual').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'manual' +const runDirectory = path.resolve('artifacts', 'runs', runId) +const resultsFile = path.join(runDirectory, 'results.json') +const verificationFile = path.join(runDirectory, 'verification.json') +const demoSeedSummaryFile = path.join(runDirectory, 'demo-seed-summary.json') +const reportFile = path.join(runDirectory, '全量E2E测试报告.md') + +const redact = (value) => String(value ?? '') + .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer ') + .replace(/(?:access|refresh|session|api)?token["'=: ]+[A-Za-z0-9._~-]+/gi, 'token=') + .replace(/password["'=: ]+[^\s,;}]+/gi, 'password=') + .replace(/eyJ[A-Za-z0-9._~-]+/g, '') + +const collectSpecs = (suites, ancestors = []) => { + const rows = [] + for (const suite of suites || []) { + const next = [...ancestors, suite.title].filter(Boolean) + for (const spec of suite.specs || []) { + for (const test of spec.tests || []) { + const results = test.results || [] + const last = results.at(-1) || {} + const status = last.status || test.status || 'unknown' + const attachments = results.flatMap((result) => result.attachments || []) + rows.push({ title: [...next, spec.title].filter(Boolean).join(' / '), status, duration: results.reduce((sum, item) => sum + (item.duration || 0), 0), attachments, errors: results.flatMap((item) => item.errors || []) }) + } + } + rows.push(...collectSpecs(suite.suites, next)) + } + return rows +} + +const decodeJsonAttachment = (attachment) => { + if (attachment?.contentType !== 'application/json' || !attachment.body) return null + try { return JSON.parse(Buffer.from(attachment.body, 'base64').toString('utf8')) } catch { return null } +} + +let payload = { suites: [], stats: {} } +let parseError = '' +try { payload = JSON.parse(fs.readFileSync(resultsFile, 'utf8')) } catch (error) { parseError = redact(error) } +let verification = null +try { verification = JSON.parse(fs.readFileSync(verificationFile, 'utf8')) } catch { verification = null } +let demoSeedSummary = null +try { demoSeedSummary = JSON.parse(fs.readFileSync(demoSeedSummaryFile, 'utf8')) } catch { demoSeedSummary = null } +const rows = collectSpecs(payload.suites) +const counts = { passed: 0, failed: 0, skipped: 0, timedOut: 0, interrupted: 0, unknown: 0 } +for (const row of rows) counts[row.status] = (counts[row.status] || 0) + 1 +const activityRows = [] +const nonEmptyRows = [] +for (const row of rows) { + for (const attachment of row.attachments) { + const decoded = decodeJsonAttachment(attachment) + if (!decoded) continue + if (Array.isArray(decoded.activities)) { + for (const activity of decoded.activities) { + activityRows.push({ + test: row.title, + module: activity.module || decoded.title || attachment.name, + action: activity.action || '未命名步骤', + result: activity.result || 'UNKNOWN', + status: activity.httpStatus ?? '', + detail: activity.detail || '', + }) + } + } + if (decoded.route && Array.isArray(decoded.datasets)) { + nonEmptyRows.push({ + test: row.title, + route: decoded.route, + seedId: decoded.seedId || '', + datasets: decoded.datasets, + values: Array.isArray(decoded.values) ? decoded.values : [], + }) + } + } +} +const activityCounts = activityRows.reduce((result, item) => { + const key = String(item.result).toUpperCase() + result[key] = (result[key] || 0) + 1 + return result +}, {}) + +const screenshots = fs.existsSync(path.join(runDirectory, 'screenshots')) + ? fs.readdirSync(path.join(runDirectory, 'screenshots')).filter((name) => name.endsWith('.png')).sort() + : [] +const now = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full', timeStyle: 'long', timeZone: 'Asia/Shanghai' }).format(new Date()) +const lines = [ + '# AI Person 全量 E2E 测试报告', '', + `- 批次:\`${runId}\``, + `- 生成时间:${now}`, + `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``, + '- 浏览器:Playwright Chromium(单 worker,真实 API/数据库;不使用业务 Mock)', + '- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '', + '## 结果汇总', '', + '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |', + '|---:|---:|---:|---:|---:|---:|', + `| ${rows.length} | ${counts.passed || 0} | ${counts.failed || 0} | ${counts.skipped || 0} | ${(counts.timedOut || 0) + (counts.interrupted || 0)} | ${screenshots.length} |`, '', + '## 用例明细', '', + '| # | 状态 | 用例 | 耗时 |', '|---:|---|---|---:|', +] +rows.forEach((row, index) => lines.push(`| ${index + 1} | ${row.status} | ${redact(row.title).replace(/\|/g, '\\|')} | ${(row.duration / 1000).toFixed(2)}s |`)) +if (demoSeedSummary) { + const createdTotal = Number(demoSeedSummary.createdTotal ?? 0) + const existingTotal = Object.values(demoSeedSummary.existing || {}).reduce((sum, value) => sum + Number(value || 0), 0) + const restoredTotal = Object.values(demoSeedSummary.restored || {}).reduce((sum, value) => sum + Number(value || 0), 0) + lines.push('', '## 持久演示数据', '', + `- Seed:\`${redact(demoSeedSummary.seedId || 'unknown')}\``, + `- 本批新建 ${createdTotal} 条,复用 ${existingTotal} 条,恢复 ${restoredTotal} 条;数据来自正式数据库,不属于 \`APE2E\` 临时资源。`, + '- 运行摘要:[`demo-seed-summary.json`](demo-seed-summary.json)', '') +} +if (nonEmptyRows.length) { + lines.push('', '## 非空页面验收', '', + '成功截图只会在对应页面的真实行/卡片、配置值及 manifest 演示标记全部通过后生成。', '', + '| # | 路由 | 数据集 | 数量 | 演示标记 |', + '|---:|---|---|---:|---|') + let index = 0 + for (const item of nonEmptyRows) { + for (const dataset of item.datasets) { + index += 1 + lines.push(`| ${index} | ${redact(item.route)} | ${redact(dataset.label)} | ${Number(dataset.count || 0)} | ${redact(dataset.matchedMarker || item.seedId || '真实配置数据').replace(/\|/g, '\\|')} |`) + } + for (const value of item.values) { + index += 1 + lines.push(`| ${index} | ${redact(item.route)} | ${redact(value.label)} | ${Number(value.valueLength || 0)} | 已加载配置值 |`) + } + } + lines.push('') +} +if (activityRows.length) { + const activitySummary = Object.entries(activityCounts) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key} ${value}`) + .join('、') + lines.push('', '## 真实数据提交明细', '', + `共记录 ${activityRows.length} 个真实 API/数据库步骤:${activitySummary || '无'}。`, '', + '| # | 模块 | 操作 | 结果 | HTTP | 说明 |', + '|---:|---|---|---|---:|---|') + activityRows.forEach((item, index) => lines.push(`| ${index + 1} | ${redact(item.module).replace(/\|/g, '\\|')} | ${redact(item.action).replace(/\|/g, '\\|')} | ${redact(item.result)} | ${redact(item.status)} | ${redact(item.detail).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|').slice(0, 240)} |`)) +} +lines.push('', '## 数据提交与清理约定', '', + '- 测试创建数据统一使用 `APE2E-` 标记;写入后会重新读取或刷新页面确认持久化。', + '- 可逆全局配置先快照、测试后恢复;资源按依赖逆序清理。', + '- 外部数字人、ASR/TTS、FFmpeg、第三方知识或真实终端若环境不满足,会明确标记 skipped,不以 Mock 代替通过。', + '- 密码、访问令牌、API Key、终端密钥和一次性临时密码不得出现在截图或附件中。', '') + +if (verification) { + const safety = verification.artifactSafety || {} + const sweep = verification.resourceSweep || {} + lines.push('## 执行后验收', '', + `- 结论:**${verification.passed ? 'PASS' : 'FAIL'}**`, + `- 制品安全扫描:文本文件 ${safety.scannedTextFiles ?? 0} 个;真实密码命中 ${safety.passwordLiteralOccurrences ?? 0};Bearer/JWT 候选 ${(safety.bearerCandidates ?? 0) + (safety.jwtCandidates ?? 0)};临时密码候选 ${safety.generatedPasswordCandidates ?? 0};trace/video ${safety.traceOrVideoFiles ?? 0}。`, + `- 数据残留扫描:正式 API 端点 ${sweep.checkedEndpoints ?? 0} 个;异常或残留端点 ${sweep.failedOrResidualEndpoints ?? 0};\`APE2E\` 标记命中 ${sweep.totalMarkerOccurrences ?? 0}。`, '') + const endpointFailures = Array.isArray(sweep.endpoints) + ? sweep.endpoints.filter((item) => item.status !== 200 || item.markerOccurrences !== 0) + : [] + if (endpointFailures.length) { + lines.push('### 后验收异常', '') + for (const item of endpointFailures) { + lines.push(`- \`${redact(item.endpoint)}\`:HTTP ${redact(item.status)},标记命中 ${redact(item.markerOccurrences)}。`) + } + lines.push('') + } +} + +const cleanupFailures = activityRows.filter((item) => String(item.result).toUpperCase() === 'CLEANUP_FAILED') +if (cleanupFailures.length) { + lines.push('## 数据清理告警', '', + `发现 ${cleanupFailures.length} 个清理失败步骤;本批次不得视为“无测试数据残留”。`, '') + for (const item of cleanupFailures) { + lines.push(`- **${redact(item.module)} / ${redact(item.action)}**:${redact(item.detail).replace(/\r?\n/g, ' ').slice(0, 500)}`) + } + lines.push('') +} + +if (screenshots.length) { + lines.push('## 截图索引', '') + for (const name of screenshots) lines.push(`- [${name}](screenshots/${encodeURIComponent(name)})`) + lines.push('') +} +const failed = rows.filter((row) => !['passed', 'skipped'].includes(row.status)) +if (failed.length || parseError) { + lines.push('## 失败与诊断', '') + if (parseError) lines.push(`- 结果文件读取失败:${parseError}`) + for (const row of failed) { + lines.push(`### ${redact(row.title)}`, '') + const message = row.errors.map((item) => redact(item.message || item.stack || item)).join('\n').slice(0, 4000) + lines.push('```text', message || `status=${row.status}`, '```', '') + } +} + +lines.push('## 制品', '', + '- HTML 报告:[`html-report/index.html`](html-report/index.html)', + '- JSON 结果:[`results.json`](results.json)', + ...(demoSeedSummary ? ['- 持久演示数据摘要:[`demo-seed-summary.json`](demo-seed-summary.json)'] : []), + '- 后验收结果:[`verification.json`](verification.json)', + '- 原始失败附件:[`test-results`](test-results/)', + '- 安全说明:真实凭据用例全局关闭 trace/video,避免保存密码、Token 或一次性密钥快照。', '') +fs.mkdirSync(runDirectory, { recursive: true }) +fs.writeFileSync(reportFile, `${lines.join('\n')}\n`, 'utf8') +console.log(`Markdown report: ${reportFile}`) diff --git a/scripts/run-full.mjs b/scripts/run-full.mjs new file mode 100644 index 0000000..cb951f5 --- /dev/null +++ b/scripts/run-full.mjs @@ -0,0 +1,302 @@ +import { randomBytes } from 'node:crypto' +import { spawn, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import path from 'node:path' + +const requested = process.env.APE2E_RUN_ID?.trim() +const runId = (requested || new Date().toISOString().replace(/[:.]/g, '-')) + .replace(/[^A-Za-z0-9._-]+/g, '-') + .slice(0, 80) +const runDirectory = path.resolve('artifacts', 'runs', runId) +if (!runDirectory.startsWith(path.resolve('artifacts', 'runs') + path.sep)) throw new Error('非法运行目录') +fs.rmSync(runDirectory, { recursive: true, force: true }) +fs.mkdirSync(runDirectory, { recursive: true }) + +const env = { ...process.env, APE2E_RUN_ID: runId } +const packageRunner = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +const execute = (args) => { + // Windows 不能通过 CreateProcess 直接执行 .cmd;这些参数均由脚本内固定分支产生, + // 因此只在 Windows 上交给系统命令解释器启动 pnpm。 + const result = spawnSync(packageRunner, args, { + env, + stdio: 'inherit', + shell: process.platform === 'win32', + }) + if (result.error) console.error(`命令启动失败:${result.error.message}`) + return result +} + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)) + +function seedDurableDemoData() { + if (env.E2E_SEED_DEMO_DATA === 'false') { + console.log('持久演示数据:调用者已显式跳过自动补齐') + return + } + const apiDirectory = path.resolve('..', 'ai_person_api') + const seedScript = path.join(apiDirectory, 'scripts', 'seed_demo_business_data.py') + if (!fs.existsSync(seedScript)) throw new Error(`持久演示数据脚本不存在:${seedScript}`) + const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3') + const result = spawnSync(python, [seedScript, '--summary-json'], { + cwd: apiDirectory, + env: process.env, + encoding: 'utf8', + windowsHide: true, + }) + if (result.error) throw new Error(`持久演示数据脚本启动失败:${result.error.message}`) + if (result.status !== 0) { + const detail = String(result.stderr || result.stdout || '').trim().slice(0, 2_000) + throw new Error(`持久演示数据补齐失败(exit ${result.status}):${detail}`) + } + const summary = String(result.stdout || '').trim() + if (!summary) throw new Error('持久演示数据脚本没有返回验收摘要') + fs.writeFileSync(path.join(runDirectory, 'demo-seed-summary.json'), `${summary}\n`, 'utf8') + console.log('持久演示数据:已按 manifest 幂等补齐并完成素材校验') +} + +async function probeRuntimeApi(baseURL, timeoutMs = 3_000) { + try { + const response = await fetch(`${baseURL.replace(/\/$/, '')}/open/v1/remote-terminals/ape2e-runtime-probe/heartbeat`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ runtimeVersion: 'APE2E-PROBE' }), + signal: AbortSignal.timeout(timeoutMs), + }) + // 未知终端 + 已配置 pepper 必须走恒定时间鉴权并返回 401。 + // pepper 缺失时为 503;Vite 未代理 /open 时通常返回 SPA 的 200 HTML。 + return response.status === 401 + } catch { + return false + } +} + +async function nextFreePort() { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + reject(new Error('无法分配临时远控 API 端口')) + return + } + server.close((error) => error ? reject(error) : resolve(address.port)) + }) + }) +} + +async function waitForTemporaryRuntime(runtime, baseURL, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (runtime.startError) throw runtime.startError + if (runtime.child.exitCode !== null || runtime.child.signalCode !== null) { + throw new Error(`临时远控 API 提前退出:${runtime.child.exitCode ?? runtime.child.signalCode}`) + } + try { + const health = await fetch(`${baseURL}/api/v1/health`, { signal: AbortSignal.timeout(2_000) }) + if (health.ok && await probeRuntimeApi(baseURL, 2_000)) return + } catch { + // Uvicorn 和数据库连接仍在启动,短暂等待后重试。 + } + await delay(250) + } + throw new Error('临时远控 API 在 30 秒内未就绪') +} + +async function startTemporaryRuntimeApi() { + const apiDirectory = path.resolve('..', 'ai_person_api') + const appFile = path.join(apiDirectory, 'app', 'main.py') + if (!fs.existsSync(appFile)) throw new Error(`无法启动临时远控 API:不存在 ${appFile}`) + + const port = await nextFreePort() + const baseURL = `http://127.0.0.1:${port}` + const python = env.E2E_PYTHON_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'python.exe' : 'python3') + const childEnv = { + ...process.env, + // 只存在于子进程环境;不写文件、不打印,也不传递给报告生成器。 + DH_REMOTE_TERMINAL_PEPPER: randomBytes(48).toString('base64url'), + DH_OPERATIONS_IMPORT_SECRET: randomBytes(48).toString('base64url'), + } + const child = spawn(python, [ + '-m', 'uvicorn', 'app.main:app', + '--host', '127.0.0.1', + '--port', String(port), + '--app-dir', apiDirectory, + '--log-level', 'warning', + ], { + cwd: apiDirectory, + env: childEnv, + stdio: 'ignore', + windowsHide: true, + detached: false, + }) + const runtime = { child, baseURL, startError: null } + child.once('error', (error) => { runtime.startError = error }) + await waitForTemporaryRuntime(runtime, baseURL) + console.log(`远控运行 API:已启动本次测试专用进程(${baseURL},PID ${child.pid})`) + return runtime +} + +async function waitForTemporaryWeb(web, baseURL, timeoutMs = 45_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (web.startError) throw web.startError + if (web.child.exitCode !== null || web.child.signalCode !== null) { + throw new Error(`临时 Web 提前退出:${web.child.exitCode ?? web.child.signalCode}`) + } + try { + const login = await fetch(`${baseURL}/login`, { signal: AbortSignal.timeout(2_000) }) + const config = await fetch(`${baseURL}/api/auth/v1/system-config/public`, { signal: AbortSignal.timeout(2_000) }) + if (login.ok && config.ok) return + } catch { + // Vite 依赖预构建或 API 代理仍在启动,短暂等待后重试。 + } + await delay(250) + } + throw new Error('临时 Web 在 45 秒内未就绪') +} + +async function startTemporaryWeb(apiBaseURL) { + const webDirectory = path.resolve('..', 'ai_person_web') + const viteEntry = path.join(webDirectory, 'node_modules', 'vite', 'bin', 'vite.js') + if (!fs.existsSync(viteEntry)) throw new Error(`无法启动临时 Web:不存在 ${viteEntry}`) + + const port = await nextFreePort() + const baseURL = `http://127.0.0.1:${port}` + const child = spawn(process.execPath, [ + viteEntry, + '--host', '127.0.0.1', + '--port', String(port), + '--strictPort', + ], { + cwd: webDirectory, + env: { + ...process.env, + AI_PERSON_API_PROXY: apiBaseURL, + }, + stdio: 'ignore', + windowsHide: true, + detached: false, + }) + const web = { child, baseURL, startError: null } + child.once('error', (error) => { web.startError = error }) + await waitForTemporaryWeb(web, baseURL) + console.log(`E2E Web:已启动本次测试专用进程(${baseURL},PID ${child.pid})`) + return web +} + +async function stopTemporaryRuntimeApi(runtime) { + if (!runtime || runtime.child.exitCode !== null || runtime.child.signalCode !== null) return + const exited = new Promise((resolve) => runtime.child.once('exit', resolve)) + runtime.child.kill('SIGTERM') + await Promise.race([exited, delay(5_000)]) + if (runtime.child.exitCode === null && runtime.child.signalCode === null) { + runtime.child.kill('SIGKILL') + await Promise.race([exited, delay(5_000)]) + } + if (runtime.child.exitCode === null && runtime.child.signalCode === null) { + throw new Error(`无法停止本次测试启动的临时远控 API(PID ${runtime.child.pid})`) + } + console.log(`远控运行 API:已停止本次测试专用进程(PID ${runtime.child.pid})`) +} + +async function stopTemporaryWeb(web) { + if (!web || web.child.exitCode !== null || web.child.signalCode !== null) return + const exited = new Promise((resolve) => web.child.once('exit', resolve)) + web.child.kill('SIGTERM') + await Promise.race([exited, delay(5_000)]) + if (web.child.exitCode === null && web.child.signalCode === null) { + web.child.kill('SIGKILL') + await Promise.race([exited, delay(5_000)]) + } + if (web.child.exitCode === null && web.child.signalCode === null) { + throw new Error(`无法停止本次测试启动的临时 Web(PID ${web.child.pid})`) + } + console.log(`E2E Web:已停止本次测试专用进程(PID ${web.child.pid})`) +} + +async function prepareRuntimeApi() { + const configured = env.E2E_RUNTIME_API_URL?.trim() + if (configured) { + env.E2E_RUNTIME_API_URL = configured.replace(/\/$/, '') + console.log('远控运行 API:使用调用者显式配置的 E2E_RUNTIME_API_URL') + return null + } + + const pageBaseURL = (env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '') + const candidates = [...new Set([pageBaseURL, 'http://127.0.0.1:8001'])] + for (const candidate of candidates) { + if (await probeRuntimeApi(candidate)) { + env.E2E_RUNTIME_API_URL = candidate + console.log(`远控运行 API:使用已就绪服务 ${candidate}`) + return null + } + } + + const runtime = await startTemporaryRuntimeApi() + env.E2E_RUNTIME_API_URL = runtime.baseURL + return runtime +} + +async function prepareTestStack() { + if (env.E2E_USE_EXISTING_STACK === 'true') { + return { runtime: await prepareRuntimeApi(), web: null } + } + + const runtime = await startTemporaryRuntimeApi() + try { + const web = await startTemporaryWeb(runtime.baseURL) + env.E2E_RUNTIME_API_URL = runtime.baseURL + env.BASE_URL = web.baseURL + return { runtime, web } + } catch (error) { + await stopTemporaryRuntimeApi(runtime) + throw error + } +} + +console.log(`APE2E runId: ${runId}`) +const typecheck = execute(['exec', 'tsc', '--noEmit']) +let tests = { status: typecheck.status ?? 1 } +let runtime = null +let web = null +let runtimeLifecycleStatus = 0 + +if (typecheck.status === 0) { + try { + const stack = await prepareTestStack() + runtime = stack.runtime + web = stack.web + seedDurableDemoData() + const requestedFiles = (env.APE2E_TEST_FILES || '') + .split(',') + .map((item) => item.trim().replaceAll('\\', '/')) + .filter(Boolean) + if (requestedFiles.some((item) => !/^tests\/[A-Za-z0-9._/-]+\.spec\.ts$/.test(item))) { + throw new Error('APE2E_TEST_FILES 只能包含 tests/ 下以 .spec.ts 结尾的相对路径') + } + const selectedFiles = env.APE2E_REMOTE_RUNTIME_ONLY === 'true' + ? ['tests/remote-terminal-runtime.spec.ts'] + : requestedFiles + const playwrightArgs = ['exec', 'playwright', 'test', ...selectedFiles, '--project=chromium'] + tests = execute(playwrightArgs) + } catch (error) { + runtimeLifecycleStatus = 1 + console.error(`E2E 临时运行环境准备失败:${error instanceof Error ? error.message : String(error)}`) + } +} + +const verification = spawnSync(process.execPath, ['scripts/verify-run.mjs'], { env, stdio: 'inherit' }) +const report = spawnSync(process.execPath, ['scripts/generate-report.mjs'], { env, stdio: 'inherit' }) +if (report.status !== 0) console.error('Markdown 报告生成失败') +try { + await stopTemporaryWeb(web) + await stopTemporaryRuntimeApi(runtime) +} catch (error) { + runtimeLifecycleStatus = 1 + console.error(`E2E 临时运行环境清理失败:${error instanceof Error ? error.message : String(error)}`) +} +process.exitCode = tests.status || runtimeLifecycleStatus || verification.status || report.status || 0 diff --git a/scripts/verify-run.mjs b/scripts/verify-run.mjs new file mode 100644 index 0000000..26cfa5d --- /dev/null +++ b/scripts/verify-run.mjs @@ -0,0 +1,141 @@ +import fs from 'node:fs' +import path from 'node:path' + +const runId = (process.env.APE2E_RUN_ID || 'manual').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'manual' +const runDirectory = path.resolve('artifacts', 'runs', runId) +const outputFile = path.join(runDirectory, 'verification.json') +const baseURL = (process.env.BASE_URL || 'http://127.0.0.1:8003').replace(/\/$/, '') +const accountFile = path.resolve(process.env.E2E_ACCOUNT_FILE || '..', process.env.E2E_ACCOUNT_FILE ? '' : '账号.MD') + +function readCredentials() { + const content = fs.readFileSync(accountFile, 'utf8') + const username = content.match(/^\s*账号\s*[::]\s*(.+?)\s*$/m)?.[1]?.trim() + const password = content.match(/^\s*密码\s*[::]\s*(.+?)\s*$/m)?.[1]?.trim() + if (!username || !password) throw new Error('账号文件缺少“账号”或“密码”字段') + return { username, password } +} + +async function requestText(url, options = {}) { + const response = await fetch(`${baseURL}${url}`, { + ...options, + signal: AbortSignal.timeout(30_000), + }) + return { response, text: await response.text() } +} + +function walkFiles(directory) { + if (!fs.existsSync(directory)) return [] + const result = [] + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name) + if (entry.isDirectory()) result.push(...walkFiles(filename)) + else if (entry.isFile()) result.push(filename) + } + return result +} + +function occurrenceCount(value, pattern) { + return [...value.matchAll(pattern)].length +} + +async function main() { + fs.mkdirSync(runDirectory, { recursive: true }) + const { username, password } = readCredentials() + const login = await requestText('/api/auth/v1/auth/login', { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ roleCode: 'admin', username, password, rememberMe: false }), + }) + if (!login.response.ok) throw new Error(`管理员后验收登录失败:HTTP ${login.response.status}`) + const loginBody = JSON.parse(login.text) + const token = loginBody?.data?.accessToken || loginBody?.data?.tokens?.accessToken + if (!token) throw new Error('管理员后验收登录未返回 accessToken') + + const marker = 'APE2E' + const keyword = encodeURIComponent(marker) + const endpoints = [ + `/api/auth/v1/users?keyword=${keyword}&page=1&size=100`, + '/api/auth/v1/departments/tree', + '/api/auth/v1/roles/all', + '/api/auth/v1/menus/tree', + '/api/auth/v1/system-config', + ...['LLM', 'TTS', 'ASR', 'VOICE_CLONE', 'SCENE'].map((type) => `/api/v1/capabilities?capabilityType=${type}&keyword=${keyword}&page=1&pageSize=100`), + `/api/v1/avatars?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/knowledge/bases?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/knowledge/documents?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/agent-projects?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/realtime-agents?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/sensitive-words?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/wake-words?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/hot-words?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/remote-terminals?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/tools?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/video-projects?keyword=${keyword}&page=1&pageSize=100`, + `/api/v1/videos?keyword=${keyword}&page=1&pageSize=100`, + '/api/v1/video-folders', + ] + const endpointResults = [] + for (const endpoint of endpoints) { + try { + const result = await requestText(endpoint, { headers: { authorization: `Bearer ${token}` } }) + endpointResults.push({ + endpoint: endpoint.split('?')[0], + status: result.response.status, + markerOccurrences: occurrenceCount(result.text, /APE2E/gi), + }) + } catch (error) { + endpointResults.push({ endpoint: endpoint.split('?')[0], status: 0, markerOccurrences: -1, error: String(error?.message || error).slice(0, 300) }) + } + } + + const textExtensions = new Set(['.md', '.json', '.html', '.txt']) + const textFiles = walkFiles(runDirectory).filter((filename) => textExtensions.has(path.extname(filename).toLowerCase()) && filename !== outputFile) + const artifactSafety = { + scannedTextFiles: textFiles.length, + passwordLiteralFiles: 0, + passwordLiteralOccurrences: 0, + bearerCandidates: 0, + jwtCandidates: 0, + generatedPasswordCandidates: 0, + traceOrVideoFiles: walkFiles(runDirectory).filter((filename) => ['.zip', '.webm'].includes(path.extname(filename).toLowerCase())).length, + } + for (const filename of textFiles) { + const value = fs.readFileSync(filename, 'utf8') + const literalMatches = value.split(password).length - 1 + if (literalMatches > 0) artifactSafety.passwordLiteralFiles += 1 + artifactSafety.passwordLiteralOccurrences += literalMatches + artifactSafety.bearerCandidates += occurrenceCount(value, /Bearer\s+[A-Za-z0-9._~-]{20,}/gi) + artifactSafety.jwtCandidates += occurrenceCount(value, /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g) + artifactSafety.generatedPasswordCandidates += occurrenceCount(value, /(?:Init|Final|Reset)@[A-Za-z0-9!@#$%^&*._~-]{4,}/g) + } + + const endpointFailures = endpointResults.filter((item) => item.status !== 200 || item.markerOccurrences !== 0) + const artifactLeaks = artifactSafety.passwordLiteralOccurrences + + artifactSafety.bearerCandidates + + artifactSafety.jwtCandidates + + artifactSafety.generatedPasswordCandidates + + artifactSafety.traceOrVideoFiles + const verification = { + runId, + checkedAt: new Date().toISOString(), + passed: endpointFailures.length === 0 && artifactLeaks === 0, + artifactSafety, + resourceSweep: { + marker, + checkedEndpoints: endpointResults.length, + failedOrResidualEndpoints: endpointFailures.length, + totalMarkerOccurrences: endpointResults.reduce((sum, item) => sum + Math.max(0, item.markerOccurrences), 0), + endpoints: endpointResults, + }, + } + fs.writeFileSync(outputFile, `${JSON.stringify(verification, null, 2)}\n`, 'utf8') + console.log(`Post-run verification: ${verification.passed ? 'PASS' : 'FAIL'}; endpoints=${endpointResults.length}; residual=${verification.resourceSweep.totalMarkerOccurrences}; leaks=${artifactLeaks}`) + if (!verification.passed) process.exitCode = 1 +} + +main().catch((error) => { + fs.mkdirSync(runDirectory, { recursive: true }) + fs.writeFileSync(outputFile, `${JSON.stringify({ runId, passed: false, error: String(error?.message || error).slice(0, 500) }, null, 2)}\n`, 'utf8') + console.error(`Post-run verification failed: ${String(error?.message || error)}`) + process.exitCode = 1 +}) diff --git a/tests/00-preflight.spec.ts b/tests/00-preflight.spec.ts new file mode 100644 index 0000000..fb9bf8c --- /dev/null +++ b/tests/00-preflight.spec.ts @@ -0,0 +1,39 @@ +import { captureScreenshot, expect, test } from './fixtures' + +const serviceChecks = [ + { name: 'Web 登录入口', path: '/login', contentType: /text\/html/i }, + { name: '公开系统配置接口', path: '/api/auth/v1/system-config/public', contentType: /application\/json/i }, + { name: 'API 健康检查', path: '/api/v1/health', contentType: /application\/json/i }, +] as const + +test('预检:Web、Web 代理与 API 健康检查均可用', async ({ request }) => { + for (const service of serviceChecks) { + await test.step(service.name, async () => { + const response = await request.get(service.path) + expect(response.ok(), `${service.name} 请求失败:HTTP ${response.status()}`).toBeTruthy() + expect(response.headers()['content-type'] ?? '', `${service.name} 返回类型异常`) + .toMatch(service.contentType) + }) + } +}) + +test('预检:登录页展示四种身份入口并截图', async ({ page }, testInfo) => { + await page.goto('/login') + await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible() + + const expectedRoles = [ + { code: 'administrative', label: '行政' }, + { code: 'teacher', label: '教员' }, + { code: 'student', label: '学员' }, + { code: 'admin', label: '管理员' }, + ] as const + + const roleButtons = page.locator('.login-role-options > button') + await expect(roleButtons).toHaveCount(expectedRoles.length) + for (const role of expectedRoles) { + await expect(page.locator(`[data-role-code="${role.code}"]`)).toBeVisible() + await expect(page.locator(`[data-role-code="${role.code}"]`)).toContainText(role.label) + } + + await captureScreenshot(page, testInfo, 'preflight-login-four-identities') +}) diff --git a/tests/agent-management-ui-lifecycle.spec.ts b/tests/agent-management-ui-lifecycle.spec.ts new file mode 100644 index 0000000..22fdb7f --- /dev/null +++ b/tests/agent-management-ui-lifecycle.spec.ts @@ -0,0 +1,332 @@ +import type { APIRequestContext, Locator, Page } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData, loginAsAdmin } from './helpers' + +type JsonRecord = Record +type Headers = Record + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' + ? value as JsonRecord + : {} + +async function getAgent(request: APIRequestContext, headers: Headers, agentId: string) { + const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + expect(response.status(), '智能体详情接口应返回成功').toBe(200) + return asRecord(envelopeData(await response.json())) +} + +async function saveThroughPage(page: Page, agentId: string) { + const responsePromise = page.waitForResponse((response) => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === `/api/v1/realtime-agents/${agentId}` + )) + const saveButton = page.getByRole('button', { name: '保存配置' }) + await expect(saveButton).toBeVisible() + await saveButton.click() + const response = await responsePromise + expect(response.status(), '页面保存智能体配置应返回成功').toBe(200) + await expect(page.locator('.el-message').filter({ hasText: /配置已保存/ }).last()).toBeVisible() +} + +async function reloadSectionAndCapture( + page: Page, + testInfo: Parameters[1], + tabName: string, + screenshotName: string, +) { + await page.reload() + const tabs = page.getByRole('navigation', { name: '配置分区' }) + await expect(tabs).toBeVisible() + await expect(tabs.getByRole('button', { name: new RegExp(tabName) })).toHaveClass(/is-active/) + await captureScreenshot(page, testInfo, screenshotName) +} + +async function cleanupAgent(request: APIRequestContext, headers: Headers, agentId: string) { + const detailResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + if (detailResponse.status() === 404) return + if (detailResponse.status() !== 200) return + let agent = asRecord(envelopeData(await detailResponse.json())) + if (String(agent.status) === 'published') { + const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, { + headers, + data: { dataVersion: Number(agent.dataVersion) }, + }) + if (disabled.status() === 200) agent = asRecord(envelopeData(await disabled.json())) + } + await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { + headers, + params: { dataVersion: String(Number(agent.dataVersion)) }, + }) +} + +function visibleDetail(page: Page) { + return page.locator('.detail-body:visible') +} + +async function selectFirstRadio(card: Locator) { + const label = card.locator('label:has(input[type="radio"])').first() + const radio = label.locator('input[type="radio"]') + await expect(radio).toBeVisible() + await radio.check() + return (await label.locator('b').first().textContent())?.trim() || '' +} + +test('智能体管理与六区配置全部通过页面提交、刷新持久化并完成发布生命周期', async ({ page, request }, testInfo) => { + test.setTimeout(360_000) + const headers = await authHeaders(request) + const name = `${runPrefix}-页面配置教员` + const identityPrompt = `你是${name},只依据装备检修知识回答,并在操作前提示停机、断电、验电和卸压。` + const description = '用于液压与电气检修教学的页面提交验证智能体' + const welcome = '您好,我是页面配置验证教员,请选择需要学习的检修项目。' + const fallback = '当前资料未覆盖该问题,请换一种问法或联系现场教员。' + const sensitiveReply = '该输入不符合教学规范,请重新描述装备检修问题。' + const suggestion = '液压系统卸压后应检查哪些项目?' + let agentId = '' + let deleted = false + + try { + await loginAsAdmin(page) + await page.goto('/agents/manage') + await expect(page.getByRole('heading', { name: '虚拟教员智能体' })).toBeVisible() + + await page.getByRole('button', { name: '新建智能体' }).click() + const createDialog = page.locator('.el-dialog:visible').filter({ hasText: '新建智能体' }) + await expect(createDialog).toBeVisible() + await createDialog.locator('input[placeholder*="装备维修"]').fill(name) + await createDialog.getByRole('button', { name: '90 天' }).click() + const expiryInput = createDialog.locator('input[type="date"]') + await expect(expiryInput).not.toHaveValue('') + await captureScreenshot(page, testInfo, 'agent-create-real-form-filled') + await createDialog.getByRole('button', { name: '创建', exact: true }).click() + + await page.waitForURL((url) => /^\/agents\/(?!manage(?:\/|$))[^/]+$/.test(url.pathname)) + agentId = new URL(page.url()).pathname.split('/').filter(Boolean).at(-1) || '' + expect(agentId, '页面新建成功后 URL 应包含智能体 ID').not.toBe('') + let persisted = await getAgent(request, headers, agentId) + expect(persisted.name).toBe(name) + expect(persisted.status).toBe('draft') + expect(persisted.expiresAt).toBeTruthy() + + // 数字人:必须从页面真实选择,保存后通过 API 和刷新后的 aria 状态双重核验。 + const tabs = page.getByRole('navigation', { name: '配置分区' }) + await tabs.getByRole('button', { name: /^数字人/ }).click() + const avatarOptions = visibleDetail(page).locator('.avatar-option') + await expect(avatarOptions.first(), '持久 DEMO 数据应至少提供一个可用数字人').toBeVisible() + const selectedAvatar = avatarOptions.nth(Math.min(1, await avatarOptions.count() - 1)) + const avatarName = (await selectedAvatar.locator('b').textContent())?.trim() || '' + if (await selectedAvatar.getAttribute('aria-pressed') !== 'true') await selectedAvatar.click() + await saveThroughPage(page, agentId) + persisted = await getAgent(request, headers, agentId) + expect((persisted.avatarIds as unknown[]).length).toBeGreaterThan(0) + expect(persisted.avatarName).toBe(avatarName) + await reloadSectionAndCapture(page, testInfo, '数字人', 'agent-avatar-saved-and-reloaded') + await expect(visibleDetail(page).locator('.avatar-option.is-selected').filter({ hasText: avatarName })).toBeVisible() + + // AI 内核:知识库、LLM、TTS、ASR、播报音色与速度全部经页面控件提交。 + await tabs.getByRole('button', { name: /^AI内核/ }).click() + const core = visibleDetail(page) + const coreCards = core.locator('.core-grid > .detail-card') + await expect(coreCards).toHaveCount(4) + const knowledgeCheckbox = coreCards.nth(0).locator('input[type="checkbox"]').first() + await expect(knowledgeCheckbox, '持久 DEMO 数据应至少提供一个知识库').toBeVisible() + await knowledgeCheckbox.check() + const llmName = await selectFirstRadio(coreCards.nth(1)) + const ttsName = await selectFirstRadio(coreCards.nth(2)) + const asrName = await selectFirstRadio(coreCards.nth(3)) + // 选择带真实样音的能力资产音色。新建草稿会回落显示离线引擎 speaker, + // 但只有能力资产音色才是当前正式持久化模型可引用的资源。 + const voiceOptions = core.locator('.voice-option') + const capabilityVoice = core.locator('.voice-option:has(.voice-preview-button:not(:disabled))').first() + await expect(capabilityVoice, '持久 DEMO 数据应至少提供一个带样音的可用播报音色').toBeVisible() + const voiceCheckbox = capabilityVoice.locator('input[type="checkbox"]') + await voiceCheckbox.check() + for (let index = 0; index < await voiceOptions.count(); index += 1) { + const option = voiceOptions.nth(index) + const checkbox = option.locator('input[type="checkbox"]') + const preview = option.locator('.voice-preview-button') + if (await preview.isDisabled() && await checkbox.isChecked()) await checkbox.uncheck() + } + await core.locator('.voice-speed-field input[type="range"]').fill('1.15') + await saveThroughPage(page, agentId) + persisted = await getAgent(request, headers, agentId) + expect((persisted.knowledgeBaseIds as unknown[]).length).toBeGreaterThan(0) + expect(persisted.llmModel).toBe(llmName) + expect(persisted.ttsModel).toBe(ttsName) + expect(persisted.asrModel).toBe(asrName) + expect((persisted.voiceSpeakers as unknown[]).length).toBeGreaterThan(0) + expect(Number(persisted.voiceSpeed)).toBeCloseTo(1.15) + await reloadSectionAndCapture(page, testInfo, 'AI内核', 'agent-core-saved-and-reloaded') + await expect(visibleDetail(page).locator('input[name="agent-llm"]:checked')).toHaveCount(1) + + // 身份设置。 + await tabs.getByRole('button', { name: /^身份设置/ }).click() + const identity = visibleDetail(page) + await identity.locator('.identity-editor textarea').fill(identityPrompt) + await identity.locator('input[placeholder*="一句话说明用途"]').fill(description) + await saveThroughPage(page, agentId) + persisted = await getAgent(request, headers, agentId) + expect(persisted.systemPrompt).toBe(identityPrompt) + expect(persisted.description).toBe(description) + await reloadSectionAndCapture(page, testInfo, '身份设置', 'agent-identity-saved-and-reloaded') + await expect(visibleDetail(page).locator('.identity-editor textarea')).toHaveValue(identityPrompt) + + // 场景外观:真实选择一项已入库场景素材,同时修改互动按钮与挂件位置。 + await tabs.getByRole('button', { name: /^场景外观/ }).click() + const scene = visibleDetail(page) + let sceneType = '' + for (const candidate of [ + { label: '背景图片', value: 'image' }, + { label: '背景视频', value: 'video' }, + { label: '网页背景', value: 'page' }, + ]) { + await scene.getByRole('button', { name: candidate.label }).click() + if (await scene.locator('.scene-asset').count()) { + sceneType = candidate.value + break + } + } + expect(sceneType, '持久 DEMO 数据应至少提供一项背景图片、视频或网页素材').not.toBe('') + await scene.locator('.scene-asset input[type="radio"]').first().check() + await scene.locator('.detail-card').filter({ hasText: '显示互动按钮' }).getByLabel('否', { exact: true }).check() + await scene.getByLabel('左上', { exact: true }).check() + await saveThroughPage(page, agentId) + persisted = await getAgent(request, headers, agentId) + expect(persisted.sceneBackgroundType).toBe(sceneType) + expect(persisted.sceneAssetId).toBeTruthy() + expect(persisted.showInteractionButtons).toBe(false) + expect(persisted.uiPosition).toBe('top-left') + await reloadSectionAndCapture(page, testInfo, '场景外观', 'agent-scene-saved-and-reloaded') + await expect(visibleDetail(page).locator('.scene-asset.is-selected')).toHaveCount(1) + + // 交互设置:三段话术、交互方式、屏保开关、时长和持久素材。 + await tabs.getByRole('button', { name: /^交互设置/ }).click() + const interaction = visibleDetail(page) + await interaction.locator('textarea[placeholder*="您好,我是装备维修"]').fill(welcome) + await interaction.locator('textarea[placeholder*="暂未找到相关资料"]').fill(fallback) + await interaction.locator('textarea[placeholder*="不适宜内容"]').fill(sensitiveReply) + const voiceMode = interaction.getByRole('button', { name: '语音提问' }) + if ((await voiceMode.getAttribute('class') || '').includes('is-active')) await voiceMode.click() + const screensaverSwitch = interaction.locator('.switch-row .el-switch') + if (await screensaverSwitch.getAttribute('aria-checked') !== 'true') await screensaverSwitch.click() + await interaction.locator('input[type="number"][min="10"]').fill('45') + const screensaver = interaction.locator('.screensaver-option').first() + await expect(screensaver, '持久 DEMO 场景素材应可直接用于屏保').toBeVisible() + await screensaver.click() + await saveThroughPage(page, agentId) + persisted = await getAgent(request, headers, agentId) + expect(persisted.welcomeMessage).toBe(welcome) + expect(persisted.fallbackMessage).toBe(fallback) + expect(persisted.sensitiveReply).toBe(sensitiveReply) + expect(persisted.interactionModes).toEqual(['text']) + expect(persisted.screensaverEnabled).toBe(true) + expect(Number(persisted.screensaverIdleSeconds)).toBe(45) + expect(persisted.screensaverUrl).toBeTruthy() + await reloadSectionAndCapture(page, testInfo, '交互设置', 'agent-interaction-saved-and-reloaded') + await expect(visibleDetail(page).locator('textarea[placeholder*="您好,我是装备维修"]')).toHaveValue(welcome) + + // UI 自定义:开关、颜色、尺寸、位置、推荐问题均通过页面控件修改。 + await tabs.getByRole('button', { name: /^UI自定义/ }).click() + const ui = visibleDetail(page) + const waveformRow = ui.locator('.ui-component-row').filter({ hasText: '语音波形' }) + await waveformRow.locator('input[type="checkbox"]').check() + const suggestionsRow = ui.locator('.ui-component-row').filter({ hasText: '推荐问题' }) + await suggestionsRow.locator('input[type="checkbox"]').check() + await ui.locator('.color-grid label').filter({ hasText: '主题色' }).locator('input[type="color"]').fill('#155e75') + await ui.locator('.size-grid label').filter({ hasText: '面板宽度' }).locator('input[type="number"]').fill('438') + await ui.locator('.size-grid label').filter({ hasText: '面板高度' }).locator('input[type="number"]').fill('688') + await ui.locator('.size-grid select').selectOption('top-left') + if (!await ui.locator('.suggestion-row input').count()) { + await ui.getByRole('button', { name: '添加推荐问题' }).click() + } + await ui.locator('.suggestion-row input').first().fill(suggestion) + await saveThroughPage(page, agentId) + persisted = await getAgent(request, headers, agentId) + const uiConfig = asRecord(persisted.uiConfig) + expect(uiConfig.themeColor).toBe('#155e75') + expect(uiConfig.panelWidth).toBe(438) + expect(uiConfig.panelHeight).toBe(688) + expect(uiConfig.panelPosition).toBe('top-left') + expect(uiConfig.suggestions).toContain(suggestion) + expect((uiConfig.components as JsonRecord[]).some((item) => item.key === 'waveform' && item.enabled === true)).toBeTruthy() + await reloadSectionAndCapture(page, testInfo, 'UI自定义', 'agent-ui-saved-and-reloaded') + await expect(visibleDetail(page).locator('.color-grid label').filter({ hasText: '主题色' }).locator('input[type="color"]')).toHaveValue('#155e75') + + // 回到管理页:真实筛选、延期、接入信息、发布、停用、换密钥、删除。 + await page.getByRole('button', { name: '返回智能体列表' }).click() + await expect(page).toHaveURL(/\/agents\/manage/) + const keyword = page.locator('.agent-search input') + await keyword.fill(name) + await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('draft') + const row = page.locator(`[data-testid="agent-row-${agentId}"]`) + await expect(row).toBeVisible() + await captureScreenshot(page, testInfo, 'agent-list-filtered-draft-with-real-data') + + const beforeExtension = new Date(String((await getAgent(request, headers, agentId)).expiresAt)).getTime() + await row.getByRole('button', { name: '设置有效期' }).click() + await page.locator('.el-dropdown-menu:visible').getByText('+30 天', { exact: true }).click() + await expect(page.locator('.el-message').filter({ hasText: '有效期已延长' }).last()).toBeVisible() + const afterExtension = new Date(String((await getAgent(request, headers, agentId)).expiresAt)).getTime() + expect(afterExtension - beforeExtension).toBeGreaterThan(29 * 86_400_000) + + await row.getByRole('button', { name: '接入信息' }).click() + const accessDialog = page.locator('.el-dialog:visible').filter({ hasText: `接入信息 · ${name}` }) + await expect(accessDialog).toBeVisible() + await expect(accessDialog).toContainText('独立页面地址') + const accessCodeBlocks = accessDialog.locator('pre code') + await expect(accessCodeBlocks).toHaveCount(3) + await expect(accessCodeBlocks.first()).not.toHaveText('') + await captureScreenshot(page, testInfo, 'agent-access-information-nonempty') + await accessDialog.locator('.el-dialog__headerbtn').click() + + await row.getByRole('button', { name: '发布', exact: true }).click() + await expect(row).toHaveCount(0) + expect((await getAgent(request, headers, agentId)).status).toBe('published') + await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('published') + await expect(row).toBeVisible() + await captureScreenshot(page, testInfo, 'agent-published-through-list-ui') + + await row.getByRole('button', { name: '停用', exact: true }).click() + await expect(row).toHaveCount(0) + expect((await getAgent(request, headers, agentId)).status).toBe('disabled') + await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('disabled') + await expect(row).toBeVisible() + + const oldPreview = String((await getAgent(request, headers, agentId)).apiKeyPreview) + await row.getByRole('button', { name: '换密钥' }).click() + const confirm = page.locator('.el-message-box:visible') + await expect(confirm).toBeVisible() + await confirm.getByRole('button', { name: '确认轮换' }).click() + const keyDialog = page.locator('.el-dialog:visible').filter({ hasText: '新的接入密钥' }) + await expect(keyDialog).toBeVisible() + const keySecret = keyDialog.locator('.key-reveal code') + await expect(keySecret).not.toHaveText('') + const newPreview = String((await getAgent(request, headers, agentId)).apiKeyPreview) + expect(newPreview).not.toBe(oldPreview) + await captureScreenshot(page, testInfo, 'agent-key-rotated-secret-masked', [keySecret]) + await keyDialog.locator('.el-dialog__headerbtn').click() + + await row.getByRole('button', { name: '删除' }).click() + const deleteConfirm = page.locator('.el-message-box:visible') + await expect(deleteConfirm).toBeVisible() + await deleteConfirm.getByRole('button', { name: '确认删除' }).click() + await expect(row).toHaveCount(0) + const absent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + expect(absent.status()).toBe(404) + deleted = true + + await keyword.fill('') + await page.locator('.agent-filter').filter({ hasText: '状态' }).locator('select').selectOption('all') + + await attachJson(testInfo, '智能体页面提交验收', { + name, + agentId, + pageSubmittedSections: ['数字人', 'AI内核', '身份设置', '场景外观', '交互设置', 'UI自定义'], + managementActions: ['新建', '关键词筛选', '状态筛选', '设置有效期', '接入信息', '发布', '停用', '换密钥', '删除'], + persistenceChecks: '每个分区保存后均通过正式详情 API 核验,并刷新页面截图复查。', + cleanupComplete: true, + }) + } finally { + if (agentId && !deleted) await cleanupAgent(request, headers, agentId) + } +}) diff --git a/tests/agent-public-lifecycle.spec.ts b/tests/agent-public-lifecycle.spec.ts new file mode 100644 index 0000000..49613cb --- /dev/null +++ b/tests/agent-public-lifecycle.spec.ts @@ -0,0 +1,920 @@ +import fs from 'node:fs' +import path from 'node:path' +import { randomBytes } from 'node:crypto' +import { execFileSync } from 'node:child_process' + +import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData, loginAsAdmin } from './helpers' + +type JsonRecord = Record +type Headers = Record + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' + ? value as JsonRecord + : {} + +const responseData = async (response: APIResponse, expected: number | number[], label: string) => { + const expectedStatuses = Array.isArray(expected) ? expected : [expected] + expect(expectedStatuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + return asRecord(envelopeData(await response.json())) +} + +const addPass = ( + activities: Activity[], + module: string, + action: string, + response: APIResponse, + resourceId?: string, + detail?: string, +) => activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail }) + +async function waitForJob( + request: APIRequestContext, + headers: Headers, + jobId: string, + timeoutMs = 120_000, +) { + const deadline = Date.now() + timeoutMs + let latest: JsonRecord = {} + while (Date.now() < deadline) { + const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) + expect(response.ok(), `读取数字形象任务失败:HTTP ${response.status()}`).toBeTruthy() + latest = asRecord(envelopeData(await response.json())) + const status = String(latest.status ?? '').toLowerCase() + if (status === 'succeeded') return latest + if (status === 'failed' || status === 'cancelled') { + throw new Error(`数字形象任务未成功:${String(latest.errorMessage ?? latest.message ?? status)}`) + } + await new Promise((resolve) => setTimeout(resolve, 750)) + } + throw new Error(`数字形象任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`) +} + +function createShortWebm(outputPath: string) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + const ffmpeg = process.env.FFMPEG_BINARY?.trim() || 'ffmpeg' + execFileSync(ffmpeg, [ + '-y', + '-hide_banner', + '-loglevel', 'error', + '-f', 'lavfi', + '-i', 'testsrc2=size=640x360:rate=15', + '-f', 'lavfi', + '-i', 'sine=frequency=660:sample_rate=48000', + '-t', '1.8', + '-c:v', 'libvpx-vp9', + '-deadline', 'realtime', + '-cpu-used', '8', + '-b:v', '420k', + '-c:a', 'libopus', + '-b:a', '48k', + outputPath, + ], { stdio: 'pipe', timeout: 60_000, windowsHide: true }) + const size = fs.statSync(outputPath).size + expect(size, '本机 FFmpeg 应生成非空的真实 WebM 测试视频').toBeGreaterThan(10_000) +} + +async function latestAgent( + request: APIRequestContext, + headers: Headers, + agentId: string, +) { + const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + return { response, item: response.status() === 200 ? asRecord(envelopeData(await response.json())) : {} } +} + +async function cleanupAgent( + request: APIRequestContext, + headers: Headers, + agentId: string, + activities: Activity[], + cleanupErrors: string[], +) { + try { + const { response, item } = await latestAgent(request, headers, agentId) + if (response.status() === 404) return + if (response.status() !== 200) throw new Error(`读取失败 HTTP ${response.status()}`) + let current = item + if (String(current.status ?? '').toLowerCase() === 'published') { + const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, { + headers, + data: { dataVersion: Number(current.dataVersion) }, + }) + if (disabled.status() !== 200) throw new Error(`停用失败 HTTP ${disabled.status()}`) + current = asRecord(envelopeData(await disabled.json())) + } + const removed = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { + headers, + params: { dataVersion: String(Number(current.dataVersion)) }, + }) + if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) + activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: agentId }) + } catch (error) { + const message = `智能体清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: agentId, detail: message }) + } +} + +async function cleanupAvatar( + request: APIRequestContext, + headers: Headers, + avatarId: string, + activities: Activity[], + cleanupErrors: string[], +) { + try { + const detail = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) + if (detail.status() === 404) return + if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`) + const item = asRecord(envelopeData(await detail.json())) + const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { + headers, + params: { dataVersion: String(Number(item.dataVersion)) }, + }) + if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) + activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: avatarId }) + } catch (error) { + const message = `数字形象清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: avatarId, detail: message }) + } +} + +async function cleanupVideoResources( + request: APIRequestContext, + headers: Headers, + refs: { videoId: string; folderId: string; projectId: string }, + activities: Activity[], + cleanupErrors: string[], +) { + const cleanupDetail = async (module: string, id: string, detailPath: string, deletePath: string) => { + if (!id) return + try { + const detail = await request.get(detailPath, { headers }) + if (detail.status() === 404) return + if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`) + const item = asRecord(envelopeData(await detail.json())) + const removed = await request.delete(deletePath, { + headers, + params: { dataVersion: String(Number(item.dataVersion)) }, + }) + if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) + activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) + } catch (error) { + const message = `${module}清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) + } + } + + await cleanupDetail('成片', refs.videoId, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`) + + if (refs.folderId) { + try { + const response = await request.get('/api/v1/video-folders', { headers }) + if (response.status() === 200) { + const payload = asRecord(envelopeData(await response.json())) + const item = (Array.isArray(payload.items) ? payload.items : []) + .map(asRecord) + .find((candidate) => String(candidate.id ?? '') === refs.folderId) + if (item) { + const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(refs.folderId)}`, { + headers, + params: { dataVersion: String(Number(item.dataVersion)) }, + }) + if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`) + activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: refs.folderId }) + } + } else { + throw new Error(`读取失败 HTTP ${response.status()}`) + } + } catch (error) { + const message = `视频文件夹清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: refs.folderId, detail: message }) + } + } + + await cleanupDetail('视频工程', refs.projectId, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`) +} + +async function attachReport(testInfo: TestInfo, activities: Activity[], cleanupErrors: string[]) { + await attachJson(testInfo, '智能体公开交付生命周期与清理', { + runId, + runPrefix, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '报告未记录管理员密码、访问令牌、会话令牌、WebSocket ticket 或一次性 API Key。', + }) +} + +test('数字形象、七区配置、发布、公开问答、反馈、停用与清理完整闭环', async ({ page, request }, testInfo) => { + test.setTimeout(600_000) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + // 原型的专属数字人链路明确要求上传本人视频,而不是静态头像图片。 + const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') + expect(fs.existsSync(fixturePath), `数字形象测试素材不存在:${fixturePath}`).toBeTruthy() + + let avatarId = '' + let agentId = '' + const videoRefs = { videoId: '', folderId: '', projectId: '' } + let generatedWebmPath = '' + let captureJobId = '' + let captureTitle = '' + let primaryError: unknown + const allowedOrigin = new URL(process.env.BASE_URL || 'http://127.0.0.1:8003').origin + const accessPassword = `ApE2e!${randomBytes(12).toString('hex')}A1` + + try { + const createdAvatarResponse = await request.post('/api/v1/avatars', { + headers, + multipart: { + name: `${runPrefix}-维修教员形象`, + resize: 'true', + matting: 'off', + file: { + name: `${runPrefix}-avatar.mp4`, + mimeType: 'video/mp4', + buffer: fs.readFileSync(fixturePath), + }, + }, + timeout: 120_000, + }) + const avatarPayload = await responseData(createdAvatarResponse, 201, '上传创建数字形象') + const createdAvatar = asRecord(avatarPayload.avatar) + const avatarJob = asRecord(avatarPayload.job) + avatarId = String(createdAvatar.id ?? '') + const jobId = String(avatarJob.id ?? '') + expect(avatarId).not.toBe('') + expect(jobId).not.toBe('') + addPass(activities, '数字形象', '上传原型视频并创建任务', createdAvatarResponse, avatarId) + + await waitForJob(request, headers, jobId) + const readyResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) + const readyAvatar = await responseData(readyResponse, 200, '刷新数字形象处理结果') + expect(String(readyAvatar.status).toLowerCase()).toBe('ready') + addPass(activities, '数字形象', '任务完成并持久化 READY 状态', readyResponse, avatarId) + + const editedAvatarResponse = await request.put(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { + headers, + data: { + name: `${runPrefix}-维修教员形象-已编辑`, + role: '装备维修教员', + specialty: '液压与电气检修', + gender: '男', + visibility: 'internal', + dataVersion: Number(readyAvatar.dataVersion), + }, + }) + const editedAvatar = await responseData(editedAvatarResponse, 200, '编辑数字形象') + expect(editedAvatar.name).toBe(`${runPrefix}-维修教员形象-已编辑`) + addPass(activities, '数字形象', '编辑并刷新持久化', editedAvatarResponse, avatarId) + + const ttsResponse = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' }, + }) + const ttsPage = await responseData(ttsResponse, 200, '读取正式 TTS 能力') + const ttsItems = (Array.isArray(ttsPage.items) ? ttsPage.items : []).map(asRecord) + const tts = ttsItems.find((item) => item.capabilityType === 'TTS' && item.enabled === true) + const ttsId = String(tts?.id ?? '') + expect(ttsId, '平台必须存在一项已启用的正式 TTS 能力').not.toBe('') + addPass(activities, '视频工程', '选择平台正式 TTS 能力', ttsResponse, ttsId) + + const createProjectResponse = await request.post('/api/v1/video-projects', { + headers, + data: { + name: `${runPrefix}-设备点检口播工程`, + scriptText: '开始检修前,请先停机、断电、验电并确认液压系统已经卸压。', + avatarId, + voiceCapabilityId: ttsId, + voiceSpeed: 1, + orientation: 'portrait', + backgroundType: 'workshop', + subtitleEnabled: true, + subtitleFont: 'Microsoft YaHei', + subtitleFontSize: 42, + subtitlePosition: 'bottom', + durationMs: 1800, + }, + }) + let videoProject = await responseData(createProjectResponse, 201, '创建视频工程') + videoRefs.projectId = String(videoProject.id ?? '') + expect(videoRefs.projectId).not.toBe('') + expect(videoProject.voiceCapabilityId).toBe(ttsId) + addPass(activities, '视频工程', '创建并绑定形象与正式 TTS', createProjectResponse, videoRefs.projectId) + + const updateProjectResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { + headers, + data: { + name: `${runPrefix}-设备点检标准口播工程`, + scriptText: '停机、断电、验电、卸压后,确认压力表归零再开始设备点检。', + voiceSpeed: 1.05, + dataVersion: Number(videoProject.dataVersion), + }, + }) + videoProject = await responseData(updateProjectResponse, 200, '编辑视频工程') + expect(videoProject.voiceSpeed).toBe(1.05) + addPass(activities, '视频工程', '编辑工程基本参数', updateProjectResponse, videoRefs.projectId) + + const timelineResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}/timeline`, { + headers, + data: { + dataVersion: Number(videoProject.dataVersion), + durationMs: 1800, + tracks: [ + { + code: 'AVATAR', name: '数字人', order: 0, + clips: [{ id: `${runId}-avatar`.slice(0, 64), type: 'AVATAR', startMs: 0, endMs: 1800 }], + }, + { + code: 'MEDIA', name: '画面素材', order: 1, + clips: [{ id: `${runId}-media`.slice(0, 64), type: 'TITLE', text: '设备点检安全提示', startMs: 0, endMs: 1800 }], + }, + { + code: 'AUDIO', name: '正式配音', order: 2, + clips: [{ id: `${runId}-audio`.slice(0, 64), type: 'AUDIO', capabilityId: ttsId, startMs: 0, endMs: 1800 }], + }, + { + code: 'SUBTITLE', name: '同步字幕', order: 3, + clips: [{ id: `${runId}-subtitle`.slice(0, 64), type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 1800, style: { font: 'Microsoft YaHei', size: 42 } }], + }, + ], + }, + }) + videoProject = await responseData(timelineResponse, 200, '保存四轨时间线') + expect(asRecord(videoProject.timeline).tracks).toBeInstanceOf(Array) + addPass(activities, '视频工程', '保存四轨时间线', timelineResponse, videoRefs.projectId) + + const refreshedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers }) + videoProject = await responseData(refreshedProjectResponse, 200, '刷新视频工程') + expect((asRecord(videoProject.timeline).tracks as unknown[]).map((track) => String(asRecord(track).code))) + .toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE']) + addPass(activities, '视频工程', '刷新后四轨及片段保持一致', refreshedProjectResponse, videoRefs.projectId) + + const createFolderResponse = await request.post('/api/v1/video-folders', { + headers, + data: { name: `${runPrefix}-装备点检成片`, description: 'E2E 真实采集成片临时文件夹' }, + }) + let videoFolder = await responseData(createFolderResponse, 201, '创建视频文件夹') + videoRefs.folderId = String(videoFolder.id ?? '') + expect(videoRefs.folderId).not.toBe('') + addPass(activities, '视频文件夹', '创建文件夹', createFolderResponse, videoRefs.folderId) + + const updateFolderResponse = await request.put(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, { + headers, + data: { + name: `${runPrefix}-装备点检正式成片`, + description: '设备点检口播与安全培训成片', + dataVersion: Number(videoFolder.dataVersion), + }, + }) + videoFolder = await responseData(updateFolderResponse, 200, '编辑视频文件夹') + expect(videoFolder.name).toBe(`${runPrefix}-装备点检正式成片`) + addPass(activities, '视频文件夹', '编辑并持久化文件夹', updateFolderResponse, videoRefs.folderId) + + generatedWebmPath = testInfo.outputPath(`${runPrefix}-real-capture.webm`) + createShortWebm(generatedWebmPath) + captureTitle = `${runPrefix}-真实浏览器采集成片` + const captureResponse = await request.post('/api/v1/captures', { + headers, + multipart: { + kind: 'oral', + title: captureTitle, + avatarId, + text: '停机、断电、验电、卸压后再进行设备点检。', + speaker: '1', + speed: '0.7', + subtitleEnabled: 'true', + file: { + name: `${runPrefix}-real-capture.webm`, + mimeType: 'video/webm', + buffer: fs.readFileSync(generatedWebmPath), + }, + }, + timeout: 120_000, + }) + const capture = await responseData(captureResponse, 202, '提交真实 WebM 采集文件') + captureJobId = String(capture.jobId ?? '') + expect(captureJobId).toMatch(/^[a-f0-9]{32}$/) + addPass(activities, '视频采集', '本机 FFmpeg WebM 提交后台转码', captureResponse, captureJobId) + fs.rmSync(generatedWebmPath, { force: true }) + generatedWebmPath = '' + + const completedJob = await waitForJob(request, headers, captureJobId, 180_000) + expect(String(completedJob.status).toLowerCase()).toBe('succeeded') + addPass(activities, '制作队列', '真实 DB 任务完成', captureResponse, captureJobId) + + const jobListResponse = await request.get('/api/v1/jobs', { + headers, + params: { keyword: captureTitle, status: 'SUCCEEDED', page: '1', pageSize: '20' }, + }) + const jobList = await responseData(jobListResponse, 200, '筛选制作任务') + expect((Array.isArray(jobList.items) ? jobList.items : []).map(asRecord) + .some((item) => String(item.id ?? '') === captureJobId)).toBeTruthy() + addPass(activities, '制作队列', '按标题和状态筛选任务', jobListResponse, captureJobId) + + const jobDetailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}`, { headers }) + const jobDetail = await responseData(jobDetailResponse, 200, '读取任务详情') + expect(String(jobDetail.status).toLowerCase()).toBe('succeeded') + addPass(activities, '制作队列', '读取完成状态与任务详情', jobDetailResponse, captureJobId) + + const jobEventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/events`, { headers }) + const jobEvents = await responseData(jobEventsResponse, 200, '读取任务事件') + expect(Array.isArray(jobEvents.items) ? jobEvents.items.length : 0).toBeGreaterThan(0) + addPass(activities, '制作队列', '读取真实任务事件流', jobEventsResponse, captureJobId) + + const jobResultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/result`, { headers }) + expect(jobResultResponse.status()).toBe(200) + expect(String(jobResultResponse.headers()['content-type'] ?? '')).toContain('video/mp4') + expect((await jobResultResponse.body()).length).toBeGreaterThan(10_000) + addPass(activities, '制作队列', '下载真实任务结果', jobResultResponse, captureJobId) + + const commitResponse = await request.post(`/api/v1/videos/capture-jobs/${encodeURIComponent(captureJobId)}/commit`, { + headers, + data: { + projectId: videoRefs.projectId, + folderId: videoRefs.folderId, + title: `${runPrefix}-设备点检安全口播`, + description: '真实 WebM 经 FFmpeg 转码并提交为平台成片。', + category: '装备维修', + tags: ['设备点检', '安全培训', '设备点检'], + visibility: 'private', + }, + }) + let video = await responseData(commitResponse, 201, '提交采集任务为成片') + videoRefs.videoId = String(video.id ?? '') + expect(videoRefs.videoId).not.toBe('') + expect(video.status).toBe('ready') + expect(video.folderId).toBe(videoRefs.folderId) + expect(video.tags).toEqual(['设备点检', '安全培训']) + addPass(activities, '成片管理', '任务提交为真实持久化成片', commitResponse, videoRefs.videoId) + + const linkedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers }) + const linkedProject = await responseData(linkedProjectResponse, 200, '刷新成片关联工程') + expect(linkedProject.lastVideoId).toBe(videoRefs.videoId) + expect(linkedProject.status).toBe('ready') + addPass(activities, '视频工程', '工程回写最新成片与 READY 状态', linkedProjectResponse, videoRefs.projectId) + + const protectedFileResponse = await request.get(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/file`, { headers }) + expect(protectedFileResponse.status()).toBe(200) + expect(String(protectedFileResponse.headers()['content-type'] ?? '')).toContain('video/mp4') + expect((await protectedFileResponse.body()).length).toBeGreaterThan(10_000) + addPass(activities, '成片管理', '登录态下载真实 MP4 文件', protectedFileResponse, videoRefs.videoId) + + const updateVideoResponse = await request.put(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, { + headers, + data: { + title: `${runPrefix}-设备点检标准安全口播`, + description: '停机、断电、验电、卸压后的标准设备点检流程。', + category: '维修工艺', + tags: ['点检', '安全', '点检'], + visibility: 'private', + folderId: videoRefs.folderId, + dataVersion: Number(video.dataVersion), + }, + }) + video = await responseData(updateVideoResponse, 200, '编辑成片信息') + expect(video.title).toBe(`${runPrefix}-设备点检标准安全口播`) + expect(video.tags).toEqual(['点检', '安全']) + addPass(activities, '成片管理', '编辑标题、分类、标签与文件夹', updateVideoResponse, videoRefs.videoId) + + const sharePassword = `Vs!${randomBytes(14).toString('hex')}A1` + const shareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, { + headers, + data: { + expiresInSeconds: 3600, + password: sharePassword, + maxAccessCount: 1, + dataVersion: Number(video.dataVersion), + }, + }) + const limitedShare = await responseData(shareResponse, 201, '创建单次密码分享') + const limitedToken = String(limitedShare.shareToken ?? '') + expect(limitedToken).toContain('.') + expect(String(limitedShare.tokenPreview ?? '')).not.toContain(limitedToken) + addPass(activities, '成片分享', '签发密码保护且限访问一次的分享', shareResponse, videoRefs.videoId, '密码与完整令牌仅保存在内存') + + const rawFileDenied = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}/file`, { + headers: { 'X-Share-Password': sharePassword }, + }) + expect(rawFileDenied.status()).toBe(404) + addPass(activities, '成片分享', '未取得媒体 grant 时拒绝直接访问文件', rawFileDenied, videoRefs.videoId) + + const wrongPasswordResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { + headers: { 'X-Share-Password': `${sharePassword}-wrong` }, + }) + expect(wrongPasswordResponse.status()).toBe(404) + addPass(activities, '成片分享', '错误分享密码以不存在响应拒绝,避免枚举链接', wrongPasswordResponse, videoRefs.videoId) + + const limitedHeaders = { 'X-Share-Password': sharePassword } + const limitedDetailResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders }) + const limitedDetail = await responseData(limitedDetailResponse, 200, '单次访问分享详情') + expect(limitedDetail.title).toBe(`${runPrefix}-设备点检标准安全口播`) + expect(limitedDetail.ownerId).toBeUndefined() + const grantedFileUrl = String(limitedDetail.fileUrl ?? '') + expect(grantedFileUrl).toContain('grantExpires=') + expect(grantedFileUrl).toContain('grantSignature=') + addPass(activities, '成片分享', '密码通过后签发短时媒体 grant', limitedDetailResponse, videoRefs.videoId) + + const grantedHeadResponse = await request.head(grantedFileUrl, { headers: limitedHeaders }) + expect(grantedHeadResponse.status()).toBe(200) + addPass(activities, '成片分享', '媒体 grant 支持 HEAD', grantedHeadResponse, videoRefs.videoId) + + const grantedRangeResponse = await request.get(grantedFileUrl, { + headers: { ...limitedHeaders, Range: 'bytes=0-1023' }, + }) + expect(grantedRangeResponse.status()).toBe(206) + expect((await grantedRangeResponse.body()).length).toBeGreaterThan(0) + addPass(activities, '成片分享', '媒体 grant 支持 Range 分段播放', grantedRangeResponse, videoRefs.videoId) + + const grantedFileResponse = await request.get(grantedFileUrl, { headers: limitedHeaders }) + expect(grantedFileResponse.status()).toBe(200) + expect((await grantedFileResponse.body()).length).toBeGreaterThan(10_000) + addPass(activities, '成片分享', '同一 grant 支持完整 GET 播放', grantedFileResponse, videoRefs.videoId) + + const exhaustedResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders }) + expect(exhaustedResponse.status()).toBe(404) + addPass(activities, '成片分享', '第二次访问因 maxAccessCount=1 被拒绝', exhaustedResponse, videoRefs.videoId) + + const rotatedPassword = `Vr!${randomBytes(14).toString('hex')}A1` + const rotateShareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/rotate-share`, { + headers, + data: { + expiresInSeconds: 3600, + password: rotatedPassword, + maxAccessCount: 2, + dataVersion: Number(limitedShare.dataVersion), + }, + }) + const rotatedShare = await responseData(rotateShareResponse, 201, '轮换成片分享') + const rotatedToken = String(rotatedShare.shareToken ?? '') + expect(rotatedToken).not.toBe(limitedToken) + addPass(activities, '成片分享', '轮换分享令牌', rotateShareResponse, videoRefs.videoId, '新旧完整令牌均未写入报告') + + const oldTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders }) + expect(oldTokenResponse.status()).toBe(404) + addPass(activities, '成片分享', '轮换后旧令牌立即失效', oldTokenResponse, videoRefs.videoId) + + await page.goto(`/share/videos/${encodeURIComponent(rotatedToken)}`) + await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible() + const rotatedPasswordInput = page.locator('#share-password') + await rotatedPasswordInput.fill(rotatedPassword) + await page.getByRole('button', { name: '验证并播放' }).click() + await expect(page.getByRole('heading', { name: `${runPrefix}-设备点检标准安全口播` })).toBeVisible({ timeout: 30_000 }) + const sharedVideo = page.locator('.shared-video-player video') + await expect(sharedVideo).toBeVisible() + await captureScreenshot(page, testInfo, 'protected-shared-video-page', [rotatedPasswordInput]) + addPass(activities, '成片分享', '公开分享页密码验证并展示成片信息', rotateShareResponse, videoRefs.videoId) + + const revokeShareResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, { + headers, + data: { reason: 'APE2E 分享闭环完成' }, + }) + const revoked = await responseData(revokeShareResponse, 200, '停止成片分享') + expect(revoked.revoked).toBe(true) + addPass(activities, '成片分享', '停止分享', revokeShareResponse, videoRefs.videoId) + + const revokedTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(rotatedToken)}`, { + headers: { 'X-Share-Password': rotatedPassword }, + }) + expect(revokedTokenResponse.status()).toBe(404) + addPass(activities, '成片分享', '停止后公开链接失效', revokedTokenResponse, videoRefs.videoId) + + const deleteVideoResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, { + headers, + params: { dataVersion: String(Number(rotatedShare.dataVersion)) }, + }) + await responseData(deleteVideoResponse, 200, '删除成片') + const deletedVideoId = videoRefs.videoId + videoRefs.videoId = '' + addPass(activities, '成片管理', '删除成片', deleteVideoResponse, deletedVideoId) + expect((await request.get(`/api/v1/videos/${encodeURIComponent(deletedVideoId)}`, { headers })).status()).toBe(404) + + const folderListResponse = await request.get('/api/v1/video-folders', { headers }) + const folderList = await responseData(folderListResponse, 200, '刷新文件夹版本') + const currentFolder = (Array.isArray(folderList.items) ? folderList.items : []).map(asRecord) + .find((item) => String(item.id ?? '') === videoRefs.folderId) + expect(currentFolder).toBeTruthy() + const deleteFolderResponse = await request.delete(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, { + headers, + params: { dataVersion: String(Number(currentFolder?.dataVersion)) }, + }) + await responseData(deleteFolderResponse, 200, '删除视频文件夹') + const deletedFolderId = videoRefs.folderId + videoRefs.folderId = '' + addPass(activities, '视频文件夹', '成片删除后逆序删除文件夹', deleteFolderResponse, deletedFolderId) + + const currentProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers }) + const currentProject = await responseData(currentProjectResponse, 200, '刷新工程版本') + const deleteProjectResponse = await request.delete(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { + headers, + params: { dataVersion: String(Number(currentProject.dataVersion)) }, + }) + await responseData(deleteProjectResponse, 200, '删除视频工程') + const deletedProjectId = videoRefs.projectId + videoRefs.projectId = '' + addPass(activities, '视频工程', '成片与文件夹删除后逆序删除工程', deleteProjectResponse, deletedProjectId) + expect((await request.get(`/api/v1/video-projects/${encodeURIComponent(deletedProjectId)}`, { headers })).status()).toBe(404) + + const createdAgentResponse = await request.post('/api/v1/realtime-agents', { + headers, + data: { + name: `${runPrefix}-公开维修教员`, + expiresAt: '2035-08-15T12:00:00+08:00', + }, + }) + let agent = await responseData(createdAgentResponse, 201, '创建智能体草稿') + agentId = String(agent.id ?? '') + expect(agentId).not.toBe('') + expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('') + addPass(activities, '智能体', '创建草稿并一次性签发密钥', createdAgentResponse, agentId, '密钥仅在内存校验,未写入报告') + + const saveSection = async (section: string, data: JsonRecord, label: string) => { + const response = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/sections/${section}`, { + headers, + data: { ...data, dataVersion: Number(agent.dataVersion) }, + }) + agent = await responseData(response, 200, label) + addPass(activities, '智能体七区配置', label, response, agentId) + } + + await saveSection('avatar', { avatarIds: [avatarId], avatarId }, '形象与声音区保存') + await saveSection('core', { + description: `${runPrefix} 装备维修公开问答验证`, + systemPrompt: '你是装备维修数字教员。回答应准确、简洁,并优先引用知识库。', + }, '基本信息区保存') + await saveSection('identity', { + llmModel: 'llm-local-main', + knowledgeBaseIds: ['default'], + knowledgeDocumentIds: [], + interactionModes: ['text'], + }, '模型与知识区保存') + await saveSection('scene', { + sceneBackgroundType: 'transparent', + showInteractionButtons: true, + background: '#0f2d32', + }, '场景区保存') + await saveSection('interaction', { + welcomeMessage: '欢迎进入装备维修实训,请输入你的问题。', + fallbackMessage: '暂未找到匹配资料,请换一种问法。', + sensitiveReply: '该问题无法回答。', + }, '交互区保存') + await saveSection('ui', { + uiPosition: 'fullscreen', + brandVisible: true, + uiConfig: { schemaVersion: 1, suggestedQuestions: ['如何开展设备点检?'] }, + }, '界面区保存') + await saveSection('delivery', { + accessMode: 'internal', + allowedOrigins: [], + concurrencyLimit: 3, + brandVisible: true, + }, '交付区保存') + + const persistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + agent = await responseData(persistedResponse, 200, '刷新智能体并核验七区持久化') + expect(agent.avatarIds).toEqual([avatarId]) + expect(agent.knowledgeBaseIds).toEqual(['default']) + expect(agent.interactionModes).toEqual(['text']) + expect(agent.accessMode).toBe('internal') + expect(agent.concurrencyLimit).toBe(3) + addPass(activities, '智能体七区配置', '刷新后字段保持一致', persistedResponse, agentId) + + // 原型的「发布接入」是用户可编辑的正式配置,不能只在 API 层验证。 + await loginAsAdmin(page) + await page.goto('/jobs') + await page.getByRole('tab', { name: '全部任务' }).click() + const jobSearch = page.getByRole('searchbox', { name: '搜索任务' }) + await jobSearch.fill(captureTitle) + const jobRow = page.locator('tbody tr').filter({ hasText: captureTitle }).first() + await expect(jobRow).toContainText('已完成', { timeout: 30_000 }) + await jobRow.getByRole('button', { name: '详情' }).click() + const jobDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last() + await expect(jobDialog).toContainText(captureJobId) + await expect(jobDialog).toContainText('任务已完成') + await captureScreenshot(page, testInfo, 'capture-job-list-and-detail') + addPass(activities, '制作队列', '页面筛选、完成状态与详情展示', jobDetailResponse, captureJobId) + + await page.goto(`/agents/${encodeURIComponent(agentId)}?tab=delivery`) + await expect(page.getByText('访问策略', { exact: true })).toBeVisible() + const accessPolicyCard = page.locator('.detail-card').filter({ hasText: '访问策略' }).last() + await accessPolicyCard.locator('label').filter({ hasText: '访问方式' }).locator('select').selectOption('password') + const passwordInput = accessPolicyCard.locator('input[type="password"]') + await passwordInput.fill(accessPassword) + const advanced = accessPolicyCard.locator('details.delivery-advanced') + await advanced.locator('summary').click() + await advanced.locator('textarea').fill(`${allowedOrigin}\n${allowedOrigin}`) + await captureScreenshot(page, testInfo, 'agent-delivery-policy-before-save', [ + passwordInput, + page.locator('.delivery-field pre'), + ]) + await page.getByRole('button', { name: '保存配置' }).click() + await expect(page.locator('.el-message').filter({ hasText: /配置已保存|保存成功/ }).last()).toBeVisible() + + const uiPersistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + agent = await responseData(uiPersistedResponse, 200, '刷新核验发布接入页面提交') + expect(agent.accessMode).toBe('password') + expect(agent.allowedOrigins).toEqual([allowedOrigin]) + expect(agent.brandVisible).toBe(true) + expect(agent.accessPassword).toBeUndefined() + await expect(passwordInput).toHaveValue('') + addPass(activities, '智能体七区配置', '发布接入页面保存密码、Origin 去重和平台标识', uiPersistedResponse, agentId) + + const publishResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/publish`, { + headers, + data: { dataVersion: Number(agent.dataVersion) }, + }) + agent = await responseData(publishResponse, 200, '发布智能体') + expect(agent.status).toBe('published') + const slug = String(agent.slug ?? '') + expect(slug).not.toBe('') + addPass(activities, '智能体', '发布成功', publishResponse, agentId) + + const integrationResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/integration`, { headers }) + const integration = await responseData(integrationResponse, 200, '读取接入信息') + expect(String(integration.sessionEndpoint ?? '')).toContain(slug) + expect(String(integration.chatEndpoint ?? '')).toContain(slug) + addPass(activities, '智能体', '接入信息端点可用', integrationResponse, agentId) + + const extensionResponse = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/expiration`, { + headers, + data: { extendDays: 30, dataVersion: Number(agent.dataVersion) }, + }) + agent = await responseData(extensionResponse, 200, '延长有效期') + addPass(activities, '智能体', '延长有效期', extensionResponse, agentId) + + const rotateResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/rotate-key`, { + headers, + data: { dataVersion: Number(agent.dataVersion) }, + }) + agent = await responseData(rotateResponse, 200, '轮换一次性 API Key') + expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('') + addPass(activities, '智能体', '轮换一次性 API Key', rotateResponse, agentId, '新密钥仅在内存校验,未写入报告') + + const publicConfigResponse = await request.get(`/open/v1/realtime/${encodeURIComponent(slug)}`, { + headers: { Origin: allowedOrigin }, + }) + const publicConfig = await responseData(publicConfigResponse, 200, '读取公开最小配置') + expect(publicConfig.name).toBe(`${runPrefix}-公开维修教员`) + expect(publicConfig.systemPrompt).toBeUndefined() + expect(publicConfig.apiKeyPreview).toBeUndefined() + addPass(activities, '公开访问', '读取公开最小配置且不泄露内部字段', publicConfigResponse, agentId) + + const rejectedSession = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, { + headers: { Origin: allowedOrigin }, + data: { password: `${accessPassword}-wrong` }, + }) + expect(rejectedSession.status()).toBe(401) + addPass(activities, '公开访问', '错误访问密码被拒绝', rejectedSession, agentId) + + const sessionResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, { + headers: { Origin: allowedOrigin }, + data: { password: accessPassword }, + }) + const session = await responseData(sessionResponse, 201, '创建公开会话') + const sessionToken = String(session.sessionToken ?? '') + expect(sessionToken).toMatch(/^dhs_/) + expect(String(session.websocketTicket ?? '')).toMatch(/^dhw_/) + addPass(activities, '公开访问', '创建短期会话与一次性 WebSocket ticket', sessionResponse, agentId) + + const chatPayload = { + message: '请简要说明设备点检的主要目的。', + sessionToken, + clientMessageId: `${runId}-public-chat`.slice(0, 64), + synthesizeAudio: false, + } + const firstChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, { + headers: { Origin: allowedOrigin }, + data: chatPayload, + timeout: 120_000, + }) + const firstChat = await responseData(firstChatResponse, 200, '公开文字问答') + expect(String(firstChat.reply ?? '').length).toBeGreaterThan(0) + expect(String(firstChat.requestId ?? '').length).toBeGreaterThan(0) + addPass(activities, '公开访问', '真实文字问答并落库', firstChatResponse, agentId, `引用 ${Array.isArray(firstChat.citations) ? firstChat.citations.length : 0} 条`) + + const repeatedChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, { + headers: { Origin: allowedOrigin }, + data: chatPayload, + timeout: 120_000, + }) + const repeatedChat = await responseData(repeatedChatResponse, 200, '重复 clientMessageId 幂等回放') + expect(repeatedChat.requestId).toBe(firstChat.requestId) + addPass(activities, '公开访问', '相同 clientMessageId 不重复生成消息', repeatedChatResponse, agentId) + + const feedbackResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/feedback`, { + headers: { Origin: allowedOrigin }, + data: { sessionToken, satisfaction: 'satisfied' }, + }) + const feedback = await responseData(feedbackResponse, 200, '提交满意度') + expect(feedback.satisfaction).toBe('satisfied') + addPass(activities, '公开访问', '满意度反馈持久化', feedbackResponse, agentId) + + await page.goto(`/agents/manage?keyword=${encodeURIComponent(runPrefix)}`) + await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`) + const persistedAgentImage = page.getByAltText(`${runPrefix}-公开维修教员形象`).first() + await expect(persistedAgentImage).toBeVisible() + await expect.poll( + () => persistedAgentImage.evaluate((image) => (image as HTMLImageElement).naturalWidth), + { message: '智能体列表中的真实数字形象缩略图必须成功加载' }, + ).toBeGreaterThan(0) + await captureScreenshot(page, testInfo, 'agent-published-and-persisted') + + await page.goto(`/live/${encodeURIComponent(slug)}`) + await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`) + const publicPassword = page.locator('input[type="password"]') + await publicPassword.fill(accessPassword) + await page.getByRole('textbox', { name: '维修问题' }).fill('请说明日常设备点检应关注哪些内容。') + const publicSessionResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/sessions$/.test(new URL(response.url()).pathname)) + const publicChatResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/chat$/.test(new URL(response.url()).pathname)) + await page.getByRole('button', { name: '发送问题' }).click() + expect((await publicSessionResponsePromise).status(), 'Live 页面应成功创建受 Origin 约束的公开会话').toBe(201) + expect((await publicChatResponsePromise).status(), 'Live 页面应成功完成真实文字问答').toBe(200) + await expect(page.getByText('本次回答是否有帮助?')).toBeVisible({ timeout: 120_000 }) + await page.getByRole('button', { name: '满意', exact: true }).click() + await expect(page.getByText('感谢反馈,已记录本次评价。')).toBeVisible() + await captureScreenshot(page, testInfo, 'public-live-chat-and-feedback', [publicPassword]) + addPass(activities, '公开访问', 'Live 页面密码验证、真实问答与满意度反馈', feedbackResponse, agentId) + + const endResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/end`, { + headers: { Origin: allowedOrigin }, + data: { sessionToken, result: 'normal' }, + }) + const ended = await responseData(endResponse, 200, '结束公开会话') + expect(ended.ended).toBe(true) + addPass(activities, '公开访问', '正常结束会话', endResponse, agentId) + + const disableResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, { + headers, + data: { dataVersion: Number(agent.dataVersion) }, + }) + agent = await responseData(disableResponse, 200, '停用智能体') + expect(agent.status).toBe('disabled') + addPass(activities, '智能体', '停用并持久化', disableResponse, agentId) + + const deniedAfterDisable = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, { + headers: { Origin: allowedOrigin }, + data: { password: accessPassword }, + }) + expect([401, 404], `停用后不应再创建公开会话:HTTP ${deniedAfterDisable.status()}`).toContain(deniedAfterDisable.status()) + addPass(activities, '公开访问', '停用后公开会话被拒绝', deniedAfterDisable, agentId) + + const deleteAgentResponse = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { + headers, + params: { dataVersion: String(Number(agent.dataVersion)) }, + }) + await responseData(deleteAgentResponse, 200, '删除智能体') + addPass(activities, '智能体', '删除并从列表消失', deleteAgentResponse, agentId) + const deletedAgentId = agentId + agentId = '' + const absentAgent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(deletedAgentId)}`, { headers }) + expect(absentAgent.status()).toBe(404) + + const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) + const avatarDetail = await responseData(avatarDetailResponse, 200, '删除前刷新数字形象版本') + const deleteAvatarResponse = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { + headers, + params: { dataVersion: String(Number(avatarDetail.dataVersion)) }, + timeout: 120_000, + }) + await responseData(deleteAvatarResponse, 200, '删除数字形象') + addPass(activities, '数字形象', '删除并确认无残留', deleteAvatarResponse, avatarId) + const deletedAvatarId = avatarId + avatarId = '' + const absentAvatar = await request.get(`/api/v1/avatars/${encodeURIComponent(deletedAvatarId)}`, { headers }) + expect(absentAvatar.status()).toBe(404) + } catch (error) { + primaryError = error + activities.push({ + module: '智能体公开交付', + action: '主流程', + result: 'FAIL', + detail: error instanceof Error ? error.message : String(error), + }) + } finally { + if (generatedWebmPath) fs.rmSync(generatedWebmPath, { force: true }) + await cleanupVideoResources(request, headers, videoRefs, activities, cleanupErrors) + if (agentId) await cleanupAgent(request, headers, agentId, activities, cleanupErrors) + if (avatarId) await cleanupAvatar(request, headers, avatarId, activities, cleanupErrors) + await attachReport(testInfo, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '智能体/数字形象清理必须全部成功').toEqual([]) +}) diff --git a/tests/ai-model-ui-lifecycle.spec.ts b/tests/ai-model-ui-lifecycle.spec.ts new file mode 100644 index 0000000..3d8fb7d --- /dev/null +++ b/tests/ai-model-ui-lifecycle.spec.ts @@ -0,0 +1,255 @@ +import type { APIRequestContext, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin, visibleDialog } from './helpers' + +type JsonRecord = Record +type Headers = Record + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface ResponseLike { + status(): number + json(): Promise +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function responseData(response: ResponseLike, expected: number | number[], label: string) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + return asRecord(envelopeData(await response.json())) +} + +function addPass( + activities: Activity[], + action: string, + response?: ResponseLike, + resourceId?: string, + detail?: string, +) { + activities.push({ module: 'AI模型', action, result: 'PASS', httpStatus: response?.status(), resourceId, detail }) +} + +async function listTestModels(request: APIRequestContext, headers: Headers) { + const response = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType: 'LLM', keyword: runPrefix, page: '1', pageSize: '200' }, + }) + const payload = await responseData(response, 200, '读取测试 AI 模型') + return asRecords(payload.items ?? payload.records) +} + +async function cleanupModels( + request: APIRequestContext, + headers: Headers, + activities: Activity[], + cleanupErrors: string[], +) { + try { + for (const item of await listTestModels(request, headers)) { + if (!String(item.name ?? '').startsWith(runPrefix)) continue + const id = String(item.id ?? '') + if (!id) continue + const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers }) + if (detailResponse.status() === 404) continue + const detail = await responseData(detailResponse, 200, '刷新待清理 AI 模型版本') + const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`AI 模型 ${id} 删除失败:HTTP ${removed.status()}`) + activities.push({ module: 'AI模型', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) + } + } catch (error) { + const message = `AI 模型兜底清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: 'AI模型', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) + } +} + +async function attachLifecycleReport( + testInfo: TestInfo, + activities: Activity[], + cleanupErrors: string[], +) { + await attachJson(testInfo, 'AI模型UI-页面生命周期与清理', { + runId, + runPrefix, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '报告未记录管理员密码、访问令牌或外部模型服务密钥。测试模型只使用不可解析的本地配置标识。', + }) +} + +test('AI模型页面:禁用安全模型新增、参数编辑、可解释预览、校验、启停、筛选与删除', async ({ page, request }, testInfo) => { + test.setTimeout(120_000) + const headers = await authHeaders(request) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const modelName = `${runPrefix}-离线内容安全模型`.slice(0, 50) + const initialDescription = `${runPrefix} 维修问答输入输出安全审查`.slice(0, 200) + const editedDescription = `${runPrefix} 维修问答输入输出安全审查,返回命中规则与处置依据`.slice(0, 200) + const initialReference = `${runPrefix.toLowerCase()}-safety-guard:v1` + const editedReference = `${runPrefix.toLowerCase()}-safety-guard:v2?threshold=0.12&explain=true` + let modelId = '' + let primaryError: unknown + + await cleanupModels(request, headers, activities, cleanupErrors) + activities.length = 0 + cleanupErrors.length = 0 + + try { + await loginAsAdmin(page) + await page.goto('/assets/ai-models') + await expect(page.getByText('AI模型', { exact: true }).first()).toBeVisible() + await expect(page.getByText('正在加载配置')).toHaveCount(0) + + await page.getByRole('button', { name: /新增AI模型/ }).click() + let dialog = visibleDialog(page) + await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill(modelName) + await dialog.locator('.capability-form > label').filter({ hasText: '分类' }).locator('select').selectOption({ label: '离线问答引擎' }) + await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(initialDescription) + await dialog.locator('.capability-form-technical summary').click() + await dialog.locator('.capability-form-technical input').fill(initialReference) + const enabledSwitch = dialog.locator('.switch-field .el-switch') + if ((await enabledSwitch.getAttribute('class'))?.includes('is-checked')) await enabledSwitch.click() + + const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/capabilities(?:\?|$)/.test(response.url())) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const createResponse = await createResponsePromise + const created = await responseData(createResponse, 201, '页面新增禁用安全模型') + modelId = String(created.id ?? '') + expect(modelId).not.toBe('') + + let card = page.locator('.capability-card').filter({ hasText: modelName }) + await expect(card).toBeVisible() + await expect(card.locator('.status-pill')).toHaveText('已停用') + const createdDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) + const createdDetail = await responseData(createdDetailResponse, 200, 'API 后验新增安全模型') + expect(createdDetail).toMatchObject({ + name: modelName, + type: '离线问答引擎', + description: initialDescription, + reference: initialReference, + enabled: false, + }) + addPass(activities, '通过页面新增禁用状态的离线内容安全模型并完成 API 后验', createResponse, modelId) + await captureScreenshot(page, testInfo, 'AI模型-01-禁用安全模型页面新增') + + await card.getByRole('button', { name: '编辑', exact: true }).click() + dialog = visibleDialog(page) + await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(editedDescription) + await dialog.locator('.capability-form-technical summary').click() + await dialog.locator('.capability-form-technical input').fill(editedReference) + const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes(`/api/v1/capabilities/${modelId}`) + && !response.url().includes('/status')) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const editResponse = await editResponsePromise + await responseData(editResponse, 200, '页面编辑安全模型参数') + + await page.reload() + const keyword = page.getByPlaceholder('搜索AI模型名称、分类或说明') + await keyword.fill(modelName) + card = page.locator('.capability-card').filter({ hasText: modelName }) + await expect(card).toContainText(editedDescription) + await card.locator('.capability-technical-info summary').click() + await expect(card).toContainText(editedReference) + const editedDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) + const editedDetail = await responseData(editedDetailResponse, 200, 'API 后验编辑安全模型参数') + expect(editedDetail).toMatchObject({ description: editedDescription, reference: editedReference, enabled: false }) + addPass(activities, '页面编辑模型用途与配置参数,刷新和 API 均保持', editResponse, modelId) + await captureScreenshot(page, testInfo, 'AI模型-02-参数编辑刷新持久化') + + await card.getByRole('button', { name: '预览', exact: true }).click() + const previewMessage = page.locator('.el-message:visible').last() + await expect(previewMessage).toContainText(modelName) + await expect(previewMessage).toContainText('离线问答引擎') + await expect(previewMessage).toContainText('用途') + await expect(previewMessage).toContainText(editedDescription) + await expect(previewMessage).toContainText(editedReference) + addPass(activities, '页面预览展示模型分类、用途和配置标识的可解释结果', undefined, modelId) + await captureScreenshot(page, testInfo, 'AI模型-03-可解释预览结果') + + const verifyResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && response.url().includes(`/api/v1/capabilities/${modelId}/verify`)) + await card.getByRole('button', { name: '校验本地配置' }).click() + const verifyResponse = await verifyResponsePromise + const verified = await responseData(verifyResponse, 200, '页面校验安全模型配置') + expect(verified).toMatchObject({ valid: true, message: '配置可用' }) + await expect(page.getByText('配置可用').last()).toBeVisible() + const verifiedDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) + const verifiedDetail = await responseData(verifiedDetailResponse, 200, 'API 后验模型校验状态') + expect(verifiedDetail.validationStatus).toBe('AVAILABLE') + addPass(activities, '页面校验配置并以 API 确认 AVAILABLE 状态', verifyResponse, modelId) + await captureScreenshot(page, testInfo, 'AI模型-04-本地配置校验通过') + + const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes(`/api/v1/capabilities/${modelId}/status`)) + await card.getByRole('button', { name: '启用', exact: true }).click() + const enableResponse = await enableResponsePromise + await responseData(enableResponse, 200, '页面启用安全模型') + await expect(card.locator('.status-pill')).toHaveText('可用') + + const disableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes(`/api/v1/capabilities/${modelId}/status`)) + await card.getByRole('button', { name: '停用', exact: true }).click() + const disableResponse = await disableResponsePromise + await responseData(disableResponse, 200, '页面停用安全模型') + await expect(card.locator('.status-pill')).toHaveText('已停用') + + await page.reload() + await keyword.fill(modelName) + card = page.locator('.capability-card').filter({ hasText: modelName }) + await expect(card.locator('.status-pill')).toHaveText('已停用') + const statusFilter = page.getByLabel('状态筛选') + await statusFilter.selectOption('enabled') + await expect(card).toHaveCount(0) + await expect(page.getByText('没有匹配的配置')).toBeVisible() + await statusFilter.selectOption('disabled') + await expect(page.locator('.capability-card').filter({ hasText: modelName })).toBeVisible() + const disabledDetailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(modelId)}`, { headers }) + const disabledDetail = await responseData(disabledDetailResponse, 200, 'API 后验最终停用状态') + expect(disabledDetail.enabled).toBe(false) + addPass(activities, '页面启用后再停用,刷新、状态筛选和 API 状态一致', disableResponse, modelId) + await captureScreenshot(page, testInfo, 'AI模型-05-停用筛选刷新持久化') + + await statusFilter.selectOption('all') + card = page.locator('.capability-card').filter({ hasText: modelName }) + await card.getByRole('button', { name: '删除', exact: true }).click() + const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' + && response.url().includes(`/api/v1/capabilities/${modelId}`)) + await confirmMessageBox(page, /确定/) + const deleteResponse = await deleteResponsePromise + await responseData(deleteResponse, 200, '页面删除安全模型') + const deletedModelId = modelId + modelId = '' + await expect(page.locator('.capability-card').filter({ hasText: modelName })).toHaveCount(0) + const absent = await request.get(`/api/v1/capabilities/${encodeURIComponent(deletedModelId)}`, { headers }) + expect(absent.status()).toBe(404) + addPass(activities, '页面删除并确认 API 不可读取', deleteResponse, deletedModelId) + await captureScreenshot(page, testInfo, 'AI模型-06-删除后无残留') + } catch (error) { + primaryError = error + activities.push({ module: 'AI模型', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + if (modelId) await cleanupModels(request, headers, activities, cleanupErrors) + await attachLifecycleReport(testInfo, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, 'AI 模型测试资源必须清理干净').toEqual([]) +}) diff --git a/tests/assets-knowledge-ui.spec.ts b/tests/assets-knowledge-ui.spec.ts new file mode 100644 index 0000000..681e91d --- /dev/null +++ b/tests/assets-knowledge-ui.spec.ts @@ -0,0 +1,553 @@ +import type { APIRequestContext, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin, visibleDialog } from './helpers' + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +type JsonRecord = Record +type Headers = Record + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface ResponseLike { + status(): number + json(): Promise +} + +interface DictionaryItem { + id?: string + kind: 'term' | 'homophone' + term?: string + wrongText?: string + rightText?: string + enabled: boolean + builtIn?: boolean +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function responseData(response: ResponseLike, expected: number | number[], label: string) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + return asRecord(envelopeData(await response.json())) +} + +function addPass( + activities: Activity[], + module: string, + action: string, + response?: ResponseLike, + resourceId?: string, + detail?: string, +) { + activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail }) +} + +async function attachLifecycleReport( + testInfo: TestInfo, + title: string, + activities: Activity[], + cleanupErrors: string[], +) { + await attachJson(testInfo, `${title}-页面生命周期与清理`, { + runId, + runPrefix, + title, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '报告未记录管理员密码、访问令牌或外部服务密钥。', + }) +} + +async function waitForDocumentReady( + request: APIRequestContext, + headers: Headers, + documentId: string, + timeoutMs = 60_000, +) { + const deadline = Date.now() + timeoutMs + let latest: JsonRecord = {} + while (Date.now() < deadline) { + const response = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers }) + latest = await responseData(response, 200, '等待知识资料建立索引') + const status = String(latest.status ?? '').toLowerCase() + if (status === 'ready') return latest + if (status === 'failed') throw new Error(`知识资料建立索引失败:${String(latest.errorMessage ?? '未返回原因')}`) + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`知识资料在 ${timeoutMs}ms 内未进入 ready,最后状态:${String(latest.status ?? 'unknown')}`) +} + +async function cleanupKnowledgeByPrefix( + request: APIRequestContext, + headers: Headers, + activities: Activity[], + cleanupErrors: string[], +) { + try { + const listed = await request.get('/api/v1/knowledge/bases', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '200' }, + }) + const page = await responseData(listed, 200, '清理前读取知识库') + for (const base of asRecords(page.items)) { + const baseId = String(base.id ?? '') + if (!baseId || !String(base.name ?? '').startsWith(runPrefix)) continue + const documentsResponse = await request.get('/api/v1/knowledge/documents', { + headers, + params: { knowledgeBaseId: baseId, page: '1', pageSize: '200' }, + }) + if (documentsResponse.status() === 200) { + const documents = asRecord(envelopeData(await documentsResponse.json())) + for (const item of asRecords(documents.items)) { + const documentId = String(item.id ?? '') + if (!documentId) continue + const detailResponse = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers }) + if (detailResponse.status() !== 200) continue + const detail = asRecord(envelopeData(await detailResponse.json())) + const removed = await request.delete(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`资料 ${documentId} 删除失败:HTTP ${removed.status()}`) + activities.push({ module: '维修知识', action: '资料失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: documentId }) + } + } + const currentList = await request.get('/api/v1/knowledge/bases', { + headers, + params: { keyword: String(base.name ?? runPrefix), page: '1', pageSize: '200' }, + }) + const currentPage = asRecord(envelopeData(await currentList.json())) + const current = asRecords(currentPage.items).find((item) => String(item.id) === baseId) + if (!current) continue + const removedBase = await request.delete(`/api/v1/knowledge/bases/${encodeURIComponent(baseId)}`, { + headers, + params: { dataVersion: String(Number(current.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removedBase.status())) throw new Error(`知识库 ${baseId} 删除失败:HTTP ${removedBase.status()}`) + activities.push({ module: '维修知识', action: '知识库失败兜底清理', result: 'CLEANED', httpStatus: removedBase.status(), resourceId: baseId }) + } + } catch (error) { + const message = `知识库兜底清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '维修知识', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) + } +} + +const dictionaryItem = (value: JsonRecord): DictionaryItem => ({ + id: String(value.id ?? '') || undefined, + kind: String(value.kind) === 'homophone' ? 'homophone' : 'term', + term: value.term == null ? undefined : String(value.term), + wrongText: value.wrongText == null ? undefined : String(value.wrongText), + rightText: value.rightText == null ? undefined : String(value.rightText), + enabled: value.enabled !== false, + builtIn: value.builtIn == null ? undefined : Boolean(value.builtIn), +}) + +const dictionaryKey = (item: DictionaryItem) => item.kind === 'term' + ? `term:${item.term ?? ''}` + : `homophone:${item.wrongText ?? ''}=>${item.rightText ?? ''}` + +const dictionarySummary = (items: DictionaryItem[]) => items + .map((item) => ({ + kind: item.kind, + term: item.term ?? '', + wrongText: item.wrongText ?? '', + rightText: item.rightText ?? '', + enabled: item.enabled, + builtIn: Boolean(item.builtIn), + })) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))) + +async function getDictionary(request: APIRequestContext, headers: Headers) { + const response = await request.get('/api/v1/asr-dictionaries', { headers }) + const payload = await responseData(response, 200, '读取 ASR 词典') + return { + items: asRecords(payload.items).map(dictionaryItem), + dataVersion: Number(payload.dataVersion ?? 0), + } +} + +async function saveDictionarySnapshot( + request: APIRequestContext, + headers: Headers, + desired: DictionaryItem[], + current: { items: DictionaryItem[]; dataVersion: number }, +) { + const ids = new Map(current.items.map((item) => [dictionaryKey(item), item.id])) + return request.put('/api/v1/asr-dictionaries', { + headers, + data: { + items: desired.map((item) => ({ + ...(ids.get(dictionaryKey(item)) ? { id: ids.get(dictionaryKey(item)) } : {}), + kind: item.kind, + ...(item.kind === 'term' + ? { term: item.term } + : { wrongText: item.wrongText, rightText: item.rightText }), + enabled: item.enabled, + builtIn: item.builtIn, + })), + dataVersion: current.dataVersion, + }, + }) +} + +async function restoreDictionary( + request: APIRequestContext, + headers: Headers, + snapshot: DictionaryItem[], +) { + let current = await getDictionary(request, headers) + let response = await saveDictionarySnapshot(request, headers, snapshot, current) + await responseData(response, 200, '恢复 ASR 词典原始内容') + + // 页面保存会重新生成词典行。若原快照含内置标记,先让服务恢复标记,再按快照做最后一次精确裁剪。 + if (snapshot.some((item) => item.builtIn)) { + response = await request.post('/api/v1/asr-dictionaries/restore-built-in', { headers, data: {} }) + await responseData(response, 200, '恢复 ASR 内置词条标记') + current = await getDictionary(request, headers) + response = await saveDictionarySnapshot(request, headers, snapshot, current) + await responseData(response, 200, '按原快照精确裁剪 ASR 词典') + } + return response +} + +async function cleanupCapabilityByPrefix( + request: APIRequestContext, + headers: Headers, + activities: Activity[], + cleanupErrors: string[], +) { + try { + const response = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType: 'ASR', keyword: runPrefix, page: '1', pageSize: '200' }, + }) + const page = await responseData(response, 200, '清理前读取 ASR 能力') + for (const item of asRecords(page.items ?? page.records)) { + const id = String(item.id ?? '') + if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue + const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers }) + if (detailResponse.status() === 404) continue + const detail = await responseData(detailResponse, 200, '清理前刷新 ASR 能力版本') + const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`ASR 能力 ${id} 删除失败:HTTP ${removed.status()}`) + activities.push({ module: 'ASR模型', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) + } + } catch (error) { + const message = `ASR 能力兜底清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: 'ASR模型', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) + } +} + +test('维修知识页面:个人库、资料重建、启停、问答引用、下载与删除完整闭环', async ({ page, request }, testInfo) => { + test.setTimeout(150_000) + const headers = await authHeaders(request) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const baseName = `${runPrefix}-液压安全知识库`.slice(0, 80) + const documentName = `${runPrefix}-液压安全规程.txt`.slice(0, 150) + const editedDocumentName = `${runPrefix}-液压安全规程-已重建.txt`.slice(0, 150) + const uniqueRule = `${runPrefix}液压安全口令:拆卸测试液压管路前必须先停机、断电、验电、完全卸压、挂牌上锁,并确认压力表归零。` + let baseId = '' + let documentId = '' + let primaryError: unknown + + await cleanupKnowledgeByPrefix(request, headers, activities, cleanupErrors) + cleanupErrors.length = 0 + activities.length = 0 + + try { + await loginAsAdmin(page) + await page.goto('/knowledge/library') + await page.getByRole('tab', { name: '用户自建' }).click() + await expect(page.getByRole('button', { name: /新建知识库/ })).toBeVisible() + + await page.getByRole('button', { name: /新建知识库/ }).click() + const createDialog = visibleDialog(page) + await createDialog.locator('label').filter({ hasText: '知识库名称' }).locator('input').fill(baseName) + await createDialog.locator('label').filter({ hasText: '知识库说明' }).locator('textarea').fill(`${runPrefix} 页面提交与持久化验证`) + await createDialog.locator('label').filter({ hasText: '专业分类' }).locator('select').selectOption({ label: '液压气动' }) + await createDialog.locator('label').filter({ hasText: '标签' }).locator('input').fill('液压,安全,APE2E') + const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/knowledge\/bases(?:\?|$)/.test(response.url())) + await createDialog.getByRole('button', { name: '创建并进入' }).click() + const createResponse = await createResponsePromise + const createdBase = await responseData(createResponse, 201, '页面创建个人知识库') + baseId = String(createdBase.id ?? '') + expect(baseId).not.toBe('') + await expect(page.getByRole('heading', { name: baseName })).toBeVisible() + addPass(activities, '维修知识', '页面创建个人知识库并进入详情', createResponse, baseId) + await captureScreenshot(page, testInfo, '01-personal-knowledge-base-created') + + const uploadResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/knowledge\/documents(?:\?|$)/.test(response.url())) + await page.locator('input[type="file"]').setInputFiles({ + name: documentName, + mimeType: 'text/plain', + buffer: Buffer.from(`${uniqueRule}\n复核要求:作业完成后空载试运行并记录压力、温升和异常声响。`, 'utf8'), + }) + const uploadResponse = await uploadResponsePromise + const uploadPayload = await responseData(uploadResponse, 201, '页面上传知识资料') + documentId = String(asRecord(uploadPayload.document).id ?? '') + expect(documentId).not.toBe('') + await waitForDocumentReady(request, headers, documentId) + await page.reload() + const uploadedRow = page.locator('.knowledge-document-list article').filter({ hasText: documentName }) + await expect(uploadedRow).toContainText(/个片段/) + addPass(activities, '维修知识', 'TXT 上传、后台索引并刷新为可用', uploadResponse, documentId) + await captureScreenshot(page, testInfo, '02-document-uploaded-ready') + + await uploadedRow.getByRole('button', { name: '编辑资料' }).click() + let editor = visibleDialog(page) + await expect(editor.getByText('正在读取资料正文…')).toHaveCount(0) + await editor.locator('label').filter({ hasText: '资料名称' }).locator('input').fill(editedDocumentName) + await editor.locator('label').filter({ hasText: '资料状态' }).locator('select').selectOption({ label: '禁用' }) + await editor.locator('label').filter({ hasText: '参考资料正文' }).locator('textarea').fill(`${uniqueRule}\n编辑确认:禁用资料不参与问答,重新启用后使用最新正文。`) + let updateResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/knowledge/documents/${documentId}`)) + await editor.getByRole('button', { name: '保存并重建索引' }).click() + let updateResponse = await updateResponsePromise + await responseData(updateResponse, 200, '页面编辑并禁用知识资料') + await waitForDocumentReady(request, headers, documentId) + await page.reload() + let documentRow = page.locator('.knowledge-document-list article').filter({ hasText: editedDocumentName }) + await expect(documentRow).toContainText('已禁用') + addPass(activities, '维修知识', '编辑正文、重建索引并禁用', updateResponse, documentId) + await captureScreenshot(page, testInfo, '03-document-edited-disabled') + + await documentRow.getByRole('button', { name: '编辑资料' }).click() + editor = visibleDialog(page) + await expect(editor.getByText('正在读取资料正文…')).toHaveCount(0) + await editor.locator('label').filter({ hasText: '资料状态' }).locator('select').selectOption({ label: '启用并参与问答' }) + updateResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/knowledge/documents/${documentId}`)) + await editor.getByRole('button', { name: '保存并重建索引' }).click() + updateResponse = await updateResponsePromise + await responseData(updateResponse, 200, '页面重新启用知识资料') + await waitForDocumentReady(request, headers, documentId) + await page.reload() + documentRow = page.locator('.knowledge-document-list article').filter({ hasText: editedDocumentName }) + await expect(documentRow).toContainText(/个片段/) + await expect(documentRow).not.toContainText('已禁用') + addPass(activities, '维修知识', '重新启用并刷新验证持久化', updateResponse, documentId) + await captureScreenshot(page, testInfo, '04-document-enabled-persisted') + + const question = page.getByLabel('知识库问题') + await question.fill(`${runPrefix}液压安全口令要求完成哪些步骤?`) + const answerResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/api/v1/knowledge/answer')) + await page.getByRole('button', { name: '生成有依据回答' }).click() + const answerResponse = await answerResponsePromise + const answer = await responseData(answerResponse, 200, '页面知识问答') + expect(String(answer.answer ?? '')).toContain(runPrefix) + const citations = page.locator('.knowledge-citations button:not(:disabled)') + await expect(page.locator('.knowledge-answer')).toBeVisible() + await expect(citations.first()).toBeVisible() + addPass(activities, '维修知识', '真实问答命中最新正文并显示引用', answerResponse, documentId, `引用 ${asRecords(answer.results).length} 条`) + await captureScreenshot(page, testInfo, '05-answer-with-citation') + + const downloadPromise = page.waitForEvent('download') + await citations.first().click() + const download = await downloadPromise + expect(download.suggestedFilename()).toContain('.txt') + addPass(activities, '维修知识', '从引用下载受保护原文', undefined, documentId) + await captureScreenshot(page, testInfo, '06-citation-download-complete') + + await documentRow.getByRole('button', { name: '编辑资料' }).click() + editor = visibleDialog(page) + await expect(editor.getByText('正在读取资料正文…')).toHaveCount(0) + await editor.getByRole('button', { name: '删除资料' }).click() + const deleteDocumentResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/knowledge/documents/${documentId}`)) + await confirmMessageBox(page, '删除') + const deleteDocumentResponse = await deleteDocumentResponsePromise + await responseData(deleteDocumentResponse, 200, '页面删除知识资料') + const deletedDocumentId = documentId + documentId = '' + await expect(page.locator('.knowledge-document-list article').filter({ hasText: editedDocumentName })).toHaveCount(0) + addPass(activities, '维修知识', '页面删除知识资料', deleteDocumentResponse, deletedDocumentId) + await captureScreenshot(page, testInfo, '07-document-deleted') + + await page.getByRole('button', { name: /删除知识库/ }).click() + const deleteBaseResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/knowledge/bases/${baseId}`)) + await confirmMessageBox(page, '确认删除') + const deleteBaseResponse = await deleteBaseResponsePromise + await responseData(deleteBaseResponse, 200, '页面删除个人知识库') + const deletedBaseId = baseId + baseId = '' + await expect(page).toHaveURL(/\/knowledge\/library(?:\?|$)/) + await page.getByRole('tab', { name: '用户自建' }).click() + await expect(page.locator('.knowledge-base-card').filter({ hasText: baseName })).toHaveCount(0) + addPass(activities, '维修知识', '页面删除个人知识库并从列表消失', deleteBaseResponse, deletedBaseId) + await captureScreenshot(page, testInfo, '08-knowledge-base-deleted') + } catch (error) { + primaryError = error + activities.push({ module: '维修知识', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + if (baseId || documentId) await cleanupKnowledgeByPrefix(request, headers, activities, cleanupErrors) + await attachLifecycleReport(testInfo, '维修知识UI', activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '知识库测试资源必须清理干净').toEqual([]) +}) + +test('ASR能力页面:词典保存刷新恢复与模型创建、编辑、启停、筛选、删除', async ({ page, request }, testInfo) => { + test.setTimeout(120_000) + const headers = await authHeaders(request) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const capabilityName = `${runPrefix}-本地维修ASR`.slice(0, 90) + const testTerm = `${runPrefix}支腿阀组`.slice(0, 180) + const wrongText = `${runPrefix}之腿阀组`.slice(0, 180) + const rightText = `${runPrefix}支腿阀组`.slice(0, 180) + const dictionarySnapshot = await getDictionary(request, headers) + let capabilityId = '' + let primaryError: unknown + let dictionaryRestored = false + + await cleanupCapabilityByPrefix(request, headers, activities, cleanupErrors) + cleanupErrors.length = 0 + activities.length = 0 + + try { + await loginAsAdmin(page) + await page.goto('/assets/asr-models') + const dictionary = page.locator('.asr-dictionary') + await dictionary.locator('summary').click() + await expect(dictionary.getByRole('button', { name: '保存词典' })).toBeEnabled() + const termInput = dictionary.locator('label').filter({ hasText: '专有名词' }).locator('textarea') + const homophoneInput = dictionary.locator('label').filter({ hasText: '同音词纠正' }).locator('textarea') + const originalTerms = await termInput.inputValue() + const originalHomophones = await homophoneInput.inputValue() + await termInput.fill([originalTerms, testTerm].filter(Boolean).join('\n')) + await homophoneInput.fill([originalHomophones, `${wrongText}=${rightText}`].filter(Boolean).join('\n')) + const dictionarySaveResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && /\/api\/v1\/asr-dictionaries(?:\?|$)/.test(response.url())) + await dictionary.getByRole('button', { name: '保存词典' }).click() + const dictionarySaveResponse = await dictionarySaveResponsePromise + await responseData(dictionarySaveResponse, 200, '页面保存 ASR 词典') + await expect(page.getByText(/词典已保存/).last()).toBeVisible() + addPass(activities, 'ASR词典', '页面追加专有名词与同音词', dictionarySaveResponse) + await captureScreenshot(page, testInfo, '09-asr-dictionary-saved') + + await page.reload() + await dictionary.locator('summary').click() + await expect(termInput).toHaveValue(new RegExp(testTerm)) + await expect(homophoneInput).toHaveValue(new RegExp(wrongText)) + const persistedDictionary = await getDictionary(request, headers) + expect(persistedDictionary.items.some((item) => item.kind === 'term' && item.term === testTerm)).toBeTruthy() + expect(persistedDictionary.items.some((item) => item.kind === 'homophone' && item.wrongText === wrongText && item.rightText === rightText)).toBeTruthy() + addPass(activities, 'ASR词典', '页面刷新与 API 均保留词典改动') + await captureScreenshot(page, testInfo, '10-asr-dictionary-refresh-persisted') + + await page.getByRole('button', { name: /新增ASR模型/ }).click() + let dialog = visibleDialog(page) + await dialog.locator('.capability-form > label').filter({ hasText: '名称' }).locator('input').fill(capabilityName) + await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(`${runPrefix} 页面创建的本地识别能力`) + await dialog.locator('label.file-field').filter({ hasText: '识别热词' }).locator('textarea').fill(`${runPrefix}液压缸\n${runPrefix}连接销`) + await dialog.locator('.capability-form-technical summary').click() + await dialog.locator('.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-local-asr`) + const enabledSwitch = dialog.locator('.switch-field .el-switch') + if ((await enabledSwitch.getAttribute('class'))?.includes('is-checked')) await enabledSwitch.click() + const createResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/capabilities(?:\?|$)/.test(response.url())) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const createResponse = await createResponsePromise + const created = await responseData(createResponse, 201, '页面创建 ASR 能力') + capabilityId = String(created.id ?? '') + expect(capabilityId).not.toBe('') + let card = page.locator('.capability-card').filter({ hasText: capabilityName }) + await expect(card).toBeVisible() + await expect(card.locator('.status-pill')).toHaveText('已停用') + addPass(activities, 'ASR模型', '页面创建禁用状态的本地 ASR', createResponse, capabilityId) + await captureScreenshot(page, testInfo, '11-asr-model-created-disabled') + + await card.getByRole('button', { name: '编辑', exact: true }).click() + dialog = visibleDialog(page) + await dialog.locator('.capability-form > label').filter({ hasText: '说明' }).locator('textarea').fill(`${runPrefix} 已编辑、刷新后应保持`) + await dialog.locator('label.file-field').filter({ hasText: '识别热词' }).locator('textarea').fill(`${runPrefix}液压缸\n${runPrefix}连接销\n${runPrefix}蓄能器`) + const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${capabilityId}`) && !response.url().includes('/status')) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const editResponse = await editResponsePromise + const edited = await responseData(editResponse, 200, '页面编辑 ASR 能力') + expect(asRecords(edited.hotWords).length || (Array.isArray(edited.hotWords) ? edited.hotWords.length : 0)).toBe(3) + addPass(activities, 'ASR模型', '页面编辑说明与识别热词', editResponse, capabilityId) + + await page.reload() + const search = page.getByPlaceholder(/搜索ASR模型名称/) + await search.fill(capabilityName) + card = page.locator('.capability-card').filter({ hasText: capabilityName }) + await expect(card).toContainText('已编辑、刷新后应保持') + await expect(card).toContainText(`${runPrefix}蓄能器`) + addPass(activities, 'ASR模型', '刷新后编辑内容保持', undefined, capabilityId) + await captureScreenshot(page, testInfo, '12-asr-model-edit-persisted') + + const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' && response.url().includes(`/api/v1/capabilities/${capabilityId}/status`)) + await card.getByRole('button', { name: '启用', exact: true }).click() + const enableResponse = await enableResponsePromise + await responseData(enableResponse, 200, '页面启用 ASR 能力') + await expect(card.locator('.status-pill')).toHaveText('可用') + addPass(activities, 'ASR模型', '页面启用能力', enableResponse, capabilityId) + + await page.reload() + await search.fill(capabilityName) + card = page.locator('.capability-card').filter({ hasText: capabilityName }) + await expect(card.locator('.status-pill')).toHaveText('可用') + const statusFilter = page.getByLabel('状态筛选') + await statusFilter.selectOption('enabled') + await expect(card).toBeVisible() + await statusFilter.selectOption('disabled') + await expect(card).toHaveCount(0) + await expect(page.getByText('没有匹配的配置')).toBeVisible() + addPass(activities, 'ASR模型', '刷新验证启用状态并完成状态筛选', undefined, capabilityId) + await captureScreenshot(page, testInfo, '13-asr-status-filter') + + await statusFilter.selectOption('all') + card = page.locator('.capability-card').filter({ hasText: capabilityName }) + await card.getByRole('button', { name: '删除', exact: true }).click() + const deleteResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes(`/api/v1/capabilities/${capabilityId}`)) + await confirmMessageBox(page, /确定/) + const deleteResponse = await deleteResponsePromise + await responseData(deleteResponse, 200, '页面删除 ASR 能力') + const deletedCapabilityId = capabilityId + capabilityId = '' + await expect(page.locator('.capability-card').filter({ hasText: capabilityName })).toHaveCount(0) + const absent = await request.get(`/api/v1/capabilities/${encodeURIComponent(deletedCapabilityId)}`, { headers }) + expect(absent.status()).toBe(404) + addPass(activities, 'ASR模型', '页面删除并确认 API 不可读', deleteResponse, deletedCapabilityId) + await captureScreenshot(page, testInfo, '14-asr-model-deleted') + } catch (error) { + primaryError = error + activities.push({ module: 'ASR能力与词典', action: '页面完整闭环', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + if (capabilityId) await cleanupCapabilityByPrefix(request, headers, activities, cleanupErrors) + try { + await restoreDictionary(request, headers, dictionarySnapshot.items) + const restored = await getDictionary(request, headers) + expect(dictionarySummary(restored.items)).toEqual(dictionarySummary(dictionarySnapshot.items)) + dictionaryRestored = true + activities.push({ module: 'ASR词典', action: '精确恢复运行前语义快照', result: 'CLEANED', detail: `恢复 ${restored.items.length} 条词典数据` }) + } catch (error) { + const message = `ASR 词典恢复异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: 'ASR词典', action: '恢复原快照', result: 'CLEANUP_FAILED', detail: message }) + } + await attachLifecycleReport(testInfo, 'ASR能力与词典UI', activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(dictionaryRestored, 'ASR 词典必须恢复为运行前快照').toBeTruthy() + expect(cleanupErrors, 'ASR 测试能力与词典必须清理恢复').toEqual([]) +}) diff --git a/tests/auth-navigation.spec.ts b/tests/auth-navigation.spec.ts new file mode 100644 index 0000000..d88e31f --- /dev/null +++ b/tests/auth-navigation.spec.ts @@ -0,0 +1,129 @@ +import { captureScreenshot, expect, test } from './fixtures' +import { confirmMessageBox, loginAsAdmin, readAdminCredentials } from './helpers' + +// 认证用例会临时把真实凭据填入密码框。密码框本身不会进入成功截图, +// 同时关闭 trace/video,避免失败制品保存表单快照。 +test.use({ trace: 'off', video: 'off' }) + +const roleCases = [ + { code: 'administrative', label: '行政', accountLabel: '行政账号', organizationSelects: 1 }, + { code: 'teacher', label: '教员', accountLabel: '教员账号', organizationSelects: 2 }, + { code: 'student', label: '学员', accountLabel: '学员账号', organizationSelects: 0 }, + { code: 'admin', label: '管理员', accountLabel: '管理员账号', organizationSelects: 0 }, +] as const + +test('登录页按行政、教员、学员、管理员展示真实身份控件', async ({ page }, testInfo) => { + await page.goto('/login') + await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible() + await expect(page.locator('.login-role-options > button')).toHaveCount(4) + expect((await page.locator('.login-role-options > button').allTextContents()).map((item) => item.trim())) + .toEqual(roleCases.map((item) => item.label)) + await expect(page.getByRole('button', { name: /登录系统/ })).toBeDisabled() + await expect(page.getByText('管理员输入账号登录,其他身份请选择对应账号后输入密码。')).toBeVisible() + + for (const role of roleCases) { + await test.step(`${role.label}身份控件`, async () => { + await page.locator(`[data-role-code="${role.code}"]`).click() + await expect(page.locator(`[data-role-code="${role.code}"]`)).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('.login-role-fields')).toContainText(role.accountLabel) + + const username = page.locator('input[name="username"]') + if (role.code === 'admin') { + await expect(username).toBeVisible() + await expect(username).toHaveAttribute('placeholder', '请输入管理员账号') + await expect(page.locator('.login-role-fields .el-select')).toHaveCount(0) + } else { + await expect(username).toHaveCount(0) + // 非管理员由“组织选择(如需要)+ 账号选择”组成,不允许手工输入账号。 + await expect(page.locator('.login-role-fields .el-select')).toHaveCount(role.organizationSelects + 1) + await expect(page.locator('.login-identity-picker')).toBeVisible() + } + }) + } + + await page.locator('[data-role-code="admin"]').click() + const password = page.locator('input[name="password"]') + await expect(password).toHaveAttribute('type', 'password') + await page.getByRole('button', { name: '显示密码' }).click() + await expect(password).toHaveAttribute('type', 'text') + await page.getByRole('button', { name: '隐藏密码' }).click() + await expect(password).toHaveAttribute('type', 'password') + + await captureScreenshot(page, testInfo, 'login-four-identities') +}) + +test('错误密码被拒绝且不会建立登录会话', async ({ page }, testInfo) => { + const credentials = readAdminCredentials() + await page.goto('/login') + await page.locator('[data-role-code="admin"]').click() + await page.locator('input[name="username"]').fill(credentials.username) + await page.locator('input[name="password"]').fill('APE2E-invalid-password-2026') + await page.getByRole('button', { name: /登录系统/ }).click() + + await expect(page).toHaveURL(/\/login(?:\?|$)/) + await expect(page.locator('#login-error')).not.toBeEmpty() + await expect(page.locator('.app-shell')).toHaveCount(0) + + // 成功截图不保留任何账号或密码输入值。 + await page.locator('input[name="username"]').fill('') + await page.locator('input[name="password"]').fill('') + await captureScreenshot(page, testInfo, 'login-invalid-credential-rejected') +}) + +test('管理员可以登录、进入个人菜单并安全退出', async ({ page }, testInfo) => { + await loginAsAdmin(page) + await expect(page).toHaveURL(/\/overview(?:\?|$)/) + await expect(page.locator('.app-shell')).toBeVisible() + await expect(page.getByRole('heading', { name: '实时数字人智能体' })).toBeVisible() + await expect(page.locator('input[name="password"]')).toHaveCount(0) + await captureScreenshot(page, testInfo, 'admin-login-success') + + await page.locator('.user-menu-trigger').click() + await expect(page.locator('#user-popover')).toBeVisible() + await expect(page.getByRole('menuitem', { name: /个人中心/ })).toBeVisible() + await expect(page.getByRole('menuitem', { name: /退出登录/ })).toBeVisible() + await captureScreenshot(page, testInfo, 'admin-account-menu') + + await page.getByRole('menuitem', { name: /退出登录/ }).click() + await confirmMessageBox(page, '退出登录') + await expect(page).toHaveURL(/\/login(?:\?|$)/) + await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible() +}) + +test('兼容入口和未知地址重定向到正式路由', async ({ page }) => { + await loginAsAdmin(page) + + const redirects = [ + { source: '/agents/hot-words', target: /\/knowledge\/hot-words(?:\?|$)/ }, + { source: '/studio?mode=offline', target: /\/videos\/create\?.*mode=offline/ }, + { source: '/works', target: /\/agents\/manage(?:\?|$)/ }, + { source: '/videos', target: /\/videos\/manage(?:\?|$)/ }, + { source: '/avatars', target: /\/assets\/avatars(?:\?|$)/ }, + { source: '/knowledge', target: /\/knowledge\/library(?:\?|$)/ }, + { source: '/operations/sensitive-words', target: /\/knowledge\/sensitive-words(?:\?|$)/ }, + { source: '/organization/structure', target: /\/organization\/departments(?:\?|$)/ }, + { source: '/change-password', target: /\/overview(?:\?|$)/ }, + { source: '/APE2E-route-does-not-exist', target: /\/overview(?:\?|$)/ }, + ] + + for (const item of redirects) { + await test.step(item.source, async () => { + await page.goto(item.source) + await expect(page).toHaveURL(item.target) + await expect(page.locator('.app-shell')).toBeVisible() + }) + } + + await page.goto('/agents/create') + await expect(page).toHaveURL(/\/agents\/manage\?.*create=1/) + await expect(page.getByRole('dialog', { name: '新建智能体' })).toBeVisible() + await page.getByRole('button', { name: '取消', exact: true }).click() +}) + +test('兼容入口 /studio 应能解析到实时智能体创建入口', async ({ page }) => { + await loginAsAdmin(page) + await page.goto('/studio') + + await expect(page).toHaveURL(/\/agents\/manage\?.*create=1/) + await expect(page.getByRole('dialog', { name: '新建智能体' })).toBeVisible() +}) diff --git a/tests/avatar-capabilities-ui.spec.ts b/tests/avatar-capabilities-ui.spec.ts new file mode 100644 index 0000000..347402f --- /dev/null +++ b/tests/avatar-capabilities-ui.spec.ts @@ -0,0 +1,640 @@ +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync } from 'node:child_process' + +import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData, loginAsAdmin } from './helpers' + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +type JsonRecord = Record +type Headers = Record +type CapabilityType = 'VOICE_CLONE' | 'TTS' | 'SCENE' + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface AvatarPreferences { + defaultAvatarId: string + builtInVisible: boolean + dataVersion: number +} + +interface ResponseLike { + status(): number + json(): Promise +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function responseData(response: ResponseLike, expected: number | number[], label: string) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + return asRecord(envelopeData(await response.json())) +} + +function addPass( + activities: Activity[], + module: string, + action: string, + response?: ResponseLike, + resourceId?: string, + detail?: string, +) { + activities.push({ module, action, result: 'PASS', httpStatus: response?.status(), resourceId, detail }) +} + +async function attachLifecycleReport( + testInfo: TestInfo, + title: string, + activities: Activity[], + cleanupErrors: string[], +) { + await attachJson(testInfo, `${title}-生命周期与清理`, { + runId, + runPrefix, + title, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '报告不记录管理员密码、访问令牌或媒体签名地址。', + }) +} + +async function getAvatarPreferences(request: APIRequestContext, headers: Headers): Promise { + const response = await request.get('/api/v1/avatar-preferences', { headers }) + const value = await responseData(response, 200, '读取数字人平台偏好') + return { + defaultAvatarId: String(value.defaultAvatarId ?? ''), + builtInVisible: value.builtInVisible !== false, + dataVersion: Number(value.dataVersion ?? 0), + } +} + +async function restoreAvatarPreferences( + request: APIRequestContext, + headers: Headers, + snapshot: AvatarPreferences, +) { + const current = await getAvatarPreferences(request, headers) + const response = await request.put('/api/v1/avatar-preferences', { + headers, + data: { + defaultAvatarId: snapshot.defaultAvatarId, + builtInVisible: snapshot.builtInVisible, + dataVersion: current.dataVersion, + }, + }) + await responseData(response, 200, '恢复数字人平台偏好快照') + return response +} + +async function listAvatars(request: APIRequestContext, headers: Headers, keyword = '') { + const response = await request.get('/api/v1/avatars', { + headers, + params: { keyword, page: '1', pageSize: '200' }, + }) + const page = await responseData(response, 200, '读取数字形象列表') + return { response, items: asRecords(page.items ?? page.records) } +} + +async function cleanupAvatarsByPrefix( + request: APIRequestContext, + headers: Headers, + activities: Activity[], + cleanupErrors: string[], + ownedNames: Set, +) { + try { + const { items } = await listAvatars(request, headers, runPrefix) + for (const item of items) { + const id = String(item.id ?? '') + if (!id || !ownedNames.has(String(item.name ?? ''))) continue + const detailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(id)}`, { headers }) + if (detailResponse.status() === 404) continue + const detail = await responseData(detailResponse, 200, '数字人清理前刷新版本') + const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, + timeout: 120_000, + }) + if (![200, 404].includes(removed.status())) throw new Error(`数字人 ${id} 删除失败:HTTP ${removed.status()}`) + activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) + } + } catch (error) { + const message = `数字人兜底清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) + } +} + +async function listCapabilities( + request: APIRequestContext, + headers: Headers, + capabilityType: CapabilityType, + keyword = '', +) { + const response = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType, keyword, page: '1', pageSize: '200' }, + }) + const page = await responseData(response, 200, `读取 ${capabilityType} 能力列表`) + return { response, items: asRecords(page.items ?? page.records) } +} + +async function cleanupCapabilitiesByPrefix( + request: APIRequestContext, + headers: Headers, + activities: Activity[], + cleanupErrors: string[], + knownFileCodes: Set, +) { + for (const capabilityType of ['VOICE_CLONE', 'TTS', 'SCENE'] as const) { + try { + const { items } = await listCapabilities(request, headers, capabilityType, runPrefix) + for (const item of items) { + const id = String(item.id ?? '') + if (!id || !String(item.name ?? '').startsWith(runPrefix)) continue + const detailResponse = await request.get(`/api/v1/capabilities/${encodeURIComponent(id)}`, { headers }) + if (detailResponse.status() === 404) continue + const detail = await responseData(detailResponse, 200, '能力清理前刷新版本') + const mediaFileCode = String(detail.mediaFileCode ?? '') + if (mediaFileCode) knownFileCodes.add(mediaFileCode) + const removed = await request.delete(`/api/v1/capabilities/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`能力 ${id} 删除失败:HTTP ${removed.status()}`) + activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) + } + } catch (error) { + const message = `${capabilityType} 兜底清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: capabilityType, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) + } + } + + for (const fileCode of knownFileCodes) { + try { + const removed = await request.delete(`/api/v1/files/${encodeURIComponent(fileCode)}`, { headers }) + if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) + activities.push({ module: '能力媒体', action: '解除引用后清理上传文件', result: 'CLEANED', httpStatus: removed.status(), resourceId: fileCode }) + } catch (error) { + const message = `能力媒体 ${fileCode} 清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '能力媒体', action: '清理上传文件', result: 'CLEANUP_FAILED', resourceId: fileCode, detail: message }) + } + } +} + +function avatarCard(page: Page, name: string) { + return page.locator('.avatar-card').filter({ hasText: name }).first() +} + +function capabilityCard(page: Page, name: string) { + return page.locator('.capability-card').filter({ hasText: name }).first() +} + +function dialogField(dialog: Locator, label: string) { + return dialog.locator('.capability-form > label').filter({ hasText: label }).first() +} + +async function waitForToast(page: Page, text: string | RegExp) { + await expect(page.locator('.el-message').filter({ hasText: text }).last()).toBeVisible() +} + +async function deleteCapabilityThroughUi(page: Page, name: string) { + const card = capabilityCard(page, name) + await card.getByRole('button', { name: '删除' }).click() + const box = page.locator('.el-message-box:visible').last() + await expect(box).toBeVisible() + await box.getByRole('button', { name: /确定|确认/ }).click() + await waitForToast(page, '已删除') + await expect(capabilityCard(page, name)).toHaveCount(0) +} + +function createVoiceFixture(sourceVideo: string, outputPath: string) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + execFileSync(process.env.FFMPEG_BINARY?.trim() || 'ffmpeg', [ + '-y', '-hide_banner', '-loglevel', 'error', '-i', sourceVideo, + '-vn', '-t', '12', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', outputPath, + ], { stdio: 'pipe', timeout: 60_000, windowsHide: true }) + expect(fs.statSync(outputPath).size, '从原型本人视频提取的真实 WAV 音频不能为空').toBeGreaterThan(100_000) +} + +test('数字形象 UI:真实本人视频生成、筛选、预览、编辑、停启、默认设置与快照恢复', async ({ page, request }, testInfo) => { + test.setTimeout(480_000) + const headers = await authHeaders(request) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') + const originalName = `${runPrefix}-UI本人视频形象`.slice(0, 80) + const editedName = `${runPrefix}-UI液压维修教员`.slice(0, 80) + let preferenceSnapshot: AvatarPreferences | null = null + let primaryError: unknown + + expect(fs.existsSync(sourceVideo), `原型本人视频不存在:${sourceVideo}`).toBeTruthy() + + try { + const upstreamHealth = await request.get('/human/health') + expect(upstreamHealth.status(), '数字人生成上游健康检查必须成功').toBe(200) + addPass(activities, '数字形象', '真实上游健康检查', upstreamHealth) + + preferenceSnapshot = await getAvatarPreferences(request, headers) + activities.push({ module: '数字形象偏好', action: '保存默认形象与内置显示快照', result: 'PASS' }) + + await loginAsAdmin(page) + await page.goto('/assets/avatars') + await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() + + const builtInToggle = page.locator('.avatar-demo-toggle') + const builtInSwitch = builtInToggle.getByRole('switch') + const builtInSwitchControl = builtInToggle.locator('.el-switch') + if (!(await builtInSwitch.isChecked())) { + await builtInSwitchControl.click() + await waitForToast(page, '已显示内置示例形象') + } + + const { items: visibleAvatars } = await listAvatars(request, headers) + const previewAvatar = visibleAvatars.find((item) => [ + 'a1000000000000000000000000000001', + 'a1000000000000000000000000000002', + 'a1000000000000000000000000000003', + 'a1000000000000000000000000000004', + ].includes(String(item.id ?? ''))) + expect(previewAvatar, '平台应保留至少一个可播放口播样片的内置形象').toBeTruthy() + + const previewName = String(previewAvatar?.name ?? '') + const nameSearch = page.getByPlaceholder('搜索数字人名称') + await nameSearch.fill(previewName) + await expect(avatarCard(page, previewName)).toBeVisible() + const previewGender = String(previewAvatar?.gender ?? '') + if (previewGender === '男' || previewGender === '女') { + await page.locator('.avatar-gender-filter select').selectOption(previewGender) + await expect(avatarCard(page, previewName)).toBeVisible() + } + await captureScreenshot(page, testInfo, '01-avatar-name-and-gender-filter') + addPass(activities, '数字形象', '名称与性别组合筛选') + + await avatarCard(page, previewName).getByRole('button', { name: '预览口播' }).click() + const previewDialog = page.locator('.avatar-preview-dialog:visible').last() + const previewVideo = previewDialog.locator('video') + await expect(previewVideo).toBeVisible() + await expect(previewVideo).toHaveAttribute('src', /avatar-previews\/.+\.mp4/) + const previewSource = String(await previewVideo.getAttribute('src')) + const previewPoster = String(await previewVideo.getAttribute('poster')) + const sourceResponse = await request.get(previewSource) + expect(sourceResponse.status()).toBe(200) + expect(sourceResponse.headers()['content-type']).toContain('video/mp4') + const posterResponse = await request.get(previewPoster) + expect(posterResponse.status()).toBe(200) + expect(posterResponse.headers()['content-type']).toMatch(/^image\//) + await captureScreenshot(page, testInfo, '02-builtin-avatar-real-video-preview') + addPass(activities, '数字形象', '内置口播真实视频与封面预览', sourceResponse, String(previewAvatar?.id ?? ''), '已校验真实 MP4/封面响应;Playwright Chromium 的 H.264 解码能力不作为服务端文件可用性判据') + await previewDialog.locator('.el-dialog__headerbtn').click() + + await nameSearch.fill('') + await page.locator('.avatar-gender-filter select').selectOption('') + await page.getByRole('button', { name: '创建数字人' }).click() + const createDialog = page.locator('.avatar-create-dialog:visible').last() + await expect(createDialog).toBeVisible() + await createDialog.locator('input[type="file"]').setInputFiles(sourceVideo) + await createDialog.locator('.avatar-create-fields input').fill(originalName) + await createDialog.locator('details.avatar-create-advanced summary').click() + await createDialog.locator('select').selectOption('off') + await captureScreenshot(page, testInfo, '03-avatar-real-video-ready-to-submit') + await createDialog.getByRole('button', { name: '开始创建' }).click() + await expect(createDialog.getByText('创建完成,已加入形象库')).toBeVisible({ timeout: 240_000 }) + await captureScreenshot(page, testInfo, '04-avatar-upstream-job-succeeded') + await createDialog.getByRole('button', { name: '关闭', exact: true }).click() + + await nameSearch.fill(originalName) + const createdCard = avatarCard(page, originalName) + await expect(createdCard).toBeVisible() + await expect(createdCard).toContainText('可预览') + const { response: createdListResponse, items: createdItems } = await listAvatars(request, headers, originalName) + const createdAvatar = createdItems.find((item) => String(item.name ?? '') === originalName) + const avatarId = String(createdAvatar?.id ?? '') + expect(avatarId).not.toBe('') + expect(String(createdAvatar?.status ?? '').toLowerCase()).toBe('ready') + addPass(activities, '数字形象', '页面上传并等待正式任务进入 READY', createdListResponse, avatarId) + + await createdCard.getByRole('button', { name: '编辑' }).click() + const editDialog = page.locator('.admin-form-dialog:visible').last() + await editDialog.getByLabel('数字人名称').fill(editedName) + await editDialog.getByLabel('角色').fill('装备维修教员') + await editDialog.locator('.el-form-item').filter({ hasText: '性别' }).locator('.el-select').click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '男' }).click() + await editDialog.getByLabel('专业方向').fill('液压与电气检修实训') + const visibilityField = editDialog.locator('.avatar-visibility-field') + const visibilitySwitch = visibilityField.getByRole('switch') + if (await visibilitySwitch.isChecked()) await visibilityField.locator('.el-switch').click() + await captureScreenshot(page, testInfo, '05-avatar-profile-and-visibility-edit') + await editDialog.getByRole('button', { name: '保存修改' }).click() + await waitForToast(page, '数字人信息已更新') + + await nameSearch.fill(editedName) + const editedCard = avatarCard(page, editedName) + await expect(editedCard).toContainText('装备维修教员 · 液压与电气检修实训') + await expect(editedCard).toContainText('平台共享') + const detailAfterEdit = await responseData( + await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }), + 200, + '刷新验证数字人页面编辑', + ) + expect(detailAfterEdit).toMatchObject({ + name: editedName, + role: '装备维修教员', + specialty: '液压与电气检修实训', + gender: '男', + visibility: 'internal', + }) + addPass(activities, '数字形象', '页面编辑并刷新持久化', undefined, avatarId) + + await editedCard.getByRole('button', { name: '停用' }).click() + const disableBox = page.locator('.el-message-box:visible').last() + await expect(disableBox).toContainText('停用后不会出现在业务选择器中') + await disableBox.getByRole('button', { name: '确认停用' }).click() + await waitForToast(page, '数字人已停用') + await expect(avatarCard(page, editedName)).toContainText('已停用') + await captureScreenshot(page, testInfo, '06-avatar-disabled-through-ui') + await avatarCard(page, editedName).getByRole('button', { name: '启用' }).click() + await waitForToast(page, '数字人已启用') + await expect(avatarCard(page, editedName)).toContainText('可预览') + addPass(activities, '数字形象', '页面二次确认停用并重新启用', undefined, avatarId) + + await avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' }).click() + await waitForToast(page, new RegExp(`已将${editedName}设为平台默认数字人`)) + await expect(avatarCard(page, editedName)).toContainText('默认数字人') + const defaultPreferences = await getAvatarPreferences(request, headers) + expect(defaultPreferences.defaultAvatarId).toBe(avatarId) + addPass(activities, '数字形象偏好', '页面设为平台默认数字人', undefined, avatarId) + + await nameSearch.fill('') + const currentSwitchState = await builtInSwitch.isChecked() + await builtInSwitchControl.click() + await waitForToast(page, currentSwitchState ? '已隐藏内置示例形象' : '已显示内置示例形象') + expect(await builtInSwitch.isChecked()).toBe(!currentSwitchState) + await captureScreenshot(page, testInfo, '07-builtin-avatar-visibility-toggled') + addPass(activities, '数字形象偏好', '页面切换内置示例显示配置') + + const restoredResponse = await restoreAvatarPreferences(request, headers, preferenceSnapshot) + addPass(activities, '数字形象偏好', '按测试前快照恢复默认形象与内置显示', restoredResponse) + preferenceSnapshot = null + await page.reload() + await expect(page.getByRole('heading', { name: '数字形象' })).toBeVisible() + await page.getByPlaceholder('搜索数字人名称').fill(editedName) + await expect(avatarCard(page, editedName).getByRole('button', { name: '设为默认数字人' })).toBeEnabled() + + await avatarCard(page, editedName).getByRole('button', { name: '删除' }).click() + const deleteBox = page.locator('.el-message-box:visible').last() + await expect(deleteBox).toContainText(`确定删除“${editedName}”吗`) + await deleteBox.getByRole('button', { name: '确认删除' }).click() + await waitForToast(page, '数字人已删除') + await expect(avatarCard(page, editedName)).toHaveCount(0) + const absent = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) + expect(absent.status()).toBe(404) + await captureScreenshot(page, testInfo, '08-avatar-deleted-and-filter-empty') + addPass(activities, '数字形象', '页面删除且正式 API 不可再读取', absent, avatarId) + } catch (error) { + primaryError = error + activities.push({ module: '数字形象 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + if (preferenceSnapshot) { + try { + const restored = await restoreAvatarPreferences(request, headers, preferenceSnapshot) + activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANED', httpStatus: restored.status() }) + } catch (error) { + const message = `数字人偏好恢复异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '数字形象偏好', action: 'finally 快照恢复', result: 'CLEANUP_FAILED', detail: message }) + } + } + await cleanupAvatarsByPrefix(request, headers, activities, cleanupErrors, new Set([originalName, editedName])) + await attachLifecycleReport(testInfo, '数字形象 UI', activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '数字形象测试数据与平台偏好必须完整恢复').toEqual([]) +}) + +test('能力资产 UI:声音克隆、TTS 与场景素材的真实文件、编辑、筛选、预览、停启和删除闭环', async ({ page, request }, testInfo) => { + test.setTimeout(360_000) + const headers = await authHeaders(request) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const knownFileCodes = new Set() + const sourceVideo = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') + const sceneImage = path.resolve('../ai_person/public/brand/DA_bg.jpg') + const voiceFixture = testInfo.outputPath(`${runPrefix}-real-voice.wav`) + let primaryError: unknown + + expect(fs.existsSync(sourceVideo)).toBeTruthy() + expect(fs.existsSync(sceneImage)).toBeTruthy() + createVoiceFixture(sourceVideo, voiceFixture) + + const voiceName = `${runPrefix}-维修教员克隆音色`.slice(0, 50) + const voiceEditedName = `${runPrefix}-维修讲解克隆音色`.slice(0, 50) + const ttsName = `${runPrefix}-本地标准中文TTS`.slice(0, 50) + const ttsEditedName = `${runPrefix}-本地自然口播TTS`.slice(0, 50) + const sceneName = `${runPrefix}-数字车间背景`.slice(0, 50) + const sceneEditedName = `${runPrefix}-数字车间实训背景`.slice(0, 50) + + try { + await loginAsAdmin(page) + + await page.goto('/assets/voice-clones') + await expect(page.getByRole('heading', { name: '声音克隆' })).toBeVisible() + await page.getByRole('button', { name: /新建克隆音色/ }).click() + let dialog = page.locator('.admin-form-dialog:visible').last() + await dialogField(dialog, '名称').locator('input').fill(voiceName) + await dialogField(dialog, '分类').locator('select').selectOption('男声教员') + await dialogField(dialog, '说明').locator('textarea').fill('装备维修实训问答与安全口播音色') + await dialog.locator('.voice-capture-panel input[type="file"]').setInputFiles(voiceFixture) + await expect(dialog.locator('.voice-capture-panel audio')).toBeVisible() + await captureScreenshot(page, testInfo, '09-voice-clone-real-audio-before-save') + const voiceUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url())) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const voiceUpload = await voiceUploadPromise + const voiceFile = await responseData(voiceUpload, 201, '上传真实克隆音色样本') + knownFileCodes.add(String(voiceFile.id ?? '')) + await waitForToast(page, '配置已创建') + + let search = page.getByPlaceholder('搜索声音克隆名称、分类或说明') + await search.fill(voiceName) + let card = capabilityCard(page, voiceName) + await expect(card).toBeVisible() + const { response: voiceListResponse, items: voiceItems } = await listCapabilities(request, headers, 'VOICE_CLONE', voiceName) + const voiceId = String(voiceItems.find((item) => String(item.name ?? '') === voiceName)?.id ?? '') + expect(voiceId).not.toBe('') + addPass(activities, '声音克隆', '页面上传真实 WAV 并创建', voiceListResponse, voiceId) + + await card.getByRole('button', { name: '试听' }).click() + const audio = card.locator('.inline-media-preview audio') + await expect(audio).toBeVisible() + await expect(audio).toHaveAttribute('src', /.+/) + await expect.poll(() => audio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0) + await captureScreenshot(page, testInfo, '10-voice-clone-signed-audio-preview') + addPass(activities, '声音克隆', '短期签名音频真实试听', undefined, voiceId) + + await card.getByRole('button', { name: '编辑' }).click() + dialog = page.locator('.admin-form-dialog:visible').last() + await dialogField(dialog, '名称').locator('input').fill(voiceEditedName) + await dialogField(dialog, '分类').locator('select').selectOption('讲解播报') + await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于维修工序讲解与风险提示') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await waitForToast(page, '配置已更新') + await expect(dialog).toBeHidden() + await search.fill(voiceEditedName) + card = capabilityCard(page, voiceEditedName) + await expect(card).toContainText('讲解播报') + await expect(card).toContainText('已编辑:用于维修工序讲解与风险提示') + const editedAudio = card.locator('.inline-media-preview audio') + if (!(await editedAudio.count())) await card.getByRole('button', { name: '试听' }).click() + await expect(editedAudio).toBeVisible() + await expect.poll(() => editedAudio.evaluate((element) => (element as HTMLAudioElement).readyState)).toBeGreaterThan(0) + addPass(activities, '声音克隆', '页面编辑并刷新持久化', undefined, voiceId) + + await card.getByRole('button', { name: '停用' }).click() + await waitForToast(page, '已停用') + await page.getByLabel('状态筛选').selectOption('disabled') + await expect(capabilityCard(page, voiceEditedName)).toContainText('已停用') + await captureScreenshot(page, testInfo, '11-voice-clone-disabled-filter') + await capabilityCard(page, voiceEditedName).getByRole('button', { name: '启用' }).click() + await waitForToast(page, '已启用') + await page.getByLabel('状态筛选').selectOption('all') + addPass(activities, '声音克隆', '停用筛选并重新启用', undefined, voiceId) + await deleteCapabilityThroughUi(page, voiceEditedName) + addPass(activities, '声音克隆', '页面删除', undefined, voiceId) + + await page.goto('/assets/voice-models') + await expect(page.getByRole('heading', { name: '语音模型' })).toBeVisible() + await page.getByRole('button', { name: /新增语音模型/ }).click() + dialog = page.locator('.admin-form-dialog:visible').last() + await dialogField(dialog, '名称').locator('input').fill(ttsName) + await dialogField(dialog, '分类').locator('select').selectOption('标准中文') + await dialogField(dialog, '说明').locator('textarea').fill('本地实时问答标准中文播报') + await dialog.locator('details.capability-form-technical summary').click() + await dialog.locator('details.capability-form-technical input').fill(`${runPrefix.toLowerCase()}-tts-standard-zh`) + await captureScreenshot(page, testInfo, '12-tts-local-reference-before-save') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await waitForToast(page, '配置已创建') + + search = page.getByPlaceholder('搜索语音模型名称、分类或说明') + await search.fill(ttsName) + card = capabilityCard(page, ttsName) + await expect(card).toBeVisible() + const { response: ttsListResponse, items: ttsItems } = await listCapabilities(request, headers, 'TTS', ttsName) + const ttsId = String(ttsItems.find((item) => String(item.name ?? '') === ttsName)?.id ?? '') + expect(ttsId).not.toBe('') + addPass(activities, 'TTS', '页面创建正式本地能力引用', ttsListResponse, ttsId) + + await card.locator('details.capability-technical-info summary').click() + await card.getByRole('button', { name: '校验本地配置' }).click() + await waitForToast(page, /配置可用|校验通过/) + await card.getByRole('button', { name: '预览' }).click() + await waitForToast(page, new RegExp(`${ttsName}适合`)) + await card.getByRole('button', { name: '编辑' }).click() + dialog = page.locator('.admin-form-dialog:visible').last() + await dialogField(dialog, '名称').locator('input').fill(ttsEditedName) + await dialogField(dialog, '分类').locator('select').selectOption('自然口播') + await dialogField(dialog, '说明').locator('textarea').fill('已编辑:面向数字教员自然口播') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await waitForToast(page, '配置已更新') + await expect(dialog).toBeHidden() + await search.fill(ttsEditedName) + card = capabilityCard(page, ttsEditedName) + await expect(card).toContainText('自然口播') + await card.getByRole('button', { name: '停用' }).click() + await waitForToast(page, '已停用') + await card.getByRole('button', { name: '启用' }).click() + await waitForToast(page, '已启用') + await captureScreenshot(page, testInfo, '13-tts-edited-validated-and-enabled') + addPass(activities, 'TTS', '校验、预览、编辑、停用与启用', undefined, ttsId) + await deleteCapabilityThroughUi(page, ttsEditedName) + addPass(activities, 'TTS', '页面删除', undefined, ttsId) + + await page.goto('/assets/scene-materials') + await expect(page.getByRole('heading', { name: '场景素材' })).toBeVisible() + await page.getByRole('tab', { name: '背景图片' }).click() + await page.getByRole('button', { name: /新增场景素材/ }).click() + dialog = page.locator('.admin-form-dialog:visible').last() + await dialogField(dialog, '名称').locator('input').fill(sceneName) + await dialogField(dialog, '分类').locator('select').selectOption('背景图片') + await dialogField(dialog, '说明').locator('textarea').fill('数字车间设备维修实训背景') + await dialog.locator('.file-field input[type="file"]').setInputFiles(sceneImage) + const formPreview = dialog.locator('.scene-form-preview') + await expect(formPreview).toBeVisible() + await expect.poll(() => formPreview.locator('.scene-form-foreground').evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await captureScreenshot(page, testInfo, '14-scene-real-image-upload-preview') + const sceneUploadPromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/api\/v1\/files(?:\?|$)/.test(response.url())) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const sceneUpload = await sceneUploadPromise + const sceneFile = await responseData(sceneUpload, 201, '上传真实场景图片') + knownFileCodes.add(String(sceneFile.id ?? '')) + await waitForToast(page, '配置已创建') + + search = page.getByPlaceholder('搜索场景素材名称、分类或说明') + await search.fill(sceneName) + card = capabilityCard(page, sceneName) + await expect(card).toBeVisible() + const { response: sceneListResponse, items: sceneItems } = await listCapabilities(request, headers, 'SCENE', sceneName) + const sceneId = String(sceneItems.find((item) => String(item.name ?? '') === sceneName)?.id ?? '') + expect(sceneId).not.toBe('') + addPass(activities, '场景素材', '页面上传真实图片并创建', sceneListResponse, sceneId) + + await card.getByRole('button', { name: '预览' }).click() + const scenePreview = card.locator('.inline-media-preview.scene-preview img') + await expect(scenePreview).toBeVisible() + await expect.poll(() => scenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await captureScreenshot(page, testInfo, '15-scene-signed-image-list-preview') + await card.getByRole('button', { name: '编辑' }).click() + dialog = page.locator('.admin-form-dialog:visible').last() + await dialogField(dialog, '名称').locator('input').fill(sceneEditedName) + await dialogField(dialog, '说明').locator('textarea').fill('已编辑:用于液压与电气联合检修实训') + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await waitForToast(page, '配置已更新') + await expect(dialog).toBeHidden() + await search.fill(sceneEditedName) + card = capabilityCard(page, sceneEditedName) + await expect(card).toContainText('已编辑:用于液压与电气联合检修实训') + const editedScenePreview = card.locator('.inline-media-preview.scene-preview img') + if (!(await editedScenePreview.count())) await card.getByRole('button', { name: '预览' }).click() + await expect(editedScenePreview).toBeVisible() + await expect.poll(() => editedScenePreview.evaluate((element) => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0) + await card.getByRole('button', { name: '停用' }).click() + await waitForToast(page, '已停用') + await page.getByLabel('状态筛选').selectOption('disabled') + await expect(capabilityCard(page, sceneEditedName)).toBeVisible() + await captureScreenshot(page, testInfo, '16-scene-edited-disabled-and-filtered') + await capabilityCard(page, sceneEditedName).getByRole('button', { name: '启用' }).click() + await waitForToast(page, '已启用') + await page.getByLabel('状态筛选').selectOption('all') + addPass(activities, '场景素材', '分类筛选、预览、编辑、停用与启用', undefined, sceneId) + await deleteCapabilityThroughUi(page, sceneEditedName) + addPass(activities, '场景素材', '页面删除', undefined, sceneId) + } catch (error) { + primaryError = error + activities.push({ module: '能力资产 UI', action: '主流程', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + fs.rmSync(voiceFixture, { force: true }) + await cleanupCapabilitiesByPrefix(request, headers, activities, cleanupErrors, knownFileCodes) + await attachLifecycleReport(testInfo, '能力资产 UI', activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '能力资产及上传媒体必须完整清理').toEqual([]) +}) diff --git a/tests/business-api-lifecycle.spec.ts b/tests/business-api-lifecycle.spec.ts new file mode 100644 index 0000000..7ffebf8 --- /dev/null +++ b/tests/business-api-lifecycle.spec.ts @@ -0,0 +1,1175 @@ +import fs from 'node:fs' +import path from 'node:path' + +import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test' + +import { attachJson, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData } from './helpers' + +type JsonRecord = Record +type Headers = Record + +interface VersionedRef { + id: string + dataVersion: number +} + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) + ? value.map(asRecord) + : [] + +const versioned = (value: unknown): VersionedRef => { + const item = asRecord(value) + const id = String(item.id ?? '') + const dataVersion = Number(item.dataVersion) + if (!id || !Number.isInteger(dataVersion) || dataVersion < 0) { + throw new Error('API 响应缺少有效的资源 id 或 dataVersion') + } + return { id, dataVersion } +} + +const assertStatus = (response: APIResponse, expected: number | number[], label: string) => { + const values = Array.isArray(expected) ? expected : [expected] + expect(values, `${label}:HTTP ${response.status()}`).toContain(response.status()) +} + +async function responseData( + response: APIResponse, + expected: number | number[], + label: string, +): Promise { + assertStatus(response, expected, label) + let body: unknown + try { + body = await response.json() + } catch { + throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) + } + return envelopeData(body) +} + +const addPass = ( + activities: Activity[], + module: string, + action: string, + response: APIResponse, + resourceId?: string, + detail?: string, +) => activities.push({ + module, + action, + result: 'PASS', + httpStatus: response.status(), + resourceId, + detail, +}) + +async function attachCaseReport( + testInfo: TestInfo, + title: string, + activities: Activity[], + cleanupErrors: string[], +) { + await attachJson(testInfo, `${title}-生命周期与清理`, { + runId, + runPrefix, + title, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '报告未记录登录密码、访问令牌、智能体一次性 API Key 或请求头。', + }) +} + +async function cleanupVersioned( + request: APIRequestContext, + headers: Headers, + reference: VersionedRef | null, + module: string, + detailPath: (id: string) => string, + deletePath: (id: string) => string, + activities: Activity[], + cleanupErrors: string[], +) { + if (!reference) return + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + const detail = await request.get(detailPath(reference.id), { headers }) + if (detail.status() === 404) { + activities.push({ module, action: '失败兜底清理', result: 'CLEANED', resourceId: reference.id, detail: '资源已不存在' }) + return + } + if (detail.status() !== 200) { + const message = `${module} 清理读取失败:HTTP ${detail.status()}` + cleanupErrors.push(message) + activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: detail.status(), resourceId: reference.id, detail: message }) + return + } + const current = versioned(envelopeData(await detail.json())) + const removed = await request.delete(deletePath(reference.id), { + headers, + params: { dataVersion: String(current.dataVersion) }, + }) + if (removed.status() === 200 || removed.status() === 404) { + const absent = await request.get(detailPath(reference.id), { headers }) + if (absent.status() === 404) { + activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: reference.id }) + return + } + } + if (removed.status() !== 409 || attempt === 1) { + const message = `${module} 清理删除失败:HTTP ${removed.status()}` + cleanupErrors.push(message) + activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: reference.id, detail: message }) + return + } + } + } catch (error) { + const message = `${module} 清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: reference.id, detail: message }) + } +} + +async function cleanupKnowledgeBase( + request: APIRequestContext, + headers: Headers, + reference: VersionedRef | null, + activities: Activity[], + cleanupErrors: string[], +) { + if (!reference) return + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + const listed = await request.get('/api/v1/knowledge/bases', { + headers, + params: { page: '1', pageSize: '200', keyword: runPrefix }, + }) + if (listed.status() !== 200) { + const message = `知识库清理读取失败:HTTP ${listed.status()}` + cleanupErrors.push(message) + activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: listed.status(), resourceId: reference.id, detail: message }) + return + } + const page = asRecord(envelopeData(await listed.json())) + const current = asRecords(page.items).find((item) => String(item.id ?? '') === reference.id) + if (!current) { + activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANED', resourceId: reference.id, detail: '资源已不存在' }) + return + } + const removed = await request.delete(`/api/v1/knowledge/bases/${reference.id}`, { + headers, + params: { dataVersion: String(Number(current.dataVersion)) }, + }) + if (removed.status() === 200 || removed.status() === 404) { + activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: reference.id }) + return + } + if (removed.status() !== 409 || attempt === 1) { + const message = `知识库清理删除失败:HTTP ${removed.status()}` + cleanupErrors.push(message) + activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', httpStatus: removed.status(), resourceId: reference.id, detail: message }) + return + } + } + } catch (error) { + const message = `知识库清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: '知识库', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: reference.id, detail: message }) + } +} + +async function waitForDocumentReady( + request: APIRequestContext, + headers: Headers, + documentId: string, + timeoutMs = 30_000, +) { + const deadline = Date.now() + timeoutMs + let latest: JsonRecord = {} + while (Date.now() < deadline) { + const response = await request.get(`/api/v1/knowledge/documents/${documentId}`, { headers }) + latest = await responseData(response, 200, '读取知识资料处理状态') + const status = String(latest.status ?? '').toLowerCase() + if (status === 'ready') return latest + if (status === 'failed') throw new Error(`知识资料处理失败:${String(latest.errorMessage ?? '未返回原因')}`) + await new Promise((resolve) => setTimeout(resolve, 300)) + } + throw new Error(`知识资料在 ${timeoutMs}ms 内未进入 ready 状态,最后状态:${String(latest.status ?? 'unknown')}`) +} + +test.describe('正式业务 API 数据生命周期', () => { + test('能力资产:LLM 创建、读取、编辑、状态、校验与删除', async ({ request }, testInfo) => { + const module = '能力资产' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + let resource: VersionedRef | null = null + let primaryError: unknown + + try { + const createdResponse = await request.post('/api/v1/capabilities', { + headers, + data: { + capabilityType: 'LLM', + name: `${runPrefix}-本地语言模型`, + type: '本地模型', + description: 'APE2E 生命周期验证,不调用外部模型。', + reference: `${runPrefix.toLowerCase()}-local-llm`, + embedUrl: '', + hotWords: [], + enabled: false, + }, + }) + const created = await responseData(createdResponse, 201, '创建 LLM 能力') + resource = versioned(created) + expect(created.capabilityType).toBe('LLM') + expect(created.enabled).toBe(false) + addPass(activities, module, '创建禁用的本地 LLM 能力', createdResponse, resource.id) + + const detailResponse = await request.get(`/api/v1/capabilities/${resource.id}`, { headers }) + const detail = await responseData(detailResponse, 200, '读取 LLM 能力') + expect(detail.name).toBe(`${runPrefix}-本地语言模型`) + addPass(activities, module, '详情读取', detailResponse, resource.id) + + const listResponse = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType: 'LLM', keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const page = asRecord(await responseData(listResponse, 200, '筛选 LLM 能力')) + expect(asRecords(page.items).some((item) => String(item.id) === resource?.id)).toBeTruthy() + addPass(activities, module, '列表筛选', listResponse, resource.id) + + const updateResponse = await request.put(`/api/v1/capabilities/${resource.id}`, { + headers, + data: { + name: `${runPrefix}-本地语言模型-修订`, + description: 'APE2E 已提交编辑。', + dataVersion: resource.dataVersion, + }, + }) + const updated = await responseData(updateResponse, 200, '编辑 LLM 能力') + const staleVersion = resource.dataVersion + resource = versioned(updated) + expect(updated.description).toBe('APE2E 已提交编辑。') + addPass(activities, module, '编辑并推进数据版本', updateResponse, resource.id) + + const staleResponse = await request.put(`/api/v1/capabilities/${resource.id}/status`, { + headers, + data: { enabled: true, dataVersion: staleVersion }, + }) + assertStatus(staleResponse, 409, '能力资产过期版本必须冲突') + addPass(activities, module, 'CAS 过期写入拦截', staleResponse, resource.id) + + const enabledResponse = await request.put(`/api/v1/capabilities/${resource.id}/status`, { + headers, + data: { enabled: true, dataVersion: resource.dataVersion }, + }) + const enabled = await responseData(enabledResponse, 200, '启用 LLM 能力') + resource = versioned(enabled) + expect(enabled.enabled).toBe(true) + addPass(activities, module, '启用状态提交', enabledResponse, resource.id) + + const verifyResponse = await request.post(`/api/v1/capabilities/${resource.id}/verify`, { headers }) + const verified = await responseData(verifyResponse, 200, '校验本地 LLM 配置') + expect(verified.valid).toBe(true) + addPass(activities, module, '本地配置校验(无外部请求)', verifyResponse, resource.id) + + const refreshedResponse = await request.get(`/api/v1/capabilities/${resource.id}`, { headers }) + const refreshed = await responseData(refreshedResponse, 200, '刷新能力版本') + resource = versioned(refreshed) + expect(refreshed.validationStatus).toBe('AVAILABLE') + + const id = resource.id + const deleteResponse = await request.delete(`/api/v1/capabilities/${id}`, { + headers, + params: { dataVersion: String(resource.dataVersion) }, + }) + await responseData(deleteResponse, 200, '删除 LLM 能力') + resource = null + addPass(activities, module, '版本化删除', deleteResponse, id) + const absent = await request.get(`/api/v1/capabilities/${id}`, { headers }) + assertStatus(absent, 404, '删除后的能力不可读取') + addPass(activities, module, '删除后不可读取', absent, id) + } catch (error) { + primaryError = error + } finally { + await cleanupVersioned( + request, + headers, + resource, + module, + (id) => `/api/v1/capabilities/${id}`, + (id) => `/api/v1/capabilities/${id}`, + activities, + cleanupErrors, + ) + await attachCaseReport(testInfo, module, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '能力资产测试数据必须清理干净').toEqual([]) + }) + + test('知识库:知识库与 TXT 文档写入、检索问答、编辑和删除', async ({ request }, testInfo) => { + test.setTimeout(120_000) + const module = '知识库' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + let knowledgeBase: VersionedRef | null = null + let document: VersionedRef | null = null + let primaryError: unknown + const marker = `${runPrefix}-液压校验标记` + + try { + const baseResponse = await request.post('/api/v1/knowledge/bases', { + headers, + data: { + name: `${runPrefix}-设备检修知识库`, + description: 'APE2E 真实知识库数据提交。', + category: '端到端验证', + tags: ['APE2E', '设备检修'], + }, + }) + const base = await responseData(baseResponse, 201, '创建知识库') + knowledgeBase = versioned(base) + expect(base.scope).toBe('personal') + addPass(activities, module, '创建知识库', baseResponse, knowledgeBase.id) + + const baseListResponse = await request.get('/api/v1/knowledge/bases', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const basePage = asRecord(await responseData(baseListResponse, 200, '筛选知识库')) + expect(asRecords(basePage.items).some((item) => String(item.id) === knowledgeBase?.id)).toBeTruthy() + addPass(activities, module, '知识库列表筛选', baseListResponse, knowledgeBase.id) + + const baseUpdateResponse = await request.put(`/api/v1/knowledge/bases/${knowledgeBase.id}`, { + headers, + data: { + name: `${runPrefix}-设备检修知识库-修订`, + description: 'APE2E 已提交知识库编辑。', + category: '端到端验证', + tags: ['APE2E', '知识问答'], + enabled: true, + dataVersion: knowledgeBase.dataVersion, + }, + }) + const baseUpdated = await responseData(baseUpdateResponse, 200, '编辑知识库') + knowledgeBase = versioned(baseUpdated) + addPass(activities, module, '编辑知识库', baseUpdateResponse, knowledgeBase.id) + + const initialContent = `设备检修知识:${marker}。拆卸液压管路前必须停机、断电并完成卸压。` + const uploadResponse = await request.post('/api/v1/knowledge/documents', { + headers, + multipart: { + knowledgeBaseId: knowledgeBase.id, + file: { + name: `${runPrefix}-检修规程.txt`, + mimeType: 'text/plain', + buffer: Buffer.from(initialContent, 'utf8'), + }, + }, + }) + const uploaded = asRecord(await responseData(uploadResponse, 201, '上传 TXT 知识资料')) + const uploadedDocument = asRecord(uploaded.document) + document = versioned(uploadedDocument) + expect(String(asRecord(uploaded.job).id ?? '')).not.toBe('') + addPass(activities, module, '上传 TXT 并创建索引任务', uploadResponse, document.id) + + let current = await waitForDocumentReady(request, headers, document.id) + document = versioned(current) + expect(Number(current.chunkCount)).toBeGreaterThanOrEqual(1) + activities.push({ module, action: '轮询等待 TXT 索引 ready', result: 'PASS', resourceId: document.id }) + + const documentsResponse = await request.get('/api/v1/knowledge/documents', { + headers, + params: { knowledgeBaseId: knowledgeBase.id, page: '1', pageSize: '20' }, + }) + const documentsPage = asRecord(await responseData(documentsResponse, 200, '按知识库读取资料')) + expect(asRecords(documentsPage.items).some((item) => String(item.id) === document?.id)).toBeTruthy() + addPass(activities, module, '资料列表筛选', documentsResponse, document.id) + + const downloadResponse = await request.get(`/api/v1/knowledge/documents/${document.id}/file`, { headers }) + assertStatus(downloadResponse, 200, '下载 TXT 原始资料') + expect((await downloadResponse.body()).toString('utf8')).toContain(marker) + addPass(activities, module, '下载并核对 TXT 内容', downloadResponse, document.id) + + const searchResponse = await request.post('/api/v1/knowledge/search', { + headers, + data: { query: marker, knowledgeBaseId: knowledgeBase.id, documentIds: [document.id], topK: 5 }, + }) + const search = asRecord(await responseData(searchResponse, 200, '检索 TXT 知识')) + expect(asRecords(search.results).some((item) => String(item.documentId) === document?.id)).toBeTruthy() + addPass(activities, module, '本地知识检索', searchResponse, document.id) + + const policyResponse = await request.get('/api/v1/knowledge/provider-policy', { headers }) + const policy = await responseData(policyResponse, 200, '读取知识问答提供方策略') + const mode = String(policy.mode ?? 'local').toLowerCase() + if (policy.enabled === false || mode === 'local') { + const answerResponse = await request.post('/api/v1/knowledge/answer', { + headers, + data: { query: marker, knowledgeBaseId: knowledgeBase.id, documentIds: [document.id], topK: 5 }, + }) + const answer = asRecord(await responseData(answerResponse, 200, '执行本地知识问答')) + expect(String(answer.answer ?? '')).toContain(marker) + expect(asRecords(answer.results).some((item) => String(item.documentId) === document?.id)).toBeTruthy() + addPass(activities, module, '知识问答与引用核对', answerResponse, document.id, `providerMode=${mode}`) + } else { + activities.push({ + module, + action: '第三方/混合知识问答', + result: 'SKIP', + detail: `能力探测到 providerMode=${mode};为避免真实调用外部上游,仅完成本地 search 契约验证。`, + }) + } + + const updatedContent = `修订后的设备检修知识:${marker}-修订。复装后必须进行低压泄漏检测。` + const documentUpdateResponse = await request.put(`/api/v1/knowledge/documents/${document.id}`, { + headers, + data: { + name: `${runPrefix}-检修规程-修订.txt`, + enabled: true, + content: updatedContent, + dataVersion: document.dataVersion, + }, + }) + const documentUpdated = await responseData(documentUpdateResponse, 200, '编辑知识资料正文') + document = versioned(documentUpdated) + addPass(activities, module, '编辑 TXT 名称与正文', documentUpdateResponse, document.id) + current = await waitForDocumentReady(request, headers, document.id) + document = versioned(current) + expect(String(current.content ?? '')).toContain(`${marker}-修订`) + + const documentId = document.id + const documentDeleteResponse = await request.delete(`/api/v1/knowledge/documents/${documentId}`, { + headers, + params: { dataVersion: String(document.dataVersion) }, + }) + await responseData(documentDeleteResponse, 200, '删除知识资料') + document = null + addPass(activities, module, '删除 TXT 资料', documentDeleteResponse, documentId) + const documentAbsent = await request.get(`/api/v1/knowledge/documents/${documentId}`, { headers }) + assertStatus(documentAbsent, 404, '删除后的知识资料不可读取') + + const baseId = knowledgeBase.id + const baseDeleteResponse = await request.delete(`/api/v1/knowledge/bases/${baseId}`, { + headers, + params: { dataVersion: String(knowledgeBase.dataVersion) }, + }) + await responseData(baseDeleteResponse, 200, '删除知识库') + knowledgeBase = null + addPass(activities, module, '删除空知识库', baseDeleteResponse, baseId) + const afterDeleteResponse = await request.get('/api/v1/knowledge/bases', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '200' }, + }) + const afterDeletePage = asRecord(await responseData(afterDeleteResponse, 200, '复查知识库删除结果')) + expect(asRecords(afterDeletePage.items).some((item) => String(item.id) === baseId)).toBe(false) + addPass(activities, module, '删除后列表不可见', afterDeleteResponse, baseId) + } catch (error) { + primaryError = error + } finally { + await cleanupVersioned( + request, + headers, + document, + '知识资料', + (id) => `/api/v1/knowledge/documents/${id}`, + (id) => `/api/v1/knowledge/documents/${id}`, + activities, + cleanupErrors, + ) + await cleanupKnowledgeBase(request, headers, knowledgeBase, activities, cleanupErrors) + await attachCaseReport(testInfo, module, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '知识库测试数据必须清理干净').toEqual([]) + }) + + test('智能体:项目与 Draft 智能体的创建、编辑、成员关系和删除', async ({ request }, testInfo) => { + const module = '智能体项目' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + let project: VersionedRef | null = null + let agent: VersionedRef | null = null + let primaryError: unknown + + try { + const projectResponse = await request.post('/api/v1/agent-projects', { + headers, + data: { + name: `${runPrefix}-实训智能体项目`, + code: `${runPrefix.toLowerCase()}-agent-project`, + description: 'APE2E 项目创建数据。', + unit: '端到端测试单元', + status: 'enabled', + }, + }) + const projectCreated = await responseData(projectResponse, 201, '创建智能体项目') + project = versioned(projectCreated) + addPass(activities, module, '创建项目', projectResponse, project.id) + + const projectListResponse = await request.get('/api/v1/agent-projects', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const projectPage = asRecord(await responseData(projectListResponse, 200, '筛选智能体项目')) + expect(asRecords(projectPage.items).some((item) => String(item.id) === project?.id)).toBeTruthy() + addPass(activities, module, '项目列表筛选', projectListResponse, project.id) + + const projectUpdateResponse = await request.put(`/api/v1/agent-projects/${project.id}`, { + headers, + data: { + name: `${runPrefix}-实训智能体项目-修订`, + code: `${runPrefix.toLowerCase()}-agent-project`, + description: 'APE2E 已提交项目编辑。', + unit: '端到端测试单元', + status: 'enabled', + dataVersion: project.dataVersion, + }, + }) + project = versioned(await responseData(projectUpdateResponse, 200, '编辑智能体项目')) + addPass(activities, module, '编辑项目', projectUpdateResponse, project.id) + + const disabledResponse = await request.put(`/api/v1/agent-projects/${project.id}/status`, { + headers, + data: { status: 'disabled', dataVersion: project.dataVersion }, + }) + const disabled = await responseData(disabledResponse, 200, '停用智能体项目') + project = versioned(disabled) + expect(disabled.status).toBe('disabled') + addPass(activities, module, '停用项目', disabledResponse, project.id) + + const enabledResponse = await request.put(`/api/v1/agent-projects/${project.id}/status`, { + headers, + data: { status: 'enabled', dataVersion: project.dataVersion }, + }) + project = versioned(await responseData(enabledResponse, 200, '重新启用智能体项目')) + addPass(activities, module, '重新启用项目', enabledResponse, project.id) + + const agentResponse = await request.post('/api/v1/realtime-agents', { + headers, + data: { + name: `${runPrefix}-Draft数字教员`, + projectId: project.id, + description: 'APE2E Draft 智能体。', + interactionModes: ['text'], + expiresAt: '2030-08-15T12:00:00+08:00', + }, + }) + const agentCreated = await responseData(agentResponse, 201, '创建 Draft 智能体') + agent = versioned(agentCreated) + expect(agentCreated.status).toBe('draft') + addPass(activities, module, '创建 Draft 智能体(一次性 Key 不写报告)', agentResponse, agent.id) + + const agentDetailResponse = await request.get(`/api/v1/realtime-agents/${agent.id}`, { headers }) + const agentDetail = await responseData(agentDetailResponse, 200, '读取 Draft 智能体') + expect(agentDetail.projectId).toBe(project.id) + expect(agentDetail.apiKey).toBeUndefined() + expect(agentDetail.issuedApiKey).toBeUndefined() + addPass(activities, module, '详情读取且不重复泄露一次性 Key', agentDetailResponse, agent.id) + + const agentUpdateResponse = await request.put(`/api/v1/realtime-agents/${agent.id}/sections/core`, { + headers, + data: { + description: 'APE2E 已提交 Draft 核心配置编辑。', + systemPrompt: '你是设备检修数字教员,仅依据正式安全规程回答。', + welcomeMessage: '欢迎进行设备检修实训。', + fallbackMessage: '当前资料不足,请联系教员。', + interactionModes: ['text'], + dataVersion: agent.dataVersion, + }, + }) + const agentUpdated = await responseData(agentUpdateResponse, 200, '编辑 Draft 智能体') + agent = versioned(agentUpdated) + expect(agentUpdated.description).toContain('Draft 核心配置编辑') + addPass(activities, module, '编辑 Draft 核心配置', agentUpdateResponse, agent.id) + + const membersResponse = await request.put(`/api/v1/agent-projects/${project.id}/members`, { + headers, + data: { agentIds: [agent.id], dataVersion: project.dataVersion }, + }) + const members = await responseData(membersResponse, 200, '提交项目成员') + expect(Number(members.memberCount)).toBe(1) + const projectAfterMembersResponse = await request.get(`/api/v1/agent-projects/${project.id}`, { headers }) + project = versioned(await responseData(projectAfterMembersResponse, 200, '刷新项目成员版本')) + addPass(activities, module, '项目成员关系提交', membersResponse, project.id) + + const publishResponse = await request.post(`/api/v1/realtime-agents/${agent.id}/publish`, { + headers, + data: { dataVersion: agent.dataVersion }, + }) + assertStatus(publishResponse, 409, '配置不完整的 Draft 智能体不得发布') + addPass(activities, module, 'Draft 发布前完整性校验拦截', publishResponse, agent.id) + + const agentId = agent.id + const agentDeleteResponse = await request.delete(`/api/v1/realtime-agents/${agentId}`, { + headers, + params: { dataVersion: String(agent.dataVersion) }, + }) + await responseData(agentDeleteResponse, 200, '删除 Draft 智能体') + agent = null + addPass(activities, module, '删除 Draft 智能体', agentDeleteResponse, agentId) + const agentAbsent = await request.get(`/api/v1/realtime-agents/${agentId}`, { headers }) + assertStatus(agentAbsent, 404, '删除后的 Draft 智能体不可读取') + + const refreshedProjectResponse = await request.get(`/api/v1/agent-projects/${project.id}`, { headers }) + project = versioned(await responseData(refreshedProjectResponse, 200, '刷新项目版本')) + const projectId = project.id + const projectDeleteResponse = await request.delete(`/api/v1/agent-projects/${projectId}`, { + headers, + params: { dataVersion: String(project.dataVersion) }, + }) + await responseData(projectDeleteResponse, 200, '删除智能体项目') + project = null + addPass(activities, module, '删除空项目', projectDeleteResponse, projectId) + const projectAbsent = await request.get(`/api/v1/agent-projects/${projectId}`, { headers }) + assertStatus(projectAbsent, 404, '删除后的项目不可读取') + } catch (error) { + primaryError = error + } finally { + await cleanupVersioned( + request, + headers, + agent, + 'Draft智能体', + (id) => `/api/v1/realtime-agents/${id}`, + (id) => `/api/v1/realtime-agents/${id}`, + activities, + cleanupErrors, + ) + await cleanupVersioned( + request, + headers, + project, + module, + (id) => `/api/v1/agent-projects/${id}`, + (id) => `/api/v1/agent-projects/${id}`, + activities, + cleanupErrors, + ) + await attachCaseReport(testInfo, module, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '智能体与项目测试数据必须清理干净').toEqual([]) + }) + + test('运营配置:敏感词、唤醒词、热词和工具的完整 CRUD', async ({ request }, testInfo) => { + const module = '运营配置' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + let sensitive: VersionedRef | null = null + let wake: VersionedRef | null = null + let hot: VersionedRef | null = null + let tool: VersionedRef | null = null + let primaryError: unknown + + try { + const sensitiveResponse = await request.post('/api/v1/sensitive-words', { + headers, + data: { + word: `${runPrefix}-违规操作`, + category: 'APE2E安全', + matchMode: '包含匹配', + level: '高', + replacement: '请遵守设备检修安全规程。', + remark: 'APE2E 创建', + enabled: true, + }, + }) + const sensitiveCreated = await responseData(sensitiveResponse, 201, '创建敏感词') + sensitive = versioned(sensitiveCreated) + addPass(activities, '敏感词', '创建', sensitiveResponse, sensitive.id) + + const sensitiveListResponse = await request.get('/api/v1/sensitive-words', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const sensitivePage = asRecord(await responseData(sensitiveListResponse, 200, '筛选敏感词')) + expect(asRecords(sensitivePage.items).some((item) => String(item.id) === sensitive?.id)).toBeTruthy() + addPass(activities, '敏感词', '列表筛选', sensitiveListResponse, sensitive.id) + + const sensitiveUpdateResponse = await request.put(`/api/v1/sensitive-words/${sensitive.id}`, { + headers, + data: { remark: 'APE2E 已编辑', replacement: '请停止违规操作并联系教员。', dataVersion: sensitive.dataVersion }, + }) + sensitive = versioned(await responseData(sensitiveUpdateResponse, 200, '编辑敏感词')) + addPass(activities, '敏感词', '编辑', sensitiveUpdateResponse, sensitive.id) + + const sensitiveStatusResponse = await request.put(`/api/v1/sensitive-words/${sensitive.id}/status`, { + headers, + data: { enabled: false, dataVersion: sensitive.dataVersion }, + }) + const sensitiveDisabled = await responseData(sensitiveStatusResponse, 200, '停用敏感词') + sensitive = versioned(sensitiveDisabled) + expect(sensitiveDisabled.enabled).toBe(false) + addPass(activities, '敏感词', '停用', sensitiveStatusResponse, sensitive.id) + + const sensitiveId = sensitive.id + const sensitiveDeleteResponse = await request.delete(`/api/v1/sensitive-words/${sensitiveId}`, { + headers, + params: { dataVersion: String(sensitive.dataVersion) }, + }) + await responseData(sensitiveDeleteResponse, 200, '删除敏感词') + sensitive = null + addPass(activities, '敏感词', '删除', sensitiveDeleteResponse, sensitiveId) + const sensitiveAbsent = await request.get(`/api/v1/sensitive-words/${sensitiveId}`, { headers }) + assertStatus(sensitiveAbsent, 404, '删除后的敏感词不可读取') + + const invalidWakeResponse = await request.post('/api/v1/wake-words', { + headers, + data: { + name: `${runPrefix}-非法时段探针`, + phrase: `${runPrefix}非法时段探针`, + reply: '该请求不应写入数据库。', + sensitivity: 50, + activePeriod: '24:00-25:00', + enabled: true, + }, + }) + assertStatus(invalidWakeResponse, 400, '非法唤醒时段必须在写入前被拒绝') + activities.push({ + module: '唤醒词', + action: '非法时段写入前校验', + result: 'PASS', + httpStatus: invalidWakeResponse.status(), + detail: '24:00-25:00 被拒绝,未创建不可清理的数据。', + }) + + const wakeResponse = await request.post('/api/v1/wake-words', { + headers, + data: { + name: `${runPrefix}-检修唤醒`, + phrase: `${runPrefix}你好教员`, + reply: '您好,请描述设备现象。', + sensitivity: 78, + activePeriod: '06:00-22:00', + enabled: true, + }, + }) + const wakeCreated = await responseData(wakeResponse, 201, '创建唤醒词') + wake = versioned(wakeCreated) + expect(wakeCreated.activePeriod).toBe('06:00-22:00') + addPass(activities, '唤醒词', '创建自定义时段', wakeResponse, wake.id, 'activePeriod=06:00-22:00') + + const wakeDetailResponse = await request.get(`/api/v1/wake-words/${wake.id}`, { headers }) + const wakeDetail = await responseData(wakeDetailResponse, 200, '读取自定义时段唤醒词') + expect(wakeDetail.activePeriod).toBe('06:00-22:00') + addPass(activities, '唤醒词', '读取自定义时段', wakeDetailResponse, wake.id) + + const wakeListResponse = await request.get('/api/v1/wake-words', { + headers, + params: { keyword: `${runPrefix}你好教员`, page: '1', pageSize: '20' }, + }) + const wakeList = asRecord(await responseData(wakeListResponse, 200, '检索自定义时段唤醒词')) + expect(asRecords(wakeList.items).some((item) => String(item.id) === wake?.id && item.activePeriod === '06:00-22:00')).toBeTruthy() + addPass(activities, '唤醒词', '列表检索自定义时段', wakeListResponse, wake.id) + + const wakeTestResponse = await request.post(`/api/v1/wake-words/${wake.id}/test`, { headers }) + if (wakeTestResponse.status() === 200) { + const wakeTest = await responseData(wakeTestResponse, 200, '测试唤醒词匹配') + expect(wakeTest.matched).toBe(true) + addPass(activities, '唤醒词', '本地匹配测试', wakeTestResponse, wake.id) + } else { + activities.push({ + module: '唤醒词', + action: '本地匹配测试', + result: 'FAIL', + httpStatus: wakeTestResponse.status(), + resourceId: wake.id, + detail: '已启用且刚创建的唤醒词测试接口返回非 200;继续执行后续 CRUD 并在报告中保留该真实缺陷。', + }) + expect.soft(wakeTestResponse.status(), '已启用全天唤醒词的本地匹配测试接口应返回 HTTP 200').toBe(200) + } + + const wakeUpdateResponse = await request.put(`/api/v1/wake-words/${wake.id}`, { + headers, + data: { reply: 'APE2E 已编辑唤醒回复。', sensitivity: 82, activePeriod: '06:30-21:30', dataVersion: wake.dataVersion }, + }) + if (wakeUpdateResponse.status() === 200) { + const wakeUpdated = await responseData(wakeUpdateResponse, 200, '编辑唤醒词') + expect(wakeUpdated.activePeriod).toBe('06:30-21:30') + wake = versioned(wakeUpdated) + addPass(activities, '唤醒词', '编辑并更新自定义时段', wakeUpdateResponse, wake.id, 'activePeriod=06:30-21:30') + } else { + activities.push({ module: '唤醒词', action: '编辑', result: 'FAIL', httpStatus: wakeUpdateResponse.status(), resourceId: wake.id, detail: '真实编辑请求失败;继续验证其他模块。' }) + expect.soft(wakeUpdateResponse.status(), '唤醒词编辑接口应返回 HTTP 200').toBe(200) + } + + const wakeStatusResponse = await request.put(`/api/v1/wake-words/${wake.id}/status`, { + headers, + data: { enabled: false, dataVersion: wake.dataVersion }, + }) + if (wakeStatusResponse.status() === 200) { + const wakeDisabled = await responseData(wakeStatusResponse, 200, '停用唤醒词') + expect(wakeDisabled.activePeriod).toBe('06:30-21:30') + wake = versioned(wakeDisabled) + addPass(activities, '唤醒词', '停用', wakeStatusResponse, wake.id) + } else { + activities.push({ module: '唤醒词', action: '停用', result: 'FAIL', httpStatus: wakeStatusResponse.status(), resourceId: wake.id, detail: '真实状态提交失败;继续验证其他模块。' }) + expect.soft(wakeStatusResponse.status(), '唤醒词状态接口应返回 HTTP 200').toBe(200) + } + + const wakeId = wake.id + const wakeDeleteResponse = await request.delete(`/api/v1/wake-words/${wakeId}`, { + headers, + params: { dataVersion: String(wake.dataVersion) }, + }) + if (wakeDeleteResponse.status() === 200) { + await responseData(wakeDeleteResponse, 200, '删除唤醒词') + wake = null + addPass(activities, '唤醒词', '删除', wakeDeleteResponse, wakeId) + const wakeAbsent = await request.get(`/api/v1/wake-words/${wakeId}`, { headers }) + assertStatus(wakeAbsent, 404, '删除后的唤醒词不可读取') + } else { + activities.push({ module: '唤醒词', action: '删除', result: 'FAIL', httpStatus: wakeDeleteResponse.status(), resourceId: wake.id, detail: 'API 删除失败,finally 将再次按当前版本尝试兜底清理。' }) + expect.soft(wakeDeleteResponse.status(), '唤醒词删除接口应返回 HTTP 200').toBe(200) + } + + const hotResponse = await request.post('/api/v1/hot-words', { + headers, + data: { + term: `${runPrefix}-液压泵`, + aliases: [`${runPrefix}-液压蹦`, `${runPrefix}-液压蹦`, `${runPrefix}-液压崩`], + category: 'APE2E液压系统', + description: '端到端热词。', + enabled: true, + }, + }) + const hotCreated = await responseData(hotResponse, 201, '创建热词') + hot = versioned(hotCreated) + expect(Array.isArray(hotCreated.aliases) ? hotCreated.aliases.length : 0).toBe(2) + addPass(activities, '热词', '创建并去重别名', hotResponse, hot.id) + + const hotListResponse = await request.get('/api/v1/hot-words', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const hotPage = asRecord(await responseData(hotListResponse, 200, '筛选热词')) + expect(asRecords(hotPage.items).some((item) => String(item.id) === hot?.id)).toBeTruthy() + addPass(activities, '热词', '列表筛选', hotListResponse, hot.id) + + const hotUpdateResponse = await request.put(`/api/v1/hot-words/${hot.id}`, { + headers, + data: { aliases: [`${runPrefix}-液压棒`], description: 'APE2E 已编辑热词。', dataVersion: hot.dataVersion }, + }) + const hotUpdated = await responseData(hotUpdateResponse, 200, '编辑热词') + hot = versioned(hotUpdated) + expect(hotUpdated.aliases).toEqual([`${runPrefix}-液压棒`]) + addPass(activities, '热词', '编辑', hotUpdateResponse, hot.id) + + const hotStatusResponse = await request.put(`/api/v1/hot-words/${hot.id}/status`, { + headers, + data: { enabled: false, dataVersion: hot.dataVersion }, + }) + hot = versioned(await responseData(hotStatusResponse, 200, '停用热词')) + addPass(activities, '热词', '停用', hotStatusResponse, hot.id) + + const hotId = hot.id + const hotDeleteResponse = await request.delete(`/api/v1/hot-words/${hotId}`, { + headers, + params: { dataVersion: String(hot.dataVersion) }, + }) + await responseData(hotDeleteResponse, 200, '删除热词') + hot = null + addPass(activities, '热词', '删除', hotDeleteResponse, hotId) + const hotAbsent = await request.get(`/api/v1/hot-words/${hotId}`, { headers }) + assertStatus(hotAbsent, 404, '删除后的热词不可读取') + + const toolResponse = await request.post('/api/v1/tools', { + headers, + data: { + name: `${runPrefix}-检修工具入口`, + domain: 'https://example.com', + entryPath: '/ape2e/start', + category: 'APE2E业务协同', + description: '仅验证平台工具配置,不探测外部网络。', + openMode: '新窗口打开', + enabled: false, + sortOrder: 9900, + allowedRoleCodes: [], + }, + }) + const toolCreated = await responseData(toolResponse, 201, '创建工具') + tool = versioned(toolCreated) + expect(toolCreated.connectionStatus).toBe('untested') + addPass(activities, '工具', '创建禁用工具', toolResponse, tool.id) + + const toolListResponse = await request.get('/api/v1/tools', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const toolPage = asRecord(await responseData(toolListResponse, 200, '筛选工具')) + expect(asRecords(toolPage.items).some((item) => String(item.id) === tool?.id)).toBeTruthy() + addPass(activities, '工具', '列表筛选', toolListResponse, tool.id) + + const toolUpdateResponse = await request.put(`/api/v1/tools/${tool.id}`, { + headers, + data: { + entryPath: '/ape2e/updated', + description: 'APE2E 已提交工具编辑。', + dataVersion: tool.dataVersion, + }, + }) + tool = versioned(await responseData(toolUpdateResponse, 200, '编辑工具')) + addPass(activities, '工具', '编辑', toolUpdateResponse, tool.id) + + const toolStatusResponse = await request.put(`/api/v1/tools/${tool.id}/status`, { + headers, + data: { enabled: true, dataVersion: tool.dataVersion }, + }) + tool = versioned(await responseData(toolStatusResponse, 200, '启用工具')) + addPass(activities, '工具', '启用', toolStatusResponse, tool.id) + + const launchResponse = await request.get(`/api/v1/tools/${tool.id}/launch`, { headers }) + const launch = await responseData(launchResponse, 200, '读取工具启动参数') + expect(launch.url).toBe('https://example.com/ape2e/updated') + addPass(activities, '工具', '生成启动地址(未发起外部请求)', launchResponse, tool.id) + activities.push({ + module: '工具', + action: '外部连接探测', + result: 'SKIP', + resourceId: tool.id, + detail: '工具配置 CRUD 与启动地址已验证;/check 会访问外部域名,按能力边界不执行且不使用 Mock。', + }) + const toolId = tool.id + const toolDeleteResponse = await request.delete(`/api/v1/tools/${toolId}`, { + headers, + params: { dataVersion: String(tool.dataVersion) }, + }) + await responseData(toolDeleteResponse, 200, '删除工具') + tool = null + addPass(activities, '工具', '删除', toolDeleteResponse, toolId) + const toolAbsent = await request.get(`/api/v1/tools/${toolId}`, { headers }) + assertStatus(toolAbsent, 404, '删除后的工具不可读取') + } catch (error) { + primaryError = error + } finally { + await cleanupVersioned(request, headers, tool, '工具', (id) => `/api/v1/tools/${id}`, (id) => `/api/v1/tools/${id}`, activities, cleanupErrors) + await cleanupVersioned(request, headers, hot, '热词', (id) => `/api/v1/hot-words/${id}`, (id) => `/api/v1/hot-words/${id}`, activities, cleanupErrors) + await cleanupVersioned(request, headers, wake, '唤醒词', (id) => `/api/v1/wake-words/${id}`, (id) => `/api/v1/wake-words/${id}`, activities, cleanupErrors) + await cleanupVersioned(request, headers, sensitive, '敏感词', (id) => `/api/v1/sensitive-words/${id}`, (id) => `/api/v1/sensitive-words/${id}`, activities, cleanupErrors) + await attachCaseReport(testInfo, module, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '运营配置测试数据必须清理干净').toEqual([]) + }) + + test('视频工程:创建、编辑、四轨时间线、CAS 与删除', async ({ request }, testInfo) => { + test.setTimeout(240_000) + const module = '视频工程' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + let project: VersionedRef | null = null + let generatedAvatar: VersionedRef | null = null + let primaryError: unknown + + try { + const avatarsResponse = await request.get('/api/v1/avatars', { + headers, + params: { page: '1', pageSize: '100', status: 'ready' }, + }) + const avatarsPage = asRecord(await responseData(avatarsResponse, 200, '读取可用数字形象')) + let avatarId = String(asRecords(avatarsPage.items)[0]?.id ?? '') + if (!avatarId) { + const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4') + expect(fs.existsSync(fixturePath), `视频工程依赖的真实形象素材不存在:${fixturePath}`).toBeTruthy() + const createAvatarResponse = await request.post('/api/v1/avatars', { + headers, + multipart: { + name: `${runPrefix}-视频工程依赖形象`, + resize: 'true', + matting: 'off', + file: { + name: `${runPrefix}-video-project-avatar.mp4`, + mimeType: 'video/mp4', + buffer: fs.readFileSync(fixturePath), + }, + }, + timeout: 120_000, + }) + const createdPayload = asRecord(await responseData(createAvatarResponse, 201, '创建视频工程依赖形象')) + generatedAvatar = versioned(createdPayload.avatar) + avatarId = generatedAvatar.id + const jobId = String(asRecord(createdPayload.job).id ?? '') + expect(jobId).not.toBe('') + const deadline = Date.now() + 150_000 + let ready = false + while (Date.now() < deadline) { + const jobResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) + const job = asRecord(await responseData(jobResponse, 200, '等待视频工程依赖形象')) + const status = String(job.status ?? '').toLowerCase() + if (status === 'succeeded') { + ready = true + break + } + if (['failed', 'cancelled'].includes(status)) throw new Error(`视频工程依赖形象生成失败:${status}`) + await new Promise((resolve) => setTimeout(resolve, 750)) + } + expect(ready, '视频工程依赖形象应在 150 秒内生成完成').toBeTruthy() + const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers }) + const avatarDetail = await responseData(avatarDetailResponse, 200, '刷新视频工程依赖形象') + generatedAvatar = versioned(avatarDetail) + expect(String(avatarDetail.status).toLowerCase()).toBe('ready') + addPass(activities, module, '通过真实上传生成工程依赖形象', createAvatarResponse, avatarId) + } + + const voicesResponse = await request.get('/api/v1/capabilities', { + headers, + params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' }, + }) + const voicesPage = asRecord(await responseData(voicesResponse, 200, '读取可用 TTS 能力')) + const voice = asRecords(voicesPage.items)[0] + const voiceId = String(voice?.id ?? '') + expect(voiceId, '视频工程必须选择平台已启用的正式 TTS 能力').not.toBe('') + activities.push({ module, action: '依赖能力探测', result: 'PASS', detail: '已选择平台现有 ready 数字形象和已启用音色;报告不复制资源配置。' }) + + const createResponse = await request.post('/api/v1/video-projects', { + headers, + data: { + name: `${runPrefix}-设备检修口播工程`, + scriptText: '停机、断电、卸压后方可拆卸液压管路。', + avatarId, + voiceCapabilityId: voiceId, + voiceSpeed: 1, + orientation: 'portrait', + backgroundType: 'workshop', + subtitleEnabled: true, + subtitleFont: 'Microsoft YaHei', + subtitleFontSize: 42, + subtitlePosition: 'bottom', + durationMs: 4500, + }, + }) + const created = await responseData(createResponse, 201, '创建视频工程') + project = versioned(created) + expect(created.outputWidth).toBe(1080) + expect(created.outputHeight).toBe(1920) + addPass(activities, module, '创建竖屏工程', createResponse, project.id) + + const listResponse = await request.get('/api/v1/video-projects', { + headers, + params: { keyword: runPrefix, page: '1', pageSize: '20' }, + }) + const page = asRecord(await responseData(listResponse, 200, '筛选视频工程')) + expect(asRecords(page.items).some((item) => String(item.id) === project?.id)).toBeTruthy() + addPass(activities, module, '列表筛选', listResponse, project.id) + + const updateResponse = await request.put(`/api/v1/video-projects/${project.id}`, { + headers, + data: { + name: `${runPrefix}-设备检修口播工程-修订`, + scriptText: '停机、断电、验电、卸压后,确认压力表回零。', + voiceSpeed: 1.1, + dataVersion: project.dataVersion, + }, + }) + const updated = await responseData(updateResponse, 200, '编辑视频工程') + project = versioned(updated) + expect(updated.voiceSpeed).toBe(1.1) + addPass(activities, module, '编辑工程参数', updateResponse, project.id) + + const timeline = { + dataVersion: project.dataVersion, + durationMs: 4500, + tracks: [ + { + code: 'AVATAR', + name: '数字人', + order: 0, + clips: [{ id: `${runPrefix}-avatar-clip`, type: 'AVATAR', startMs: 0, endMs: 4500 }], + }, + { code: 'MEDIA', name: '画面素材', order: 1, clips: [] }, + { + code: 'AUDIO', + name: '本地配音', + order: 2, + clips: [{ id: `${runPrefix}-audio-clip`, type: 'AUDIO', capabilityId: voiceId, startMs: 0, endMs: 4500 }], + }, + { + code: 'SUBTITLE', + name: '同步字幕', + order: 3, + clips: [{ id: `${runPrefix}-subtitle-clip`, type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 4500, style: { font: 'Microsoft YaHei', size: 42 } }], + }, + ], + } + const timelineResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline }) + const timelineSaved = await responseData(timelineResponse, 200, '保存四轨时间线') + project = versioned(timelineSaved) + const savedTimeline = asRecord(timelineSaved.timeline) + expect(asRecords(savedTimeline.tracks).map((track) => track.code)).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE']) + addPass(activities, module, '保存四轨时间线', timelineResponse, project.id) + + const staleResponse = await request.put(`/api/v1/video-projects/${project.id}/timeline`, { headers, data: timeline }) + assertStatus(staleResponse, 409, '过期时间线版本必须冲突') + addPass(activities, module, 'CAS 过期时间线拦截', staleResponse, project.id) + + const detailResponse = await request.get(`/api/v1/video-projects/${project.id}`, { headers }) + const detail = await responseData(detailResponse, 200, '读取视频工程详情') + project = versioned(detail) + expect(asRecords(asRecord(detail.timeline).tracks).length).toBe(4) + addPass(activities, module, '读取持久化时间线', detailResponse, project.id) + + const projectId = project.id + const deleteResponse = await request.delete(`/api/v1/video-projects/${projectId}`, { + headers, + params: { dataVersion: String(project.dataVersion) }, + }) + await responseData(deleteResponse, 200, '删除视频工程') + project = null + addPass(activities, module, '删除工程', deleteResponse, projectId) + const absent = await request.get(`/api/v1/video-projects/${projectId}`, { headers }) + assertStatus(absent, 404, '删除后的视频工程不可读取') + addPass(activities, module, '删除后不可读取', absent, projectId) + } catch (error) { + primaryError = error + } finally { + await cleanupVersioned( + request, + headers, + project, + module, + (id) => `/api/v1/video-projects/${id}`, + (id) => `/api/v1/video-projects/${id}`, + activities, + cleanupErrors, + ) + await cleanupVersioned( + request, + headers, + generatedAvatar, + '视频工程依赖形象', + (id) => `/api/v1/avatars/${id}`, + (id) => `/api/v1/avatars/${id}`, + activities, + cleanupErrors, + ) + await attachCaseReport(testInfo, module, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '视频工程测试数据必须清理干净').toEqual([]) + }) +}) diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 0000000..b9f81c2 --- /dev/null +++ b/tests/fixtures.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { expect, test as base, type Locator, type Page, type TestInfo } from '@playwright/test' + +export const runId = (process.env.APE2E_RUN_ID || 'manual').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'manual' +export const runPrefix = `APE2E-${runId.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()}` +const screenshotDirectory = path.resolve('artifacts', 'runs', runId, 'screenshots') + +const safeName = (value: string) => value.normalize('NFKC') + .replace(/[^\p{L}\p{N}._-]+/gu, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120) + +const stableTestName = (testInfo: TestInfo) => { + const fileStem = path.basename(testInfo.file, path.extname(testInfo.file)) + return safeName(`${testInfo.project.name}--${fileStem}--${testInfo.title}`).slice(0, 110) || 'unnamed-test' +} + +export async function captureScreenshot(page: Page, testInfo: TestInfo, label: string, masks: Locator[] = []) { + fs.mkdirSync(screenshotDirectory, { recursive: true }) + const artifactName = safeName(label).slice(0, 70) || 'success' + const screenshotPath = path.join(screenshotDirectory, `${stableTestName(testInfo)}--${artifactName}.png`) + await page.screenshot({ + path: screenshotPath, + fullPage: true, + animations: 'disabled', + caret: 'hide', + mask: masks, + maskColor: '#1f2937', + }) + await testInfo.attach(`success-${artifactName}`, { path: screenshotPath, contentType: 'image/png' }) + return screenshotPath +} + +export async function attachJson(testInfo: TestInfo, name: string, value: unknown) { + const serialized = JSON.stringify(value, (_key, item) => { + if (typeof item === 'string' && /(?:Bearer\s+|eyJ)[A-Za-z0-9._~-]+/i.test(item)) return '' + return item + }, 2) + await testInfo.attach(name, { body: Buffer.from(serialized), contentType: 'application/json' }) +} + +export const test = base + +test.beforeEach(async ({}, testInfo) => { + fs.mkdirSync(screenshotDirectory, { recursive: true }) + const prefix = `${stableTestName(testInfo)}--` + for (const filename of fs.readdirSync(screenshotDirectory)) { + if (filename.startsWith(prefix) && filename.endsWith('.png')) fs.rmSync(path.join(screenshotDirectory, filename), { force: true }) + } +}) + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === testInfo.expectedStatus || page.isClosed()) return + fs.mkdirSync(screenshotDirectory, { recursive: true }) + const filename = `${safeName(testInfo.titlePath.join('--')) || 'failed-test'}--retry-${testInfo.retry}--${Date.now()}.png` + const screenshotPath = path.join(screenshotDirectory, filename) + try { + // 失败截图同样必须遵守敏感信息约束。部分密码框支持“显示密码”, + // 因此除了 type=password,还要覆盖 autocomplete 标记和一次性密码结果区。 + const sensitiveControls = page.locator([ + 'input[type="password"]', + 'input[autocomplete="current-password"]', + 'input[autocomplete="new-password"]', + '[data-testid="generated-password"]', + '[data-testid="password-result-secret"]', + // 远控终端创建/轮换后的密钥由 Element Plus 警告框仅展示一次。 + // 即使测试恰好在该警告框打开时失败,失败截图也不得带出完整密钥。 + '.el-message-box:has-text("接入密钥")', + '[role="alertdialog"]:has-text("接入密钥")', + ].join(', ')) + await page.screenshot({ + path: screenshotPath, + fullPage: true, + animations: 'disabled', + caret: 'hide', + mask: [sensitiveControls], + maskColor: '#1f2937', + }) + await testInfo.attach('failure-screenshot', { path: screenshotPath, contentType: 'image/png' }) + } catch (error) { + await testInfo.attach('failure-screenshot-error', { body: Buffer.from(String(error)), contentType: 'text/plain' }) + } +}) + +export { expect } diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..f931219 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs' +import path from 'node:path' + +import type { APIRequestContext, Locator, Page } from '@playwright/test' + +import { expect } from './fixtures' + +export interface AdminCredentials { username: string; password: string } + +export function readAdminCredentials(): AdminCredentials { + const configured = process.env.E2E_ACCOUNT_FILE?.trim() + const filename = path.resolve(configured || '../账号.MD') + if (!fs.existsSync(filename)) throw new Error(`测试账号文件不存在:${filename}`) + const values = new Map() + for (const line of fs.readFileSync(filename, 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*([^::]+)\s*[::]\s*(.*?)\s*$/) + if (match?.[1] && match[2]) values.set(match[1].trim(), match[2].trim()) + } + const username = values.get('账号') || values.get('用户名') || '' + const password = values.get('密码') || '' + if (!username || !password) throw new Error('账号.MD 必须包含“账号”和“密码”两项') + return { username, password } +} + +type RecordValue = Record +const asRecord = (value: unknown): RecordValue => value && typeof value === 'object' ? value as RecordValue : {} +export const envelopeData = (value: unknown): T => { + const body = asRecord(value) + return ('data' in body ? body.data : value) as T +} + +export async function adminAccessToken(request: APIRequestContext) { + const credentials = readAdminCredentials() + const response = await request.post('/api/auth/v1/auth/login', { + data: { roleCode: 'admin', username: credentials.username, password: credentials.password, rememberMe: false }, + }) + expect(response.ok(), `管理员 API 登录失败:HTTP ${response.status()}`).toBeTruthy() + const data = asRecord(envelopeData(await response.json())) + const tokens = asRecord(data.tokens) + const accessToken = String(data.accessToken ?? tokens.accessToken ?? '') + if (!accessToken) throw new Error('管理员登录响应缺少 accessToken') + return accessToken +} + +export async function authHeaders(request: APIRequestContext) { + const accessToken = await adminAccessToken(request) + return { Authorization: `Bearer ${accessToken}` } +} + +export async function loginAsAdmin(page: Page) { + const credentials = readAdminCredentials() + await page.goto('/login') + await page.locator('[data-role-code="admin"]').click() + const username = page.locator('input[name="username"]') + const password = page.locator('input[name="password"]') + await username.fill(credentials.username) + await password.fill(credentials.password) + await page.getByRole('button', { name: /登录系统|正在验证身份/ }).click() + await expect(page).not.toHaveURL(/\/login(?:\?|$)/) + await expect(page.locator('.app-shell, .main-layout, .platform-shell').first()).toBeVisible() +} + +export const visibleDialog = (page: Page) => page.locator('.el-dialog:visible').last() +export const formItem = (scope: Locator, label: string | RegExp) => scope.locator('.el-form-item').filter({ hasText: label }).first() + +export async function fillFormItem(scope: Locator, label: string | RegExp, value: string) { + const control = formItem(scope, label).locator('input:not([type="hidden"]), textarea').first() + await expect(control).toBeVisible() + await control.fill(value) +} + +export async function chooseSelectOption(page: Page, scope: Locator, label: string | RegExp, option: string | RegExp) { + const item = formItem(scope, label) + await item.locator('.el-select').click() + const dropdown = page.locator('.el-select-dropdown:visible').last() + await expect(dropdown).toBeVisible() + await dropdown.locator('.el-select-dropdown__item').filter({ hasText: option }).first().click() +} + +export async function confirmMessageBox(page: Page, buttonName: string | RegExp) { + const box = page.locator('.el-message-box:visible').last() + await expect(box).toBeVisible() + await box.getByRole('button', { name: buttonName }).click() +} + +export const tableRowByText = (page: Page, text: string) => page + .locator('.el-table__body-wrapper .el-table__row, tbody tr') + .filter({ hasText: text }) + .first() diff --git a/tests/iam-extended-ui-lifecycle.spec.ts b/tests/iam-extended-ui-lifecycle.spec.ts new file mode 100644 index 0000000..fe98f67 --- /dev/null +++ b/tests/iam-extended-ui-lifecycle.spec.ts @@ -0,0 +1,487 @@ +import type { APIRequestContext, APIResponse, Page } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { + adminAccessToken, + chooseSelectOption, + confirmMessageBox, + envelopeData, + fillFormItem, + loginAsAdmin, + tableRowByText, + visibleDialog, +} from './helpers' + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-12).toLowerCase() +const data = { + parentName: `${runPrefix}-组织父部门`, + editedParentName: `${runPrefix}-已编辑父部门`, + parentCode: `APE2E_P_${suffix}`.toUpperCase().slice(0, 32), + childName: `${runPrefix}-组织子部门`, + childCode: `APE2E_C_${suffix}`.toUpperCase().slice(0, 32), + grandchildName: `${runPrefix}-组织孙部门`, + grandchildCode: `APE2E_G_${suffix}`.toUpperCase().slice(0, 32), + roleName: `${runPrefix}-范围角色`, + roleCode: `ape2e_scope_${suffix}`.slice(0, 48), + username: `ape2e_state_${suffix}`.slice(0, 32), + displayName: `${runPrefix}-状态学员`, + editedDisplayName: `${runPrefix}-已编辑状态学员`, + initialPassword: `Init@${suffix.slice(-6)}Aa1`, +} + +type ApiRecord = Record + +const auth = (token: string) => ({ Authorization: `Bearer ${token}` }) +const record = (value: unknown): ApiRecord => value && typeof value === 'object' ? value as ApiRecord : {} +const versionOf = (value: ApiRecord) => Number(value.version ?? value.dataVersion ?? 0) + +async function responseJson(response: APIResponse) { + return response.json().catch(() => ({})) +} + +function listRecords(value: unknown): ApiRecord[] { + const payload = envelopeData(value) + if (Array.isArray(payload)) return payload.map(record) + const root = record(payload) + const source = root.records ?? root.items ?? root.departments ?? root.roles ?? root.users + return Array.isArray(source) ? source.map(record) : [] +} + +async function apiDepartments(request: APIRequestContext, token: string) { + const response = await request.get('/api/auth/v1/departments', { headers: auth(token) }) + expect(response.ok(), `读取部门失败:HTTP ${response.status()}`).toBeTruthy() + return listRecords(await responseJson(response)) +} + +async function apiDepartment(request: APIRequestContext, token: string, code: string) { + return (await apiDepartments(request, token)).find((item) => String(item.code) === code) +} + +async function apiRole(request: APIRequestContext, token: string) { + const response = await request.get('/api/auth/v1/roles', { + headers: auth(token), + params: { keyword: data.roleCode, page: 1, size: 100 }, + }) + expect(response.ok(), `读取角色失败:HTTP ${response.status()}`).toBeTruthy() + return listRecords(await responseJson(response)).find((item) => String(item.code) === data.roleCode) +} + +async function apiUser(request: APIRequestContext, token: string) { + const response = await request.get('/api/auth/v1/users', { + headers: auth(token), + params: { keyword: data.username, page: 1, size: 100 }, + }) + expect(response.ok(), `读取用户失败:HTTP ${response.status()}`).toBeTruthy() + return listRecords(await responseJson(response)).find((item) => String(item.username) === data.username) +} + +async function apiRolePermissions(request: APIRequestContext, token: string, roleId: string) { + const response = await request.get(`/api/auth/v1/permissions/roles/${roleId}`, { headers: auth(token) }) + expect(response.ok(), `读取角色权限失败:HTTP ${response.status()}`).toBeTruthy() + const payload = record(envelopeData(await responseJson(response))) + const source = payload.permissions ?? payload.permissionCodes ?? payload.codes + return Array.isArray(source) ? source.map(String) : [] +} + +async function cleanupCreatedData(request: APIRequestContext, token: string) { + const result: Record = {} + const user = await apiUser(request, token) + if (user) { + const response = await request.delete(`/api/auth/v1/users/${String(user.id)}`, { + headers: auth(token), data: { version: versionOf(user) }, + }) + result.user = String(response.status()) + } else result.user = 'absent' + + const role = await apiRole(request, token) + if (role) { + const response = await request.delete(`/api/auth/v1/roles/${String(role.id)}`, { + headers: auth(token), data: { version: versionOf(role) }, + }) + result.role = String(response.status()) + } else result.role = 'absent' + + for (const [label, code] of [ + ['grandchild', data.grandchildCode], + ['child', data.childCode], + ['parent', data.parentCode], + ] as const) { + const department = await apiDepartment(request, token, code) + if (!department) { + result[label] = 'absent' + continue + } + const response = await request.post(`/api/auth/v1/departments/${String(department.id)}/migrate-and-delete`, { + headers: auth(token), data: { version: versionOf(department) }, + }) + result[label] = String(response.status()) + } + return result +} + +async function search(page: Page, placeholder: string, keyword: string) { + const input = page.getByPlaceholder(placeholder) + await expect(input).toBeVisible() + await input.fill(keyword) + await page.getByRole('button', { name: '搜索', exact: true }).click() +} + +async function openDepartment(page: Page, code: string) { + await search(page, '请输入部门、编码或负责人', code) + const row = tableRowByText(page, code) + await expect(row).toBeVisible() + return row +} + +async function createDepartment(page: Page, parentCode: string | null, name: string, code: string) { + if (parentCode) { + const parentRow = await openDepartment(page, parentCode) + await parentRow.getByRole('button', { name: '新增下级', exact: true }).click() + } else { + await page.getByRole('button', { name: '新增部门', exact: true }).click() + } + const dialog = visibleDialog(page) + await fillFormItem(dialog, '部门名称', name) + await fillFormItem(dialog, '部门编码', code) + await fillFormItem(dialog, '部门职责', `${runPrefix} 页面提交的组织层级测试数据`) + await dialog.getByRole('button', { name: '创建部门' }).click() + await expect(dialog).toBeHidden() + await expect(await openDepartment(page, code)).toContainText(name) +} + +async function logout(page: Page) { + await page.locator('.user-menu-trigger').click() + await page.getByRole('menuitem', { name: /退出登录/ }).click() + await confirmMessageBox(page, '退出登录') + await expect(page).toHaveURL(/\/login(?:\?|$)/) +} + +async function selectStudentIdentity(page: Page) { + await page.locator('[data-role-code="student"]').click() + const picker = page.locator('.login-identity-picker') + await expect(picker).toBeVisible() + await picker.click() + const option = page.locator('.login-identity-popper .el-select-dropdown__item') + .filter({ hasText: data.editedDisplayName }).first() + await expect(option).toBeVisible() + await option.click() +} + +async function clearBrowserSession(page: Page) { + await page.evaluate(() => { + window.localStorage.clear() + window.sessionStorage.clear() + }) + await page.context().clearCookies() +} + +test('IAM 扩展页面闭环:层级迁移、范围授权、停启登录与页面删除', async ({ page, request }, testInfo) => { + test.slow() + const token = await adminAccessToken(request) + await cleanupCreatedData(request, token) + await loginAsAdmin(page) + let primaryError: unknown + + try { + await test.step('父子孙部门全部页面创建,父部门编辑后刷新与 API 均持久化', async () => { + await page.goto('/organization/departments') + await createDepartment(page, null, data.parentName, data.parentCode) + await createDepartment(page, data.parentCode, data.childName, data.childCode) + await createDepartment(page, data.childCode, data.grandchildName, data.grandchildCode) + + const parentRow = await openDepartment(page, data.parentCode) + await parentRow.getByRole('button', { name: '编辑', exact: true }).click() + const dialog = visibleDialog(page) + await fillFormItem(dialog, '部门名称', data.editedParentName) + await fillFormItem(dialog, '部门职责', `${runPrefix} 已通过页面编辑并保存`) + await dialog.getByRole('button', { name: '保存修改' }).click() + await expect(dialog).toBeHidden() + await page.reload() + await expect(await openDepartment(page, data.parentCode)).toContainText(data.editedParentName) + + const parent = await apiDepartment(request, token, data.parentCode) + const child = await apiDepartment(request, token, data.childCode) + const grandchild = await apiDepartment(request, token, data.grandchildCode) + expect(parent?.name).toBe(data.editedParentName) + expect(String(child?.parentId)).toBe(String(parent?.id)) + expect(String(grandchild?.parentId)).toBe(String(child?.id)) + await captureScreenshot(page, testInfo, '01-department-hierarchy-edited-and-reloaded') + }) + + await test.step('含启用孙部门的子部门页面级联停用,并逐级页面启用', async () => { + const childRow = await openDepartment(page, data.childCode) + await childRow.getByRole('button', { name: '停用', exact: true }).click() + await confirmMessageBox(page, '级联停用') + await expect(await openDepartment(page, data.childCode)).toContainText('已停用') + expect((await apiDepartment(request, token, data.childCode))?.enabled).toBe(false) + expect((await apiDepartment(request, token, data.grandchildCode))?.enabled).toBe(false) + + await (await openDepartment(page, data.childCode)).getByRole('button', { name: '启用', exact: true }).click() + await expect(await openDepartment(page, data.childCode)).toContainText('已启用') + await (await openDepartment(page, data.grandchildCode)).getByRole('button', { name: '启用', exact: true }).click() + await expect(await openDepartment(page, data.grandchildCode)).toContainText('已启用') + expect((await apiDepartment(request, token, data.childCode))?.enabled).toBe(true) + expect((await apiDepartment(request, token, data.grandchildCode))?.enabled).toBe(true) + await page.reload() + await expect(await openDepartment(page, data.grandchildCode)).toContainText('已启用') + await captureScreenshot(page, testInfo, '02-department-cascade-disabled-and-reenabled') + }) + + await test.step('角色通过页面创建后改为自定义数据范围并选择父部门,刷新与 API 持久化', async () => { + await page.goto('/organization/roles') + await page.getByRole('button', { name: '新增角色', exact: true }).click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '角色名称', data.roleName) + await fillFormItem(dialog, '角色编码', data.roleCode) + await fillFormItem(dialog, '界面简称', '范围') + await fillFormItem(dialog, '角色说明', `${runPrefix} 自定义范围测试角色`) + await dialog.getByRole('button', { name: '创建角色' }).click() + await search(page, '请输入角色名称、编码或说明', data.roleCode) + let row = tableRowByText(page, data.roleCode) + await expect(row).toBeVisible() + await row.getByRole('button', { name: '编辑', exact: true }).click() + dialog = visibleDialog(page) + await chooseSelectOption(page, dialog, '数据范围', '自定义范围') + await chooseSelectOption(page, dialog, '自定义部门范围', data.childName) + await fillFormItem(dialog, '角色说明', `${runPrefix} CUSTOM 范围已保存`) + await dialog.getByRole('button', { name: '保存修改' }).click() + await page.reload() + await search(page, '请输入角色名称、编码或说明', data.roleCode) + row = tableRowByText(page, data.roleCode) + await expect(row).toContainText('自定义范围') + await expect(row).toContainText('CUSTOM 范围已保存') + + const role = await apiRole(request, token) + const child = await apiDepartment(request, token, data.childCode) + expect(role?.dataScope).toBe('CUSTOM') + expect((role?.departmentScopeIds as unknown[]).map(String)).toEqual([String(child?.id)]) + await captureScreenshot(page, testInfo, '03-role-custom-scope-saved-and-reloaded') + }) + + await test.step('权限页完成整组全选、跨组多选和整组取消,两次保存刷新均与 API 一致', async () => { + const row = tableRowByText(page, data.roleCode) + await row.getByRole('button', { name: '配置权限', exact: true }).click() + await expect(page).toHaveURL(/\/organization\/permissions\?role=/) + const groups = page.locator('.permission-group') + await expect(groups.first()).toBeVisible() + expect(await groups.count()).toBeGreaterThan(1) + const firstGroup = groups.nth(0) + const secondGroup = groups.nth(1) + const firstCodes = (await firstGroup.locator('.permission-item code').allTextContents()).map((item) => item.trim()) + const extraItem = secondGroup.locator('.permission-item').first() + const extraCode = (await extraItem.locator('code').innerText()).trim() + await firstGroup.getByRole('button', { name: '全选本组' }).click() + await extraItem.click() + await page.getByRole('button', { name: '保存权限配置' }).click() + await expect(page.getByText(/权限已保存并立即生效/).last()).toBeVisible() + + const role = await apiRole(request, token) + const firstSaved = await apiRolePermissions(request, token, String(role?.id)) + expect(firstSaved).toEqual(expect.arrayContaining([...firstCodes, extraCode])) + await page.reload() + await expect(page.locator('.role-selector > button.active')).toContainText(data.roleName) + for (const code of [...firstCodes, extraCode]) { + await expect(page.locator('.permission-item').filter({ hasText: code }).first()).toHaveAttribute('aria-checked', 'true') + } + + await page.locator('.permission-group').first().getByRole('button', { name: '清空' }).click() + await page.getByRole('button', { name: '保存权限配置' }).click() + await expect(page.getByText(/权限已保存并立即生效/).last()).toBeVisible() + const secondSaved = await apiRolePermissions(request, token, String(role?.id)) + expect(secondSaved).toContain(extraCode) + firstCodes.forEach((code) => expect(secondSaved).not.toContain(code)) + await page.reload() + await expect(page.locator('.permission-item').filter({ hasText: extraCode }).first()).toHaveAttribute('aria-checked', 'true') + for (const code of firstCodes) { + await expect(page.locator('.permission-item').filter({ hasText: code }).first()).toHaveAttribute('aria-checked', 'false') + } + await captureScreenshot(page, testInfo, '04-permission-group-save-clear-and-reload') + }) + + await test.step('用户页面创建并分配学员与自定义角色,编辑后刷新和 API 持久化', async () => { + await page.goto('/organization/users') + await page.getByRole('button', { name: '新增用户', exact: true }).click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '姓名', data.displayName) + await fillFormItem(dialog, '登录账号', data.username) + await chooseSelectOption(page, dialog, '所属部门', data.childName) + await fillFormItem(dialog, '初始密码', data.initialPassword) + await dialog.locator('.el-checkbox').filter({ hasText: '学员' }).first().click() + await dialog.locator('.el-checkbox').filter({ hasText: data.roleName }).first().click() + await chooseSelectOption(page, dialog, '登录默认身份', '学员') + await dialog.getByRole('button', { name: '创建用户' }).click() + await search(page, '请输入姓名、账号、部门或角色', data.username) + let row = tableRowByText(page, data.username) + await expect(row).toBeVisible() + await row.getByRole('button', { name: '编辑', exact: true }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '姓名', data.editedDisplayName) + await dialog.getByRole('button', { name: '保存修改' }).click() + await page.reload() + await search(page, '请输入姓名、账号、部门或角色', data.username) + row = tableRowByText(page, data.username) + await expect(row).toContainText(data.editedDisplayName) + const user = await apiUser(request, token) + const role = await apiRole(request, token) + expect(user?.displayName).toBe(data.editedDisplayName) + expect((user?.roleIds as unknown[]).map(String)).toContain(String(role?.id)) + await captureScreenshot(page, testInfo, '05-user-created-edited-and-reloaded') + }) + + await test.step('有关联用户时角色页面删除禁用且正式 API 拒绝,角色页面停启可持久化', async () => { + await page.goto('/organization/roles') + await search(page, '请输入角色名称、编码或说明', data.roleCode) + let row = tableRowByText(page, data.roleCode) + await expect(row.getByRole('button', { name: '删除', exact: true })).toBeDisabled() + let role = await apiRole(request, token) + const blockedDelete = await request.delete(`/api/auth/v1/roles/${String(role?.id)}`, { + headers: auth(token), data: { version: versionOf(role ?? {}) }, + }) + expect(blockedDelete.status()).toBe(409) + + await row.getByRole('button', { name: '停用', exact: true }).click() + await confirmMessageBox(page, '确认') + await expect(tableRowByText(page, data.roleCode)).toContainText('已停用') + role = await apiRole(request, token) + expect(role?.enabled).toBe(false) + await page.reload() + await search(page, '请输入角色名称、编码或说明', data.roleCode) + row = tableRowByText(page, data.roleCode) + await expect(row).toContainText('已停用') + await row.getByRole('button', { name: '启用', exact: true }).click() + await confirmMessageBox(page, '确认') + await expect(tableRowByText(page, data.roleCode)).toContainText('已启用') + expect((await apiRole(request, token))?.enabled).toBe(true) + await page.reload() + await search(page, '请输入角色名称、编码或说明', data.roleCode) + await expect(tableRowByText(page, data.roleCode)).toContainText('已启用') + await captureScreenshot(page, testInfo, '06-linked-role-delete-blocked-and-status-restored') + }) + + await test.step('删除子部门时页面迁移关联用户与直属孙部门,刷新树和 API 均无悬空引用', async () => { + await page.goto('/organization/departments') + const childRow = await openDepartment(page, data.childCode) + await expect(childRow).toContainText('1') + await childRow.getByRole('button', { name: '删除', exact: true }).click() + const dialog = visibleDialog(page) + await expect(dialog).toContainText('关联用户') + await expect(dialog).toContainText('直属下级') + const migrationSelects = dialog.locator('.el-form-item').filter({ hasText: /迁入部门|新上级/ }).locator('.el-select') + await expect(migrationSelects).toHaveCount(2) + await expect(migrationSelects.nth(0)).toContainText(data.editedParentName) + await expect(migrationSelects.nth(1)).toContainText(data.editedParentName) + await captureScreenshot(page, testInfo, '07-department-delete-migration-confirmation') + await dialog.getByRole('button', { name: '确认删除并迁移' }).click() + await expect(dialog).toBeHidden() + await page.reload() + await search(page, '请输入部门、编码或负责人', data.childCode) + await expect(tableRowByText(page, data.childCode)).toHaveCount(0) + await expect(page.locator('.department-scope-tree')).not.toContainText(data.childName) + await expect(page.locator('.department-scope-tree')).toContainText(data.grandchildName) + + const parent = await apiDepartment(request, token, data.parentCode) + const child = await apiDepartment(request, token, data.childCode) + const grandchild = await apiDepartment(request, token, data.grandchildCode) + const user = await apiUser(request, token) + expect(child).toBeUndefined() + expect(String(grandchild?.parentId)).toBe(String(parent?.id)) + expect(String(user?.departmentId)).toBe(String(parent?.id)) + const role = await apiRole(request, token) + const migratedScopeIds = (role?.departmentScopeIds as unknown[]).map(String) + expect(migratedScopeIds).toEqual([String(parent?.id)]) + await page.goto(`/organization/roles`) + await search(page, '请输入角色名称、编码或说明', data.roleCode) + await tableRowByText(page, data.roleCode).getByRole('button', { name: '编辑', exact: true }).click() + const scopeDialog = visibleDialog(page) + await expect(scopeDialog.getByText(data.editedParentName, { exact: true })).toBeVisible() + await expect(scopeDialog.getByText(data.childName, { exact: true })).toHaveCount(0) + await captureScreenshot(page, testInfo, '08-role-scope-migrated-without-deleted-department') + await scopeDialog.getByRole('button', { name: '取消' }).click() + await page.goto('/organization/departments') + await search(page, '请输入部门、编码或负责人', data.grandchildCode) + await expect(tableRowByText(page, data.grandchildCode)).toBeVisible() + await captureScreenshot(page, testInfo, '09-department-migration-tree-and-table-reloaded') + }) + + await test.step('用户页面停用后登录 API 明确拒绝且身份选择器隐藏,启用后页面登录恢复', async () => { + await page.goto('/organization/users') + await search(page, '请输入姓名、账号、部门或角色', data.username) + let row = tableRowByText(page, data.username) + await row.getByRole('button', { name: '停用', exact: true }).click() + await confirmMessageBox(page, '确认停用') + await expect(tableRowByText(page, data.username)).toContainText('已停用') + expect((await apiUser(request, token))?.enabled).toBe(false) + await page.reload() + await search(page, '请输入姓名、账号、部门或角色', data.username) + await expect(tableRowByText(page, data.username)).toContainText('已停用') + + const user = await apiUser(request, token) + await logout(page) + const rejected = await request.post('/api/auth/v1/auth/login', { + data: { roleCode: 'student', userId: String(user?.id), password: data.initialPassword, rememberMe: false }, + }) + // 公开登录接口故意不泄露“账号已停用”,与账号/密码/身份不匹配统一返回 401。 + expect(rejected.status()).toBe(401) + await page.locator('[data-role-code="student"]').click() + const picker = page.locator('.login-identity-picker') + await expect(picker).toBeVisible() + await picker.click() + await expect(page.locator('.login-identity-popper .el-select-dropdown__item').filter({ hasText: data.editedDisplayName })).toHaveCount(0) + await captureScreenshot(page, testInfo, '10-disabled-user-login-rejected-and-hidden') + + await loginAsAdmin(page) + await page.goto('/organization/users') + await search(page, '请输入姓名、账号、部门或角色', data.username) + row = tableRowByText(page, data.username) + await row.getByRole('button', { name: '启用', exact: true }).click() + await confirmMessageBox(page, '确认启用') + await expect(tableRowByText(page, data.username)).toContainText('正常') + expect((await apiUser(request, token))?.enabled).toBe(true) + await logout(page) + await selectStudentIdentity(page) + await page.locator('input[name="password"]').fill(data.initialPassword) + await page.getByRole('button', { name: '登录系统' }).click() + await expect(page).toHaveURL(/\/change-password(?:\?|$)/) + await captureScreenshot(page, testInfo, '11-reenabled-user-page-login-restored') + await clearBrowserSession(page) + await loginAsAdmin(page) + }) + + await test.step('用户页面确认删除后 API 无记录,角色解除关联后通过页面最终删除', async () => { + await page.goto('/organization/users') + await search(page, '请输入姓名、账号、部门或角色', data.username) + let row = tableRowByText(page, data.username) + await row.getByRole('button', { name: '删除', exact: true }).click() + await expect(page.locator('.el-message-box:visible')).toContainText(data.username) + await confirmMessageBox(page, '确认删除') + await expect(tableRowByText(page, data.username)).toHaveCount(0) + expect(await apiUser(request, token)).toBeUndefined() + await captureScreenshot(page, testInfo, '12-user-page-delete-confirmed') + + await page.goto('/organization/roles') + await search(page, '请输入角色名称、编码或说明', data.roleCode) + row = tableRowByText(page, data.roleCode) + await expect(row.getByRole('button', { name: '删除', exact: true })).toBeEnabled() + await row.getByRole('button', { name: '删除', exact: true }).click() + await confirmMessageBox(page, '删除角色') + await expect(tableRowByText(page, data.roleCode)).toHaveCount(0) + expect(await apiRole(request, token)).toBeUndefined() + await captureScreenshot(page, testInfo, '13-unlinked-role-page-delete-confirmed') + }) + } catch (error) { + primaryError = error + } finally { + const cleanup = await cleanupCreatedData(request, token).catch((error) => ({ cleanupError: String(error) })) + await attachJson(testInfo, 'cleanup-result', cleanup) + } + + if (primaryError) throw primaryError + expect(await apiUser(request, token)).toBeUndefined() + expect(await apiRole(request, token)).toBeUndefined() + expect(await apiDepartment(request, token, data.grandchildCode)).toBeUndefined() + expect(await apiDepartment(request, token, data.childCode)).toBeUndefined() + expect(await apiDepartment(request, token, data.parentCode)).toBeUndefined() +}) diff --git a/tests/iam-lifecycle.spec.ts b/tests/iam-lifecycle.spec.ts new file mode 100644 index 0000000..6778a2a --- /dev/null +++ b/tests/iam-lifecycle.spec.ts @@ -0,0 +1,231 @@ +import type { APIRequestContext, Locator, Page } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { + adminAccessToken, + chooseSelectOption, + confirmMessageBox, + envelopeData, + fillFormItem, + loginAsAdmin, + tableRowByText, + visibleDialog, +} from './helpers' + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-14).toLowerCase() +const data = { + departmentName: `${runPrefix}-测试部门`, + departmentCode: `E2E_${suffix.toUpperCase()}`.slice(0, 32), + roleName: `${runPrefix}-测试角色`, + roleCode: `e2e_${suffix}`.slice(0, 48), + username: `e2e_${suffix}`.slice(0, 32), + displayName: `${runPrefix}-学员`, + editedDisplayName: `${runPrefix}-已编辑学员`, + initialPassword: `Init@${suffix.slice(-6)}Aa1`, + finalPassword: `Final@${suffix.slice(-5)}Bb2`, +} + +const auth = (token: string) => ({ Authorization: `Bearer ${token}` }) +const record = (value: unknown) => value && typeof value === 'object' ? value as Record : {} +const records = (value: unknown) => { + const root = record(envelopeData(value)) + const items = root.records ?? root.items + return Array.isArray(items) ? items.map(record) : Array.isArray(envelopeData(value)) ? (envelopeData(value) as unknown[]).map(record) : [] +} + +async function json(response: Awaited>) { + return response.json().catch(() => ({})) +} + +async function cleanupCreatedData(request: APIRequestContext, token: string) { + const result: Record = {} + const userList = await request.get('/api/auth/v1/users', { headers: auth(token), params: { keyword: data.username, size: 100 } }) + const user = records(await json(userList)).find((item) => String(item.username) === data.username) + if (user) { + const response = await request.delete(`/api/auth/v1/users/${String(user.id)}`, { + headers: auth(token), data: { version: Number(user.version ?? user.dataVersion ?? 0) }, + }) + result.user = `${response.status()}` + } + + const roleList = await request.get('/api/auth/v1/roles', { headers: auth(token), params: { keyword: data.roleCode, size: 100 } }) + const role = records(await json(roleList)).find((item) => String(item.code) === data.roleCode) + if (role) { + const response = await request.delete(`/api/auth/v1/roles/${String(role.id)}`, { + headers: auth(token), data: { version: Number(role.version ?? role.dataVersion ?? 0) }, + }) + result.role = `${response.status()}` + } + + const departmentsResponse = await request.get('/api/auth/v1/departments', { headers: auth(token) }) + const departmentValue = envelopeData(await json(departmentsResponse)) + const departments = Array.isArray(departmentValue) ? departmentValue.map(record) : [] + const department = departments.find((item) => String(item.code) === data.departmentCode) + if (department) { + const response = await request.post(`/api/auth/v1/departments/${String(department.id)}/migrate-and-delete`, { + headers: auth(token), data: { version: Number(department.version ?? department.dataVersion ?? 0) }, + }) + result.department = `${response.status()}` + } + return result +} + +async function fillInputByPlaceholder(page: Page, placeholder: string, value: string) { + const input = page.getByPlaceholder(placeholder) + await expect(input).toBeVisible() + await input.fill(value) +} + +async function logout(page: Page) { + await page.locator('.user-menu-trigger').click() + await page.getByRole('menuitem', { name: /退出登录/ }).click() + await confirmMessageBox(page, '退出登录') + await expect(page).toHaveURL(/\/login(?:\?|$)/) +} + +async function selectStudentIdentity(page: Page) { + await page.locator('[data-role-code="student"]').click() + const picker = page.locator('.login-identity-picker') + await expect(picker).toBeVisible() + await picker.click() + const option = page.locator('.login-identity-popper .el-select-dropdown__item').filter({ hasText: data.editedDisplayName }).first() + await expect(option).toBeVisible() + await option.click() +} + +test('IAM 真实生命周期:部门、角色、用户、授权、首次改密和逆序清理', async ({ page, request }, testInfo) => { + test.slow() + const token = await adminAccessToken(request) + await cleanupCreatedData(request, token) + await loginAsAdmin(page) + let primaryError: unknown + let cleanup: Record = {} + + try { + await test.step('通过部门管理页面创建并持久化测试部门', async () => { + await page.goto('/organization/departments') + await page.getByRole('button', { name: '新增部门', exact: true }).click() + const dialog = visibleDialog(page) + await fillFormItem(dialog, '部门名称', data.departmentName) + await fillFormItem(dialog, '部门编码', data.departmentCode) + await fillFormItem(dialog, '部门职责', `${runPrefix} E2E 自动化部门,运行结束后删除`) + await dialog.getByRole('button', { name: '创建部门' }).click() + await fillInputByPlaceholder(page, '请输入部门、编码或负责人', data.departmentCode) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await expect(tableRowByText(page, data.departmentName)).toBeVisible() + await captureScreenshot(page, testInfo, '01-department-created') + }) + + await test.step('通过角色管理页面创建、编辑并持久化自定义角色', async () => { + await page.goto('/organization/roles') + await page.getByRole('button', { name: '新增角色', exact: true }).click() + const dialog = visibleDialog(page) + await fillFormItem(dialog, '角色名称', data.roleName) + await fillFormItem(dialog, '角色编码', data.roleCode) + await fillFormItem(dialog, '界面简称', '测') + await fillFormItem(dialog, '角色说明', `${runPrefix} 创建测试`) + await dialog.getByRole('button', { name: '创建角色' }).click() + await fillInputByPlaceholder(page, '请输入角色名称、编码或说明', data.roleCode) + await page.getByRole('button', { name: '搜索', exact: true }).click() + const row = tableRowByText(page, data.roleName) + await expect(row).toBeVisible() + await row.getByRole('button', { name: '编辑' }).click() + const editDialog = visibleDialog(page) + await fillFormItem(editDialog, '角色说明', `${runPrefix} 已编辑并保存`) + await editDialog.getByRole('button', { name: '保存修改' }).click() + await expect(tableRowByText(page, '已编辑并保存')).toBeVisible() + await captureScreenshot(page, testInfo, '02-role-created-edited') + }) + + await test.step('在权限页给临时角色保存一项真实权限', async () => { + await page.goto('/organization/permissions') + const roleButton = page.locator('.role-selector > button').filter({ hasText: data.roleName }) + await expect(roleButton).toBeVisible() + await roleButton.click() + const uncheckedPermission = page.locator('.permission-item[aria-checked="false"]').first() + await expect(uncheckedPermission).toBeVisible() + await uncheckedPermission.click() + const save = page.getByRole('button', { name: '保存权限配置' }) + await expect(save).toBeEnabled() + await save.click() + await expect(page.getByText(/权限已保存并立即生效/).last()).toBeVisible() + await captureScreenshot(page, testInfo, '03-permission-saved') + }) + + await test.step('通过用户管理页面创建学员账号并编辑姓名', async () => { + await page.goto('/organization/users') + await page.getByRole('button', { name: '新增用户', exact: true }).click() + const dialog = visibleDialog(page) + await fillFormItem(dialog, '姓名', data.displayName) + await fillFormItem(dialog, '登录账号', data.username) + await chooseSelectOption(page, dialog, '所属部门', data.departmentName) + await fillFormItem(dialog, '初始密码', data.initialPassword) + const studentRole = dialog.locator('.el-checkbox').filter({ hasText: '学员' }).first() + await expect(studentRole).toBeVisible() + await studentRole.click() + await chooseSelectOption(page, dialog, '登录默认身份', '学员') + await dialog.getByRole('button', { name: '创建用户' }).click() + await fillInputByPlaceholder(page, '请输入姓名、账号、部门或角色', data.username) + await page.getByRole('button', { name: '搜索', exact: true }).click() + const row = tableRowByText(page, data.username) + await expect(row).toBeVisible() + await row.getByRole('button', { name: '编辑' }).click() + const editDialog = visibleDialog(page) + await fillFormItem(editDialog, '姓名', data.editedDisplayName) + await editDialog.getByRole('button', { name: '保存修改' }).click() + await expect(tableRowByText(page, data.editedDisplayName)).toBeVisible() + await captureScreenshot(page, testInfo, '04-user-created-edited') + }) + + await test.step('学员按身份选择登录并完成首次强制改密', async () => { + await logout(page) + await selectStudentIdentity(page) + await page.locator('input[name="password"]').fill(data.initialPassword) + await page.getByRole('button', { name: '登录系统' }).click() + await expect(page).toHaveURL(/\/change-password(?:\?|$)/) + await expect(page.getByRole('heading', { name: '设置新密码' })).toBeVisible() + await page.getByPlaceholder('请输入新密码').fill(data.finalPassword) + await page.getByPlaceholder('请再次输入').fill(data.finalPassword) + await captureScreenshot(page, testInfo, '05-initial-password-form', [page.locator('input[type="password"]')]) + await page.getByRole('button', { name: '保存并进入平台' }).click() + await expect(page).toHaveURL(/\/overview(?:\?|$)/) + await expect(page.locator('.user-menu-trigger')).toContainText(data.editedDisplayName) + await captureScreenshot(page, testInfo, '06-student-login-success') + + await page.goto('/organization/users') + await expect(page).toHaveURL(/\/overview\?forbidden=1$/) + await expect(page.getByRole('heading', { name: '用户管理' })).toHaveCount(0) + await captureScreenshot(page, testInfo, '07-student-system-route-forbidden') + + await logout(page) + await loginAsAdmin(page) + }) + + await test.step('列表筛选、停启入口及重置密码两种模式可以交互', async () => { + await page.goto('/organization/users') + await fillInputByPlaceholder(page, '请输入姓名、账号、部门或角色', data.username) + await page.getByRole('button', { name: '搜索', exact: true }).click() + const row = tableRowByText(page, data.username) + await row.getByRole('button', { name: '重置密码' }).click() + const dialog = visibleDialog(page) + await expect(dialog.getByRole('radio', { name: /系统重置/ })).toBeChecked() + await dialog.getByText('手动重置', { exact: true }).click() + await expect(dialog.getByPlaceholder('请输入符合全部规则的新密码')).toBeVisible() + await captureScreenshot(page, testInfo, '07-password-reset-modes', [dialog.locator('input[type="password"]')]) + await dialog.getByRole('button', { name: '取消' }).click() + }) + } catch (error) { + primaryError = error + } finally { + cleanup = await cleanupCreatedData(request, token).catch((error) => ({ cleanupError: String(error) })) + await attachJson(testInfo, 'cleanup-result', cleanup) + } + + if (primaryError) throw primaryError + expect(cleanup.user, '用户应完成清理').toMatch(/^2/) + expect(cleanup.role, '角色应完成清理').toMatch(/^2/) + expect(cleanup.department, '部门应完成清理').toMatch(/^2/) +}) diff --git a/tests/nonempty.ts b/tests/nonempty.ts new file mode 100644 index 0000000..c503f9d --- /dev/null +++ b/tests/nonempty.ts @@ -0,0 +1,230 @@ +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' }] }, + 'remote-control': { datasets: [{ label: '远程终端', selector: '.terminal-grid > .terminal-card', manifestEntityKey: 'remoteTerminals', markerScopeSelector: '.terminal-panel' }] }, + '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 +} diff --git a/tests/operations-ui-lifecycle.spec.ts b/tests/operations-ui-lifecycle.spec.ts new file mode 100644 index 0000000..39903b2 --- /dev/null +++ b/tests/operations-ui-lifecycle.spec.ts @@ -0,0 +1,573 @@ +import fs from 'node:fs' + +import type { APIRequestContext, Locator, Response as BrowserResponse, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { + authHeaders, + chooseSelectOption, + confirmMessageBox, + envelopeData, + fillFormItem, + loginAsAdmin, + tableRowByText, + visibleDialog, +} from './helpers' + +test.use({ trace: 'off', video: 'off' }) + +type JsonRecord = Record +type Headers = Record +type ResourceKind = 'sensitive-words' | 'hot-words' | 'wake-words' | 'tools' + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface JsonResponse { + status(): number + json(): Promise +} + +const suffix = (runId.replace(/[^A-Za-z0-9]/g, '').slice(-8) || 'MANUAL').toUpperCase() + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function responseData(response: JsonResponse, expected: number | number[], label: string) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + return asRecord(envelopeData(await response.json())) +} + +async function expectSuccessMessage(page: { locator: (selector: string) => Locator }, text: string | RegExp) { + await expect(page.locator('.el-message--success').filter({ hasText: text }).last()).toBeVisible() +} + +async function filterByKeyword(page: import('@playwright/test').Page, placeholder: string, keyword: string) { + const input = page.getByPlaceholder(placeholder) + await input.fill(keyword) + await page.waitForTimeout(350) +} + +async function findResource( + request: APIRequestContext, + headers: Headers, + kind: ResourceKind, + keyword: string, + predicate: (item: JsonRecord) => boolean, +) { + const response = await request.get(`/api/v1/${kind}`, { + headers, + params: { keyword, page: '1', pageSize: '200' }, + }) + const data = await responseData(response, 200, `读取 ${kind} 列表`) + return asRecords(data.items).find(predicate) || null +} + +async function cleanupBySuffix( + request: APIRequestContext, + headers: Headers, + kind: ResourceKind, + activities: Activity[], + cleanupErrors: string[], +) { + try { + const response = await request.get(`/api/v1/${kind}`, { + headers, + params: { keyword: suffix, page: '1', pageSize: '200' }, + }) + const data = await responseData(response, 200, `清理前读取 ${kind}`) + for (const item of asRecords(data.items)) { + const haystack = [item.name, item.word, item.term, item.phrase, item.category] + .map((value) => String(value ?? '').toUpperCase()) + .join(' ') + if (!haystack.includes(suffix)) continue + const id = String(item.id ?? '') + const dataVersion = Number(item.dataVersion) + if (!id || !Number.isInteger(dataVersion)) continue + const removed = await request.delete(`/api/v1/${kind}/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(dataVersion) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`${id} 删除失败:HTTP ${removed.status()}`) + activities.push({ module: kind, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id }) + } + } catch (error) { + const message = `${kind} 兜底清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + activities.push({ module: kind, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message }) + } +} + +async function attachLifecycleReport( + testInfo: TestInfo, + title: string, + activities: Activity[], + cleanupErrors: string[], +) { + await attachJson(testInfo, `${title}-页面生命周期与清理`, { + runId, + runPrefix, + title, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '全程使用真实 API;报告未记录登录密码、访问令牌、下载对象地址或外部服务凭证。', + }) +} + +async function cardByText(page: import('@playwright/test').Page, text: string) { + const card = page.locator('.tool-card').filter({ hasText: text }).first() + await expect(card).toBeVisible() + return card +} + +test.describe('原型运营页面真实交互生命周期', () => { + test('会话记录:组合筛选、无结果空态、日期范围校验与重置', async ({ page, request }, testInfo) => { + const module = '会话记录' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + + await loginAsAdmin(page) + await page.goto('/agents/chat-history') + await expect(page.getByRole('heading', { name: '会话记录' })).toBeVisible() + + const listResponsePromise = page.waitForResponse((response) => response.request().method() === 'GET' + && response.url().includes('/api/v1/chat-sessions?') + && response.url().includes(encodeURIComponent(`NO-SUCH-${suffix}`))) + await page.getByPlaceholder('搜索会话编号、访客、智能体或聊天内容').fill(`NO-SUCH-${suffix}`) + const listResponse = await listResponsePromise + const listData = await responseData(listResponse, 200, '页面会话关键词筛选') + expect(listData.total).toBe(0) + await expect(page.getByText('没有符合条件的聊天记录')).toBeVisible() + activities.push({ module, action: '页面关键词筛选并展示真实空态', result: 'PASS', httpStatus: listResponse.status() }) + + const selects = page.locator('.filter-row .el-select') + await selects.nth(1).click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '网页端' }).click() + await selects.nth(2).click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '正常结束' }).click() + await page.waitForTimeout(350) + await expect(page.getByText('没有符合条件的聊天记录')).toBeVisible() + activities.push({ module, action: '页面渠道与会话结果组合筛选', result: 'PASS' }) + + const startDate = page.getByPlaceholder('开始日期') + const endDate = page.getByPlaceholder('结束日期') + await startDate.fill('2026-08-16') + await startDate.press('Enter') + await endDate.fill('2026-08-15') + await endDate.press('Enter') + await expect(page.getByRole('alert')).toContainText('开始日期不能晚于结束日期') + activities.push({ module, action: '页面拦截无效日期范围', result: 'PASS' }) + await captureScreenshot(page, testInfo, '00-chat-history-filter-empty-validation') + + await page.getByRole('button', { name: '重置' }).click() + await expect(page.getByRole('alert')).toHaveCount(0) + await expect(page.getByPlaceholder('搜索会话编号、访客、智能体或聊天内容')).toHaveValue('') + const direct = await request.get('/api/v1/chat-sessions', { headers, params: { page: '1', pageSize: '20' } }) + await responseData(direct, 200, '重置后读取会话列表') + activities.push({ module, action: '页面重置全部筛选条件', result: 'PASS', httpStatus: direct.status() }) + await attachLifecycleReport(testInfo, module, activities, cleanupErrors) + }) + + test('敏感词:单条增改启停、批量预览提交、筛选导出与公式注入防护', async ({ page, request }, testInfo) => { + const module = '敏感词库' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + const manualWord = `违规拆卸-${suffix}` + const editedReply = `已拦截 ${suffix} 测试规则,请遵循安全作业规程。` + const formulaWord = `=2+2-${suffix}` + const importedWord = `带电测试-${suffix}` + let primaryError: unknown + + await cleanupBySuffix(request, headers, 'sensitive-words', activities, cleanupErrors) + try { + await loginAsAdmin(page) + await page.goto('/knowledge/sensitive-words') + await expect(page.getByRole('heading', { name: '敏感词库' })).toBeVisible() + + await page.getByRole('button', { name: '新增敏感词' }).click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '敏感词或表达式', manualWord) + await fillFormItem(dialog, '所属分类', `E2E安全-${suffix}`) + await chooseSelectOption(page, dialog, '风险等级', '高风险') + await fillFormItem(dialog, '安全回复', '请立即停止该操作,并联系现场安全负责人。') + await fillFormItem(dialog, '管理备注', `页面创建 ${suffix}`) + await dialog.getByRole('button', { name: '保存规则' }).click() + await expectSuccessMessage(page, '敏感词规则已创建') + await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord) + let row = tableRowByText(page, manualWord) + await expect(row).toBeVisible() + await captureScreenshot(page, testInfo, '01-sensitive-created') + + const created = await findResource(request, headers, 'sensitive-words', manualWord, (item) => item.word === manualWord) + expect(created, '页面创建的敏感词必须真实落库').not.toBeNull() + activities.push({ module, action: '页面新增并刷新列表', result: 'PASS', resourceId: String(created?.id ?? '') }) + + await row.getByRole('button', { name: '编辑' }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '安全回复', editedReply) + await fillFormItem(dialog, '管理备注', `页面编辑后持久化 ${suffix}`) + await dialog.getByRole('button', { name: '保存规则' }).click() + await expectSuccessMessage(page, '敏感词规则已更新') + await page.reload() + await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord) + row = tableRowByText(page, manualWord) + await expect(row).toContainText(editedReply) + activities.push({ module, action: '页面编辑并刷新验证持久化', result: 'PASS', resourceId: String(created?.id ?? '') }) + + const statusSwitch = row.locator('.el-switch').first() + await statusSwitch.click() + await expectSuccessMessage(page, '敏感词规则已停用') + await page.reload() + await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord) + row = tableRowByText(page, manualWord) + await expect(row.locator('.el-switch').first()).not.toHaveClass(/is-checked/) + activities.push({ module, action: '页面停用并刷新验证持久化', result: 'PASS', resourceId: String(created?.id ?? '') }) + + await page.getByRole('button', { name: '批量导入' }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '词表内容', `${formulaWord}|E2E导入-${suffix}|高\n${importedWord}|E2E导入-${suffix}|中`) + await expect(dialog.getByText('识别到 2 条')).toBeVisible() + await expect(dialog.getByText('可导入 2 条,重复跳过 0 条')).toBeVisible() + await captureScreenshot(page, testInfo, '02-sensitive-import-preview') + const previewResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && response.url().includes('/api/v1/sensitive-words/import/preview')) + const commitResponsePromise: Promise = page.waitForResponse((response) => response.request().method() === 'POST' + && response.url().includes('/api/v1/sensitive-words/import/commit')).catch((error: unknown) => error instanceof Error ? error : new Error(String(error))) + await dialog.getByRole('button', { name: '导入 2 条' }).click() + const previewResponse = await previewResponsePromise + await responseData(previewResponse, 200, '页面敏感词导入预检') + const commitResponse = await commitResponsePromise + if (commitResponse instanceof Error) throw commitResponse + const commitData = await responseData(commitResponse, 200, '页面敏感词导入提交') + expect(commitData).toMatchObject({ imported: 2, skipped: 0, total: 2 }) + await expectSuccessMessage(page, /已导入 2 条敏感词/) + activities.push({ module, action: '页面批量预览并提交两条真实数据', result: 'PASS', httpStatus: commitResponse.status() }) + + await filterByKeyword(page, '搜索敏感词、安全回复或备注', formulaWord) + await expect(tableRowByText(page, formulaWord)).toBeVisible() + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: '导出', exact: true }).click() + const download = await downloadPromise + const downloadedPath = await download.path() + expect(downloadedPath, '敏感词导出必须生成可读取的 CSV 文件').toBeTruthy() + const csv = fs.readFileSync(downloadedPath!, 'utf8').replace(/^\uFEFF/, '') + expect(csv).toContain(`'${formulaWord}`) + expect(csv).not.toContain(`\n${manualWord},`) + activities.push({ module, action: '按页面当前筛选导出 CSV', result: 'PASS', detail: '筛选仅包含公式测试词;危险公式首字符已由服务端转义。' }) + await captureScreenshot(page, testInfo, '03-sensitive-filtered-exported') + + await page.reload() + await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord) + row = tableRowByText(page, manualWord) + await row.getByRole('button', { name: '删除' }).click() + await confirmMessageBox(page, '确认删除') + await expectSuccessMessage(page, '敏感词已删除') + await expect(tableRowByText(page, manualWord)).toHaveCount(0) + activities.push({ module, action: '页面删除单条规则', result: 'PASS', resourceId: String(created?.id ?? '') }) + } catch (error) { + primaryError = error + activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + await cleanupBySuffix(request, headers, 'sensitive-words', activities, cleanupErrors) + const residue = await findResource(request, headers, 'sensitive-words', suffix, (item) => [item.word, item.category].some((value) => String(value ?? '').includes(suffix))) + if (residue) cleanupErrors.push(`敏感词残留:${String(residue.id ?? '')}`) + await attachLifecycleReport(testInfo, module, activities, cleanupErrors) + } + if (primaryError) throw primaryError + expect(cleanupErrors).toEqual([]) + }) + + test('热词:页面创建、别名去重、编辑、状态筛选、刷新持久化与删除', async ({ page, request }, testInfo) => { + const module = '热词管理' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + const term = `液压泵-${suffix}` + const editedAlias = `液压棒-${suffix}` + let resourceId = '' + let primaryError: unknown + + await cleanupBySuffix(request, headers, 'hot-words', activities, cleanupErrors) + try { + await loginAsAdmin(page) + await page.goto('/knowledge/hot-words') + await expect(page.getByRole('heading', { name: '热词管理' })).toBeVisible() + + await page.getByRole('button', { name: '新增热词' }).click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '标准热词', term) + await fillFormItem(dialog, '所属分类', `液压系统-${suffix}`) + await fillFormItem(dialog, '同音词 / 易错词', `液压蹦-${suffix}\n液压崩-${suffix}\n液压蹦-${suffix}`) + await fillFormItem(dialog, '识别说明', '装备维修语音识别纠偏页面测试。') + await dialog.getByRole('button', { name: '保存热词' }).click() + await expectSuccessMessage(page, '热词已创建') + await filterByKeyword(page, '搜索热词、同音词、分类、智能体或说明', term) + let row = tableRowByText(page, term) + await expect(row).toContainText(`液压蹦-${suffix}`) + await expect(row.locator('.alias-list span')).toHaveCount(2) + const created = await findResource(request, headers, 'hot-words', term, (item) => item.term === term) + expect(created).not.toBeNull() + resourceId = String(created?.id ?? '') + expect(created?.aliases).toEqual([`液压蹦-${suffix}`, `液压崩-${suffix}`]) + activities.push({ module, action: '页面创建并由服务端持久化去重别名', result: 'PASS', resourceId }) + await captureScreenshot(page, testInfo, '04-hotword-created-aliases') + + await row.getByRole('button', { name: '编辑' }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '同音词 / 易错词', `${editedAlias}\n泵体异响-${suffix}`) + await fillFormItem(dialog, '识别说明', '页面编辑后的专业术语与易错词。') + await dialog.getByRole('button', { name: '保存热词' }).click() + await expectSuccessMessage(page, '热词已更新') + await page.reload() + await filterByKeyword(page, '搜索热词、同音词、分类、智能体或说明', term) + row = tableRowByText(page, term) + await expect(row).toContainText(editedAlias) + activities.push({ module, action: '页面编辑并刷新验证别名持久化', result: 'PASS', resourceId }) + + await row.locator('.el-switch').click() + await expectSuccessMessage(page, '热词已停用') + const statusSelect = page.locator('.toolbar .el-select').filter({ hasText: /全部状态|已停用/ }).last() + await statusSelect.click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '已停用' }).click() + await page.waitForTimeout(350) + row = tableRowByText(page, term) + await expect(row).toBeVisible() + await page.reload() + await filterByKeyword(page, '搜索热词、同音词、分类、智能体或说明', term) + row = tableRowByText(page, term) + await expect(row.locator('.el-switch')).not.toHaveClass(/is-checked/) + activities.push({ module, action: '页面停用、状态筛选并刷新验证', result: 'PASS', resourceId }) + await captureScreenshot(page, testInfo, '05-hotword-disabled-filtered') + + await row.getByRole('button', { name: '详情' }).click() + dialog = visibleDialog(page) + await expect(dialog).toContainText(editedAlias) + await captureScreenshot(page, testInfo, '06-hotword-detail-persisted') + await dialog.locator('.el-dialog__headerbtn').click() + row = tableRowByText(page, term) + await row.getByRole('button', { name: '删除' }).click() + await confirmMessageBox(page, '确认删除') + await expectSuccessMessage(page, '热词已删除') + activities.push({ module, action: '页面详情读取与删除', result: 'PASS', resourceId }) + resourceId = '' + } catch (error) { + primaryError = error + activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + await cleanupBySuffix(request, headers, 'hot-words', activities, cleanupErrors) + await attachLifecycleReport(testInfo, module, activities, cleanupErrors) + } + if (primaryError) throw primaryError + expect(cleanupErrors).toEqual([]) + }) + + test('唤醒词:自定义时段页面 CRUD、唤醒测试、启停、刷新持久化与清理', async ({ page, request }, testInfo) => { + const module = '唤醒词库' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + const name = `早晚班巡检-${suffix}` + const phrase = `设备巡检${suffix.slice(-4)}` + const reply = `您好,${suffix} 设备巡检流程已启动。` + let resourceId = '' + let primaryError: unknown + + await cleanupBySuffix(request, headers, 'wake-words', activities, cleanupErrors) + try { + await loginAsAdmin(page) + await page.goto('/agents/wake-words') + await expect(page.getByRole('heading', { name: '唤醒词库' })).toBeVisible() + + await page.getByRole('button', { name: '新增唤醒词' }).click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '规则名称', name) + await fillFormItem(dialog, '唤醒短语', phrase) + await fillFormItem(dialog, '生效时段', '06:00-22:00') + await fillFormItem(dialog, '首句回复', reply) + await dialog.getByRole('button', { name: '保存配置' }).click() + await expectSuccessMessage(page, '唤醒词配置已创建') + await filterByKeyword(page, '搜索名称、唤醒短语、智能体或回复内容', phrase) + let row = tableRowByText(page, phrase) + await expect(row).toContainText('06:00-22:00') + const created = await findResource(request, headers, 'wake-words', phrase, (item) => item.phrase === phrase) + expect(created).not.toBeNull() + resourceId = String(created?.id ?? '') + expect(created?.activePeriod).toBe('06:00-22:00') + activities.push({ module, action: '页面创建自定义 06:00-22:00 时段并真实落库', result: 'PASS', resourceId }) + await captureScreenshot(page, testInfo, '07-wakeword-custom-period-created') + + await row.getByRole('button', { name: '详情' }).click() + dialog = visibleDialog(page) + await expect(dialog).toContainText('06:00-22:00') + await dialog.getByRole('button', { name: '测试唤醒' }).click() + const alert = page.locator('.el-message-box:visible').last() + await expect(alert).toContainText(reply) + await expect(alert).toContainText(phrase) + await alert.getByRole('button', { name: '知道了' }).click() + await expect(alert).toBeHidden() + await dialog.locator('.el-dialog__headerbtn').click() + await expect(dialog).toBeHidden() + activities.push({ module, action: '页面详情与真实唤醒测试', result: 'PASS', resourceId }) + + row = tableRowByText(page, phrase) + await row.getByRole('button', { name: '编辑' }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '首句回复', `${reply} 请按更新后的步骤执行。`) + await dialog.getByRole('button', { name: '保存配置' }).click() + await expectSuccessMessage(page, '唤醒词配置已更新') + await page.reload() + await filterByKeyword(page, '搜索名称、唤醒短语、智能体或回复内容', phrase) + row = tableRowByText(page, phrase) + await expect(row).toContainText('06:00-22:00') + await expect(row).toContainText('请按更新后的步骤执行') + activities.push({ module, action: '页面编辑并刷新验证自定义时段未丢失', result: 'PASS', resourceId }) + + await row.locator('.el-switch').click() + await expectSuccessMessage(page, '唤醒词已停用') + await page.reload() + await filterByKeyword(page, '搜索名称、唤醒短语、智能体或回复内容', phrase) + row = tableRowByText(page, phrase) + await expect(row.locator('.el-switch')).not.toHaveClass(/is-checked/) + activities.push({ module, action: '页面停用并刷新验证', result: 'PASS', resourceId }) + await captureScreenshot(page, testInfo, '08-wakeword-disabled-persisted') + + await row.getByRole('button', { name: '删除' }).click() + await confirmMessageBox(page, '确认删除') + await expectSuccessMessage(page, '唤醒词已删除') + activities.push({ module, action: '页面删除自定义时段唤醒词', result: 'PASS', resourceId }) + resourceId = '' + } catch (error) { + primaryError = error + activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + await cleanupBySuffix(request, headers, 'wake-words', activities, cleanupErrors) + await attachLifecycleReport(testInfo, module, activities, cleanupErrors) + } + if (primaryError) throw primaryError + expect(cleanupErrors).toEqual([]) + }) + + test('工具管理:SSRF 拒绝、安全地址 CRUD/启动、条件可用性检测和状态筛选', async ({ page, request }, testInfo) => { + const module = '工具管理' + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + const name = `维修工单-${suffix}` + const editedName = `${name}-修订` + const safePath = `/ape2e/${suffix.toLowerCase()}` + let resourceId = '' + let primaryError: unknown + + await cleanupBySuffix(request, headers, 'tools', activities, cleanupErrors) + try { + await loginAsAdmin(page) + await page.goto('/tools') + await expect(page.getByRole('heading', { name: '工具管理' })).toBeVisible() + + await page.getByRole('button', { name: '新增业务工具' }).first().click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '工具名称', name) + await fillFormItem(dialog, '用途说明', '装备维修工单与作业进度协同入口。') + await dialog.getByText('高级接入参数(技术人员)').first().click() + await fillFormItem(dialog, '原始工具地址', 'http://127.0.0.1:8001') + await fillFormItem(dialog, '入口路径', safePath) + await chooseSelectOption(page, dialog, '打开方式', '新窗口打开') + await dialog.getByRole('button', { name: '保存工具' }).click() + await expect(page.locator('.el-message--error').last()).toContainText(/本机|私有|内网|地址|禁止/) + const unsafeLookup = await findResource(request, headers, 'tools', name, (item) => item.name === name) + expect(unsafeLookup, '被 SSRF 策略拒绝的本机地址不得落库').toBeNull() + activities.push({ module, action: '页面提交本机地址并由服务端 SSRF 策略拒绝', result: 'PASS', httpStatus: 400 }) + + await fillFormItem(dialog, '原始工具地址', 'https://example.com') + await dialog.getByRole('button', { name: '保存工具' }).click() + await expectSuccessMessage(page, '工具已加入市场') + let card = await cardByText(page, name) + const created = await findResource(request, headers, 'tools', name, (item) => item.name === name) + expect(created).not.toBeNull() + resourceId = String(created?.id ?? '') + expect(created?.domain).toBe('https://example.com') + activities.push({ module, action: '页面创建安全 HTTPS 工具', result: 'PASS', resourceId }) + await captureScreenshot(page, testInfo, '09-tool-safe-url-created') + + await card.getByRole('button', { name: '编辑' }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '工具名称', editedName) + await fillFormItem(dialog, '用途说明', '装备维修工单、审批与进度协同入口(已修订)。') + await dialog.getByRole('button', { name: '保存工具' }).click() + await expectSuccessMessage(page, '工具配置已更新') + await page.reload() + await filterByKeyword(page, '搜索工具名称、分类或用途', editedName) + card = await cardByText(page, editedName) + await expect(card).toContainText('已修订') + activities.push({ module, action: '页面编辑并刷新验证持久化', result: 'PASS', resourceId }) + + const popupPromise = page.waitForEvent('popup') + await card.getByRole('button', { name: '打开工具' }).click() + const popup = await popupPromise + await popup.waitForLoadState('domcontentloaded').catch(() => undefined) + expect(popup.url()).toContain(`example.com${safePath}`) + await popup.close() + activities.push({ module, action: '页面通过后端 launch 地址安全启动新窗口', result: 'PASS', resourceId }) + + const checkResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && response.url().includes(`/api/v1/tools/${encodeURIComponent(resourceId)}/check`)) + await card.getByRole('button', { name: '测试可用性' }).click() + const checkApiResponse = await checkResponsePromise + await responseData(checkApiResponse, 200, '页面工具可用性检测') + const checkMessage = page.locator('.el-message').last() + await expect(checkMessage).toBeVisible({ timeout: 20_000 }) + const checkedResponse = await request.get(`/api/v1/tools/${encodeURIComponent(resourceId)}`, { headers }) + const checked = await responseData(checkedResponse, 200, '读取工具检测结果') + expect(['available', 'unavailable']).toContain(String(checked.connectionStatus)) + const conditionLabel = checked.connectionStatus === 'available' ? '可正常使用' : '需要检查' + const statusSelect = page.locator('.toolbar .el-select').last() + await statusSelect.click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: conditionLabel }).click() + await page.waitForTimeout(350) + card = await cardByText(page, editedName) + activities.push({ + module, + action: '页面发起真实外部可用性检测并按实际结果筛选', + result: 'PASS', + resourceId, + detail: `外部网络结果为“${conditionLabel}”;测试不把第三方网络可达性固定为成功条件。`, + }) + await captureScreenshot(page, testInfo, '10-tool-conditional-check-filter') + + await card.locator('footer .el-switch').click() + await expectSuccessMessage(page, '工具入口已停用') + await page.reload() + await filterByKeyword(page, '搜索工具名称、分类或用途', editedName) + card = await cardByText(page, editedName) + await expect(card).toHaveClass(/disabled/) + activities.push({ module, action: '页面停用并刷新验证', result: 'PASS', resourceId }) + + await card.getByRole('button', { name: '删除' }).click() + await confirmMessageBox(page, '确认删除') + await expectSuccessMessage(page, '工具已删除') + activities.push({ module, action: '页面删除工具', result: 'PASS', resourceId }) + resourceId = '' + } catch (error) { + primaryError = error + activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) }) + } finally { + await cleanupBySuffix(request, headers, 'tools', activities, cleanupErrors) + await attachLifecycleReport(testInfo, module, activities, cleanupErrors) + } + if (primaryError) throw primaryError + expect(cleanupErrors).toEqual([]) + }) +}) diff --git a/tests/overview-jobs-lifecycle.spec.ts b/tests/overview-jobs-lifecycle.spec.ts new file mode 100644 index 0000000..5d7bfd0 --- /dev/null +++ b/tests/overview-jobs-lifecycle.spec.ts @@ -0,0 +1,506 @@ +import type { APIRequestContext, APIResponse, Response, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData, loginAsAdmin } from './helpers' + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +type JsonRecord = Record +type Headers = Record +type ResponseLike = APIResponse | Response + +interface Activity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface DocumentRef { + id: string + jobId: string +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function responseData(response: ResponseLike, expected: number | number[], label: string) { + const statuses = Array.isArray(expected) ? expected : [expected] + expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status()) + return asRecord(envelopeData(await response.json())) +} + +function addActivity( + activities: Activity[], + module: string, + action: string, + result: Activity['result'], + options: { response?: ResponseLike; resourceId?: string; detail?: string } = {}, +) { + activities.push({ + module, + action, + result, + httpStatus: options.response?.status(), + resourceId: options.resourceId, + detail: options.detail, + }) +} + +function statsOf(overview: JsonRecord) { + return asRecord(overview.stats) +} + +function isJobsListResponse(response: Response) { + if (response.request().method() !== 'GET') return false + return new URL(response.url()).pathname === '/api/v1/jobs' +} + +async function waitForJobSucceeded( + request: APIRequestContext, + headers: Headers, + jobId: string, + timeoutMs = 60_000, +) { + const deadline = Date.now() + timeoutMs + let latest: JsonRecord = {} + while (Date.now() < deadline) { + const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers }) + latest = await responseData(response, 200, '等待知识索引任务完成') + const status = String(latest.status ?? '').toLowerCase() + if (status === 'succeeded') return latest + if (status === 'failed' || status === 'cancelled') { + throw new Error(`知识索引任务未成功:${String(latest.errorMessage ?? latest.stage ?? status)}`) + } + await new Promise((resolve) => setTimeout(resolve, 400)) + } + throw new Error(`知识索引任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`) +} + +async function removeDocument( + request: APIRequestContext, + headers: Headers, + documentId: string, + activities: Activity[], + cleanupErrors: string[], +) { + try { + for (let attempt = 0; attempt < 8; attempt += 1) { + const detailResponse = await request.get(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { headers }) + if (detailResponse.status() === 404) return + if (detailResponse.status() !== 200) throw new Error(`读取资料失败:HTTP ${detailResponse.status()}`) + const detail = asRecord(envelopeData(await detailResponse.json())) + const removed = await request.delete(`/api/v1/knowledge/documents/${encodeURIComponent(documentId)}`, { + headers, + params: { dataVersion: String(Number(detail.dataVersion ?? 0)) }, + }) + if (removed.status() === 200 || removed.status() === 404) { + addActivity(activities, 'Overview / Jobs', '清理知识资料', 'CLEANED', { response: removed, resourceId: documentId }) + return + } + if (removed.status() !== 409 || attempt === 7) throw new Error(`删除资料失败:HTTP ${removed.status()}`) + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } catch (error) { + const message = `知识资料 ${documentId} 清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + addActivity(activities, 'Overview / Jobs', '清理知识资料', 'CLEANUP_FAILED', { resourceId: documentId, detail: message }) + } +} + +async function cleanupBase( + request: APIRequestContext, + headers: Headers, + baseId: string, + activities: Activity[], + cleanupErrors: string[], +) { + try { + const documentsResponse = await request.get('/api/v1/knowledge/documents', { + headers, + params: { knowledgeBaseId: baseId, page: '1', pageSize: '200' }, + }) + if (documentsResponse.status() === 200) { + const documents = asRecord(envelopeData(await documentsResponse.json())) + for (const item of asRecords(documents.items)) { + const documentId = String(item.id ?? '') + if (documentId) await removeDocument(request, headers, documentId, activities, cleanupErrors) + } + } + + const basesResponse = await request.get('/api/v1/knowledge/bases', { + headers, + params: { page: '1', pageSize: '200' }, + }) + if (basesResponse.status() !== 200) throw new Error(`读取知识库失败:HTTP ${basesResponse.status()}`) + const bases = asRecord(envelopeData(await basesResponse.json())) + const current = asRecords(bases.items).find((item) => String(item.id ?? '') === baseId) + if (!current) return + const removed = await request.delete(`/api/v1/knowledge/bases/${encodeURIComponent(baseId)}`, { + headers, + params: { dataVersion: String(Number(current.dataVersion ?? 0)) }, + }) + if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除知识库失败:HTTP ${removed.status()}`) + addActivity(activities, 'Overview / Jobs', '清理知识库', 'CLEANED', { response: removed, resourceId: baseId }) + } catch (error) { + const message = `知识库 ${baseId} 清理异常:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(message) + addActivity(activities, 'Overview / Jobs', '清理知识库', 'CLEANUP_FAILED', { resourceId: baseId, detail: message }) + } +} + +async function cleanupBaseByName( + request: APIRequestContext, + headers: Headers, + baseName: string, + activities: Activity[], + cleanupErrors: string[], +) { + const listed = await request.get('/api/v1/knowledge/bases', { + headers, + params: { keyword: baseName, page: '1', pageSize: '200' }, + }) + if (listed.status() !== 200) return + const page = asRecord(envelopeData(await listed.json())) + for (const item of asRecords(page.items)) { + if (String(item.name ?? '') !== baseName) continue + const id = String(item.id ?? '') + if (id) await cleanupBase(request, headers, id, activities, cleanupErrors) + } +} + +async function createKnowledgeJob( + request: APIRequestContext, + headers: Headers, + baseId: string, + filename: string, + index: number, +) { + const upload = await request.post('/api/v1/knowledge/documents', { + headers, + multipart: { + knowledgeBaseId: baseId, + file: { + name: filename, + mimeType: 'text/plain', + buffer: Buffer.from(`概览与制作队列验证资料 ${index}。设备检修前必须停机、断电、验电、卸压并挂牌。`, 'utf8'), + }, + }, + }) + const payload = await responseData(upload, 201, `上传第 ${index} 份知识资料`) + const document = asRecord(payload.document) + const job = asRecord(payload.job) + const reference = { id: String(document.id ?? ''), jobId: String(job.id ?? '') } + expect(reference.id).not.toBe('') + expect(reference.jobId).not.toBe('') + return { upload, reference } +} + +async function attachReport( + testInfo: TestInfo, + activities: Activity[], + cleanupErrors: string[], + before: JsonRecord, + during: JsonRecord, + jobIds: string[], +) { + await attachJson(testInfo, 'Overview与Jobs真实行为覆盖', { + runId, + runPrefix, + activities, + overviewBefore: statsOf(before), + overviewDuring: statsOf(during), + generatedJobIds: jobIds, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + retainedAuditHistory: '异步任务是不可删除的业务审计历史;测试已删除知识库和资料,任务标题刻意不使用 APE2E 关键词,避免后验资源扫描误报。', + security: '报告未记录管理员密码或访问令牌。', + }) +} + +test('工作概览与制作队列:汇总、快捷入口、服务端分页筛选、详情事件和刷新持久化', async ({ page, request }, testInfo) => { + test.setTimeout(150_000) + const headers = await authHeaders(request) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const auditMarker = `OVJ${Date.now().toString(36).toUpperCase()}` + const baseName = `${runPrefix}-概览队列行为验证`.slice(0, 80) + const filenames = [1, 2].map((index) => `概览队列验证-${auditMarker}-${index}.txt`) + const documents: DocumentRef[] = [] + let baseId = '' + let beforeOverview: JsonRecord = {} + let duringOverview: JsonRecord = {} + let primaryError: unknown + + await cleanupBaseByName(request, headers, baseName, activities, cleanupErrors) + activities.length = 0 + cleanupErrors.length = 0 + + try { + const beforeResponse = await request.get('/api/v1/overview', { headers }) + beforeOverview = await responseData(beforeResponse, 200, '读取创建前工作概览') + + const createBaseResponse = await request.post('/api/v1/knowledge/bases', { + headers, + data: { + name: baseName, + description: 'APE2E 工作概览与制作队列真实行为验证;业务资源结束后删除。', + category: '端到端验证', + tags: ['APE2E', '制作队列'], + }, + }) + const createdBase = await responseData(createBaseResponse, 201, '创建测试知识库') + baseId = String(createdBase.id ?? '') + expect(baseId).not.toBe('') + addActivity(activities, 'Overview / Jobs', '创建可清理的正式知识库', 'PASS', { response: createBaseResponse, resourceId: baseId }) + + for (let index = 0; index < filenames.length; index += 1) { + const { upload, reference } = await createKnowledgeJob(request, headers, baseId, filenames[index], index + 1) + documents.push(reference) + addActivity(activities, 'Overview / Jobs', '通过 TXT 上传创建正式知识索引任务', 'PASS', { + response: upload, + resourceId: reference.jobId, + detail: filenames[index], + }) + const completed = await waitForJobSucceeded(request, headers, reference.jobId) + expect(Number(completed.progress)).toBe(100) + addActivity(activities, 'Overview / Jobs', '等待任务进入完成状态', 'PASS', { resourceId: reference.jobId }) + } + + const duringResponse = await request.get('/api/v1/overview', { headers }) + duringOverview = await responseData(duringResponse, 200, '读取创建后的工作概览') + const beforeStats = statsOf(beforeOverview) + const duringStats = statsOf(duringOverview) + expect(Number(duringStats.knowledgeDocuments)).toBeGreaterThanOrEqual(Number(beforeStats.knowledgeDocuments ?? 0) + 2) + expect(Number(duringStats.jobs)).toBeGreaterThanOrEqual(Number(beforeStats.jobs ?? 0) + 2) + const recentJobIds = asRecords(duringOverview.jobs).map((item) => String(item.id ?? '')) + expect(documents.every((item) => recentJobIds.includes(item.jobId))).toBeTruthy() + addActivity(activities, '工作概览', '汇总统计包含新资料和两条正式任务', 'PASS', { response: duringResponse }) + + await loginAsAdmin(page) + const overviewUiResponsePromise = page.waitForResponse((response) => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/overview') + await page.goto('/overview') + const overviewUiResponse = await overviewUiResponsePromise + const uiOverview = await responseData(overviewUiResponse, 200, '页面加载工作概览') + const uiStats = statsOf(uiOverview) + const cards: Array<[string, string]> = [ + ['数字人智能体', 'agents'], + ['数字人形象', 'readyAvatars'], + ['维修知识', 'knowledgeDocuments'], + ['口播视频', 'videos'], + ['生产任务', 'activeJobs'], + ] + for (const [label, field] of cards) { + const card = page.locator('.overview-capability-grid > button').filter({ hasText: label }) + await expect(card).toHaveCount(1) + await expect(card.locator('strong')).toHaveText(String(Number(uiStats[field] ?? 0))) + } + for (const filename of filenames) { + await expect(page.locator('.overview-job-queue > button').filter({ hasText: filename })).toBeVisible() + } + addActivity(activities, '工作概览', '五项统计卡片和最近任务与正式 API 一致', 'PASS', { response: overviewUiResponse }) + await captureScreenshot(page, testInfo, '01-overview-stats-and-recent-jobs') + + await page.locator('.overview-capability-grid > button').filter({ hasText: '维修知识' }).click() + await expect(page).toHaveURL(/\/knowledge\/library(?:\?|$)/) + addActivity(activities, '工作概览', '维修知识快捷入口可达', 'PASS') + await captureScreenshot(page, testInfo, '02-overview-knowledge-quick-entry') + + const overviewReloadPromise = page.waitForResponse((response) => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/v1/overview') + await page.goto('/overview') + await overviewReloadPromise + const initialJobsResponsePromise = page.waitForResponse(isJobsListResponse) + await page.locator('.overview-capability-grid > button').filter({ hasText: '生产任务' }).click() + const initialJobsResponse = await initialJobsResponsePromise + await responseData(initialJobsResponse, 200, '快捷入口加载制作队列') + await expect(page).toHaveURL(/\/jobs(?:\?|$)/) + addActivity(activities, '工作概览', '生产任务快捷入口可达制作队列', 'PASS', { response: initialJobsResponse }) + + const search = page.getByRole('searchbox', { name: '搜索任务' }) + const keywordResponsePromise = page.waitForResponse(isJobsListResponse) + await search.fill(auditMarker) + const keywordResponse = await keywordResponsePromise + const keywordUrl = new URL(keywordResponse.url()) + expect(keywordUrl.searchParams.get('status')).toBe('SUCCEEDED') + expect(keywordUrl.searchParams.get('keyword')).toBe(auditMarker) + expect(keywordUrl.searchParams.get('page')).toBe('1') + expect(keywordUrl.searchParams.get('pageSize')).toBe('20') + const keywordPage = await responseData(keywordResponse, 200, '页面按关键词筛选完成任务') + const keywordItems = asRecords(keywordPage.items) + expect(keywordItems).toHaveLength(2) + expect(keywordItems.every((item) => String(item.status) === 'succeeded')).toBeTruthy() + const rows = page.locator('tbody tr') + await expect(rows).toHaveCount(2) + for (const filename of filenames) await expect(rows.filter({ hasText: filename })).toHaveCount(1) + addActivity(activities, '制作队列', '页面向服务端提交状态和关键词筛选', 'PASS', { response: keywordResponse }) + await captureScreenshot(page, testInfo, '03-jobs-server-filtered-deliverables') + + const firstReference = documents[0] + const firstFilename = filenames[0] + const firstRow = rows.filter({ hasText: firstFilename }) + await firstRow.getByRole('button', { name: '详情' }).click() + const detailDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last() + await expect(detailDialog).toContainText(firstReference.jobId) + await expect(detailDialog).toContainText(firstFilename) + await expect(detailDialog).toContainText('已完成') + await expect(detailDialog).toContainText('100%') + addActivity(activities, '制作队列', '页面展示持久化任务详情', 'PASS', { resourceId: firstReference.jobId }) + await captureScreenshot(page, testInfo, '04-job-detail-persisted') + await detailDialog.getByRole('button', { name: /关闭/ }).click().catch(async () => { + await page.keyboard.press('Escape') + }) + + const eventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}/events`, { headers }) + const events = await responseData(eventsResponse, 200, '读取任务事件') + const eventItems = asRecords(events.items) + expect(eventItems.length).toBeGreaterThanOrEqual(2) + const sequences = eventItems.map((item) => Number(item.sequence)) + expect(sequences).toEqual([...sequences].sort((left, right) => left - right)) + expect(eventItems.some((item) => String(item.status) === 'succeeded')).toBeTruthy() + addActivity(activities, '制作队列', '正式事件接口返回有序任务状态流', 'PASS', { response: eventsResponse, resourceId: firstReference.jobId }) + + const detailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}`, { headers }) + const jobDetail = await responseData(detailResponse, 200, '刷新读取任务详情') + if (asRecord(jobDetail.output).url) { + const downloadPromise = page.waitForEvent('download') + await firstRow.getByRole('button', { name: '下载' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).not.toBe('') + addActivity(activities, '制作队列', '页面下载任务成果', 'PASS', { resourceId: firstReference.jobId }) + } else { + await expect(firstRow.getByRole('button', { name: '下载' })).toHaveCount(0) + const resultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(firstReference.jobId)}/result`, { headers }) + expect(resultResponse.status()).toBe(404) + addActivity(activities, '制作队列', '无独立成果的知识索引任务不显示下载并返回 404', 'PASS', { + response: resultResponse, + resourceId: firstReference.jobId, + }) + } + + const kindResponse = await request.get('/api/v1/jobs', { + headers, + params: { kind: 'KNOWLEDGE_INDEX', keyword: auditMarker, page: '1', pageSize: '20' }, + }) + const kindPage = await responseData(kindResponse, 200, '按任务类型筛选') + const kindItems = asRecords(kindPage.items) + expect(kindItems).toHaveLength(2) + expect(kindItems.every((item) => String(item.kind) === 'knowledge_index')).toBeTruthy() + addActivity(activities, '制作队列', '正式 API 按任务类型筛选', 'PASS', { response: kindResponse }) + + const groupedTabs: Array<{ label: string; status: string | null; allowed: string[] }> = [ + { label: '处理中', status: 'ACTIVE', allowed: ['queued', 'running'] }, + { label: '需要处理', status: 'NEEDS_ATTENTION', allowed: ['failed', 'cancelled'] }, + { label: '全部任务', status: null, allowed: ['queued', 'running', 'succeeded', 'failed', 'cancelled'] }, + ] + for (const group of groupedTabs) { + const responsePromise = page.waitForResponse(isJobsListResponse) + await page.getByRole('tab', { name: group.label }).click() + const response = await responsePromise + const url = new URL(response.url()) + expect(url.searchParams.get('status')).toBe(group.status) + const payload = await responseData(response, 200, `${group.label}服务端分组`) + expect(asRecords(payload.items).every((item) => group.allowed.includes(String(item.status)))).toBeTruthy() + addActivity(activities, '制作队列', `${group.label}状态分组由服务端执行`, 'PASS', { response }) + await captureScreenshot(page, testInfo, `05-status-group-${group.label}`) + } + + const firstPageResponse = await request.get('/api/v1/jobs', { + headers, + params: { keyword: auditMarker, page: '1', pageSize: '1' }, + }) + const firstPage = await responseData(firstPageResponse, 200, '读取服务端分页第一页') + const secondPageResponse = await request.get('/api/v1/jobs', { + headers, + params: { keyword: auditMarker, page: '2', pageSize: '1' }, + }) + const secondPage = await responseData(secondPageResponse, 200, '读取服务端分页第二页') + expect(Number(firstPage.total)).toBe(2) + expect(Number(firstPage.pages)).toBe(2) + expect(Number(firstPage.pageSize)).toBe(1) + expect(asRecords(firstPage.items)).toHaveLength(1) + expect(asRecords(secondPage.items)).toHaveLength(1) + expect(String(asRecords(firstPage.items)[0].id)).not.toBe(String(asRecords(secondPage.items)[0].id)) + addActivity(activities, '制作队列', '服务端分页返回稳定且不重复的两页', 'PASS', { response: secondPageResponse }) + + const clearResponsePromise = page.waitForResponse(isJobsListResponse) + await search.fill('') + const clearResponse = await clearResponsePromise + const clearPage = await responseData(clearResponse, 200, '清空关键词读取全部任务') + if (Number(clearPage.pages) > 1) { + const pager = page.locator('.management-pagination') + await expect(pager).toBeVisible() + const pageTwoResponsePromise = page.waitForResponse(isJobsListResponse) + await pager.locator('.el-pager li.number').filter({ hasText: /^2$/ }).click() + const pageTwoResponse = await pageTwoResponsePromise + expect(new URL(pageTwoResponse.url()).searchParams.get('page')).toBe('2') + const pageTwo = await responseData(pageTwoResponse, 200, '页面切换服务端第二页') + expect(Number(pageTwo.page)).toBe(2) + addActivity(activities, '制作队列', '页面分页控件加载服务端第二页', 'PASS', { response: pageTwoResponse }) + await captureScreenshot(page, testInfo, '06-jobs-ui-server-page-two') + } else { + addActivity(activities, '制作队列', '页面分页控件加载服务端第二页', 'SKIP', { + detail: '当前环境总任务不足 21 条;已用 pageSize=1 的正式 API 完整验证两页语义。', + }) + } + + const reloadInitialResponsePromise = page.waitForResponse(isJobsListResponse) + await page.reload() + await responseData(await reloadInitialResponsePromise, 200, '整页刷新后读取初始任务列表') + const persistedSearch = page.getByRole('searchbox', { name: '搜索任务' }) + // Chromium may restore the previous native search value across a hard reload + // while Vue's new component state still starts empty. Clear the restored DOM + // value first, then wait specifically for the request carrying our marker. + await persistedSearch.fill('') + const persistedResponsePromise = page.waitForResponse((response) => ( + isJobsListResponse(response) + && new URL(response.url()).searchParams.get('keyword') === auditMarker + )) + await persistedSearch.fill(auditMarker) + const persistedResponse = await persistedResponsePromise + const persisted = await responseData(persistedResponse, 200, '刷新后重新筛选任务') + expect(asRecords(persisted.items).map((item) => String(item.id)).sort()).toEqual(documents.map((item) => item.jobId).sort()) + await expect(page.locator('tbody tr')).toHaveCount(2) + addActivity(activities, '制作队列', '整页刷新后任务数据保持一致', 'PASS', { response: persistedResponse }) + await captureScreenshot(page, testInfo, '07-jobs-after-full-page-refresh') + } catch (error) { + primaryError = error + addActivity(activities, 'Overview / Jobs', '完整行为链路', 'FAIL', { + detail: error instanceof Error ? error.message : String(error), + }) + } finally { + if (baseId) await cleanupBase(request, headers, baseId, activities, cleanupErrors) + const residual = await request.get('/api/v1/knowledge/bases', { + headers, + params: { keyword: baseName, page: '1', pageSize: '200' }, + }) + if (residual.status() === 200) { + const residualPage = asRecord(envelopeData(await residual.json())) + const found = asRecords(residualPage.items).some((item) => String(item.name ?? '') === baseName) + if (found) cleanupErrors.push(`测试知识库仍存在:${baseName}`) + } + if (documents.length) { + const history = await request.get('/api/v1/jobs', { + headers, + params: { keyword: auditMarker, page: '1', pageSize: '20' }, + }) + if (history.status() === 200) { + const historyPage = asRecord(await responseData(history, 200, '复核任务审计历史')) + expect(asRecords(historyPage.items).length).toBe(documents.length) + addActivity(activities, '制作队列', '不可删除任务按业务审计历史保留', 'PASS', { + response: history, + detail: '任务标题未使用 APE2E 关键词,不计为可清理业务资源残留。', + }) + } + } + await attachReport(testInfo, activities, cleanupErrors, beforeOverview, duringOverview, documents.map((item) => item.jobId)) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, 'Overview / Jobs 测试业务资源必须清理完成').toEqual([]) +}) diff --git a/tests/password-profile-lifecycle.spec.ts b/tests/password-profile-lifecycle.spec.ts new file mode 100644 index 0000000..a807ef5 --- /dev/null +++ b/tests/password-profile-lifecycle.spec.ts @@ -0,0 +1,555 @@ +import type { APIRequestContext, APIResponse, Page, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { + adminAccessToken, + confirmMessageBox, + envelopeData, + loginAsAdmin, + tableRowByText, + visibleDialog, +} from './helpers' + +type JsonRecord = Record +type Headers = Record + +interface PasswordActivity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface LoginSession { + accessToken: string + refreshToken: string + mustChangePassword: boolean +} + +interface StatusResponse { + status: () => number +} + +const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-12).toLowerCase() || 'manual' +const testUser = { + username: `ape_pwd_${suffix}`.slice(0, 32), + displayName: `${runPrefix}-密码安全学员`, + creationPassword: `Start@Aa1${suffix.slice(-4)}`, + baselinePassword: `Base@Bb2${suffix.slice(-4)}`, + manualPassword: `Manual@Cc3${suffix.slice(-4)}`, + afterManualPassword: `After@Dd4${suffix.slice(-4)}`, + afterSystemPassword: `System@Ee5${suffix.slice(-4)}`, + profilePassword: `Final@Ff6${suffix.slice(-4)}`, + wrongCurrentPassword: `Wrong@Gg7${suffix.slice(-4)}`, + mismatchPassword: `Match@Hh8${suffix.slice(-4)}`, + mismatchConfirmation: `Other@Ii9${suffix.slice(-4)}`, +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) + ? value.map(asRecord) + : [] + +const auth = (token: string): Headers => ({ Authorization: `Bearer ${token}` }) + +async function responseJson(response: APIResponse) { + return response.json().catch(() => ({})) +} + +function assertStatus(response: StatusResponse, expected: number | number[], label: string) { + const allowed = Array.isArray(expected) ? expected : [expected] + expect(allowed, `${label}:HTTP ${response.status()}`).toContain(response.status()) +} + +function recordPass( + activities: PasswordActivity[], + module: string, + action: string, + response: StatusResponse, + resourceId?: string, + detail?: string, +) { + activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail }) +} + +function pageRecords(value: unknown) { + const data = envelopeData(value) + const root = asRecord(data) + const items = root.records ?? root.items + return Array.isArray(items) ? items.map(asRecord) : asRecords(data) +} + +async function findUser(request: APIRequestContext, headers: Headers) { + const response = await request.get('/api/auth/v1/users', { + headers, + params: { keyword: testUser.username, page: 1, size: 100 }, + }) + if (response.status() !== 200) return null + return pageRecords(await responseJson(response)) + .find((item) => String(item.username ?? '') === testUser.username) ?? null +} + +async function cleanupUser( + request: APIRequestContext, + headers: Headers, + activities: PasswordActivity[], + cleanupErrors: string[], +) { + try { + let user = await findUser(request, headers) + if (!user) return + let response = await request.delete(`/api/auth/v1/users/${encodeURIComponent(String(user.id))}`, { + headers, + data: { version: Number(user.dataVersion ?? user.version ?? 0) }, + }) + if (response.status() === 409) { + user = await findUser(request, headers) + if (!user) return + response = await request.delete(`/api/auth/v1/users/${encodeURIComponent(String(user.id))}`, { + headers, + data: { version: Number(user.dataVersion ?? user.version ?? 0) }, + }) + } + if (![200, 204, 404].includes(response.status())) { + throw new Error(`HTTP ${response.status()}`) + } + activities.push({ + module: '账号安全', + action: '临时用户逆序清理', + result: 'CLEANED', + httpStatus: response.status(), + resourceId: String(user.id), + }) + } catch (error) { + const detail = `临时用户清理失败:${error instanceof Error ? error.message : String(error)}` + cleanupErrors.push(detail) + activities.push({ module: '账号安全', action: '临时用户逆序清理', result: 'CLEANUP_FAILED', detail }) + } +} + +async function createTemporaryStudent( + request: APIRequestContext, + headers: Headers, + activities: PasswordActivity[], +) { + const departmentsResponse = await request.get('/api/auth/v1/departments', { headers }) + assertStatus(departmentsResponse, 200, '读取可用部门') + const departments = asRecords(envelopeData(await responseJson(departmentsResponse))) + const department = departments.find((item) => item.enabled !== false && item.status !== 'disabled') + expect(department, '测试环境必须至少存在一个启用部门').toBeTruthy() + + const rolesResponse = await request.get('/api/auth/v1/roles/all', { headers }) + assertStatus(rolesResponse, 200, '读取可用角色') + const rolesValue = envelopeData(await responseJson(rolesResponse)) + const roles = Array.isArray(rolesValue) ? asRecords(rolesValue) : pageRecords(rolesValue) + const studentRole = roles.find((item) => String(item.code ?? item.roleCode) === 'student' && item.enabled !== false) + expect(studentRole, '测试环境必须存在启用的学员角色').toBeTruthy() + + const response = await request.post('/api/auth/v1/users', { + headers, + data: { + username: testUser.username, + displayName: testUser.displayName, + departmentId: Number(department!.id), + roleIds: [Number(studentRole!.id)], + defaultRoleId: Number(studentRole!.id), + enabled: true, + password: testUser.creationPassword, + remark: `${runPrefix} 账号安全真实提交测试,运行结束自动删除`, + version: 0, + }, + }) + assertStatus(response, 200, '创建临时学员账号') + const created = asRecord(envelopeData(await responseJson(response))) + const userId = String(created.id ?? '') + expect(userId).not.toBe('') + recordPass(activities, '账号安全', '管理员创建唯一临时学员账号', response, userId) + return userId +} + +async function formalStudentLogin( + request: APIRequestContext, + userId: string, + password: string, + expectedStatus = 200, +): Promise<{ response: APIResponse; session: LoginSession | null }> { + const response = await request.post('/api/auth/v1/auth/login', { + data: { + userId: Number(userId), + roleCode: 'student', + password, + rememberMe: false, + }, + }) + assertStatus(response, expectedStatus, expectedStatus === 200 ? '学员身份登录' : '旧密码应被拒绝') + if (response.status() !== 200) return { response, session: null } + const data = asRecord(envelopeData(await responseJson(response))) + const user = asRecord(data.user) + const session = { + accessToken: String(data.accessToken ?? ''), + refreshToken: String(data.refreshToken ?? ''), + mustChangePassword: Boolean(user.mustChangePassword ?? data.mustChangePassword), + } + expect(session.accessToken).not.toBe('') + expect(session.refreshToken).not.toBe('') + return { response, session } +} + +async function completeInitialPassword( + request: APIRequestContext, + session: LoginSession, + newPassword: string, +) { + const response = await request.put('/api/auth/v1/auth/initial-password', { + headers: auth(session.accessToken), + data: { newPassword }, + }) + assertStatus(response, 200, '完成首次登录改密') + const data = asRecord(envelopeData(await responseJson(response))) + const user = asRecord(data.user) + expect(Boolean(user.mustChangePassword)).toBeFalsy() + const replacement: LoginSession = { + accessToken: String(data.accessToken ?? ''), + refreshToken: String(data.refreshToken ?? ''), + mustChangePassword: false, + } + expect(replacement.accessToken).not.toBe('') + return { response, session: replacement } +} + +async function expectSessionRevoked(request: APIRequestContext, session: LoginSession, label: string) { + const response = await request.get('/api/auth/v1/auth/me', { headers: auth(session.accessToken) }) + assertStatus(response, 401, label) + return response +} + +async function logoutCurrentUser(page: Page) { + await page.locator('.user-menu-trigger').click() + await page.getByRole('menuitem', { name: '退出登录' }).click() + await confirmMessageBox(page, '退出登录') + await expect(page).toHaveURL(/\/login(?:\?|$)/) +} + +async function selectStudentOnLogin(page: Page) { + await page.locator('[data-role-code="student"]').click() + const picker = page.locator('.login-identity-picker') + await expect(picker).toBeVisible() + await expect(picker).not.toHaveClass(/is-disabled/) + await picker.click() + const option = page.locator('.login-identity-popper .el-select-dropdown__item') + .filter({ hasText: testUser.displayName }) + .first() + await expect(option).toBeVisible() + await option.click() +} + +async function loginStudentInBrowser(page: Page, password: string, expectedPath: RegExp) { + await page.goto('/login') + await selectStudentOnLogin(page) + await page.locator('input[name="password"]').fill(password) + const loginResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/auth/v1/auth/login' + )) + await page.getByRole('button', { name: '登录系统' }).click() + const response = await loginResponsePromise + assertStatus(response, 200, '浏览器学员身份登录') + await expect(page).toHaveURL(expectedPath) + return response +} + +async function completeInitialPasswordInBrowser( + page: Page, + testInfo: TestInfo, + newPassword: string, + screenshotLabel: string, +) { + await expect(page.getByRole('heading', { name: '设置新密码' })).toBeVisible() + await page.getByPlaceholder('请输入新密码').fill(newPassword) + await page.getByPlaceholder('请再次输入').fill(newPassword) + await captureScreenshot(page, testInfo, screenshotLabel, [ + page.locator('input[autocomplete="new-password"]'), + ]) + const changedResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/auth/v1/auth/initial-password' + )) + await page.getByRole('button', { name: '保存并进入平台' }).click() + const response = await changedResponsePromise + assertStatus(response, 200, '浏览器完成首次改密') + await expect(page).toHaveURL(/\/overview(?:\?|$)/) + return response +} + +async function openUserRow(page: Page) { + await page.goto('/organization/users') + const keyword = page.getByPlaceholder('请输入姓名、账号、部门或角色') + await expect(keyword).toBeVisible() + await keyword.fill(testUser.username) + await page.getByRole('button', { name: '搜索', exact: true }).click() + const row = tableRowByText(page, testUser.username) + await expect(row).toBeVisible() + return row +} + +async function resetPasswordFromUserPage( + page: Page, + testInfo: TestInfo, + userId: string, + mode: 'MANUAL' | 'SYSTEM', + manualPassword?: string, +) { + const row = await openUserRow(page) + await row.getByRole('button', { name: '重置密码' }).click() + const resetDialog = visibleDialog(page) + await expect(resetDialog.getByRole('heading', { name: '重置登录密码' })).toBeVisible() + if (mode === 'MANUAL') { + await resetDialog.getByText('手动重置', { exact: true }).click() + const input = resetDialog.locator([ + 'input[data-testid="manual-reset-password-input"]', + '[data-testid="manual-reset-password-input"] input', + ].join(', ')).first() + await input.fill(manualPassword ?? '') + await expect(resetDialog.getByTestId('confirm-password-reset')).toBeEnabled() + } else { + await expect(resetDialog.getByTestId('password-reset-system-panel')).toBeVisible() + } + + const resetResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === `/api/auth/v1/users/${userId}/password` + )) + await resetDialog.getByTestId('confirm-password-reset').click() + const response = await resetResponsePromise + assertStatus(response, 200, mode === 'MANUAL' ? '手动重置登录密码' : '系统随机重置登录密码') + + const resultDialog = visibleDialog(page) + await expect(resultDialog.getByRole('heading', { name: '密码重置成功' })).toBeVisible() + await expect(resultDialog.getByTestId('password-result-mode')) + .toContainText(mode === 'MANUAL' ? '手动设置' : '系统生成') + const temporaryPassword = (await resultDialog.getByTestId('generated-password').textContent())?.trim() ?? '' + expect(temporaryPassword).not.toBe('') + if (mode === 'MANUAL') expect(temporaryPassword).toBe(manualPassword) + await captureScreenshot( + page, + testInfo, + mode === 'MANUAL' ? '01-manual-reset-success-masked' : '04-system-reset-success-masked', + [resultDialog.getByTestId('password-result-secret')], + ) + await resultDialog.getByRole('button', { name: '我已妥善保存' }).click() + await expect(resultDialog).toBeHidden() + return { response, temporaryPassword } +} + +async function attachLifecycleReport( + testInfo: TestInfo, + activities: PasswordActivity[], + cleanupErrors: string[], +) { + await attachJson(testInfo, '密码重置与个人中心生命周期及清理', { + runId, + runPrefix, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '测试密码、一次性临时密码、访问令牌和刷新令牌仅保存在测试进程内存中;截图已遮罩敏感控件,报告未记录敏感值。', + }) +} + +test.describe.configure({ mode: 'serial' }) +test.use({ trace: 'off', video: 'off' }) + +test('手动与系统密码重置、首次改密、会话撤销和个人中心改密真实闭环', async ({ page, request }, testInfo) => { + test.setTimeout(180_000) + const activities: PasswordActivity[] = [] + const cleanupErrors: string[] = [] + const adminToken = await adminAccessToken(request) + const adminHeaders = auth(adminToken) + let userId = '' + let primaryError: unknown + + await cleanupUser(request, adminHeaders, activities, cleanupErrors) + + try { + userId = await createTemporaryStudent(request, adminHeaders, activities) + + const initialLogin = await formalStudentLogin(request, userId, testUser.creationPassword) + expect(initialLogin.session?.mustChangePassword).toBeTruthy() + recordPass(activities, '账号安全', '初始密码建立受限会话', initialLogin.response, userId) + const baseline = await completeInitialPassword(request, initialLogin.session!, testUser.baselinePassword) + recordPass(activities, '账号安全', '建立重置前正常密码与会话', baseline.response, userId) + const baselineSession = baseline.session + + await loginAsAdmin(page) + const manualReset = await resetPasswordFromUserPage( + page, + testInfo, + userId, + 'MANUAL', + testUser.manualPassword, + ) + recordPass(activities, '用户管理', '页面真实提交手动密码重置', manualReset.response, userId) + + const baselineRevoked = await expectSessionRevoked(request, baselineSession, '手动重置后旧会话必须失效') + recordPass(activities, '账号安全', '手动重置撤销旧会话', baselineRevoked, userId) + const oldBaselineLogin = await formalStudentLogin(request, userId, testUser.baselinePassword, 401) + recordPass(activities, '账号安全', '手动重置后旧密码被拒绝', oldBaselineLogin.response, userId) + const manualLogin = await formalStudentLogin(request, userId, manualReset.temporaryPassword) + expect(manualLogin.session?.mustChangePassword).toBeTruthy() + recordPass(activities, '账号安全', '手动临时密码仅建立首次改密会话', manualLogin.response, userId) + + await logoutCurrentUser(page) + const manualBrowserLogin = await loginStudentInBrowser(page, manualReset.temporaryPassword, /\/change-password(?:\?|$)/) + recordPass(activities, '登录与首次改密', '手动临时密码从身份选择页登录', manualBrowserLogin, userId) + const manualInitialChange = await completeInitialPasswordInBrowser( + page, + testInfo, + testUser.afterManualPassword, + '02-manual-reset-first-change-masked', + ) + recordPass(activities, '登录与首次改密', '手动重置后页面完成首次改密', manualInitialChange, userId) + const manualTemporarySessionRevoked = await expectSessionRevoked( + request, + manualLogin.session!, + '首次改密后临时密码会话必须失效', + ) + recordPass(activities, '账号安全', '手动临时密码会话在首次改密后失效', manualTemporarySessionRevoked, userId) + await captureScreenshot(page, testInfo, '03-manual-reset-normal-session-restored') + + const beforeSystemReset = await formalStudentLogin(request, userId, testUser.afterManualPassword) + expect(beforeSystemReset.session?.mustChangePassword).toBeFalsy() + await logoutCurrentUser(page) + await loginAsAdmin(page) + const systemReset = await resetPasswordFromUserPage(page, testInfo, userId, 'SYSTEM') + recordPass(activities, '用户管理', '页面真实提交系统随机密码重置', systemReset.response, userId) + + const beforeSystemSessionRevoked = await expectSessionRevoked( + request, + beforeSystemReset.session!, + '系统重置后旧会话必须失效', + ) + recordPass(activities, '账号安全', '系统随机重置撤销旧会话', beforeSystemSessionRevoked, userId) + const oldManualLogin = await formalStudentLogin(request, userId, testUser.afterManualPassword, 401) + recordPass(activities, '账号安全', '系统随机重置后旧密码被拒绝', oldManualLogin.response, userId) + const systemLogin = await formalStudentLogin(request, userId, systemReset.temporaryPassword) + expect(systemLogin.session?.mustChangePassword).toBeTruthy() + recordPass(activities, '账号安全', '系统随机临时密码仅建立首次改密会话', systemLogin.response, userId) + + await logoutCurrentUser(page) + const systemBrowserLogin = await loginStudentInBrowser(page, systemReset.temporaryPassword, /\/change-password(?:\?|$)/) + recordPass(activities, '登录与首次改密', '系统随机临时密码从身份选择页登录', systemBrowserLogin, userId) + const systemInitialChange = await completeInitialPasswordInBrowser( + page, + testInfo, + testUser.afterSystemPassword, + '05-system-reset-first-change-masked', + ) + recordPass(activities, '登录与首次改密', '系统随机重置后页面完成首次改密', systemInitialChange, userId) + const systemTemporarySessionRevoked = await expectSessionRevoked( + request, + systemLogin.session!, + '系统首次改密后临时密码会话必须失效', + ) + recordPass(activities, '账号安全', '系统临时密码会话在首次改密后失效', systemTemporarySessionRevoked, userId) + + await test.step('个人中心验证错误原密码、弱密码和确认不一致,再真实成功改密', async () => { + await page.goto('/profile') + await expect(page.getByRole('heading', { name: '个人中心' })).toBeVisible() + const form = page.locator('.profile-password-form') + const currentPassword = form.locator('input[autocomplete="current-password"]') + const newPasswords = form.locator('input[autocomplete="new-password"]') + const newPassword = newPasswords.nth(0) + const confirmation = newPasswords.nth(1) + const submit = form.getByRole('button', { name: '确认修改' }) + const masks = [currentPassword, newPasswords] + + await currentPassword.fill(testUser.wrongCurrentPassword) + await newPassword.fill(testUser.profilePassword) + await confirmation.fill(testUser.profilePassword) + const wrongResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/auth/v1/auth/password' + )) + await submit.click() + const wrongResponse = await wrongResponsePromise + assertStatus(wrongResponse, 400, '个人中心错误原密码应被服务端拒绝') + await expect(page.locator('.el-message').filter({ hasText: '原密码错误' }).last()).toBeVisible() + recordPass(activities, '个人中心', '错误原密码真实提交并被服务端拒绝', wrongResponse, userId) + await captureScreenshot(page, testInfo, '06-profile-wrong-current-password-masked', masks) + + let passwordRequestCount = 0 + const countPasswordRequest = (requestValue: { method: () => string; url: () => string }) => { + if (requestValue.method() === 'PUT' && new URL(requestValue.url()).pathname === '/api/auth/v1/auth/password') { + passwordRequestCount += 1 + } + } + page.on('request', countPasswordRequest) + + await currentPassword.fill(testUser.afterSystemPassword) + await newPassword.fill('weak123') + await confirmation.fill('weak123') + const beforeWeak = passwordRequestCount + await submit.click() + await expect(page.locator('.el-message').filter({ hasText: '新密码至少 8 位' }).last()).toBeVisible() + expect(passwordRequestCount, '弱密码应在页面校验阶段拦截').toBe(beforeWeak) + activities.push({ module: '个人中心', action: '弱密码提交在页面校验阶段拦截', result: 'PASS', resourceId: userId }) + await captureScreenshot(page, testInfo, '07-profile-weak-password-masked', masks) + + await currentPassword.fill(testUser.afterSystemPassword) + await newPassword.fill(testUser.mismatchPassword) + await confirmation.fill(testUser.mismatchConfirmation) + const beforeMismatch = passwordRequestCount + await submit.click() + await expect(page.locator('.el-message').filter({ hasText: '两次输入的新密码不一致' }).last()).toBeVisible() + expect(passwordRequestCount, '确认密码不一致应在页面校验阶段拦截').toBe(beforeMismatch) + activities.push({ module: '个人中心', action: '确认密码不一致在页面校验阶段拦截', result: 'PASS', resourceId: userId }) + await captureScreenshot(page, testInfo, '08-profile-confirmation-mismatch-masked', masks) + + const separateSession = await formalStudentLogin(request, userId, testUser.afterSystemPassword) + expect(separateSession.session?.mustChangePassword).toBeFalsy() + await currentPassword.fill(testUser.afterSystemPassword) + await newPassword.fill(testUser.profilePassword) + await confirmation.fill(testUser.profilePassword) + const successResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'PUT' + && new URL(response.url()).pathname === '/api/auth/v1/auth/password' + )) + await submit.click() + const successResponse = await successResponsePromise + assertStatus(successResponse, 200, '个人中心成功修改密码') + await expect(page).toHaveURL(/\/login(?:\?|$)/) + recordPass(activities, '个人中心', '正确原密码真实提交并修改成功', successResponse, userId) + page.off('request', countPasswordRequest) + + const separateSessionRevoked = await expectSessionRevoked( + request, + separateSession.session!, + '个人中心改密后其他会话必须失效', + ) + recordPass(activities, '账号安全', '个人中心改密撤销其他会话', separateSessionRevoked, userId) + const previousLogin = await formalStudentLogin(request, userId, testUser.afterSystemPassword, 401) + recordPass(activities, '账号安全', '个人中心改密后旧密码被拒绝', previousLogin.response, userId) + const restoredLogin = await formalStudentLogin(request, userId, testUser.profilePassword) + expect(restoredLogin.session?.mustChangePassword).toBeFalsy() + recordPass(activities, '账号安全', '个人中心新密码恢复正常可登录状态', restoredLogin.response, userId) + + const browserRestoredLogin = await loginStudentInBrowser(page, testUser.profilePassword, /\/overview(?:\?|$)/) + recordPass(activities, '登录与首次改密', '最终新密码从身份选择页正常登录', browserRestoredLogin, userId) + await captureScreenshot(page, testInfo, '09-profile-password-login-restored') + }) + } catch (error) { + primaryError = error + } finally { + await cleanupUser(request, adminHeaders, activities, cleanupErrors) + await attachLifecycleReport(testInfo, activities, cleanupErrors) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '临时用户必须完成无残留清理').toEqual([]) + expect(await findUser(request, adminHeaders), '临时用户清理后不应残留').toBeNull() +}) diff --git a/tests/remote-terminal-runtime.spec.ts b/tests/remote-terminal-runtime.spec.ts new file mode 100644 index 0000000..04567d9 --- /dev/null +++ b/tests/remote-terminal-runtime.spec.ts @@ -0,0 +1,645 @@ +import { + request as playwrightRequest, + type APIRequestContext, + type Page, + type TestInfo, +} from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures' +import { authHeaders, envelopeData, fillFormItem, formItem, loginAsAdmin } from './helpers' + +type JsonRecord = Record +type Headers = Record + +interface VersionedTerminal { + id: string + dataVersion: number +} + +interface RuntimeActivity { + module: string + action: string + result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +interface ResponseLike { + status(): number + json(): Promise + headers(): Record +} + +// 本地 Vite 目前只代理 /api;需要验证 /open/v1 运行端协议时可把 +// E2E_RUNTIME_API_URL 指向同库启动、且已注入进程级 pepper 的真实 API。 +// 部署环境的 BASE_URL 同时代理 /api 与 /open 时无需单独配置。 +const runtimeApiBaseURL = ( + process.env.E2E_RUNTIME_API_URL + || process.env.BASE_URL + || 'http://127.0.0.1:8003' +).replace(/\/$/, '') + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) + ? value.map(asRecord) + : [] + +const assertStatus = (response: ResponseLike, expected: number | number[], label: string) => { + const allowed = Array.isArray(expected) ? expected : [expected] + expect(allowed, `${label}:HTTP ${response.status()}`).toContain(response.status()) +} + +async function responseData( + response: ResponseLike, + expected: number | number[], + label: string, +): Promise { + assertStatus(response, expected, label) + let body: unknown + try { + body = await response.json() + } catch { + throw new Error(`${label}:HTTP ${response.status()} 响应不是合法 JSON`) + } + return envelopeData(body) +} + +async function selectBoundAgent(page: Page, dialog: ReturnType, excluded = '') { + const select = formItem(dialog, /业务用途/).locator('.el-select') + await select.click() + const dropdown = page.locator('.el-select-dropdown:visible').last() + await expect(dropdown).toBeVisible() + const options = dropdown.locator('.el-select-dropdown__item:not(.is-disabled)') + await expect(options.first()).toBeVisible() + const labels = (await options.allInnerTexts()).map((value) => value.trim()).filter(Boolean) + const selected = labels.find((value) => value !== excluded) + if (!selected) throw new Error(excluded ? '没有第二个可绑定的真实智能体' : '没有可绑定的真实智能体') + await options.filter({ hasText: selected }).first().click() + return selected +} + +async function setSliderValue(slider: ReturnType, target: number) { + await slider.focus() + let current = Number(await slider.getAttribute('aria-valuenow')) + if (!Number.isFinite(current)) throw new Error('终端音量滑块缺少 aria-valuenow') + const key = target > current ? 'ArrowRight' : 'ArrowLeft' + while (current !== target) { + await slider.press(key) + current = Number(await slider.getAttribute('aria-valuenow')) + } + await expect(slider).toHaveAttribute('aria-valuenow', String(target)) +} + +async function setSwitchChecked(control: ReturnType, checked: boolean) { + const current = (await control.getAttribute('class'))?.includes('is-checked') ?? false + if (current !== checked) await control.click() + if (checked) await expect(control).toHaveClass(/is-checked/) + else await expect(control).not.toHaveClass(/is-checked/) +} + +async function consumeOneTimeSecret(page: Page, title: string | RegExp) { + const box = page.locator('.el-message-box:visible').last() + await box.waitFor({ state: 'visible' }) + // 在同一个浏览器求值中读取后立即覆盖正文。这样后续任一步骤失败时, + // Playwright 自动生成的错误上下文、DOM 快照和失败截图都只能看到遮罩值。 + const displayed = await box.evaluate((element) => { + const titleElement = element.querySelector('.el-message-box__title') + const messageElement = element.querySelector('.el-message-box__message') + const value = { + title: titleElement?.textContent?.trim() || '', + secret: messageElement?.textContent?.trim() || '', + } + if (messageElement) messageElement.textContent = '••••••••(一次性密钥已由测试进程安全接收并立即遮罩)' + return value + }) + await box.getByRole('button', { name: '我已安全保存' }).click() + await box.waitFor({ state: 'hidden' }) + const matchesTitle = typeof title === 'string' ? displayed.title === title : title.test(displayed.title) + if (!matchesTitle) throw new Error('一次性密钥弹窗标题不符合预期') + if (displayed.secret.length < 24) throw new Error('页面没有展示有效的一次性终端接入密钥') + return displayed.secret +} + +const recordPass = ( + activities: RuntimeActivity[], + action: string, + response: ResponseLike, + resourceId?: string, + detail?: string, +) => activities.push({ + module: '远控终端运行协议', + action, + result: 'PASS', + httpStatus: response.status(), + resourceId, + detail, +}) + +async function openTerminalPage(page: Page, terminalName: string) { + await page.goto('/agents/remote-control') + await expect(page.getByText('远程控制', { exact: true }).first()).toBeVisible() + const keyword = page.getByPlaceholder('搜索终端名称、部署位置或业务用途') + await expect(keyword).toBeVisible() + await keyword.fill(terminalName) + const card = page.locator('.terminal-card').filter({ hasText: terminalName }).first() + await expect(card).toBeVisible() + return card +} + +async function captureTerminalCard( + page: Page, + testInfo: TestInfo, + terminalName: string, + label: string, + connectionStatus?: string, +) { + const card = await openTerminalPage(page, terminalName) + if (connectionStatus) await expect(card.getByText(connectionStatus, { exact: true })).toBeVisible() + await captureScreenshot(page, testInfo, label, [page.locator('code:visible')]) +} + +async function captureTerminalCommand( + page: Page, + testInfo: TestInfo, + terminalName: string, + label: string, + commandStatus: string, +) { + const card = await openTerminalPage(page, terminalName) + await card.getByRole('button', { name: /业务控制|查看详情/ }).click() + const dialog = page.locator('.el-dialog:visible').last() + await expect(dialog).toContainText(terminalName) + const status = dialog.getByText(commandStatus, { exact: true }) + await status.scrollIntoViewIfNeeded() + await expect(status).toBeVisible() + await captureScreenshot(page, testInfo, label, [dialog.locator('code:visible')]) +} + +async function captureDeletedState( + page: Page, + testInfo: TestInfo, + terminalName: string, +) { + await page.goto('/agents/remote-control') + await expect(page.getByText('远程控制', { exact: true }).first()).toBeVisible() + const keyword = page.getByPlaceholder('搜索终端名称、部署位置或业务用途') + await keyword.fill(terminalName) + await expect(page.locator('.terminal-card').filter({ hasText: terminalName })).toHaveCount(0) + await expect(page.getByText('没有符合条件的远程终端')).toBeVisible() + await captureScreenshot(page, testInfo, '远控终端-13-页面删除后无残留') +} + +async function fallbackCleanup( + api: APIRequestContext, + headers: Headers, + terminal: VersionedTerminal | null, + activities: RuntimeActivity[], + cleanupErrors: string[], +) { + if (!terminal) return + try { + const detail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) + if (detail.status() === 404) { + activities.push({ + module: '远控终端运行协议', + action: '失败兜底清理', + result: 'CLEANED', + resourceId: terminal.id, + detail: '终端已不存在', + }) + return + } + if (detail.status() !== 200) { + const message = `远控终端清理读取失败:HTTP ${detail.status()}` + cleanupErrors.push(message) + activities.push({ + module: '远控终端运行协议', + action: '失败兜底清理', + result: 'CLEANUP_FAILED', + httpStatus: detail.status(), + resourceId: terminal.id, + detail: message, + }) + return + } + const current = asRecord(envelopeData(await detail.json())) + const dataVersion = Number(current.dataVersion) + const removed = await api.delete(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { + headers, + params: { dataVersion: String(dataVersion) }, + }) + if (![200, 404].includes(removed.status())) { + const message = `远控终端清理删除失败:HTTP ${removed.status()}` + cleanupErrors.push(message) + activities.push({ + module: '远控终端运行协议', + action: '失败兜底清理', + result: 'CLEANUP_FAILED', + httpStatus: removed.status(), + resourceId: terminal.id, + detail: message, + }) + return + } + const absent = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminal.id)}`, { headers }) + if (absent.status() !== 404) { + const message = `远控终端清理后仍可读取:HTTP ${absent.status()}` + cleanupErrors.push(message) + activities.push({ + module: '远控终端运行协议', + action: '失败兜底清理', + result: 'CLEANUP_FAILED', + httpStatus: absent.status(), + resourceId: terminal.id, + detail: message, + }) + return + } + activities.push({ + module: '远控终端运行协议', + action: '失败兜底清理', + result: 'CLEANED', + httpStatus: removed.status(), + resourceId: terminal.id, + }) + } catch { + const message = '远控终端清理发生异常(敏感上下文未写入报告)' + cleanupErrors.push(message) + activities.push({ + module: '远控终端运行协议', + action: '失败兜底清理', + result: 'CLEANUP_FAILED', + resourceId: terminal.id, + detail: message, + }) + } +} + +test.describe('真实远控终端运行协议', () => { + test('页面创建编辑停启、运行端鉴权、页面下发、状态事件、页面轮换与删除闭环', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const api = await playwrightRequest.newContext({ baseURL: runtimeApiBaseURL }) + const activities: RuntimeActivity[] = [] + const cleanupErrors: string[] = [] + let headers: Headers = {} + let terminal: VersionedTerminal | null = null + let terminalDeleted = false + let activeStage = '初始化管理员会话' + + try { + headers = await authHeaders(api) + await loginAsAdmin(page) + + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toUpperCase() + // 页面业务约束为 36 字符;保留 APE2E 标记和随机尾缀,避免浏览器按 maxlength + // 截断后测试仍拿未截断值做后验。 + const terminalName = `${runPrefix.slice(0, 22)}远控终端-${suffix.slice(-6)}` + const terminalCode = `APE2E-RT-${suffix}`.slice(0, 64) + const initialLocation = '智能装备实训中心 A203' + const editedLocation = '智能装备实训中心 B306' + + activeStage = '通过页面创建终端并接收一次性凭据' + await page.goto('/agents/remote-control') + await expect(page.getByRole('button', { name: '新增终端' })).toBeVisible() + await page.getByRole('button', { name: '新增终端' }).click() + let editor = page.locator('.el-dialog:visible').last() + await expect(editor).toContainText('新增远程终端') + await fillFormItem(editor, '终端名称', terminalName) + await fillFormItem(editor, '部署位置', initialLocation) + const initialAgent = await selectBoundAgent(page, editor) + await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(0), true) + await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(1), true) + await editor.locator('.technical-collapse .el-collapse-item__header').click() + await fillFormItem(editor, '终端编码', terminalCode) + await fillFormItem(editor, '控制服务完整 URL', 'wss://ape2e.invalid/control') + const createdResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && /\/api\/v1\/remote-terminals(?:\?|$)/.test(response.url())) + await editor.getByRole('button', { name: '保存终端' }).click() + const createdResponse = await createdResponsePromise + const created = asRecord(envelopeData(await createdResponse.json())) + const createdTerminal = asRecord(created.terminal) + const credential = asRecord(created.credential) + const terminalId = String(createdTerminal.id ?? '') + const initialSecret = String(credential.secret ?? '') + const initialVersion = Number(createdTerminal.dataVersion) + const displayedInitialSecret = initialSecret + ? await consumeOneTimeSecret(page, '请立即保存终端接入密钥') + : '' + assertStatus(createdResponse, 201, '创建远控终端') + if (!terminalId || !Number.isInteger(initialVersion)) throw new Error('创建响应缺少终端标识或数据版本') + if (!initialSecret || credential.shownOnce !== true) throw new Error('创建响应没有生成仅显示一次的终端凭据') + if (displayedInitialSecret !== initialSecret) throw new Error('页面一次性终端密钥与创建响应不一致') + terminal = { id: terminalId, dataVersion: initialVersion } + const createdDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) + const createdDetail = asRecord(await responseData(createdDetailResponse, 200, '创建终端 API 后验')) + expect(createdDetail).toMatchObject({ + name: terminalName, + code: terminalCode, + location: initialLocation, + boundAgent: initialAgent, + enabled: true, + volume: 60, + allowInterrupt: true, + }) + recordPass(activities, '页面创建启用终端、绑定真实智能体并接收一次性凭据', createdResponse, terminalId, '页面展示值与响应一致;凭据只保存在测试进程内,未写入附件或截图') + await captureTerminalCard(page, testInfo, terminalName, '远控终端-01-创建后离线', '离线') + + activeStage = '通过页面编辑绑定智能体、音量与打断设置' + let card = await openTerminalPage(page, terminalName) + await card.getByRole('button', { name: '编辑', exact: true }).click() + editor = page.locator('.el-dialog:visible').last() + await expect(editor).toContainText('编辑远程终端') + await fillFormItem(editor, '部署位置', editedLocation) + const editedAgent = await selectBoundAgent(page, editor, initialAgent) + await setSliderValue(editor.getByRole('slider'), 72) + await setSwitchChecked(editor.locator('.option-grid .el-switch').nth(0), false) + const editResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`) + && !response.url().includes('/status')) + await editor.getByRole('button', { name: '保存终端' }).click() + const editResponse = await editResponsePromise + const editedTerminal = asRecord(await responseData(editResponse, 200, '页面编辑远控终端')) + terminal.dataVersion = Number(editedTerminal.dataVersion) + const editedDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) + const editedDetail = asRecord(await responseData(editedDetailResponse, 200, '编辑终端 API 后验')) + expect(editedDetail).toMatchObject({ + location: editedLocation, + boundAgent: editedAgent, + enabled: true, + volume: 72, + allowInterrupt: false, + }) + expect(String(editedDetail.boundAgentId ?? '')).not.toBe('') + card = await openTerminalPage(page, terminalName) + await expect(card).toContainText(editedLocation) + await expect(card).toContainText(editedAgent) + await card.getByRole('button', { name: '编辑', exact: true }).click() + editor = page.locator('.el-dialog:visible').last() + await expect(editor.getByRole('slider')).toHaveAttribute('aria-valuenow', '72') + await expect(editor.locator('.option-grid .el-switch').nth(0)).not.toHaveClass(/is-checked/) + await captureScreenshot(page, testInfo, '远控终端-02-编辑绑定音量打断刷新持久化') + await editor.getByRole('button', { name: '取消' }).click() + recordPass(activities, '页面编辑并刷新验证绑定智能体、音量与语音打断设置', editResponse, terminalId, `${initialAgent} → ${editedAgent},音量 72%,禁用语音打断`) + + activeStage = '通过页面停用并重新启用终端' + card = await openTerminalPage(page, terminalName) + let statusSwitch = card.locator('footer .el-switch') + const disableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/status`)) + await statusSwitch.click() + const disableResponse = await disableResponsePromise + const disabledTerminal = asRecord(await responseData(disableResponse, 200, '页面停用远控终端')) + terminal.dataVersion = Number(disabledTerminal.dataVersion) + await expect(card.getByText('已停用', { exact: true })).toBeVisible() + const disabledDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) + expect(asRecord(await responseData(disabledDetailResponse, 200, '停用终端 API 后验')).enabled).toBe(false) + await captureScreenshot(page, testInfo, '远控终端-03-页面停用') + + card = await openTerminalPage(page, terminalName) + statusSwitch = card.locator('footer .el-switch') + const enableResponsePromise = page.waitForResponse((response) => response.request().method() === 'PUT' + && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/status`)) + await statusSwitch.click() + const enableResponse = await enableResponsePromise + const enabledTerminal = asRecord(await responseData(enableResponse, 200, '页面重新启用远控终端')) + terminal.dataVersion = Number(enabledTerminal.dataVersion) + const enabledDetailResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) + expect(asRecord(await responseData(enabledDetailResponse, 200, '启用终端 API 后验')).enabled).toBe(true) + recordPass(activities, '页面停用终端', disableResponse, terminalId) + recordPass(activities, '页面重新启用终端', enableResponse, terminalId) + + const runtimePath = `/open/v1/remote-terminals/${encodeURIComponent(terminalId)}` + + activeStage = '拒绝缺失和错误凭据的心跳' + const missingHeartbeat = await api.post(`${runtimePath}/heartbeat`, { + data: { runtimeVersion: 'APE2E-1.0' }, + }) + assertStatus(missingHeartbeat, 401, '缺失凭据心跳必须拒绝') + recordPass(activities, '缺失凭据心跳被拒绝', missingHeartbeat, terminalId) + const wrongHeartbeat = await api.post(`${runtimePath}/heartbeat`, { + headers: { 'X-Terminal-Secret': 'ape2e-intentionally-invalid' }, + data: { runtimeVersion: 'APE2E-1.0' }, + }) + assertStatus(wrongHeartbeat, 401, '错误凭据心跳必须拒绝') + recordPass(activities, '错误凭据心跳被拒绝', wrongHeartbeat, terminalId) + await captureTerminalCard(page, testInfo, terminalName, '远控终端-04-非法心跳拒绝后仍离线', '离线') + + activeStage = '使用正确凭据上报心跳' + const runtimeHeaders = { 'X-Terminal-Secret': initialSecret } + const heartbeatResponse = await api.post(`${runtimePath}/heartbeat`, { + headers: runtimeHeaders, + data: { runtimeVersion: 'APE2E-1.0' }, + }) + const heartbeat = asRecord(await responseData(heartbeatResponse, 200, '终端正确心跳')) + expect(heartbeat.online).toBe(true) + expect(heartbeatResponse.headers()['cache-control']).toBe('no-store') + recordPass(activities, '正确凭据心跳使终端在线', heartbeatResponse, terminalId, '响应带 Cache-Control: no-store') + await captureTerminalCard(page, testInfo, terminalName, '远控终端-05-正确心跳后在线', '在线') + + activeStage = '通过管理页面下发唤醒指令' + card = await openTerminalPage(page, terminalName) + await card.getByRole('button', { name: /业务控制/ }).click() + let detailDialog = page.locator('.el-dialog:visible').last() + await expect(detailDialog).toContainText('终端在线') + const commandResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`)) + await detailDialog.locator('.command-grid button').filter({ hasText: '远程唤醒' }).click() + const commandResponse = await commandResponsePromise + const command = asRecord(await responseData(commandResponse, 202, '下发远控指令')) + const commandId = String(command.id ?? '') + if (!commandId) throw new Error('指令下发响应缺少指令标识') + expect(command.statusCode).toBe('QUEUED') + await expect(detailDialog.locator('.command-history')).toContainText('远程唤醒') + await expect(detailDialog.locator('.command-history')).toContainText('等待终端确认') + recordPass(activities, '管理页面下发唤醒指令并立即显示历史', commandResponse, commandId, '状态 QUEUED') + await captureScreenshot(page, testInfo, '远控终端-06-页面下发指令已排队', [detailDialog.locator('code:visible')]) + + activeStage = '运行端拉取指令并生成投递事件' + const pullResponse = await api.post(`${runtimePath}/commands/pull`, { + headers: runtimeHeaders, + data: { limit: 10, waitSeconds: 0 }, + }) + const pulled = asRecord(await responseData(pullResponse, 200, '运行端拉取指令')) + const items = asRecords(pulled.items) + expect(items).toHaveLength(1) + const delivery = items[0]! + expect(String(delivery.id ?? '')).toBe(commandId) + expect(delivery.deliveryAttempt).toBe(1) + expect(delivery.redelivery).toBe(false) + const deliveryId = String(delivery.deliveryId ?? '') + if (deliveryId.length < 8) throw new Error('指令拉取响应缺少有效投递标识') + recordPass(activities, '运行端拉取指令并产生 SENT 投递事件', pullResponse, commandId, '首次投递且非重投') + await captureTerminalCommand(page, testInfo, terminalName, '远控终端-07-运行端已拉取指令', '等待终端确认') + + const reportPath = `${runtimePath}/commands/${encodeURIComponent(commandId)}/reports` + const ackReport = `report-${suffix}-ack`.slice(0, 64) + const executingReport = `report-${suffix}-executing`.slice(0, 64) + const succeededReport = `report-${suffix}-succeeded`.slice(0, 64) + + activeStage = '上报 ACK 并验证 reportId 幂等' + const ackPayload = { reportId: ackReport, deliveryId, status: 'ACK' } + const ackResponse = await api.post(reportPath, { headers: runtimeHeaders, data: ackPayload }) + const ack = asRecord(await responseData(ackResponse, 200, '上报 ACK')) + expect(ack.idempotent).toBe(false) + expect(ack.status).toBe('ACK') + const replayResponse = await api.post(reportPath, { headers: runtimeHeaders, data: ackPayload }) + const replay = asRecord(await responseData(replayResponse, 200, '重复上报 ACK')) + expect(replay.idempotent).toBe(true) + expect(replay.status).toBe('ACK') + recordPass(activities, '上报 ACK 事件', ackResponse, commandId, '状态 ACKNOWLEDGED') + recordPass(activities, '相同 reportId 重放保持幂等', replayResponse, commandId, 'idempotent=true,未重复推进状态') + await captureTerminalCommand(page, testInfo, terminalName, '远控终端-08-ACK与幂等重放', '已送达') + + activeStage = '上报 EXECUTING 事件' + const executingResponse = await api.post(reportPath, { + headers: runtimeHeaders, + data: { + reportId: executingReport, + deliveryId, + status: 'EXECUTING', + result: { message: '开始执行唤醒', durationMs: 8 }, + }, + }) + const executing = asRecord(await responseData(executingResponse, 200, '上报 EXECUTING')) + expect(executing.idempotent).toBe(false) + expect(executing.status).toBe('EXECUTING') + recordPass(activities, '上报 EXECUTING 事件', executingResponse, commandId, '状态 EXECUTING') + await captureTerminalCommand(page, testInfo, terminalName, '远控终端-09-指令执行中', '执行中') + + activeStage = '上报 SUCCEEDED 事件' + const succeededResponse = await api.post(reportPath, { + headers: runtimeHeaders, + data: { + reportId: succeededReport, + deliveryId, + status: 'SUCCEEDED', + result: { message: '唤醒执行完成', durationMs: 28 }, + }, + }) + const succeeded = asRecord(await responseData(succeededResponse, 200, '上报 SUCCEEDED')) + expect(succeeded.idempotent).toBe(false) + expect(succeeded.status).toBe('SUCCEEDED') + recordPass(activities, '上报 SUCCEEDED 事件', succeededResponse, commandId, '状态 SUCCEEDED') + await captureTerminalCommand(page, testInfo, terminalName, '远控终端-10-指令执行成功', '执行成功') + + activeStage = '管理端详情和历史聚合校验事件链' + const commandDetailResponse = await api.get(`/api/v1/remote-commands/${encodeURIComponent(commandId)}`, { headers }) + const commandDetail = asRecord(await responseData(commandDetailResponse, 200, '读取远控指令详情')) + expect(commandDetail.statusCode).toBe('SUCCEEDED') + expect(commandDetail.deliveryAttempts).toBe(1) + expect(String(commandDetail.ackAt ?? '')).not.toBe('') + const historyResponse = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/commands`, { + headers, + params: { page: '1', pageSize: '20' }, + }) + const history = asRecord(await responseData(historyResponse, 200, '读取远控指令历史')) + const historyItem = asRecords(history.items).find((item) => String(item.id ?? '') === commandId) + expect(historyItem?.statusCode).toBe('SUCCEEDED') + recordPass(activities, '管理端详情与历史聚合校验完整事件链', historyResponse, commandId, 'SENT → ACKNOWLEDGED → EXECUTING → SUCCEEDED') + await captureTerminalCommand(page, testInfo, terminalName, '远控终端-11-页面历史与事件链', '执行成功') + + activeStage = '通过页面轮换凭据并验证新旧凭据边界' + card = await openTerminalPage(page, terminalName) + await card.getByRole('button', { name: /业务控制/ }).click() + detailDialog = page.locator('.el-dialog:visible').last() + await detailDialog.locator('.console-technical-collapse .el-collapse-item__header').click() + const rotateResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' + && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}/rotate-secret`)) + await detailDialog.getByRole('button', { name: '轮换接入密钥' }).click() + let messageBox = page.locator('.el-message-box:visible').last() + await expect(messageBox).toContainText('轮换后旧密钥将立即失效') + await messageBox.getByRole('button', { name: '确认轮换' }).click() + const rotateResponse = await rotateResponsePromise + const rotated = asRecord(envelopeData(await rotateResponse.json())) + const rotatedCredential = asRecord(rotated.credential) + const rotatedSecret = String(rotatedCredential.secret ?? '') + const rotatedVersion = Number(rotated.dataVersion) + const displayedRotatedSecret = rotatedSecret + ? await consumeOneTimeSecret(page, '新终端接入密钥(仅显示一次)') + : '' + assertStatus(rotateResponse, 200, '轮换终端凭据') + if (!rotatedSecret || rotatedSecret === initialSecret || rotatedCredential.shownOnce !== true) { + throw new Error('终端凭据轮换未生成独立且仅显示一次的新凭据') + } + if (!Number.isInteger(rotatedVersion) || rotatedVersion <= terminal.dataVersion) throw new Error('凭据轮换后数据版本没有递增') + if (displayedRotatedSecret !== rotatedSecret) throw new Error('页面一次性新密钥与轮换响应不一致') + terminal.dataVersion = rotatedVersion + const oldSecretHeartbeat = await api.post(`${runtimePath}/heartbeat`, { + headers: runtimeHeaders, + data: { runtimeVersion: 'APE2E-1.0' }, + }) + assertStatus(oldSecretHeartbeat, 401, '轮换后旧凭据必须失效') + const rotatedHeaders = { 'X-Terminal-Secret': rotatedSecret } + const newSecretHeartbeat = await api.post(`${runtimePath}/heartbeat`, { + headers: rotatedHeaders, + data: { runtimeVersion: 'APE2E-1.0' }, + }) + const newHeartbeat = asRecord(await responseData(newSecretHeartbeat, 200, '轮换后新凭据心跳')) + expect(newHeartbeat.online).toBe(true) + recordPass(activities, '页面轮换终端一次性凭据', rotateResponse, terminalId, '新凭据仅保存在测试进程内,未写入附件或截图') + recordPass(activities, '轮换后旧凭据被拒绝', oldSecretHeartbeat, terminalId) + recordPass(activities, '轮换后新凭据可用', newSecretHeartbeat, terminalId) + await captureTerminalCard(page, testInfo, terminalName, '远控终端-12-页面轮换后新凭据在线', '在线') + + activeStage = '通过页面删除终端并确认资源不可访问' + card = await openTerminalPage(page, terminalName) + const deletedResponsePromise = page.waitForResponse((response) => response.request().method() === 'DELETE' + && response.url().includes(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`)) + await card.getByRole('button', { name: '删除', exact: true }).click() + messageBox = page.locator('.el-message-box:visible').last() + await expect(messageBox).toContainText(`确定删除远程终端“${terminalName}”吗`) + await messageBox.getByRole('button', { name: '确认删除' }).click() + const deletedResponse = await deletedResponsePromise + const deleted = asRecord(await responseData(deletedResponse, 200, '删除远控终端')) + expect(deleted.deleted).toBe(true) + const absentDetail = await api.get(`/api/v1/remote-terminals/${encodeURIComponent(terminalId)}`, { headers }) + assertStatus(absentDetail, 404, '删除后终端详情必须不可访问') + const absentCommand = await api.get(`/api/v1/remote-commands/${encodeURIComponent(commandId)}`, { headers }) + assertStatus(absentCommand, 404, '删除终端后关联指令详情必须不可访问') + const absentListResponse = await api.get('/api/v1/remote-terminals', { + headers, + params: { page: '1', pageSize: '20', keyword: terminalName }, + }) + const absentList = asRecord(await responseData(absentListResponse, 200, '删除后检索远控终端')) + expect(absentList.total).toBe(0) + const deletedRuntimeHeartbeat = await api.post(`${runtimePath}/heartbeat`, { + headers: rotatedHeaders, + data: { runtimeVersion: 'APE2E-1.0' }, + }) + assertStatus(deletedRuntimeHeartbeat, 401, '删除后运行端凭据必须失效') + terminalDeleted = true + recordPass(activities, '页面删除终端并确认管理端无残留', deletedResponse, terminalId, '详情与关联指令均为 404,列表检索为 0') + recordPass(activities, '删除后运行端凭据失效', deletedRuntimeHeartbeat, terminalId) + await captureDeletedState(page, testInfo, terminalName) + } catch (error) { + activities.push({ + module: '远控终端运行协议', + action: activeStage, + result: 'FAIL', + resourceId: terminal?.id, + detail: '阶段断言未通过;敏感请求上下文未写入活动附件。', + }) + throw error + } finally { + if (!terminalDeleted) await fallbackCleanup(api, headers, terminal, activities, cleanupErrors) + await attachJson(testInfo, '远控终端运行协议-生命周期与清理', { + runId, + runPrefix, + apiBase: '本机正式 API(地址已省略)', + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + verifiedEventSequence: ['SENT', 'ACKNOWLEDGED', 'EXECUTING', 'SUCCEEDED'], + managementSubmission: '创建、编辑、绑定智能体、音量/打断设置、停启、指令下发、凭据轮换与删除均由真实页面控件提交;API 仅承担后验和运行端协议。', + security: '管理员密码、访问令牌、终端一次性凭据与请求头均未写入日志、活动附件或截图;一次性密钥警告框也纳入失败截图遮罩;Playwright trace/video 已禁用。', + }) + await api.dispose() + } + + expect(cleanupErrors, '远控终端测试数据必须清理干净').toEqual([]) + }) +}) diff --git a/tests/role-login-lifecycle.spec.ts b/tests/role-login-lifecycle.spec.ts new file mode 100644 index 0000000..f0f9e4e --- /dev/null +++ b/tests/role-login-lifecycle.spec.ts @@ -0,0 +1,239 @@ +import type { APIRequestContext, APIResponse, Page, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { adminAccessToken, confirmMessageBox, envelopeData } from './helpers' + +type JsonRecord = Record + +interface DepartmentNode extends JsonRecord { + id: string + name: string + children: DepartmentNode[] +} + +interface LoginRoleCase { + code: 'administrative' | 'teacher' + label: string + displayName: string + username: string + password: string + department: DepartmentNode + organizationPath: DepartmentNode[] +} + +interface CreatedUser { + id: string + username: string + dataVersion: number +} + +test.use({ trace: 'off', video: 'off' }) + +const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-10).toLowerCase() || 'manual' + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' + ? value as JsonRecord + : {} + +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) + ? value.map(asRecord) + : [] + +async function body(response: APIResponse) { + return response.json().catch(() => ({})) +} + +function pageRecords(value: unknown) { + const data = envelopeData(value) + const root = asRecord(data) + const items = root.records ?? root.items + return Array.isArray(items) ? items.map(asRecord) : asRecords(data) +} + +function normalizeDepartment(value: unknown): DepartmentNode { + const item = asRecord(value) + return { + ...item, + id: String(item.id ?? ''), + name: String(item.name ?? ''), + children: Array.isArray(item.children) ? item.children.map(normalizeDepartment) : [], + } +} + +async function createRoleUser( + request: APIRequestContext, + headers: Record, + role: LoginRoleCase, +) { + const rolesResponse = await request.get('/api/auth/v1/roles/all', { headers }) + expect(rolesResponse.status(), '读取正式角色目录').toBe(200) + const rolesData = envelopeData(await body(rolesResponse)) + const roles = Array.isArray(rolesData) ? asRecords(rolesData) : pageRecords(rolesData) + const targetRole = roles.find((item) => String(item.code ?? item.roleCode) === role.code && item.enabled !== false) + expect(targetRole, `测试环境必须存在启用的 ${role.code} 角色`).toBeTruthy() + + const response = await request.post('/api/auth/v1/users', { + headers, + data: { + username: role.username, + displayName: role.displayName, + departmentId: Number(role.department.id), + roleIds: [Number(targetRole!.id)], + defaultRoleId: Number(targetRole!.id), + enabled: true, + password: role.password, + remark: `${runPrefix} ${role.label}身份真实登录测试,结束自动删除`, + version: 0, + }, + }) + expect(response.status(), `创建临时${role.label}账号`).toBe(200) + const created = asRecord(envelopeData(await body(response))) + return { + id: String(created.id ?? ''), + username: role.username, + dataVersion: Number(created.dataVersion ?? created.version ?? 0), + } satisfies CreatedUser +} + +async function completeInitialPassword( + request: APIRequestContext, + role: LoginRoleCase, + user: CreatedUser, +) { + const login = await request.post('/api/auth/v1/auth/login', { + data: { + roleCode: role.code, + userId: Number(user.id), + departmentId: Number(role.department.id), + password: role.password, + rememberMe: false, + }, + }) + expect(login.status(), `${role.label}初始密码登录`).toBe(200) + const loginData = asRecord(envelopeData(await body(login))) + const accessToken = String(loginData.accessToken ?? asRecord(loginData.tokens).accessToken ?? '') + expect(accessToken).not.toBe('') + + const changed = await request.put('/api/auth/v1/auth/initial-password', { + headers: { Authorization: `Bearer ${accessToken}` }, + data: { newPassword: role.password.replace('Start', 'Ready') }, + }) + expect(changed.status(), `${role.label}完成首次改密`).toBe(200) + role.password = role.password.replace('Start', 'Ready') +} + +async function selectVisibleOption(page: Page, text: string) { + const dropdown = page.locator('.el-select-dropdown:visible').last() + await expect(dropdown).toBeVisible() + const option = dropdown.locator('.el-select-dropdown__item').filter({ hasText: text }).first() + await expect(option).toBeVisible() + await option.click() +} + +async function loginRoleInBrowser(page: Page, role: LoginRoleCase, testInfo: TestInfo) { + await page.goto('/login') + await page.locator(`[data-role-code="${role.code}"]`).click() + + const organizationSelects = page.locator('.login-role-fields > .login-field .el-select:not(.login-identity-picker)') + for (let index = 0; index < role.organizationPath.length; index += 1) { + await organizationSelects.nth(index).click() + await selectVisibleOption(page, role.organizationPath[index].name) + } + + const identityPicker = page.locator('.login-identity-picker') + await expect(identityPicker).toBeEnabled() + await identityPicker.click() + await selectVisibleOption(page, role.displayName) + await page.locator('input[name="password"]').fill(role.password) + + const loginResponse = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/auth/v1/auth/login' + )) + await page.getByRole('button', { name: '登录系统' }).click() + expect((await loginResponse).status(), `${role.label}浏览器登录`).toBe(200) + await expect(page).toHaveURL(/\/overview(?:\?|$)/) + await expect(page.locator('.app-shell')).toBeVisible() + await expect(page.locator('.user-menu-trigger')).toContainText(role.displayName) + await captureScreenshot(page, testInfo, `${role.code}-real-account-login`) + + await page.locator('.user-menu-trigger').click() + await page.getByRole('menuitem', { name: /退出登录/ }).click() + await confirmMessageBox(page, '退出登录') + await expect(page).toHaveURL(/\/login(?:\?|$)/) +} + +async function cleanupUser( + request: APIRequestContext, + headers: Record, + user: CreatedUser, +) { + const lookup = await request.get('/api/auth/v1/users', { + headers, + params: { keyword: user.username, page: 1, size: 100 }, + }) + if (lookup.status() !== 200) return false + const current = pageRecords(await body(lookup)).find((item) => String(item.username ?? '') === user.username) + if (!current) return true + const deleted = await request.delete(`/api/auth/v1/users/${encodeURIComponent(String(current.id))}`, { + headers, + data: { version: Number(current.dataVersion ?? current.version ?? 0) }, + }) + return [200, 204, 404].includes(deleted.status()) +} + +test('行政和教员必须通过组织与账号选择完成真实登录', async ({ page, request }, testInfo) => { + const token = await adminAccessToken(request) + const headers = { Authorization: `Bearer ${token}` } + const contextResponse = await request.get('/api/auth/v1/auth/login-context') + expect(contextResponse.status()).toBe(200) + const context = asRecord(envelopeData(await body(contextResponse))) + const roots = (Array.isArray(context.departments) ? context.departments : []).map(normalizeDepartment) + const administrativeDepartment = roots.find((item) => item.id && item.name) + const teacherRoot = roots.find((item) => item.children.length > 0) + const teacherDepartment = teacherRoot?.children.find((item) => item.id && item.name) + expect(administrativeDepartment, '行政登录需要至少一个一级部门').toBeTruthy() + expect(teacherRoot && teacherDepartment, '教员登录需要至少一个二级部门').toBeTruthy() + + const roles: LoginRoleCase[] = [ + { + code: 'administrative', + label: '行政', + displayName: `${runPrefix}-行政登录员`, + username: `ape_adm_${suffix}`.slice(0, 32), + password: `Start@Aa1${suffix.slice(-4)}`, + department: administrativeDepartment!, + organizationPath: [administrativeDepartment!], + }, + { + code: 'teacher', + label: '教员', + displayName: `${runPrefix}-教员登录员`, + username: `ape_tch_${suffix}`.slice(0, 32), + password: `Start@Bb2${suffix.slice(-4)}`, + department: teacherDepartment!, + organizationPath: [teacherRoot!, teacherDepartment!], + }, + ] + const created: Array<{ role: LoginRoleCase; user: CreatedUser }> = [] + const cleanup: Array<{ role: string; result: 'CLEANED' | 'CLEANUP_FAILED' }> = [] + + try { + for (const role of roles) { + const user = await createRoleUser(request, headers, role) + created.push({ role, user }) + await completeInitialPassword(request, role, user) + await loginRoleInBrowser(page, role, testInfo) + } + } finally { + for (const item of [...created].reverse()) { + cleanup.push({ + role: item.role.code, + result: await cleanupUser(request, headers, item.user) ? 'CLEANED' : 'CLEANUP_FAILED', + }) + } + await attachJson(testInfo, 'role-login-cleanup', cleanup) + } + + expect(cleanup.every((item) => item.result === 'CLEANED'), '临时身份账号必须全部清理').toBeTruthy() +}) diff --git a/tests/settings-integration-lifecycle.spec.ts b/tests/settings-integration-lifecycle.spec.ts new file mode 100644 index 0000000..420024a --- /dev/null +++ b/tests/settings-integration-lifecycle.spec.ts @@ -0,0 +1,171 @@ +import type { APIResponse, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { adminAccessToken, envelopeData, loginAsAdmin } from './helpers' + +type JsonRecord = Record + +interface IntegrationSnapshot { + knowledgeProviderMode: 'local' | 'hybrid' | 'third_party' + thirdPartyKnowledgeEnabled: boolean + thirdPartyKnowledgeName: string + thirdPartyKnowledgeBaseUrl: string + thirdPartyKnowledgeApiPath: string + thirdPartyKnowledgeAuthType: 'none' | 'bearer' | 'api_key' + thirdPartyKnowledgeBaseId: string + thirdPartyKnowledgeTimeoutSeconds: number + thirdPartyKnowledgeKeyConfigured: boolean +} + +interface Activity { + module: string + action: string + result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + detail?: string +} + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} + +function normalize(value: unknown): IntegrationSnapshot { + const root = asRecord(envelopeData(value)) + const mode = String(root.knowledgeProviderMode || 'local') + const authType = String(root.thirdPartyKnowledgeAuthType || 'none') + return { + knowledgeProviderMode: mode === 'hybrid' || mode === 'third_party' ? mode : 'local', + thirdPartyKnowledgeEnabled: Boolean(root.thirdPartyKnowledgeEnabled), + thirdPartyKnowledgeName: String(root.thirdPartyKnowledgeName || ''), + thirdPartyKnowledgeBaseUrl: String(root.thirdPartyKnowledgeBaseUrl || ''), + thirdPartyKnowledgeApiPath: String(root.thirdPartyKnowledgeApiPath || '/api/v1/knowledge/search'), + thirdPartyKnowledgeAuthType: authType === 'bearer' || authType === 'api_key' ? authType : 'none', + thirdPartyKnowledgeBaseId: String(root.thirdPartyKnowledgeBaseId || ''), + thirdPartyKnowledgeTimeoutSeconds: Number(root.thirdPartyKnowledgeTimeoutSeconds || 15), + thirdPartyKnowledgeKeyConfigured: Boolean(root.thirdPartyKnowledgeKeyConfigured), + } +} + +function savePayload(value: IntegrationSnapshot) { + return { + ...value, + // The API intentionally never returns the credential. An empty value + // means keep the existing encrypted secret during both test and restore. + thirdPartyKnowledgeApiKey: '', + } +} + +async function responseBody(response: APIResponse) { + return response.json().catch(() => ({})) +} + +async function fillLabel(page: import('@playwright/test').Page, label: string, value: string) { + const field = page.locator('label').filter({ hasText: label }).first() + const input = field.locator('input:not([type="hidden"]), textarea').first() + await expect(input, `应显示字段:${label}`).toBeVisible() + await input.fill(value) +} + +async function recordScreenshot( + page: import('@playwright/test').Page, + testInfo: TestInfo, + label: string, +) { + await captureScreenshot(page, testInfo, label, [page.locator('input[type="password"]')]) +} + +test('三方配置通过页面保存、格式检查、联调失败态与刷新持久化,并精确恢复原配置', async ({ page, request }, testInfo) => { + const activities: Activity[] = [] + const token = await adminAccessToken(request) + const headers = { Authorization: `Bearer ${token}` } + const initialResponse = await request.get('/api/v1/settings/general', { headers }) + expect(initialResponse.status(), '读取三方配置快照').toBe(200) + const snapshot = normalize(await responseBody(initialResponse)) + const testName = `${runPrefix}-装备知识中台`.slice(0, 80) + const testSettings: IntegrationSnapshot = { + knowledgeProviderMode: 'local', + thirdPartyKnowledgeEnabled: false, + thirdPartyKnowledgeName: testName, + thirdPartyKnowledgeBaseUrl: 'https://demo.invalid', + thirdPartyKnowledgeApiPath: '/api/v1/knowledge/search', + thirdPartyKnowledgeAuthType: 'none', + thirdPartyKnowledgeBaseId: `equipment-${runPrefix.toLowerCase()}`.slice(0, 160), + thirdPartyKnowledgeTimeoutSeconds: 17, + thirdPartyKnowledgeKeyConfigured: snapshot.thirdPartyKnowledgeKeyConfigured, + } + + let restored = false + try { + await loginAsAdmin(page) + await page.goto('/settings/integrations') + await expect(page.getByRole('heading', { name: '三方配置' })).toBeVisible() + await expect(page.locator('.management-loading')).toHaveCount(0, { timeout: 15_000 }) + + await page.locator('input[type="radio"][value="local"]').check() + const enabled = page.locator('.integration-enable-row input[type="checkbox"]') + if (await enabled.isChecked()) await page.locator('.integration-enable-row .el-switch').click() + await fillLabel(page, '服务名称', testSettings.thirdPartyKnowledgeName) + await page.locator('.integration-advanced-settings > summary').click() + await fillLabel(page, '知识库标识', testSettings.thirdPartyKnowledgeBaseId) + await fillLabel(page, '服务地址', testSettings.thirdPartyKnowledgeBaseUrl) + await fillLabel(page, '检索接口路径', testSettings.thirdPartyKnowledgeApiPath) + await fillLabel(page, '请求超时', String(testSettings.thirdPartyKnowledgeTimeoutSeconds)) + await page.locator('label').filter({ hasText: '鉴权方式' }).locator('select').selectOption('none') + + await page.getByRole('button', { name: '检查格式' }).click() + await expect(page.getByText('配置格式检查通过,可保存后进入接口联调', { exact: true }).last()).toBeVisible() + await recordScreenshot(page, testInfo, '三方配置-01-页面填写与格式检查') + + const savedResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'PUT' && response.url().includes('/api/v1/settings/general') + )) + await page.getByRole('button', { name: '保存配置' }).click() + const savedResponse = await savedResponsePromise + expect(savedResponse.status(), await savedResponse.text()).toBe(200) + await expect(page.getByText('第三方知识库配置已保存', { exact: true }).last()).toBeVisible() + activities.push({ module: '三方配置', action: '页面保存禁用的本地策略与安全占位地址', result: 'PASS', httpStatus: 200 }) + + await page.reload() + await expect(page.locator('.management-loading')).toHaveCount(0, { timeout: 15_000 }) + await expect(page.locator('label').filter({ hasText: '服务名称' }).locator('input')).toHaveValue(testName) + await expect(page.locator('.integration-status-grid')).toContainText('平台本地知识库') + await page.locator('.integration-advanced-settings > summary').click() + await expect(page.locator('label').filter({ hasText: '服务地址' }).locator('input')).toHaveValue('https://demo.invalid') + await expect(page.locator('label').filter({ hasText: '知识库标识' }).locator('input')).toHaveValue(testSettings.thirdPartyKnowledgeBaseId) + activities.push({ module: '三方配置', action: '刷新页面后配置仍持久化', result: 'PASS' }) + await recordScreenshot(page, testInfo, '三方配置-02-刷新后持久化') + + const testResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'POST' && response.url().includes('/api/v1/settings/knowledge-provider/test') + )) + await page.getByRole('button', { name: '保存并测试连接' }).click() + const testResponse = await testResponsePromise + expect(testResponse.status(), '安全占位域名联调不得造成服务端 5xx').toBeLessThan(500) + await expect(page.locator('.integration-test-result')).toBeVisible() + activities.push({ + module: '三方配置', + action: '页面保存并执行真实服务端连接测试', + result: 'PASS', + httpStatus: testResponse.status(), + detail: 'demo.invalid 是不可达安全占位地址;预期呈现可解释失败态,不伪造第三方成功。', + }) + await recordScreenshot(page, testInfo, '三方配置-03-真实联调结果') + } finally { + const restoreResponse = await request.put('/api/v1/settings/general', { + headers, + data: savePayload(snapshot), + }) + if (restoreResponse.status() === 200) { + restored = true + activities.push({ module: '三方配置', action: '恢复测试前完整非敏感配置且保留原加密凭证', result: 'CLEANED', httpStatus: 200 }) + } else { + activities.push({ module: '三方配置', action: '恢复测试前配置', result: 'CLEANUP_FAILED', httpStatus: restoreResponse.status(), detail: await restoreResponse.text() }) + } + await attachJson(testInfo, '三方配置页面提交活动', activities) + } + + expect(restored, '全局三方配置必须恢复,避免污染后续用例').toBeTruthy() + const finalResponse = await request.get('/api/v1/settings/general', { headers }) + expect(finalResponse.status()).toBe(200) + expect(normalize(await responseBody(finalResponse))).toEqual(snapshot) +}) diff --git a/tests/settings-menu.spec.ts b/tests/settings-menu.spec.ts new file mode 100644 index 0000000..1c81c0d --- /dev/null +++ b/tests/settings-menu.spec.ts @@ -0,0 +1,203 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { adminAccessToken, envelopeData, fillFormItem, loginAsAdmin, tableRowByText } from './helpers' + +test.describe.configure({ mode: 'serial' }) + +type AnyRecord = Record +const asRecord = (value: unknown): AnyRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as AnyRecord : {} +const headers = (token: string) => ({ Authorization: `Bearer ${token}` }) +const apiJson = async (response: APIResponse) => response.json().catch(() => ({})) + +function configRoot(value: unknown) { + const root = asRecord(envelopeData(value)) + return asRecord(root.configuration ?? root.config ?? root.settings ?? root) +} + +function generalFields(value: unknown) { + const root = configRoot(value) + const general = asRecord(root.general ?? root.generalSettings ?? root) + return { + systemName: String(general.systemName ?? root.systemName ?? ''), + shortName: String(general.shortName ?? root.shortName ?? ''), + subtitle: String(general.subtitle ?? general.description ?? root.subtitle ?? root.description ?? ''), + defaultTheme: String(general.defaultTheme ?? root.defaultTheme ?? 'light') === 'dark' ? 'dark' : 'light', + allowThemeSwitch: Boolean(general.allowThemeSwitch ?? root.allowThemeSwitch ?? true), + knowledgeMenuVisible: Boolean(general.knowledgeMenuVisible ?? general.localKnowledgeMenuVisible ?? root.knowledgeMenuVisible ?? true), + } +} + +function versionMap(value: unknown) { + const root = configRoot(value) + const versions = asRecord(root.versions ?? root.dataVersions ?? root.versionMap) + return { + systemName: Number(versions.systemName ?? 0), + shortName: Number(versions.shortName ?? 0), + subtitle: Number(versions.subtitle ?? 0), + defaultTheme: Number(versions.defaultTheme ?? 0), + allowThemeSwitch: Number(versions.allowThemeSwitch ?? 0), + knowledgeMenuVisible: Number(versions.knowledgeMenuVisible ?? 0), + } +} + +interface MenuNode { + id: string + name: string + description: string + dataVersion: number + version?: number + code?: string + children: MenuNode[] +} + +function menuTree(value: unknown): MenuNode[] { + const data = envelopeData(value) + const root = asRecord(data) + const values = Array.isArray(data) ? data : Array.isArray(root.items) ? root.items : Array.isArray(root.tree) ? root.tree : [] + const normalize = (item: unknown): MenuNode => { + const node = asRecord(item) + return { + id: String(node.id ?? node.menuId ?? ''), + name: String(node.name ?? node.menuName ?? ''), + description: String(node.description ?? ''), + dataVersion: Number(node.dataVersion ?? node.version ?? 0), + code: String(node.code ?? node.menuCode ?? ''), + children: (Array.isArray(node.children) ? node.children : []).map(normalize), + } + } + return values.map(normalize) +} + +function currentVersionById(tree: MenuNode[]) { + const map = new Map() + const walk = (nodes: MenuNode[]) => nodes.forEach((node) => { map.set(node.id, node.dataVersion); walk(node.children) }) + walk(tree) + return map +} + +function restoreMenuCommand(original: MenuNode[], current: MenuNode[]) { + const versions = currentVersionById(current) + const mapNode = (node: MenuNode): AnyRecord => ({ + id: node.id, + name: node.name, + description: node.description, + version: versions.get(node.id) ?? node.dataVersion, + children: node.children.map(mapNode), + }) + return { items: original.map(mapNode) } +} + +function menuDisplaySummary(nodes: MenuNode[]): unknown[] { + return nodes.map((node) => ({ + id: node.id, + name: node.name, + description: node.description, + children: menuDisplaySummary(node.children), + })) +} + +async function getSystemConfig(request: APIRequestContext, token: string) { + const response = await request.get('/api/auth/v1/system-config', { headers: headers(token) }) + expect(response.ok()).toBeTruthy() + return apiJson(response) +} + +async function restoreSystemConfig(request: APIRequestContext, token: string, original: unknown) { + const current = await getSystemConfig(request, token) + return request.put('/api/auth/v1/system-config/general', { + headers: headers(token), + data: { ...generalFields(original), versions: versionMap(current) }, + }) +} + +async function getMenus(request: APIRequestContext, token: string) { + const response = await request.get('/api/auth/v1/menus/tree', { headers: headers(token) }) + expect(response.ok()).toBeTruthy() + return menuTree(await apiJson(response)) +} + +test('基础设置真实保存后立即应用,并精确恢复原值', async ({ page, request }, testInfo) => { + const token = await adminAccessToken(request) + const original = await getSystemConfig(request, token) + const originalFields = generalFields(original) + const temporaryName = `APE2E配置-${runPrefix.slice(-8)}` + let primaryError: unknown + let restoreStatus = 0 + try { + await loginAsAdmin(page) + await page.goto('/settings/general') + await page.getByPlaceholder('请输入系统名称').fill(temporaryName) + await page.getByPlaceholder('请输入系统简称').fill('E2E数字人') + await page.getByPlaceholder('请输入系统副标题').fill(`${runPrefix} 配置提交测试`) + await page.getByRole('button', { name: '保存基础设置' }).click() + await expect(page.getByText('基础设置已保存并立即生效').last()).toBeVisible() + await expect(page.locator('body')).toContainText(temporaryName) + await captureScreenshot(page, testInfo, '01-system-config-saved') + + await page.reload() + await expect(page.getByPlaceholder('请输入系统名称')).toHaveValue(temporaryName) + const persisted = generalFields(await getSystemConfig(request, token)) + expect(persisted.systemName).toBe(temporaryName) + expect(persisted.shortName).toBe('E2E数字人') + await attachJson(testInfo, 'system-config-persistence', { persisted: true, fields: ['systemName', 'shortName', 'subtitle'] }) + } catch (error) { + primaryError = error + } finally { + const response = await restoreSystemConfig(request, token, original) + restoreStatus = response.status() + await attachJson(testInfo, 'system-config-restore', { status: restoreStatus }) + } + if (primaryError) throw primaryError + expect(restoreStatus).toBe(200) + const restored = generalFields(await getSystemConfig(request, token)) + expect(restored).toEqual(originalFields) +}) + +test('菜单显示信息经页面保存到数据库并恢复原菜单树', async ({ page, request }, testInfo) => { + const token = await adminAccessToken(request) + const original = await getMenus(request, token) + const changedName = `E2E概览${runPrefix.slice(-4)}`.slice(0, 16) + let primaryError: unknown + let restoreStatus = 0 + try { + await loginAsAdmin(page) + await page.goto('/settings/menus') + const search = page.getByPlaceholder('请输入菜单名称、权限码或说明') + await search.fill('ai.overview.view') + await page.getByRole('button', { name: '搜索', exact: true }).click() + const row = tableRowByText(page, 'ai.overview.view') + await expect(row).toBeVisible() + await row.getByRole('button', { name: '编辑' }).click() + const drawer = page.locator('.el-drawer:visible').last() + await fillFormItem(drawer, '菜单名称', changedName) + await fillFormItem(drawer, '菜单说明', `${runPrefix} 菜单提交并恢复测试`) + await drawer.getByRole('button', { name: '应用到草稿' }).click() + const save = page.getByRole('button', { name: '保存全部' }) + await expect(save).toBeEnabled() + await save.click() + await expect(page.getByText('菜单配置已保存并同步到导航').last()).toBeVisible() + await captureScreenshot(page, testInfo, '02-menu-saved') + + const persisted = await getMenus(request, token) + const flattened: MenuNode[] = [] + const walk = (nodes: MenuNode[]) => nodes.forEach((node) => { flattened.push(node); walk(node.children) }) + walk(persisted) + expect(flattened.some((node) => node.name === changedName && node.description.includes(runPrefix))).toBeTruthy() + } catch (error) { + primaryError = error + } finally { + const current = await getMenus(request, token) + const response = await request.put('/api/auth/v1/menus/configuration', { + headers: headers(token), data: restoreMenuCommand(original, current), + }) + restoreStatus = response.status() + await attachJson(testInfo, 'menu-restore', { status: restoreStatus, restoredNodes: currentVersionById(original).size }) + } + if (primaryError) throw primaryError + expect(restoreStatus).toBe(200) + const restored = await getMenus(request, token) + const originalSummary = JSON.stringify(menuDisplaySummary(original)) + const restoredSummary = JSON.stringify(menuDisplaySummary(restored)) + expect(restoredSummary).toBe(originalSummary) +}) diff --git a/tests/settings-projects-lifecycle.spec.ts b/tests/settings-projects-lifecycle.spec.ts new file mode 100644 index 0000000..c5aee17 --- /dev/null +++ b/tests/settings-projects-lifecycle.spec.ts @@ -0,0 +1,592 @@ +import { createHash } from 'node:crypto' + +import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { + adminAccessToken, + envelopeData, + fillFormItem, + loginAsAdmin, + tableRowByText, + visibleDialog, +} from './helpers' + +test.describe.configure({ mode: 'serial' }) + +type JsonRecord = Record +type AssetName = 'logo' | 'favicon' +type Headers = Record + +interface AssetSnapshot { + configured: boolean + originalName: string | null + mediaType: string | null + sha256: string | null + bytes: Buffer | null + canRestore: boolean + untouchedReason?: string +} + +interface Activity { + module: string + action: string + result: 'PASS' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED' + httpStatus?: number + resourceId?: string + detail?: string +} + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) +const TEST_PNG_SHA256 = createHash('sha256').update(PNG_1X1).digest('hex') + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) + ? value as JsonRecord + : {} +const authorized = (token: string): Headers => ({ Authorization: `Bearer ${token}` }) +const responseJson = async (response: APIResponse) => response.json().catch(() => ({})) + +function configData(value: unknown) { + const envelope = asRecord(envelopeData(value)) + return asRecord(envelope.configuration ?? envelope.config ?? envelope.settings ?? envelope) +} + +function generalSnapshot(value: unknown) { + const root = configData(value) + const general = asRecord(root.general ?? root.generalSettings ?? root) + return { + systemName: String(general.systemName ?? root.systemName ?? ''), + shortName: String(general.shortName ?? root.shortName ?? ''), + subtitle: String(general.subtitle ?? general.description ?? root.subtitle ?? root.description ?? ''), + defaultTheme: String(general.defaultTheme ?? root.defaultTheme ?? 'light') === 'dark' ? 'dark' as const : 'light' as const, + allowThemeSwitch: Boolean(general.allowThemeSwitch ?? root.allowThemeSwitch ?? true), + knowledgeMenuVisible: Boolean( + general.knowledgeMenuVisible + ?? general.localKnowledgeMenuVisible + ?? root.knowledgeMenuVisible + ?? true, + ), + } +} + +function configVersions(value: unknown) { + const root = configData(value) + const versions = asRecord(root.versions ?? root.dataVersions ?? root.versionMap) + return { + systemName: Number(versions.systemName ?? 0), + shortName: Number(versions.shortName ?? 0), + subtitle: Number(versions.subtitle ?? 0), + defaultTheme: Number(versions.defaultTheme ?? 0), + allowThemeSwitch: Number(versions.allowThemeSwitch ?? 0), + knowledgeMenuVisible: Number(versions.knowledgeMenuVisible ?? 0), + } +} + +function assetMetadata(value: unknown, asset: AssetName) { + const root = configData(value) + const prefix = asset === 'logo' ? 'logo' : 'favicon' + const assets = asRecord(root.assets) + const nested = asRecord(assets[asset] ?? root[asset]) + return { + configured: Boolean(nested.configured ?? root[`${prefix}Configured`] ?? false), + originalName: String(nested.originalName ?? root[`${prefix}OriginalName`] ?? '').trim() || null, + mediaType: String(nested.mediaType ?? root[`${prefix}MediaType`] ?? '').trim() || null, + sha256: String(nested.sha256 ?? root[`${prefix}Sha256`] ?? '').trim().toLowerCase() || null, + version: Number(nested.dataVersion ?? nested.version ?? root[`${prefix}DataVersion`] ?? 0), + url: String(nested.url ?? root[`${prefix}Url`] ?? '').trim() || null, + } +} + +async function readConfiguration(request: APIRequestContext, token: string) { + const response = await request.get('/api/auth/v1/system-config', { headers: authorized(token) }) + expect(response.status(), '读取完整系统配置快照').toBe(200) + return responseJson(response) +} + +async function snapshotAsset( + request: APIRequestContext, + token: string, + configuration: unknown, + asset: AssetName, +): Promise { + const metadata = assetMetadata(configuration, asset) + if (!metadata.configured) { + return { ...metadata, bytes: null, canRestore: true } + } + + const response = await request.get(`/api/auth/v1/system-config/assets/${asset}`, { + headers: authorized(token), + }) + if (response.status() !== 200) { + return { + ...metadata, + bytes: null, + canRestore: false, + untouchedReason: `现有定制资源下载失败(HTTP ${response.status()}),为避免破坏不执行替换`, + } + } + const bytes = await response.body() + const digest = createHash('sha256').update(bytes).digest('hex') + if (metadata.sha256 && digest !== metadata.sha256) { + return { + ...metadata, + bytes: null, + canRestore: false, + untouchedReason: '现有定制资源下载内容与配置摘要不一致,为避免破坏不执行替换', + } + } + return { ...metadata, bytes, sha256: metadata.sha256 ?? digest, canRestore: true } +} + +async function restoreGeneral(request: APIRequestContext, token: string, original: unknown) { + const current = await readConfiguration(request, token) + return request.put('/api/auth/v1/system-config/general', { + headers: authorized(token), + data: { + ...generalSnapshot(original), + versions: configVersions(current), + }, + }) +} + +function restoredFilename(asset: AssetName, snapshot: AssetSnapshot) { + if (snapshot.originalName) return snapshot.originalName + if (snapshot.mediaType === 'image/jpeg') return `${asset}.jpg` + if (snapshot.mediaType?.includes('icon')) return `${asset}.ico` + return `${asset}.png` +} + +async function restoreAsset( + request: APIRequestContext, + token: string, + asset: AssetName, + snapshot: AssetSnapshot, +) { + if (!snapshot.canRestore) return { status: 0, skipped: true } + const currentPayload = await readConfiguration(request, token) + const current = assetMetadata(currentPayload, asset) + if (snapshot.configured) { + if (!snapshot.bytes || !snapshot.mediaType) throw new Error(`${asset} 原资源备份不完整,无法恢复`) + const response = await request.post(`/api/auth/v1/system-config/${asset}`, { + headers: authorized(token), + params: { version: String(current.version) }, + multipart: { + file: { + name: restoredFilename(asset, snapshot), + mimeType: snapshot.mediaType, + buffer: snapshot.bytes, + }, + }, + }) + return { status: response.status(), skipped: false } + } + if (!current.configured) return { status: 200, skipped: false } + const response = await request.delete(`/api/auth/v1/system-config/${asset}`, { + headers: authorized(token), + params: { version: String(current.version) }, + }) + return { status: response.status(), skipped: false } +} + +async function attachActivities(testInfo: TestInfo, title: string, activities: Activity[]) { + await attachJson(testInfo, title, { + runPrefix, + activities, + security: '附件仅记录动作、状态和资源编号,不包含登录密码、访问令牌、一次性 API Key 或品牌资源二进制。', + }) +} + +test('基础设置显示策略、知识入口与品牌资源经页面即时应用并精确恢复', async ({ page, request }, testInfo) => { + test.setTimeout(180_000) + const activities: Activity[] = [] + const token = await adminAccessToken(request) + const original = await readConfiguration(request, token) + const originalGeneral = generalSnapshot(original) + const snapshots: Record = { + logo: await snapshotAsset(request, token, original, 'logo'), + favicon: await snapshotAsset(request, token, original, 'favicon'), + } + const touched = new Set() + let primaryError: unknown + const targetTheme = originalGeneral.defaultTheme === 'light' ? 'dark' : 'light' + const targetAllowThemeSwitch = !originalGeneral.allowThemeSwitch + const targetKnowledgeVisible = !originalGeneral.knowledgeMenuVisible + + try { + await page.addInitScript(() => { + window.localStorage.removeItem('digital-human-theme') + document.cookie = 'digital-human-theme=; Max-Age=0; Path=/; SameSite=Strict' + }) + await loginAsAdmin(page) + await page.goto('/settings/general') + await expect(page.getByRole('heading', { name: '基础设置' })).toBeVisible() + + const themeRadios = page.locator('fieldset').filter({ hasText: '默认主题' }).locator('input[type="radio"]') + await expect(themeRadios).toHaveCount(2) + expect(await themeRadios.evaluateAll((nodes) => nodes.map((node) => (node as HTMLInputElement).value).sort())).toEqual(['dark', 'light']) + await page.locator(`input[type="radio"][value="${targetTheme}"]`).check() + + const themeSwitch = page.locator('.system-switch-list label').filter({ hasText: '允许用户切换主题' }) + const knowledgeSwitch = page.locator('.system-switch-list label').filter({ hasText: '显示维修知识入口' }) + const setSwitch = async (scope: ReturnType, value: boolean) => { + const input = scope.locator('input[type="checkbox"]') + if ((await input.isChecked()) !== value) await scope.locator('.el-switch').click() + await expect(input).toBeChecked({ checked: value }) + } + await setSwitch(themeSwitch, targetAllowThemeSwitch) + await setSwitch(knowledgeSwitch, targetKnowledgeVisible) + + await captureScreenshot(page, testInfo, 'settings-display-policy-before-save') + await page.getByRole('button', { name: '保存基础设置' }).click() + await expect(page.getByText('基础设置已保存并立即生效').last()).toBeVisible() + activities.push({ module: '基础设置', action: '页面保存默认主题、主题切换和维修知识入口', result: 'PASS' }) + + const persisted = generalSnapshot(await readConfiguration(request, token)) + expect(persisted).toEqual({ + ...originalGeneral, + defaultTheme: targetTheme, + allowThemeSwitch: targetAllowThemeSwitch, + knowledgeMenuVisible: targetKnowledgeVisible, + }) + await expect(page.locator('html')).toHaveAttribute('data-theme', targetTheme) + await expect(page.locator('.theme-toggle')).toHaveCount(targetAllowThemeSwitch ? 1 : 0) + await expect(page.getByText('维修知识', { exact: true })).toHaveCount(targetKnowledgeVisible ? 1 : 0) + await captureScreenshot(page, testInfo, 'settings-display-policy-immediate-application') + + for (const asset of ['logo', 'favicon'] as const) { + const snapshot = snapshots[asset] + if (!snapshot.canRestore) { + activities.push({ + module: '基础设置', + action: `${asset === 'logo' ? '系统 LOGO' : '网站图标'}上传与即时应用`, + result: 'SKIP', + detail: snapshot.untouchedReason, + }) + continue + } + + const selector = asset === 'logo' + ? 'input[type="file"][accept="image/png,image/jpeg"]' + : 'input[type="file"][accept*=".ico"]' + await page.locator(selector).setInputFiles({ + name: `${runPrefix}-${asset}.png`, + mimeType: 'image/png', + buffer: PNG_1X1, + }) + const dialog = visibleDialog(page) + await expect(dialog).toContainText(asset === 'logo' ? '预览并上传系统 LOGO' : '预览并上传网站图标') + await captureScreenshot(page, testInfo, `settings-${asset}-upload-preview`) + // 点击确认后即把资源视为已触碰;即便网络响应已成功但成功提示断言失败,finally 也必须恢复。 + touched.add(asset) + await dialog.getByRole('button', { name: '确认上传' }).click() + await expect(page.getByText(asset === 'logo' ? '系统 LOGO已更新并立即生效' : '网站图标已更新并立即生效').last()).toBeVisible() + + const afterUpload = assetMetadata(await readConfiguration(request, token), asset) + expect(afterUpload.configured).toBe(true) + expect(afterUpload.sha256).toBe(TEST_PNG_SHA256) + if (asset === 'logo') { + await expect(page.locator('.brand-mark img').first()).toHaveAttribute('src', new RegExp(TEST_PNG_SHA256)) + } else { + await expect(page.locator('link[rel~="icon"]')).toHaveAttribute('href', new RegExp(TEST_PNG_SHA256)) + } + activities.push({ + module: '基础设置', + action: `${asset === 'logo' ? '系统 LOGO' : '网站图标'}页面上传并即时应用`, + result: 'PASS', + httpStatus: 200, + }) + } + await captureScreenshot(page, testInfo, 'settings-brand-assets-immediate-application') + } catch (error) { + primaryError = error + } finally { + for (const asset of ['logo', 'favicon'] as const) { + if (!touched.has(asset)) continue + try { + const result = await restoreAsset(request, token, asset, snapshots[asset]) + expect(result.status, `${asset} 恢复响应`).toBe(200) + activities.push({ module: '基础设置', action: `恢复 ${asset} 原始资源`, result: 'CLEANED', httpStatus: result.status }) + } catch (error) { + activities.push({ + module: '基础设置', + action: `恢复 ${asset} 原始资源`, + result: 'CLEANUP_FAILED', + detail: error instanceof Error ? error.message : String(error), + }) + if (!primaryError) primaryError = error + } + } + try { + const response = await restoreGeneral(request, token, original) + expect(response.status(), '恢复基础设置原值').toBe(200) + activities.push({ module: '基础设置', action: '恢复显示策略与知识入口原值', result: 'CLEANED', httpStatus: response.status() }) + } catch (error) { + activities.push({ + module: '基础设置', + action: '恢复显示策略与知识入口原值', + result: 'CLEANUP_FAILED', + detail: error instanceof Error ? error.message : String(error), + }) + if (!primaryError) primaryError = error + } + await attachActivities(testInfo, '基础设置扩展与恢复', activities) + } + + if (primaryError) throw primaryError + const restored = await readConfiguration(request, token) + expect(generalSnapshot(restored)).toEqual(originalGeneral) + for (const asset of ['logo', 'favicon'] as const) { + const snapshot = snapshots[asset] + if (!snapshot.canRestore) continue + const metadata = assetMetadata(restored, asset) + expect(metadata.configured, `${asset} configured 精确恢复`).toBe(snapshot.configured) + expect(metadata.sha256, `${asset} 内容摘要精确恢复`).toBe(snapshot.sha256) + expect(metadata.mediaType, `${asset} MIME 精确恢复`).toBe(snapshot.mediaType) + expect(metadata.originalName, `${asset} 原文件名精确恢复`).toBe(snapshot.originalName) + } +}) + +async function apiData(response: APIResponse, expectedStatus: number, label: string) { + expect(response.status(), label).toBe(expectedStatus) + return asRecord(envelopeData(await responseJson(response))) +} + +async function projectDetail(request: APIRequestContext, headers: Headers, projectId: string) { + const response = await request.get(`/api/v1/agent-projects/${encodeURIComponent(projectId)}`, { headers }) + return { response, data: response.status() === 200 ? await apiData(response, 200, '读取项目详情') : {} } +} + +async function agentDetail(request: APIRequestContext, headers: Headers, agentId: string) { + const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + return { response, data: response.status() === 200 ? await apiData(response, 200, '读取智能体详情') : {} } +} + +async function cleanupProject( + request: APIRequestContext, + headers: Headers, + projectId: string, + activities: Activity[], +) { + const current = await projectDetail(request, headers, projectId) + if (current.response.status() === 404) return + const response = await request.delete(`/api/v1/agent-projects/${encodeURIComponent(projectId)}`, { + headers, + params: { dataVersion: String(Number(current.data.dataVersion ?? 0)) }, + }) + activities.push({ + module: '智能体组别', + action: '失败兜底删除项目', + result: response.status() === 200 ? 'CLEANED' : 'CLEANUP_FAILED', + httpStatus: response.status(), + resourceId: projectId, + }) + expect(response.status(), '失败兜底删除项目').toBe(200) +} + +async function cleanupAgent( + request: APIRequestContext, + headers: Headers, + agentId: string, + activities: Activity[], +) { + const current = await agentDetail(request, headers, agentId) + if (current.response.status() === 404) return + const response = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { + headers, + params: { dataVersion: String(Number(current.data.dataVersion ?? 0)) }, + }) + activities.push({ + module: '智能体组别', + action: '删除测试智能体', + result: response.status() === 200 ? 'CLEANED' : 'CLEANUP_FAILED', + httpStatus: response.status(), + resourceId: agentId, + }) + expect(response.status(), '删除测试智能体').toBe(200) +} + +test('智能体组别页面完成创建编辑启停筛选成员归集组内创建与删除闭环', async ({ page, request }, testInfo) => { + test.setTimeout(240_000) + const activities: Activity[] = [] + const token = await adminAccessToken(request) + const headers = authorized(token) + const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-10) || Date.now().toString().slice(-10) + const projectCode = `APE2E-${suffix}`.slice(0, 24) + const projectName = `APE2E智能体组别-${suffix}` + const editedProjectName = `${projectName}-已编辑` + const dependencyAgentName = `APE2E组别成员-${suffix}` + const scopedAgentName = `APE2E组内创建-${suffix}` + let projectId = '' + const agentIds: string[] = [] + let primaryError: unknown + + try { + await loginAsAdmin(page) + await page.goto('/agents/projects') + await expect(page.getByRole('heading', { name: '智能体组别管理' })).toBeVisible() + + await page.getByRole('button', { name: '新建项目' }).click() + let dialog = visibleDialog(page) + await fillFormItem(dialog, '项目名称', projectName) + await fillFormItem(dialog, '项目编号', projectCode) + await fillFormItem(dialog, '承担单位', '装备维修教研室') + await fillFormItem(dialog, '项目说明', `${runPrefix} 装备维修数字教员建设与交付验证`) + // 编号已知,提交前就登记为可能创建;即使成功提示未出现,finally 也能按编号查询并清理。 + projectId = projectCode + await dialog.getByRole('button', { name: '保存' }).click() + await expect(page.getByText('项目已创建').last()).toBeVisible() + let detail = await projectDetail(request, headers, projectId) + expect(detail.response.status()).toBe(200) + expect(detail.data.name).toBe(projectName) + expect(detail.data.status).toBe('enabled') + activities.push({ module: '智能体组别', action: '页面创建项目并读取数据库结果', result: 'PASS', httpStatus: 201, resourceId: projectId }) + await captureScreenshot(page, testInfo, 'projects-created-and-persisted') + + let row = tableRowByText(page, projectName) + await row.getByRole('button', { name: '编辑' }).click() + dialog = visibleDialog(page) + await fillFormItem(dialog, '项目名称', editedProjectName) + await fillFormItem(dialog, '承担单位', '装备维修与智能教学中心') + await fillFormItem(dialog, '项目说明', `${runPrefix} 已编辑:覆盖液压、电气与机械维修课目`) + await dialog.getByRole('button', { name: '保存' }).click() + await expect(page.getByText('项目已更新').last()).toBeVisible() + detail = await projectDetail(request, headers, projectId) + expect(detail.data.name).toBe(editedProjectName) + expect(detail.data.unit).toBe('装备维修与智能教学中心') + activities.push({ module: '智能体组别', action: '页面编辑项目并刷新核验', result: 'PASS', httpStatus: 200, resourceId: projectId }) + await captureScreenshot(page, testInfo, 'projects-edited-and-persisted') + + row = tableRowByText(page, editedProjectName) + await row.getByRole('button', { name: '停用', exact: true }).click() + await expect(page.getByText('项目已停用(仅归档标记,不影响项目下智能体对外服务)').last()).toBeVisible() + detail = await projectDetail(request, headers, projectId) + expect(detail.data.status).toBe('disabled') + activities.push({ module: '智能体组别', action: '页面停用项目', result: 'PASS', httpStatus: 200, resourceId: projectId }) + + const keyword = page.getByPlaceholder('搜索项目名称、编号、承担单位或负责人') + await keyword.fill(editedProjectName) + const statusSelect = page.locator('.toolbar .el-select') + await statusSelect.click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '启用中' }).click() + await expect(tableRowByText(page, editedProjectName)).toHaveCount(0) + await statusSelect.click() + await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '已停用' }).click() + await expect(tableRowByText(page, editedProjectName)).toBeVisible() + activities.push({ module: '智能体组别', action: '按关键词与启停状态筛选', result: 'PASS', resourceId: projectId }) + await captureScreenshot(page, testInfo, 'projects-disabled-filter-result') + + row = tableRowByText(page, editedProjectName) + await row.getByRole('button', { name: '启用', exact: true }).click() + await expect(page.getByText('项目已启用').last()).toBeVisible() + detail = await projectDetail(request, headers, projectId) + expect(detail.data.status).toBe('enabled') + activities.push({ module: '智能体组别', action: '页面重新启用项目', result: 'PASS', httpStatus: 200, resourceId: projectId }) + + const dependencyResponse = await request.post('/api/v1/realtime-agents', { + headers, + data: { name: dependencyAgentName, expiresAt: '2035-08-15T12:00:00+08:00' }, + }) + const dependency = await apiData(dependencyResponse, 201, '创建成员管理依赖智能体') + const dependencyAgentId = String(dependency.id ?? '') + expect(dependencyAgentId).not.toBe('') + agentIds.push(dependencyAgentId) + activities.push({ module: '智能体组别', action: 'API 创建未归属草稿智能体作为成员依赖', result: 'PASS', httpStatus: 201, resourceId: dependencyAgentId }) + + await page.goto('/agents/projects') + row = tableRowByText(page, editedProjectName) + await expect(row).toBeVisible() + await row.getByRole('button', { name: '管理成员' }).click() + dialog = visibleDialog(page) + const dependencyMember = dialog.locator('.member-row').filter({ hasText: dependencyAgentName }) + await expect(dependencyMember).toBeVisible() + await dependencyMember.locator('input[type="checkbox"]').check() + await expect(dialog.getByText('已选 1 个')).toBeVisible() + await dialog.getByRole('button', { name: '保存成员' }).click() + await expect(page.getByText('已更新项目成员,共 1 个智能体').last()).toBeVisible() + const assignedDependency = await agentDetail(request, headers, dependencyAgentId) + expect(assignedDependency.data.projectId).toBe(projectId) + detail = await projectDetail(request, headers, projectId) + expect(detail.data.agentCount).toBe(1) + activities.push({ module: '智能体组别', action: '页面勾选成员并保存归属', result: 'PASS', httpStatus: 200, resourceId: dependencyAgentId }) + await captureScreenshot(page, testInfo, 'projects-member-assignment-persisted') + + row = tableRowByText(page, editedProjectName) + await row.getByRole('button', { name: '新建智能体' }).click() + dialog = visibleDialog(page) + await expect(dialog).toContainText(`所属项目`) + await expect(dialog).toContainText(`${editedProjectName} · ${projectCode}`) + await expect(dialog.locator('select')).toHaveCount(0) + await captureScreenshot(page, testInfo, 'projects-scoped-create-entry') + await dialog.getByPlaceholder('例如:装备维修虚拟教员').fill(scopedAgentName) + await dialog.getByRole('button', { name: '长期有效' }).click() + const createdResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/v1/realtime-agents' + )) + await dialog.getByRole('button', { name: '创建', exact: true }).click() + const createdResponse = await createdResponsePromise + expect(createdResponse.status(), '项目内入口创建智能体').toBe(201) + const scopedAgent = asRecord(envelopeData(await createdResponse.json())) + const scopedAgentId = String(scopedAgent.id ?? '') + expect(scopedAgentId).not.toBe('') + agentIds.push(scopedAgentId) + expect(scopedAgent.projectId).toBe(projectId) + await expect(page).toHaveURL(new RegExp(`/agents/${encodeURIComponent(scopedAgentId)}(?:\\?|$)`)) + await expect(page.locator('body')).toContainText(scopedAgentName) + activities.push({ module: '智能体组别', action: '项目内入口锁定归属并真实创建草稿智能体', result: 'PASS', httpStatus: 201, resourceId: scopedAgentId }) + await captureScreenshot(page, testInfo, 'projects-scoped-agent-created', [ + page.locator('input[type="password"], pre, code, [data-testid*="secret"], [data-testid*="password"]'), + ]) + + await page.goto('/agents/projects') + row = tableRowByText(page, editedProjectName) + await expect(row).toContainText('共 2 个') + detail = await projectDetail(request, headers, projectId) + expect(detail.data.agentCount).toBe(2) + + await row.getByRole('button', { name: '删除' }).click() + const confirmation = page.locator('.el-message-box:visible').last() + await expect(confirmation).toContainText('下有 2 个智能体') + await expect(confirmation).toContainText('删除项目只会解除归属') + await captureScreenshot(page, testInfo, 'projects-delete-release-warning') + await confirmation.getByRole('button', { name: '确认删除' }).click() + await expect(page.getByText('项目已删除,2 个智能体转为未归属').last()).toBeVisible() + projectId = '' + await page.reload() + await expect(tableRowByText(page, editedProjectName)).toHaveCount(0) + const absentProject = await request.get(`/api/v1/agent-projects/${encodeURIComponent(projectCode)}`, { headers }) + expect(absentProject.status()).toBe(404) + for (const agentId of agentIds) { + const released = await agentDetail(request, headers, agentId) + expect(released.response.status()).toBe(200) + expect(String(released.data.projectId ?? '')).toBe('') + } + activities.push({ module: '智能体组别', action: '页面删除项目、刷新消失且成员仅解除归属', result: 'PASS', httpStatus: 200, resourceId: projectCode }) + await captureScreenshot(page, testInfo, 'projects-deleted-after-refresh') + } catch (error) { + primaryError = error + } finally { + try { + if (projectId) await cleanupProject(request, headers, projectId, activities) + } catch (error) { + if (!primaryError) primaryError = error + } + for (const agentId of agentIds) { + try { + await cleanupAgent(request, headers, agentId, activities) + } catch (error) { + if (!primaryError) primaryError = error + } + } + await attachActivities(testInfo, '智能体组别完整页面生命周期', activities) + } + + if (primaryError) throw primaryError + for (const agentId of agentIds) { + const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers }) + expect(response.status(), `测试智能体 ${agentId} 已清理`).toBe(404) + } +}) diff --git a/tests/video-ui-lifecycle.spec.ts b/tests/video-ui-lifecycle.spec.ts new file mode 100644 index 0000000..1c9abbb --- /dev/null +++ b/tests/video-ui-lifecycle.spec.ts @@ -0,0 +1,468 @@ +import type { APIRequestContext, Locator, Page, TestInfo } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' +import { authHeaders, confirmMessageBox, envelopeData, loginAsAdmin } from './helpers' + +type JsonRecord = Record +type Headers = Record + +interface Activity { + module: string + action: string + result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED' + resourceId?: string + detail?: string +} + +const MATERIAL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +) + +const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' ? value as JsonRecord : {} +const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : [] + +async function apiData(response: Awaited>, expected = 200) { + expect(response.status()).toBe(expected) + return asRecord(envelopeData(await response.json())) +} + +async function projectDetail(request: APIRequestContext, headers: Headers, id: string) { + const response = await request.get(`/api/v1/video-projects/${encodeURIComponent(id)}`, { headers }) + return { response, data: response.status() === 200 ? await apiData(response) : {} } +} + +async function videoDetail(request: APIRequestContext, headers: Headers, id: string) { + const response = await request.get(`/api/v1/videos/${encodeURIComponent(id)}`, { headers }) + return { response, data: response.status() === 200 ? await apiData(response) : {} } +} + +async function findFolder(request: APIRequestContext, headers: Headers, name: string) { + const response = await request.get('/api/v1/video-folders', { headers }) + const data = await apiData(response) + return asRecords(data.items).find((item) => String(item.name ?? '') === name) || null +} + +async function findByKeyword(request: APIRequestContext, headers: Headers, endpoint: string, keyword: string, name: string) { + const response = await request.get(endpoint, { headers, params: { keyword, page: '1', pageSize: '100' } }) + const data = await apiData(response) + return asRecords(data.items).find((item) => String(item.name ?? item.title ?? '') === name) || null +} + +async function cleanupVideo(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) { + try { + let detail = await videoDetail(request, headers, id) + if (detail.response.status() === 404) return + if (detail.response.status() !== 200) throw new Error(`HTTP ${detail.response.status()}`) + if (String(detail.data.shareStatus ?? '') === 'active') { + await request.delete(`/api/v1/videos/${encodeURIComponent(id)}/share`, { + headers, + data: { reason: 'APE2E failure cleanup' }, + }) + detail = await videoDetail(request, headers, id) + } + const removed = await request.delete(`/api/v1/videos/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) + activities.push({ module: '成片管理', action: '失败兜底清理成片', result: 'CLEANED', resourceId: id }) + } catch (error) { + const message = `成片 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}` + errors.push(message) + activities.push({ module: '成片管理', action: '失败兜底清理成片', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) + } +} + +async function cleanupFolder(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) { + try { + const response = await request.get('/api/v1/video-folders', { headers }) + const data = await apiData(response) + const folder = asRecords(data.items).find((item) => String(item.id ?? '') === id) + if (!folder) return + const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(folder.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) + activities.push({ module: '成片管理', action: '失败兜底清理文件夹', result: 'CLEANED', resourceId: id }) + } catch (error) { + const message = `文件夹 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}` + errors.push(message) + activities.push({ module: '成片管理', action: '失败兜底清理文件夹', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) + } +} + +async function cleanupProject(request: APIRequestContext, headers: Headers, id: string, activities: Activity[], errors: string[]) { + try { + const detail = await projectDetail(request, headers, id) + if (detail.response.status() === 404) return + if (detail.response.status() !== 200) throw new Error(`HTTP ${detail.response.status()}`) + const removed = await request.delete(`/api/v1/video-projects/${encodeURIComponent(id)}`, { + headers, + params: { dataVersion: String(Number(detail.data.dataVersion ?? 0)) }, + }) + if (![200, 404].includes(removed.status())) throw new Error(`HTTP ${removed.status()}`) + activities.push({ module: '视频制作', action: '清理页面创建的视频工程', result: 'CLEANED', resourceId: id }) + } catch (error) { + const message = `视频工程 ${id} 清理失败:${error instanceof Error ? error.message : String(error)}` + errors.push(message) + activities.push({ module: '视频制作', action: '清理页面创建的视频工程', result: 'CLEANUP_FAILED', resourceId: id, detail: message }) + } +} + +async function selectFirstEnabledOption(select: Locator, preferredLabel: RegExp) { + const options = await select.locator('option').evaluateAll((nodes) => nodes.map((node) => ({ + value: (node as HTMLOptionElement).value, + label: (node.textContent || '').trim(), + disabled: (node as HTMLOptionElement).disabled, + }))) + const selected = options.find((item) => !item.disabled && item.value && preferredLabel.test(item.label)) + || options.find((item) => !item.disabled && item.value) + expect(selected, '页面必须加载至少一个可选的真实语音能力').toBeTruthy() + await select.selectOption(selected!.value) + return selected! +} + +async function expectPublicShareRejected(page: Page, url: string) { + await page.goto(url) + await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible() +} + +test.describe.configure({ mode: 'serial' }) +// 成片为 H.264/AAC MP4;Playwright 自带的开源 Chromium 不包含完整专利编解码器。 +// 本用例用系统 Chrome 完成真实播放验收,其余 E2E 仍使用项目 Chromium。 +test.use({ trace: 'off', video: 'off', channel: 'chrome' }) + +test('视频制作与成片管理全部通过页面提交并真实生成成片', async ({ page, context, request }, testInfo: TestInfo) => { + test.setTimeout(15 * 60_000) + const activities: Activity[] = [] + const cleanupErrors: string[] = [] + const headers = await authHeaders(request) + const projectName = `${runPrefix}-液压安全口播` + const editedVideoName = `${runPrefix}-液压安全成片` + const folderName = `${runPrefix}-视频测试文件夹` + const renamedFolderName = `${runPrefix}-安全培训成片` + const scriptText = '检修前必须停机、断电、卸压并完成上锁挂牌。' + const materialName = `${runPrefix}-检修要点.png` + const sharePassword = 'Ape2eVideo#2026' + const rotatedPassword = 'Ape2eRotate#2026' + let projectId = '' + let videoId = '' + let folderId = '' + let primaryError: unknown + + try { + await loginAsAdmin(page) + await page.goto('/videos/create?mode=offline') + await expect(page.getByRole('heading', { name: '视频制作', exact: true })).toBeVisible() + await expect(page.locator('.studio-avatar-list button:not([disabled])').first()).toBeVisible() + await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 }) + await captureScreenshot(page, testInfo, '01-video-create-nonempty-assets') + + await page.getByRole('button', { name: /横屏(1920 × 1080)/ }).click() + const avatar = page.locator('.studio-avatar-list button:not([disabled])').first() + const avatarName = (await avatar.locator('b').textContent() || '').trim() + expect(avatarName, '必须从页面选择真实已就绪形象').not.toBe('') + await avatar.click() + + await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click() + const voiceCard = page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).first() + await expect(voiceCard).toBeVisible() + await voiceCard.locator('label').click() + await expect(voiceCard.locator('input[type="radio"]')).toBeChecked() + + await page.locator('.editor-tool-rail button').filter({ hasText: '背景' }).click() + await page.getByRole('button', { name: '实训教室', exact: true }).click() + await page.locator('.editor-tool-rail button').filter({ hasText: '元素' }).click() + await page.getByRole('button', { name: /检查标记/ }).click() + await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible() + + await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click() + await page.locator('.media-library input[type="file"]').setInputFiles({ + name: materialName, + mimeType: 'image/png', + buffer: MATERIAL_PNG, + }) + await expect(page.locator('.uploaded-media-card')).toContainText(materialName) + await expect(page.locator('.stage-media-picture-in-picture img')).toBeVisible() + + await page.getByRole('button', { name: '文本', exact: true }).last().click() + await page.getByLabel('项目名称').fill(projectName) + await page.locator('.script-field textarea').fill(scriptText) + const capabilitySelect = page.getByLabel('语音能力资产') + const selectedCapability = await selectFirstEnabledOption(capabilitySelect, /演示实训播报语音/) + await page.getByLabel('字幕', { exact: true }).check() + await page.getByRole('button', { name: '字幕样式', exact: true }).click() + await page.getByLabel('字体').selectOption('Source Han Sans SC') + await page.getByLabel('字号').selectOption('48') + await page.getByLabel('字幕位置').selectOption('center') + await captureScreenshot(page, testInfo, '02-video-project-ui-configured') + + await page.getByRole('button', { name: '保存工程', exact: true }).click() + await expect(page.getByText('工程与四轨时间线已保存').last()).toBeVisible({ timeout: 60_000 }) + await expect.poll(() => new URL(page.url()).searchParams.get('project')).not.toBeNull() + projectId = new URL(page.url()).searchParams.get('project') || '' + expect(projectId).not.toBe('') + + let persistedProject = await projectDetail(request, headers, projectId) + expect(persistedProject.response.status()).toBe(200) + expect(persistedProject.data.name).toBe(projectName) + expect(persistedProject.data.scriptText).toBe(scriptText) + expect(persistedProject.data.orientation).toBe('landscape') + expect(persistedProject.data.backgroundType).toBe('training') + expect(persistedProject.data.subtitleEnabled).toBe(true) + expect(persistedProject.data.subtitleFont).toBe('Source Han Sans SC') + expect(persistedProject.data.subtitleFontSize).toBe(48) + expect(persistedProject.data.subtitlePosition).toBe('center') + expect(String(persistedProject.data.avatarId ?? '')).not.toBe('') + expect(String(persistedProject.data.voiceCapabilityId ?? '')).not.toBe('') + const tracks = asRecords(asRecord(persistedProject.data.timeline).tracks) + expect(tracks.map((track) => String(track.code))).toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE']) + const mediaClips = asRecords(tracks.find((track) => track.code === 'MEDIA')?.clips) + expect(mediaClips.some((clip) => clip.type === 'MEDIA' && String(clip.fileId ?? ''))).toBe(true) + expect(mediaClips.some((clip) => clip.type === 'CHECK')).toBe(true) + const audioClip = asRecords(tracks.find((track) => track.code === 'AUDIO')?.clips)[0] || {} + expect(asRecord(audioClip.style).speaker).toBe(2) + expect(String(audioClip.capabilityId ?? '')).toBe(String(persistedProject.data.voiceCapabilityId ?? '')) + expect(asRecords(tracks.find((track) => track.code === 'SUBTITLE')?.clips)[0]?.text).toBe(scriptText) + activities.push({ module: '视频制作', action: '页面保存横屏工程、真实形象音色、素材、元素、字幕和四轨时间线', result: 'PASS', resourceId: projectId }) + await captureScreenshot(page, testInfo, '03-video-project-saved-four-tracks') + + await page.reload() + await expect(page.getByText('工程已保存', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByLabel('项目名称')).toHaveValue(projectName) + await expect(page.locator('.script-field textarea')).toHaveValue(scriptText) + await expect(page.getByRole('button', { name: /横屏(1920 × 1080)/ })).toHaveClass(/active/) + await page.locator('.editor-tool-rail button').filter({ hasText: '素材' }).click() + await expect(page.locator('.uploaded-media-card')).toContainText(materialName) + await expect(page.locator('.stage-overlay').filter({ hasText: '已完成检查' })).toBeVisible() + await expect(page.getByLabel('语音能力资产')).toHaveValue(selectedCapability.value) + await expect(page.locator('.timeline-clip.media')).toContainText(materialName) + await expect(page.locator('.timeline-clip.subtitle')).toContainText(scriptText) + await page.locator('.editor-tool-rail button').filter({ hasText: '音频' }).click() + await expect(page.locator('.editor-resource-list article').filter({ hasText: '温和女声' }).locator('input[type="radio"]')).toBeChecked() + activities.push({ module: '视频制作', action: '刷新 project 深链并核验全部配置持久化', result: 'PASS', resourceId: projectId }) + await captureScreenshot(page, testInfo, '04-video-project-deeplink-persisted') + + await expect(page.getByText('渲染就绪', { exact: true })).toBeVisible({ timeout: 30_000 }) + await page.getByRole('button', { name: '生成视频', exact: true }).click() + await expect(page.locator('.editor-render-progress')).toBeVisible({ timeout: 20_000 }) + await captureScreenshot(page, testInfo, '05-real-video-render-running') + const succeeded = page.locator('.editor-render-progress.is-succeeded') + await expect(succeeded).toContainText('视频生成完成', { timeout: 8 * 60_000 }) + await expect(succeeded).toContainText('100%') + activities.push({ module: '视频制作', action: '页面发起真实 TTS、浏览器录制、FFmpeg 合成与成片归档', result: 'PASS', resourceId: projectId }) + await captureScreenshot(page, testInfo, '06-real-video-render-succeeded') + + await succeeded.getByRole('button', { name: '查看作品' }).click() + await expect(page).toHaveURL(/\/videos\/manage\?video=/) + videoId = new URL(page.url()).searchParams.get('video') || '' + expect(videoId).not.toBe('') + let detail = page.locator('.video-detail-overlay') + await expect(detail).toBeVisible({ timeout: 30_000 }) + await expect(detail).toContainText(projectName) + const video = detail.locator('video') + await expect(video).toHaveAttribute('src', /.+/) + await detail.getByRole('button', { name: '播放视频', exact: true }).click() + await expect(detail.getByRole('button', { name: '暂停视频', exact: true })).toBeVisible() + await expect.poll(() => video.evaluate((element: HTMLVideoElement) => !element.paused)).toBe(true) + await detail.getByRole('button', { name: '暂停视频', exact: true }).click() + await expect(detail.getByRole('button', { name: '播放视频', exact: true })).toBeVisible() + const downloadPromise = page.waitForEvent('download', { timeout: 60_000 }) + await detail.getByRole('button', { name: '下载视频', exact: true }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toContain(projectName) + expect(await download.path()).not.toBeNull() + let persistedVideo = await videoDetail(request, headers, videoId) + expect(persistedVideo.response.status()).toBe(200) + expect(persistedVideo.data.status).toBe('ready') + expect(String(persistedVideo.data.videoFileCode ?? '')).not.toBe('') + expect(Number(persistedVideo.data.size ?? 0)).toBeGreaterThan(10_000) + persistedProject = await projectDetail(request, headers, projectId) + expect(persistedProject.data.lastVideoId).toBe(videoId) + activities.push({ module: '成片管理', action: '页面打开生成成片详情并实际播放、下载', result: 'PASS', resourceId: videoId }) + await captureScreenshot(page, testInfo, '07-generated-video-played-downloaded') + + await detail.getByRole('button', { name: '关闭' }).click() + await page.locator('.folder-create-button').click() + let dialog = page.locator('.el-dialog:visible').last() + await dialog.getByLabel('文件夹名称').fill(folderName) + const createFolderResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'POST' && new URL(response.url()).pathname === '/api/v1/video-folders' + )) + const saveFolderButton = dialog.getByRole('button', { name: '保存文件夹', exact: true }) + await expect(saveFolderButton).toBeEnabled() + await saveFolderButton.click() + const createFolderResponse = await createFolderResponsePromise + expect( + createFolderResponse.status(), + `页面新建文件夹请求失败:${await createFolderResponse.text()}`, + ).toBe(201) + await expect(page.getByText('文件夹已创建').last()).toBeVisible() + let folderArticle = page.locator('.video-folder-strip article').filter({ hasText: folderName }) + await expect(folderArticle).toBeVisible() + let folder = await findFolder(request, headers, folderName) + expect(folder).not.toBeNull() + folderId = String(folder?.id ?? '') + activities.push({ module: '成片管理', action: '页面新建成片文件夹', result: 'PASS', resourceId: folderId }) + await captureScreenshot(page, testInfo, '08-video-folder-created') + + await folderArticle.getByRole('button', { name: '重命名文件夹' }).click() + dialog = page.locator('.el-dialog:visible').last() + await dialog.getByLabel('文件夹名称').fill(renamedFolderName) + await dialog.getByRole('button', { name: '保存文件夹', exact: true }).click() + await expect(page.getByText('文件夹已更新').last()).toBeVisible() + folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName }) + await expect(folderArticle).toBeVisible() + + await page.getByPlaceholder('搜索标题、简介或标签').fill(projectName) + await page.getByRole('button', { name: '搜索', exact: true }).click() + await page.getByLabel('分类').selectOption({ label: '口播视频' }) + await page.getByLabel('可见范围').selectOption('private') + await page.getByLabel('归属').selectOption('mine') + await page.getByLabel('排序').selectOption('title') + await page.locator('.sort-direction').click() + let card = page.locator('.video-library-card').filter({ hasText: projectName }) + await expect(card).toBeVisible() + activities.push({ module: '成片管理', action: '页面重命名文件夹并组合搜索、分类、可见范围、归属与排序', result: 'PASS', resourceId: folderId }) + await captureScreenshot(page, testInfo, '09-video-folder-renamed-and-filtered') + + await card.getByRole('button', { name: '查看详情', exact: true }).click() + detail = page.locator('.video-detail-overlay') + await detail.getByRole('button', { name: '编辑', exact: true }).click() + await detail.getByLabel('标题').fill(editedVideoName) + await detail.getByLabel('简介').fill('液压系统检修前安全确认与上锁挂牌教学成片。') + await detail.getByLabel('所在文件夹').selectOption({ label: renamedFolderName }) + await detail.getByRole('textbox', { name: '分类', exact: true }).fill('安全培训') + await detail.getByLabel('标签').fill('液压系统、上锁挂牌、APE2E') + await detail.getByLabel('可见范围').selectOption('public') + await detail.getByRole('button', { name: '保存修改', exact: true }).click() + await expect(page.getByText('视频资料已更新').last()).toBeVisible() + await expect(detail).toContainText(editedVideoName) + await expect(detail).toContainText(renamedFolderName) + await expect(detail).toContainText('内网分享链接') + persistedVideo = await videoDetail(request, headers, videoId) + expect(persistedVideo.data.title).toBe(editedVideoName) + expect(persistedVideo.data.folderId).toBe(folderId) + expect(persistedVideo.data.visibility).toBe('public') + expect(persistedVideo.data.tags).toEqual(['液压系统', '上锁挂牌', 'APE2E']) + activities.push({ module: '成片管理', action: '页面编辑标题、简介、文件夹、分类、标签和可见范围', result: 'PASS', resourceId: videoId }) + await captureScreenshot(page, testInfo, '10-video-metadata-folder-visibility-updated') + + await detail.getByRole('button', { name: '创建分享链接', exact: true }).click() + dialog = page.locator('.el-dialog:visible').last() + await dialog.getByLabel('有效时长(小时)').fill('1') + await dialog.getByLabel('访问密码(可选)').fill(sharePassword) + await dialog.getByLabel('最大访问次数').fill('5') + await dialog.getByRole('button', { name: '生成分享链接', exact: true }).click() + await expect(dialog.getByText('分享链接已生成')).toBeVisible() + await expect(dialog).toContainText('最多 5 次访问') + const firstShareInput = dialog.getByLabel('本次视频分享链接') + const firstShareUrl = await firstShareInput.inputValue() + expect(new URL(firstShareUrl).pathname).toMatch(/^\/share\/videos\/[^/]+$/) + await captureScreenshot(page, testInfo, '11-video-share-password-limit-issued', [firstShareInput]) + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + + const sharedPage = await context.newPage() + await sharedPage.goto(firstShareUrl) + await expect(sharedPage.getByText('视频需要验证或链接暂不可用')).toBeVisible() + await sharedPage.getByLabel('访问密码').fill(sharePassword) + await sharedPage.getByRole('button', { name: '验证并播放' }).click() + await expect(sharedPage.getByRole('heading', { name: editedVideoName })).toBeVisible() + await expect(sharedPage.locator('video')).toBeVisible() + await captureScreenshot(sharedPage, testInfo, '12-password-protected-share-verified') + await sharedPage.close() + activities.push({ module: '成片管理', action: '页面签发密码与限次分享并在公开页验证', result: 'PASS', resourceId: videoId }) + + await detail.getByRole('button', { name: '轮换分享链接', exact: true }).click() + dialog = page.locator('.el-dialog:visible').last() + await dialog.getByLabel('有效时长(小时)').fill('2') + await dialog.getByLabel('访问密码(可选)').fill(rotatedPassword) + await dialog.getByLabel('最大访问次数').fill('3') + await dialog.getByRole('button', { name: '确认轮换', exact: true }).click() + await expect(dialog.getByText('分享链接已生成')).toBeVisible() + const rotatedShareInput = dialog.getByLabel('本次视频分享链接') + const rotatedShareUrl = await rotatedShareInput.inputValue() + expect(rotatedShareUrl).not.toBe(firstShareUrl) + await captureScreenshot(page, testInfo, '13-video-share-rotated', [rotatedShareInput]) + await dialog.getByRole('button', { name: '关闭', exact: true }).click() + const revokedOldSharePage = await context.newPage() + await expectPublicShareRejected(revokedOldSharePage, firstShareUrl) + await revokedOldSharePage.close() + + await detail.getByRole('button', { name: '停止分享', exact: true }).click() + await confirmMessageBox(page, '停止分享') + await expect(page.getByText('分享已停止').last()).toBeVisible() + persistedVideo = await videoDetail(request, headers, videoId) + expect(persistedVideo.data.shareStatus).not.toBe('active') + const revokedRotatedSharePage = await context.newPage() + await expectPublicShareRejected(revokedRotatedSharePage, rotatedShareUrl) + await revokedRotatedSharePage.close() + activities.push({ module: '成片管理', action: '页面轮换与停止分享,新旧链接均按预期失效', result: 'PASS', resourceId: videoId }) + await captureScreenshot(page, testInfo, '14-video-share-stopped') + + await detail.getByRole('button', { name: '关闭' }).click() + folderArticle = page.locator('.video-folder-strip article').filter({ hasText: renamedFolderName }) + await folderArticle.getByRole('button', { name: '删除文件夹' }).click() + await confirmMessageBox(page, '确认删除') + await expect(page.getByText('文件夹已删除,视频已移至未分类').last()).toBeVisible() + folderId = '' + await page.getByPlaceholder('搜索标题、简介或标签').fill(editedVideoName) + await page.getByLabel('分类').selectOption('') + await page.getByLabel('可见范围').selectOption('public') + await page.getByRole('button', { name: '搜索', exact: true }).click() + card = page.locator('.video-library-card').filter({ hasText: editedVideoName }) + await expect(card).toBeVisible() + await card.getByRole('button', { name: '查看详情', exact: true }).click() + detail = page.locator('.video-detail-overlay') + await expect(detail).toContainText('未分类') + persistedVideo = await videoDetail(request, headers, videoId) + expect(persistedVideo.data.folderId).toBeNull() + activities.push({ module: '成片管理', action: '页面删除含成片文件夹并核验成片移至未分类', result: 'PASS', resourceId: videoId }) + await captureScreenshot(page, testInfo, '15-video-folder-deleted-video-unfiled') + + await detail.getByRole('button', { name: '删除', exact: true }).click() + await confirmMessageBox(page, '确认删除') + await expect(page.getByText('视频资源已删除').last()).toBeVisible() + await expect(page.locator('.video-detail-overlay')).toHaveCount(0) + const deletedVideo = await videoDetail(request, headers, videoId) + expect(deletedVideo.response.status()).toBe(404) + activities.push({ module: '成片管理', action: '页面永久删除成片与关联文件', result: 'PASS', resourceId: videoId }) + videoId = '' + await captureScreenshot(page, testInfo, '16-generated-video-deleted-baseline-remains') + } catch (error) { + primaryError = error + } finally { + if (!videoId) { + const leakedVideo = await findByKeyword(request, headers, '/api/v1/videos', runPrefix, editedVideoName).catch(() => null) + || await findByKeyword(request, headers, '/api/v1/videos', runPrefix, projectName).catch(() => null) + videoId = String(leakedVideo?.id ?? '') + } + if (!folderId) { + const leakedFolder = await findFolder(request, headers, renamedFolderName).catch(() => null) + || await findFolder(request, headers, folderName).catch(() => null) + folderId = String(leakedFolder?.id ?? '') + } + if (!projectId) { + const urlProjectId = new URL(page.url()).searchParams.get('project') || '' + const leakedProject = urlProjectId + ? null + : await findByKeyword(request, headers, '/api/v1/video-projects', runPrefix, projectName).catch(() => null) + projectId = urlProjectId || String(leakedProject?.id ?? '') + } + if (videoId) await cleanupVideo(request, headers, videoId, activities, cleanupErrors) + if (folderId) await cleanupFolder(request, headers, folderId, activities, cleanupErrors) + if (projectId) await cleanupProject(request, headers, projectId, activities, cleanupErrors) + await attachJson(testInfo, '视频制作与成片管理-UI生命周期', { + runPrefix, + activities, + cleanupComplete: cleanupErrors.length === 0, + cleanupErrors, + security: '分享密码和一次性分享令牌未写入附件,包含令牌的截图区域已遮罩。', + }) + } + + if (primaryError) throw primaryError + expect(cleanupErrors, '视频 UI 生命周期测试数据必须清理干净').toEqual([]) +}) diff --git a/tests/visual-routes.spec.ts b/tests/visual-routes.spec.ts new file mode 100644 index 0000000..f72aca4 --- /dev/null +++ b/tests/visual-routes.spec.ts @@ -0,0 +1,270 @@ +import type { Page } from '@playwright/test' + +import { attachJson, captureScreenshot, expect, test } from './fixtures' +import { loginAsAdmin } from './helpers' +import { expectRouteHasNonEmptyData, hasNonEmptyAcceptance } from './nonempty' + +interface RouteCase { + path: string + heading: string + screenshot: string + placeholder?: boolean +} + +const adminRoutes: RouteCase[] = [ + { path: '/overview', heading: '实时数字人智能体', screenshot: 'overview' }, + { path: '/agents/manage', heading: '虚拟教员智能体', screenshot: 'agents' }, + { path: '/agents/projects', heading: '智能体组别管理', screenshot: 'agent-projects' }, + { path: '/agents/chat-history', heading: '会话记录', screenshot: 'chat-history' }, + { path: '/agents/wake-words', heading: '唤醒词库', screenshot: 'wake-words' }, + { path: '/agents/remote-control', heading: '远程控制', screenshot: 'remote-control' }, + { path: '/videos/create?mode=offline', heading: '视频制作', screenshot: 'video-create' }, + { path: '/videos/manage', heading: '成片管理', screenshot: 'videos' }, + { path: '/assets/avatars', heading: '数字形象', screenshot: 'avatars' }, + { path: '/assets/voice-clones', heading: '声音克隆', screenshot: 'voice-clones' }, + { path: '/assets/ai-models', heading: 'AI模型', screenshot: 'ai-models' }, + { path: '/assets/voice-models', heading: '语音模型', screenshot: 'voice-models' }, + { path: '/assets/asr-models', heading: 'ASR模型', screenshot: 'asr-models' }, + { path: '/assets/scene-materials', heading: '场景素材', screenshot: 'scene-materials' }, + { path: '/knowledge/library', heading: '维修知识', screenshot: 'knowledge' }, + { path: '/knowledge/sensitive-words', heading: '敏感词库', screenshot: 'sensitive-words' }, + { path: '/knowledge/hot-words', heading: '热词管理', screenshot: 'hot-words' }, + { path: '/jobs', heading: '制作队列', screenshot: 'jobs' }, + { path: '/tools', heading: '工具管理', screenshot: 'tools' }, + { path: '/organization/users', heading: '用户管理', screenshot: 'users' }, + { path: '/organization/departments', heading: '部门管理', screenshot: 'departments' }, + { path: '/organization/roles', heading: '角色管理', screenshot: 'roles' }, + { path: '/organization/permissions', heading: '权限管理', screenshot: 'permissions' }, + { path: '/settings/general', heading: '基础设置', screenshot: 'settings-general' }, + { path: '/settings/integrations', heading: '三方配置', screenshot: 'settings-integrations' }, + { path: '/settings/menus', heading: '菜单管理', screenshot: 'settings-menus' }, + { path: '/profile', heading: '个人中心', screenshot: 'profile' }, + { path: '/courses', heading: '课程管理', screenshot: 'placeholder-courses', placeholder: true }, + { path: '/equipment', heading: '装备空间', screenshot: 'placeholder-equipment', placeholder: true }, + { path: '/maintenance', heading: '维修任务', screenshot: 'placeholder-maintenance', placeholder: true }, +] + +interface BrowserIssueState { + consoleErrors: string[] + pageErrors: string[] + serverErrors: string[] +} + +function observeBrowserIssues(page: Page): BrowserIssueState { + const state: BrowserIssueState = { consoleErrors: [], pageErrors: [], serverErrors: [] } + page.on('console', (message) => { + if (message.type() === 'error') state.consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => state.pageErrors.push(error.message)) + page.on('response', (response) => { + const url = new URL(response.url()) + if (url.origin === new URL(page.url() || 'http://127.0.0.1').origin + && url.pathname.startsWith('/api/') && response.status() >= 500) { + state.serverErrors.push(`${response.status()} ${url.pathname}`) + } + }) + return state +} + +function takeIssues(state: BrowserIssueState) { + const result = { + consoleErrors: state.consoleErrors.splice(0), + pageErrors: state.pageErrors.splice(0), + serverErrors: state.serverErrors.splice(0), + } + return result +} + +async function expectNoDocumentOverflow(page: Page) { + const dimensions = await page.evaluate(() => ({ + viewport: document.documentElement.clientWidth, + document: document.documentElement.scrollWidth, + })) + expect.soft(dimensions.document, `页面产生横向溢出:${dimensions.document}px > ${dimensions.viewport}px`).toBeLessThanOrEqual(dimensions.viewport + 1) +} + +async function expectControlsWithinContainer(page: Page, containerSelector: string, controlsSelector: string, label: string) { + const clippedControls = await page.locator(containerSelector).evaluate((container, selector) => { + const boundary = container.getBoundingClientRect() + return [...container.querySelectorAll(selector)] + .filter((element) => { + const style = getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' + }) + .flatMap((element) => { + const rect = element.getBoundingClientRect() + const clipped = rect.left < boundary.left - 1 + || rect.right > boundary.right + 1 + || rect.top < boundary.top - 1 + || rect.bottom > boundary.bottom + 1 + return clipped ? [{ + control: element.textContent?.trim().replace(/\s+/g, ' ').slice(0, 40) || element.tagName, + boundary: [Math.round(boundary.left), Math.round(boundary.top), Math.round(boundary.right), Math.round(boundary.bottom)], + rect: [Math.round(rect.left), Math.round(rect.top), Math.round(rect.right), Math.round(rect.bottom)], + }] : [] + }) + }, controlsSelector) + expect.soft(clippedControls, `${label}存在被容器裁切的关键控件`).toEqual([]) +} + +async function openAndCaptureRoute( + page: Page, + route: RouteCase, + viewportLabel: string, + testInfo: Parameters[1], + issues: BrowserIssueState, +) { + takeIssues(issues) + await page.goto(route.path) + await expect(page).toHaveURL(new RegExp(`${route.path.split('?')[0]!.replaceAll('/', '\\/')}(?:\\?|$)`)) + await expect(page.getByRole('heading', { name: route.heading, exact: true }).first()).toBeVisible() + const loadingOverlays = page.locator('.el-loading-mask:visible, .management-loading:visible, .overview-loading:visible, .library-loading:visible') + const textLoadingStates = page + .locator(':is(.empty-state, .agent-placeholder, .agent-detail-placeholder):visible') + .filter({ hasText: /正在(?:读取|加载|汇总)/ }) + await expect(loadingOverlays).toHaveCount(0, { timeout: 20_000 }) + await expect(textLoadingStates).toHaveCount(0, { timeout: 20_000 }) + + // 菜单树由页面挂载后的接口异步加载;必须等真实行出现,不能把初始 0 节点空态当成页面完成。 + if (route.screenshot === 'settings-menus') { + await expect(page.locator('.menu-name-cell').first()).toBeVisible({ timeout: 20_000 }) + } + if (route.screenshot === 'knowledge') { + // 持久演示库包含部门共享与个人库;切到“用户自建”后再验收、截图, + // 避免只拍平台内置库而漏掉 DEMO manifest 对应的真实业务数据。 + const userBuiltTab = page.getByRole('tab', { name: '用户自建' }) + await userBuiltTab.focus() + // 移动端固定顶栏会覆盖页签的指针命中区域;使用原生按钮的键盘激活路径, + // 仍然触发页面真实交互与数据切换,并同时验证该页签具备可访问性。 + await userBuiltTab.press('Enter') + await expect(userBuiltTab).toHaveAttribute('aria-selected', 'true') + } + + if (route.placeholder) { + await expect(page.getByText('业务说明', { exact: true })).toBeVisible() + await expect(page.getByText('本页面用于展示相关业务范围,具体数据、操作内容和可见范围以实际业务配置及账号权限为准。')).toBeVisible() + await expect(page.locator('.domain-card')).toHaveCount(3) + await expect(page.locator('form, .primary-button')).toHaveCount(0) + } else { + expect(hasNonEmptyAcceptance(route.screenshot), `${route.path} 必须配置非空页面验收规则`).toBeTruthy() + const evidence = await expectRouteHasNonEmptyData(page, route.screenshot) + await attachJson(testInfo, `${viewportLabel}-${route.screenshot}-nonempty-evidence`, evidence) + } + + // 数据出现后再次确认所有页面自定义 loading 均已结束,保证截图不会记录 + // “正在读取/加载”与真实列表短暂共存的过渡帧。 + await expect(loadingOverlays).toHaveCount(0) + await expect(textLoadingStates).toHaveCount(0) + await expectNoDocumentOverflow(page) + await page.waitForTimeout(150) + await captureScreenshot(page, testInfo, `${viewportLabel}-${route.screenshot}`) + + const routeIssues = takeIssues(issues) + await attachJson(testInfo, `${viewportLabel}-${route.screenshot}-browser-issues`, routeIssues) + expect.soft(routeIssues.pageErrors, `${route.path} 发生 pageerror`).toEqual([]) + expect.soft(routeIssues.consoleErrors, `${route.path} 发生 console.error`).toEqual([]) + expect.soft(routeIssues.serverErrors, `${route.path} 出现 API 5xx`).toEqual([]) +} + +test('管理员桌面端逐页加载所有静态正式路由并截图', async ({ page }, testInfo) => { + test.setTimeout(480_000) + await page.setViewportSize({ width: 1920, height: 1080 }) + const issues = observeBrowserIssues(page) + await loginAsAdmin(page) + + for (const route of adminRoutes) { + await test.step(route.path, async () => { + await openAndCaptureRoute(page, route, 'desktop', testInfo, issues) + }) + } +}) + +test('管理员移动端逐页加载所有静态正式路由并截图', async ({ page }, testInfo) => { + test.setTimeout(480_000) + await page.setViewportSize({ width: 390, height: 844 }) + const issues = observeBrowserIssues(page) + await loginAsAdmin(page) + + for (const route of adminRoutes) { + await test.step(route.path, async () => { + await openAndCaptureRoute(page, route, 'mobile', testInfo, issues) + if (route.screenshot === 'video-create') { + await expectControlsWithinContainer( + page, + '.editor-commandbar', + '.editor-format-switch button, .editor-command-actions button', + '移动端视频制作命令栏', + ) + } + if (route.screenshot === 'settings-menus') { + await expectControlsWithinContainer( + page, + '.system-page-header', + '.system-page-actions > *', + '移动端菜单管理页头', + ) + } + }) + } +}) + +test('明暗主题、桌面侧栏和移动主导航可以实际切换', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await loginAsAdmin(page) + await expect(page.locator('#main-sidebar')).toBeVisible() + await expect(page.locator('.mobile-menu-button')).toBeHidden() + await expect(page.locator('.app-shell')).toHaveAttribute('data-theme', /light|dark/) + + const themeButton = page.locator('.theme-toggle') + if (await themeButton.count()) { + const initialTheme = await page.locator('.app-shell').getAttribute('data-theme') + await themeButton.click() + await expect(page.locator('.app-shell')).not.toHaveAttribute('data-theme', initialTheme || '') + await captureScreenshot(page, testInfo, 'desktop-theme-toggled') + await page.reload() + await expect(page.locator('.app-shell')).not.toHaveAttribute('data-theme', initialTheme || '') + await themeButton.click() + await expect(page.locator('.app-shell')).toHaveAttribute('data-theme', initialTheme || 'light') + } + await expectNoDocumentOverflow(page) + + await page.setViewportSize({ width: 390, height: 844 }) + await page.reload() + const mobileMenuButton = page.getByRole('button', { name: '打开主导航' }) + await expect(mobileMenuButton).toBeVisible() + await expect(page.locator('#main-sidebar')).toHaveAttribute('aria-hidden', 'true') + await mobileMenuButton.click() + await expect(page.locator('#main-sidebar')).toHaveClass(/mobile-open/) + await expect(page.locator('#main-sidebar')).not.toHaveAttribute('aria-hidden', 'true') + await expect(page.locator('.main-panel')).toHaveAttribute('inert', '') + await captureScreenshot(page, testInfo, 'mobile-navigation-open') + await page.keyboard.press('Escape') + await expect(page.locator('#main-sidebar')).toHaveAttribute('aria-hidden', 'true') + await expect(mobileMenuButton).toBeFocused() + await expectNoDocumentOverflow(page) +}) + +test('公开登录页和悬浮接入演示在桌面及移动视口可读', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1920, height: 1080 }) + await page.goto('/login') + await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible() + await expectNoDocumentOverflow(page) + await captureScreenshot(page, testInfo, 'desktop-public-login') + + await page.goto('/embed-demo') + await expect(page.getByRole('heading', { name: '数字人在其他系统里就是一个悬浮小图标', exact: true })).toBeVisible() + await expect(page.getByPlaceholder('请输入已发布智能体的 slug')).toBeVisible() + await expectNoDocumentOverflow(page) + await captureScreenshot(page, testInfo, 'desktop-embed-demo') + + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/login') + await expect(page.getByRole('heading', { name: '登录数字人平台', exact: true })).toBeVisible() + await expectNoDocumentOverflow(page) + await captureScreenshot(page, testInfo, 'mobile-public-login') + + await page.goto('/embed-demo') + await expect(page.getByRole('heading', { name: '数字人在其他系统里就是一个悬浮小图标', exact: true })).toBeVisible() + await expectNoDocumentOverflow(page) + await captureScreenshot(page, testInfo, 'mobile-embed-demo') +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2002c9f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +}