Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

89 linhas
13 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
  5. const root = path.join(repo, 'reports/api-error-messages-20260906')
  6. const result = JSON.parse(fs.readFileSync(path.join(root, 'results.json'), 'utf8'))
  7. const validationPath = path.join(root, 'validation.json')
  8. const validation = fs.existsSync(validationPath) ? JSON.parse(fs.readFileSync(validationPath, 'utf8')) : null
  9. const authenticationPath = path.join(root, 'authentication/summary.json')
  10. const authentication = fs.existsSync(authenticationPath) ? JSON.parse(fs.readFileSync(authenticationPath, 'utf8')) : null
  11. const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
  12. const decode = attachment => {
  13. if (!attachment?.body) return null
  14. try { return JSON.parse(Buffer.from(attachment.body, 'base64').toString('utf8')) } catch { return null }
  15. }
  16. const specs = []
  17. function visit(suite) {
  18. specs.push(...(suite.specs || []))
  19. for (const child of suite.suites || []) visit(child)
  20. }
  21. for (const suite of result.suites || []) visit(suite)
  22. const checks = specs.flatMap(spec => (spec.tests || []).map(test => {
  23. const run = test.results?.at(-1) || {}
  24. const screenshots = (run.attachments || []).filter(item => item.contentType === 'image/png' && fs.existsSync(path.join(root, 'screenshots', `${item.name}.png`)))
  25. .map(item => ({ title: item.name, path: `screenshots/${item.name}.png` }))
  26. const isolation = decode((run.attachments || []).find(item => item.name === 'API 隔离与浏览器错误'))
  27. const http = decode((run.attachments || []).find(item => item.name === 'HTTP 合同证据'))
  28. const httpBoundaries = decode((run.attachments || []).find(item => item.name === '取消请求与业务原文证据'))
  29. return {
  30. title: spec.title, status: run.status === 'passed' ? 'PASS' : String(run.status || 'NOT_RUN').toUpperCase(),
  31. durationMs: run.duration || 0, screenshots, isolation, ...(http ? { http } : {}), ...(httpBoundaries ? { httpBoundaries } : {}),
  32. }
  33. }))
  34. const passed = checks.filter(item => item.status === 'PASS').length
  35. const failed = checks.length - passed
  36. const screenshotCount = checks.reduce((sum, item) => sum + item.screenshots.length, 0)
  37. const requestCount = checks.reduce((sum, item) => sum + (item.isolation?.requests.length || 0), 0)
  38. const pageErrorCount = checks.reduce((sum, item) => sum + (item.isolation?.pageErrors.length || 0), 0)
  39. for (const check of checks) {
  40. if (check.status === 'PASS' && !check.isolation) throw new Error(`缺少隔离证据:${check.title}`)
  41. }
  42. const authenticationShots = authentication?.checks?.flatMap(check => check.screenshots || []) || []
  43. for (const shot of authenticationShots) if (!fs.existsSync(path.join(root, shot.path))) throw new Error(`认证报告缺图:${shot.path}`)
  44. const stages = [
  45. ['资料', '按本轮“接口错误使用 Message,不保留常驻错误横条”的要求检查。沿用已确认的教学业务边界:离线识别回放、观察模式、步骤反馈和表单校验仍需可见。'],
  46. ['原型', '错误发生时顶部短暂提示;可以手动关闭,约 4.5 秒自动消失。同一错误合并展示;失败后仍可重新加载,已成功取得的数据不因后续刷新失败消失。'],
  47. ['设计', '共享 Message 处理负责统一展示与缓存页面生命周期;请求层保留业务错误码、HTTP 状态和 requestId,静默请求不会自行弹窗。未取得数据与空结果保持区分。'],
  48. ['开发', '代表性覆盖内容制作、系统用户与教学实施,包括训练编辑器加载失败的重试入口。接口异常详情仅在 Message 中展示;表单必填错误、项目编码说明和离线运行模式继续保留。'],
  49. ['测试', '使用真实 Vue / Element Plus 页面、Edge 无头浏览器和浏览器级 API mock。覆盖本机与局域网入口。身份及接口数据均为合成测试数据;不登录真实账号、不写业务库,不将本轮测试称为真实服务端或设备联调。'],
  50. ]
  51. const limitations = [
  52. '这是接口失败展示的聚焦浏览器回归,未逐一遍历所有业务页面。',
  53. '页面渲染、Element Plus Message、缓存路由和 Axios 超时均运行于真实浏览器;服务端响应由测试模拟。',
  54. '模型 iframe 项为真实宿主桥接的 DOM 合同测试,使用空画布夹具,不代表完整 GLB 模型加载测试。',
  55. '保留训练业务状态、表单校验与重试入口。未扩大教学流程、权限或接口合同。',
  56. ]
  57. const evidence = {
  58. title: '接口错误 Message 回归报告', status: failed ? 'FAILED' : authentication?.counts?.legacyBlocked ? 'PASS_WITH_LEGACY_BLOCKERS' : 'PASS',
  59. generatedAt: new Date().toISOString(), startedAt: result.stats?.startTime,
  60. durationMs: result.stats?.duration, passed, total: checks.length, screenshotCount, requestCount, pageErrorCount,
  61. validation, authentication, stages: stages.map(([title, text]) => ({ title, text })), checks, limitations,
  62. reproduction: 'pnpm exec playwright test -c api-error-messages.config.ts',
  63. }
  64. fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n', 'utf8')
  65. const validationText = validation ? validation.commands.map(item => `${item.command}:${item.status}。${item.details}`).join('\n\n') : '构建与源代码审查由主任务单独记录;本报告统计浏览器回归结果。'
  66. const validationHtml = validation ? `<h2>构建与聚焦逻辑验证</h2><section class="scope">${validation.commands.map(item => `<p><strong>${escape(item.status)} · ${escape(item.scope)}</strong><br><code>${escape(item.command)}</code><br>${escape(item.details)}${item.log ? ` <a href="${escape(item.log)}">日志</a>` : ''}</p>`).join('')}</section>` : ''
  67. const checkHtml = checks.map((check, index) => `<section class="check"><div class="check-title"><span class="badge ${check.status === 'PASS' ? 'pass' : 'fail'}">${escape(check.status)}</span><h3>${index + 1}. ${escape(check.title)}</h3><small>${(check.durationMs / 1000).toFixed(1)} 秒</small></div><div class="shots">${check.screenshots.map(shot => `<figure><a href="${escape(shot.path)}" target="_blank" rel="noopener"><img src="${escape(shot.path)}" alt="${escape(shot.title)}"></a><figcaption>${escape(shot.title)}</figcaption></figure>`).join('')}</div></section>`).join('')
  68. const authenticationHtml = authentication ? `<h2>认证附加回归(独立统计)</h2><section class="scope"><p><strong>${escape(authentication.result)}</strong></p><p>${escape(authentication.environment)}</p>${authentication.limitations.map(text => `<p>${escape(text)}</p>`).join('')}<div class="links"><a href="authentication/summary.json">认证汇总证据</a>${authentication.runs.map((run, index) => `<a href="${escape(run.html)}">第 ${index + 1} 轮原始报告</a>`).join('')}</div></section>${authentication.checks.map(check => `<section class="check"><div class="check-title"><span class="badge ${check.status === 'PASS' ? 'pass' : 'fail'}">${escape(check.status)}</span><h3>${escape(check.title)}</h3></div><p>${escape(check.result)}</p>${check.rawStatus ? `<p>Playwright 原始状态:${escape(check.rawStatus)}。</p>` : ''}<div class="shots">${check.screenshots.map(shot => `<figure><a href="${escape(shot.path)}" target="_blank" rel="noopener"><img src="${escape(shot.path)}" alt="${escape(shot.title)}"></a><figcaption>${escape(shot.title)}</figcaption></figure>`).join('')}</div></section>`).join('')}` : ''
  69. fs.writeFileSync(path.join(root, 'index.html'), `<!doctype html>
  70. <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>接口错误 Message 回归报告</title>
  71. <style>*{box-sizing:border-box}body{margin:0;background:#f3f6f7;color:#183739;font-family:"Microsoft YaHei",system-ui,sans-serif;line-height:1.65}main{max-width:1220px;margin:auto;padding:28px 24px 60px}header{padding:30px;border-radius:18px;background:#084d46;color:white}h1{margin:0 0 10px;font-size:28px}h2{font-size:20px;margin:28px 0 12px}h3{font-size:16px;margin:0;flex:1;min-width:0}p{margin:10px 0}.muted{color:#c9e8e1}.stats{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}.stats span{border:1px solid #67958c;border-radius:9px;padding:8px 14px;background:#ffffff0b}.links{display:flex;gap:18px;flex-wrap:wrap;margin:14px 0}a{color:#126f62}.stages{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}.stage,.check,.scope{background:white;border:1px solid #dce7e4;border-radius:12px;padding:18px}.stage strong{color:#147160}.stage p{font-size:14px;color:#45615f}.check{margin:14px 0}.check-title{display:flex;align-items:flex-start;gap:12px}.badge{font-size:12px;border-radius:6px;padding:3px 8px;white-space:nowrap}.pass{background:#e6f5ed;color:#197344}.fail{background:#ffebeb;color:#a72e2e}small{color:#69817e;white-space:nowrap}.shots{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-top:16px}figure{margin:0;min-width:0}img{display:block;width:100%;height:auto;border:1px solid #dce7e4;border-radius:7px}figcaption{font-size:12px;color:#617b76;overflow-wrap:anywhere;margin-top:5px}.scope p{color:#45615f}code{overflow-wrap:anywhere;font-size:13px}footer{margin-top:24px;color:#69817e;font-size:12px;overflow-wrap:anywhere}@media(max-width:900px){.stages{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){main{padding:14px 12px 35px}header{padding:20px}h1{font-size:23px}.stages,.shots{grid-template-columns:1fr}.check-title{flex-wrap:wrap}.check-title h3{flex-basis:75%}.check-title small{margin-left:60px}.check{padding:13px}}
  72. </style></head><body><main><header><h1>接口错误 Message 回归报告</h1><p class="muted">资料 → 原型 → 设计 → 开发 → 测试</p><p>${failed ? '主回归存在未通过项,详见下方结果。' : '主回归:代表性接口错误按统一 Message 展示;自动消失、重试与业务状态保留已通过浏览器验证。'}</p>${authentication ? `<p>认证附加:${authentication.counts.passed} 项通过,${authentication.counts.legacyBlocked} 项旧改密入口受当前路由契约阻断;原始失败保留,未计为通过。</p>` : ''}<div class="stats"><span>主回归 ${passed} / ${checks.length} 通过</span><span>主回归 ${screenshotCount} 张截图</span><span>${requestCount} 次模拟 API 请求</span><span>${pageErrorCount} 个未捕获页面异常</span></div></header>
  73. <div class="links"><a href="report.md">Markdown 报告</a><a href="evidence.json">结构化证据</a><a href="playwright/index.html">Playwright 报告</a></div><h2>审查与实施</h2><div class="stages">${stages.map(([title, text]) => `<section class="stage"><strong>${title}</strong><p>${escape(text)}</p></section>`).join('')}</div><h2>浏览器验证(主回归)</h2>${checkHtml}${authenticationHtml}${validationHtml}<h2>范围与复现</h2><section class="scope">${limitations.map(text => `<p>${escape(text)}</p>`).join('')}<p><code>${escape(evidence.reproduction)}</code></p><p>报告重新生成:<code>node tools/build-api-error-message-report.mjs</code></p></section><footer>测试开始:${escape(evidence.startedAt)} · 报告生成:${escape(evidence.generatedAt)}</footer></main></body></html>`, 'utf8')
  74. const md = [
  75. '# 接口错误 Message 回归报告', '', `主回归:**${passed}/${checks.length} 通过**。${screenshotCount} 张截图,${requestCount} 次模拟 API 请求,${pageErrorCount} 个未捕获页面异常。`, '',
  76. ...(authentication ? [`认证附加:${authentication.counts.passed} 项通过,${authentication.counts.legacyBlocked} 项旧改密入口受当前路由契约阻断。原始失败保留,未计为通过。总状态:${evidence.status}。`, ''] : []),
  77. ...stages.flatMap(([title, text]) => [`## ${title}`, '', text, '']),
  78. '## 浏览器结果', '',
  79. ...checks.flatMap((check, index) => [`### ${index + 1}. ${check.title}`, '', `状态:${check.status}。耗时 ${(check.durationMs / 1000).toFixed(1)} 秒。`, '', ...check.screenshots.flatMap(shot => [`![${shot.title}](${shot.path})`, ''])]),
  80. ...(authentication ? ['## 认证附加回归(独立统计)', '', authentication.result, '', ...authentication.checks.flatMap(check => [`### ${check.title}`, '', `状态:${check.status}${check.rawStatus ? `;Playwright 原始状态:${check.rawStatus}` : ''}。${check.result}`, '', ...check.screenshots.flatMap(shot => [`![${shot.title}](${shot.path})`, ''])]), ...authentication.limitations.map(text => `- ${text}`), '', '[认证汇总证据](authentication/summary.json)', ''] : []),
  81. '## 范围与复现', '', ...limitations.map(text => `- ${text}`), '', validationText, '',
  82. '```powershell', evidence.reproduction, 'node tools/build-api-error-message-report.mjs', '```', '',
  83. `测试开始:${evidence.startedAt}。报告生成:${evidence.generatedAt}。`, '',
  84. ]
  85. fs.writeFileSync(path.join(root, 'report.md'), md.join('\n'), 'utf8')
  86. console.log(JSON.stringify({ status: evidence.status, passed, total: checks.length, screenshotCount, requestCount, pageErrorCount }))