選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 

207 行
11 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. const runId = (process.env.APE2E_RUN_ID || 'manual').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 80) || 'manual'
  4. const runDirectory = path.resolve('artifacts', 'runs', runId)
  5. const resultsFile = path.join(runDirectory, 'results.json')
  6. const verificationFile = path.join(runDirectory, 'verification.json')
  7. const demoSeedSummaryFile = path.join(runDirectory, 'demo-seed-summary.json')
  8. const reportFile = path.join(runDirectory, '全量E2E测试报告.md')
  9. const redact = (value) => String(value ?? '')
  10. .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, '')
  11. .replace(/Bearer\s+[A-Za-z0-9._~-]+/gi, 'Bearer <redacted>')
  12. .replace(/(?:access|refresh|session|api)?token["'=: ]+[A-Za-z0-9._~-]+/gi, 'token=<redacted>')
  13. .replace(/password["'=: ]+[^\s,;}]+/gi, 'password=<redacted>')
  14. .replace(/eyJ[A-Za-z0-9._~-]+/g, '<jwt-redacted>')
  15. const collectSpecs = (suites, ancestors = []) => {
  16. const rows = []
  17. for (const suite of suites || []) {
  18. const next = [...ancestors, suite.title].filter(Boolean)
  19. for (const spec of suite.specs || []) {
  20. for (const test of spec.tests || []) {
  21. const results = test.results || []
  22. const last = results.at(-1) || {}
  23. const status = last.status || test.status || 'unknown'
  24. const attachments = results.flatMap((result) => result.attachments || [])
  25. 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 || []) })
  26. }
  27. }
  28. rows.push(...collectSpecs(suite.suites, next))
  29. }
  30. return rows
  31. }
  32. const decodeJsonAttachment = (attachment) => {
  33. if (attachment?.contentType !== 'application/json' || !attachment.body) return null
  34. try { return JSON.parse(Buffer.from(attachment.body, 'base64').toString('utf8')) } catch { return null }
  35. }
  36. let payload = { suites: [], stats: {} }
  37. let parseError = ''
  38. try { payload = JSON.parse(fs.readFileSync(resultsFile, 'utf8')) } catch (error) { parseError = redact(error) }
  39. let verification = null
  40. try { verification = JSON.parse(fs.readFileSync(verificationFile, 'utf8')) } catch { verification = null }
  41. let demoSeedSummary = null
  42. try { demoSeedSummary = JSON.parse(fs.readFileSync(demoSeedSummaryFile, 'utf8')) } catch { demoSeedSummary = null }
  43. const rows = collectSpecs(payload.suites)
  44. const counts = { passed: 0, failed: 0, skipped: 0, timedOut: 0, interrupted: 0, unknown: 0 }
  45. for (const row of rows) counts[row.status] = (counts[row.status] || 0) + 1
  46. const activityRows = []
  47. const nonEmptyRows = []
  48. for (const row of rows) {
  49. for (const attachment of row.attachments) {
  50. const decoded = decodeJsonAttachment(attachment)
  51. if (!decoded) continue
  52. if (Array.isArray(decoded.activities)) {
  53. for (const activity of decoded.activities) {
  54. activityRows.push({
  55. test: row.title,
  56. module: activity.module || decoded.title || attachment.name,
  57. action: activity.action || '未命名步骤',
  58. result: activity.result || 'UNKNOWN',
  59. status: activity.httpStatus ?? '',
  60. detail: activity.detail || '',
  61. })
  62. }
  63. }
  64. if (decoded.route && Array.isArray(decoded.datasets)) {
  65. nonEmptyRows.push({
  66. test: row.title,
  67. route: decoded.route,
  68. seedId: decoded.seedId || '',
  69. datasets: decoded.datasets,
  70. values: Array.isArray(decoded.values) ? decoded.values : [],
  71. })
  72. }
  73. }
  74. }
  75. const activityCounts = activityRows.reduce((result, item) => {
  76. const key = String(item.result).toUpperCase()
  77. result[key] = (result[key] || 0) + 1
  78. return result
  79. }, {})
  80. const screenshots = fs.existsSync(path.join(runDirectory, 'screenshots'))
  81. ? fs.readdirSync(path.join(runDirectory, 'screenshots')).filter((name) => name.endsWith('.png')).sort()
  82. : []
  83. const now = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full', timeStyle: 'long', timeZone: 'Asia/Shanghai' }).format(new Date())
  84. const browserLabel = process.env.E2E_BROWSER_CHANNEL?.trim()
  85. ? `Playwright ${process.env.E2E_BROWSER_CHANNEL.trim()}`
  86. : 'Playwright Chromium'
  87. const lines = [
  88. '# AI Person 全量 E2E 测试报告', '',
  89. `- 批次:\`${runId}\``,
  90. `- 生成时间:${now}`,
  91. `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``,
  92. `- 浏览器:${browserLabel}(单 worker,真实 API/数据库;不使用业务 Mock)`,
  93. '- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '',
  94. '## 结果汇总', '',
  95. '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |',
  96. '|---:|---:|---:|---:|---:|---:|',
  97. `| ${rows.length} | ${counts.passed || 0} | ${counts.failed || 0} | ${counts.skipped || 0} | ${(counts.timedOut || 0) + (counts.interrupted || 0)} | ${screenshots.length} |`, '',
  98. '## 用例明细', '',
  99. '| # | 状态 | 用例 | 耗时 |', '|---:|---|---|---:|',
  100. ]
  101. rows.forEach((row, index) => lines.push(`| ${index + 1} | ${row.status} | ${redact(row.title).replace(/\|/g, '\\|')} | ${(row.duration / 1000).toFixed(2)}s |`))
  102. if (demoSeedSummary) {
  103. const createdTotal = Number(demoSeedSummary.createdTotal ?? 0)
  104. const existingTotal = Object.values(demoSeedSummary.existing || {}).reduce((sum, value) => sum + Number(value || 0), 0)
  105. const restoredTotal = Object.values(demoSeedSummary.restored || {}).reduce((sum, value) => sum + Number(value || 0), 0)
  106. lines.push('', '## 持久演示数据', '',
  107. `- Seed:\`${redact(demoSeedSummary.seedId || 'unknown')}\``,
  108. `- 本批新建 ${createdTotal} 条,复用 ${existingTotal} 条,恢复 ${restoredTotal} 条;数据来自正式数据库,不属于 \`APE2E\` 临时资源。`,
  109. '- 运行摘要:[`demo-seed-summary.json`](demo-seed-summary.json)', '')
  110. }
  111. if (nonEmptyRows.length) {
  112. lines.push('', '## 非空页面验收', '',
  113. '成功截图只会在对应页面的真实行/卡片、配置值及 manifest 演示标记全部通过后生成。', '',
  114. '| # | 路由 | 数据集 | 数量 | 演示标记 |',
  115. '|---:|---|---|---:|---|')
  116. let index = 0
  117. for (const item of nonEmptyRows) {
  118. for (const dataset of item.datasets) {
  119. index += 1
  120. lines.push(`| ${index} | ${redact(item.route)} | ${redact(dataset.label)} | ${Number(dataset.count || 0)} | ${redact(dataset.matchedMarker || item.seedId || '真实配置数据').replace(/\|/g, '\\|')} |`)
  121. }
  122. for (const value of item.values) {
  123. index += 1
  124. lines.push(`| ${index} | ${redact(item.route)} | ${redact(value.label)} | ${Number(value.valueLength || 0)} | 已加载配置值 |`)
  125. }
  126. }
  127. lines.push('')
  128. }
  129. if (activityRows.length) {
  130. const activitySummary = Object.entries(activityCounts)
  131. .sort(([left], [right]) => left.localeCompare(right))
  132. .map(([key, value]) => `${key} ${value}`)
  133. .join('、')
  134. lines.push('', '## 真实数据提交明细', '',
  135. `共记录 ${activityRows.length} 个真实 API/数据库步骤:${activitySummary || '无'}。`, '',
  136. '| # | 模块 | 操作 | 结果 | HTTP | 说明 |',
  137. '|---:|---|---|---|---:|---|')
  138. 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)} |`))
  139. }
  140. lines.push('', '## 数据提交与清理约定', '',
  141. '- 测试创建数据统一使用 `APE2E-<runId>` 标记;写入后会重新读取或刷新页面确认持久化。',
  142. '- 可逆全局配置先快照、测试后恢复;资源按依赖逆序清理。',
  143. '- 外部数字人、ASR/TTS、FFmpeg、第三方知识或真实终端若环境不满足,会明确标记 skipped,不以 Mock 代替通过。',
  144. '- 密码、访问令牌、API Key、终端密钥和一次性临时密码不得出现在截图或附件中。', '')
  145. if (verification) {
  146. const safety = verification.artifactSafety || {}
  147. const sweep = verification.resourceSweep || {}
  148. lines.push('## 执行后验收', '',
  149. `- 结论:**${verification.passed ? 'PASS' : 'FAIL'}**`,
  150. `- 制品安全扫描:文本文件 ${safety.scannedTextFiles ?? 0} 个;真实密码命中 ${safety.passwordLiteralOccurrences ?? 0};Bearer/JWT 候选 ${(safety.bearerCandidates ?? 0) + (safety.jwtCandidates ?? 0)};临时密码候选 ${safety.generatedPasswordCandidates ?? 0};trace/video ${safety.traceOrVideoFiles ?? 0}。`,
  151. `- 数据残留扫描:正式 API 端点 ${sweep.checkedEndpoints ?? 0} 个;异常或残留端点 ${sweep.failedOrResidualEndpoints ?? 0};\`APE2E\` 标记命中 ${sweep.totalMarkerOccurrences ?? 0}。`, '')
  152. const endpointFailures = Array.isArray(sweep.endpoints)
  153. ? sweep.endpoints.filter((item) => item.status !== 200 || item.markerOccurrences !== 0)
  154. : []
  155. if (endpointFailures.length) {
  156. lines.push('### 后验收异常', '')
  157. for (const item of endpointFailures) {
  158. lines.push(`- \`${redact(item.endpoint)}\`:HTTP ${redact(item.status)},标记命中 ${redact(item.markerOccurrences)}。`)
  159. }
  160. lines.push('')
  161. }
  162. }
  163. const cleanupFailures = activityRows.filter((item) => String(item.result).toUpperCase() === 'CLEANUP_FAILED')
  164. if (cleanupFailures.length) {
  165. lines.push('## 数据清理告警', '',
  166. `发现 ${cleanupFailures.length} 个清理失败步骤;本批次不得视为“无测试数据残留”。`, '')
  167. for (const item of cleanupFailures) {
  168. lines.push(`- **${redact(item.module)} / ${redact(item.action)}**:${redact(item.detail).replace(/\r?\n/g, ' ').slice(0, 500)}`)
  169. }
  170. lines.push('')
  171. }
  172. if (screenshots.length) {
  173. lines.push('## 截图索引', '')
  174. for (const name of screenshots) lines.push(`- [${name}](screenshots/${encodeURIComponent(name)})`)
  175. lines.push('')
  176. }
  177. const failed = rows.filter((row) => !['passed', 'skipped'].includes(row.status))
  178. if (failed.length || parseError) {
  179. lines.push('## 失败与诊断', '')
  180. if (parseError) lines.push(`- 结果文件读取失败:${parseError}`)
  181. for (const row of failed) {
  182. lines.push(`### ${redact(row.title)}`, '')
  183. const message = row.errors.map((item) => redact(item.message || item.stack || item)).join('\n').slice(0, 4000)
  184. lines.push('```text', message || `status=${row.status}`, '```', '')
  185. }
  186. }
  187. lines.push('## 制品', '',
  188. '- HTML 报告:[`html-report/index.html`](html-report/index.html)',
  189. '- JSON 结果:[`results.json`](results.json)',
  190. ...(demoSeedSummary ? ['- 持久演示数据摘要:[`demo-seed-summary.json`](demo-seed-summary.json)'] : []),
  191. '- 后验收结果:[`verification.json`](verification.json)',
  192. '- 原始失败附件:[`test-results`](test-results/)',
  193. '- 安全说明:真实凭据用例全局关闭 trace/video,避免保存密码、Token 或一次性密钥快照。', '')
  194. fs.mkdirSync(runDirectory, { recursive: true })
  195. fs.writeFileSync(reportFile, `${lines.join('\n')}\n`, 'utf8')
  196. console.log(`Markdown report: ${reportFile}`)