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}`)