|
- 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 <redacted>')
- .replace(/(?:access|refresh|session|api)?token["'=: ]+[A-Za-z0-9._~-]+/gi, 'token=<redacted>')
- .replace(/password["'=: ]+[^\s,;}]+/gi, 'password=<redacted>')
- .replace(/eyJ[A-Za-z0-9._~-]+/g, '<jwt-redacted>')
-
- 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 browserLabel = process.env.E2E_BROWSER_CHANNEL?.trim()
- ? `Playwright ${process.env.E2E_BROWSER_CHANNEL.trim()}`
- : 'Playwright Chromium'
- const lines = [
- '# AI Person 全量 E2E 测试报告', '',
- `- 批次:\`${runId}\``,
- `- 生成时间:${now}`,
- `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``,
- `- 浏览器:${browserLabel}(单 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-<runId>` 标记;写入后会重新读取或刷新页面确认持久化。',
- '- 可逆全局配置先快照、测试后恢复;资源按依赖逆序清理。',
- '- 外部数字人、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}`)
|