Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 

204 righe
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 lines = [
  85. '# AI Person 全量 E2E 测试报告', '',
  86. `- 批次:\`${runId}\``,
  87. `- 生成时间:${now}`,
  88. `- 目标地址:\`${process.env.BASE_URL || 'http://127.0.0.1:8003'}\``,
  89. '- 浏览器:Playwright Chromium(单 worker,真实 API/数据库;不使用业务 Mock)',
  90. '- 凭据:从本机《账号.MD》运行时读取,未写入报告、截图或版本库', '',
  91. '## 结果汇总', '',
  92. '| 总数 | 通过 | 失败 | 跳过 | 超时/中断 | 截图 |',
  93. '|---:|---:|---:|---:|---:|---:|',
  94. `| ${rows.length} | ${counts.passed || 0} | ${counts.failed || 0} | ${counts.skipped || 0} | ${(counts.timedOut || 0) + (counts.interrupted || 0)} | ${screenshots.length} |`, '',
  95. '## 用例明细', '',
  96. '| # | 状态 | 用例 | 耗时 |', '|---:|---|---|---:|',
  97. ]
  98. rows.forEach((row, index) => lines.push(`| ${index + 1} | ${row.status} | ${redact(row.title).replace(/\|/g, '\\|')} | ${(row.duration / 1000).toFixed(2)}s |`))
  99. if (demoSeedSummary) {
  100. const createdTotal = Number(demoSeedSummary.createdTotal ?? 0)
  101. const existingTotal = Object.values(demoSeedSummary.existing || {}).reduce((sum, value) => sum + Number(value || 0), 0)
  102. const restoredTotal = Object.values(demoSeedSummary.restored || {}).reduce((sum, value) => sum + Number(value || 0), 0)
  103. lines.push('', '## 持久演示数据', '',
  104. `- Seed:\`${redact(demoSeedSummary.seedId || 'unknown')}\``,
  105. `- 本批新建 ${createdTotal} 条,复用 ${existingTotal} 条,恢复 ${restoredTotal} 条;数据来自正式数据库,不属于 \`APE2E\` 临时资源。`,
  106. '- 运行摘要:[`demo-seed-summary.json`](demo-seed-summary.json)', '')
  107. }
  108. if (nonEmptyRows.length) {
  109. lines.push('', '## 非空页面验收', '',
  110. '成功截图只会在对应页面的真实行/卡片、配置值及 manifest 演示标记全部通过后生成。', '',
  111. '| # | 路由 | 数据集 | 数量 | 演示标记 |',
  112. '|---:|---|---|---:|---|')
  113. let index = 0
  114. for (const item of nonEmptyRows) {
  115. for (const dataset of item.datasets) {
  116. index += 1
  117. lines.push(`| ${index} | ${redact(item.route)} | ${redact(dataset.label)} | ${Number(dataset.count || 0)} | ${redact(dataset.matchedMarker || item.seedId || '真实配置数据').replace(/\|/g, '\\|')} |`)
  118. }
  119. for (const value of item.values) {
  120. index += 1
  121. lines.push(`| ${index} | ${redact(item.route)} | ${redact(value.label)} | ${Number(value.valueLength || 0)} | 已加载配置值 |`)
  122. }
  123. }
  124. lines.push('')
  125. }
  126. if (activityRows.length) {
  127. const activitySummary = Object.entries(activityCounts)
  128. .sort(([left], [right]) => left.localeCompare(right))
  129. .map(([key, value]) => `${key} ${value}`)
  130. .join('、')
  131. lines.push('', '## 真实数据提交明细', '',
  132. `共记录 ${activityRows.length} 个真实 API/数据库步骤:${activitySummary || '无'}。`, '',
  133. '| # | 模块 | 操作 | 结果 | HTTP | 说明 |',
  134. '|---:|---|---|---|---:|---|')
  135. 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)} |`))
  136. }
  137. lines.push('', '## 数据提交与清理约定', '',
  138. '- 测试创建数据统一使用 `APE2E-<runId>` 标记;写入后会重新读取或刷新页面确认持久化。',
  139. '- 可逆全局配置先快照、测试后恢复;资源按依赖逆序清理。',
  140. '- 外部数字人、ASR/TTS、FFmpeg、第三方知识或真实终端若环境不满足,会明确标记 skipped,不以 Mock 代替通过。',
  141. '- 密码、访问令牌、API Key、终端密钥和一次性临时密码不得出现在截图或附件中。', '')
  142. if (verification) {
  143. const safety = verification.artifactSafety || {}
  144. const sweep = verification.resourceSweep || {}
  145. lines.push('## 执行后验收', '',
  146. `- 结论:**${verification.passed ? 'PASS' : 'FAIL'}**`,
  147. `- 制品安全扫描:文本文件 ${safety.scannedTextFiles ?? 0} 个;真实密码命中 ${safety.passwordLiteralOccurrences ?? 0};Bearer/JWT 候选 ${(safety.bearerCandidates ?? 0) + (safety.jwtCandidates ?? 0)};临时密码候选 ${safety.generatedPasswordCandidates ?? 0};trace/video ${safety.traceOrVideoFiles ?? 0}。`,
  148. `- 数据残留扫描:正式 API 端点 ${sweep.checkedEndpoints ?? 0} 个;异常或残留端点 ${sweep.failedOrResidualEndpoints ?? 0};\`APE2E\` 标记命中 ${sweep.totalMarkerOccurrences ?? 0}。`, '')
  149. const endpointFailures = Array.isArray(sweep.endpoints)
  150. ? sweep.endpoints.filter((item) => item.status !== 200 || item.markerOccurrences !== 0)
  151. : []
  152. if (endpointFailures.length) {
  153. lines.push('### 后验收异常', '')
  154. for (const item of endpointFailures) {
  155. lines.push(`- \`${redact(item.endpoint)}\`:HTTP ${redact(item.status)},标记命中 ${redact(item.markerOccurrences)}。`)
  156. }
  157. lines.push('')
  158. }
  159. }
  160. const cleanupFailures = activityRows.filter((item) => String(item.result).toUpperCase() === 'CLEANUP_FAILED')
  161. if (cleanupFailures.length) {
  162. lines.push('## 数据清理告警', '',
  163. `发现 ${cleanupFailures.length} 个清理失败步骤;本批次不得视为“无测试数据残留”。`, '')
  164. for (const item of cleanupFailures) {
  165. lines.push(`- **${redact(item.module)} / ${redact(item.action)}**:${redact(item.detail).replace(/\r?\n/g, ' ').slice(0, 500)}`)
  166. }
  167. lines.push('')
  168. }
  169. if (screenshots.length) {
  170. lines.push('## 截图索引', '')
  171. for (const name of screenshots) lines.push(`- [${name}](screenshots/${encodeURIComponent(name)})`)
  172. lines.push('')
  173. }
  174. const failed = rows.filter((row) => !['passed', 'skipped'].includes(row.status))
  175. if (failed.length || parseError) {
  176. lines.push('## 失败与诊断', '')
  177. if (parseError) lines.push(`- 结果文件读取失败:${parseError}`)
  178. for (const row of failed) {
  179. lines.push(`### ${redact(row.title)}`, '')
  180. const message = row.errors.map((item) => redact(item.message || item.stack || item)).join('\n').slice(0, 4000)
  181. lines.push('```text', message || `status=${row.status}`, '```', '')
  182. }
  183. }
  184. lines.push('## 制品', '',
  185. '- HTML 报告:[`html-report/index.html`](html-report/index.html)',
  186. '- JSON 结果:[`results.json`](results.json)',
  187. ...(demoSeedSummary ? ['- 持久演示数据摘要:[`demo-seed-summary.json`](demo-seed-summary.json)'] : []),
  188. '- 后验收结果:[`verification.json`](verification.json)',
  189. '- 原始失败附件:[`test-results`](test-results/)',
  190. '- 安全说明:真实凭据用例全局关闭 trace/video,避免保存密码、Token 或一次性密钥快照。', '')
  191. fs.mkdirSync(runDirectory, { recursive: true })
  192. fs.writeFileSync(reportFile, `${lines.join('\n')}\n`, 'utf8')
  193. console.log(`Markdown report: ${reportFile}`)