import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const root = path.join(repo, 'reports/api-error-messages-20260906') const result = JSON.parse(fs.readFileSync(path.join(root, 'results.json'), 'utf8')) const validationPath = path.join(root, 'validation.json') const validation = fs.existsSync(validationPath) ? JSON.parse(fs.readFileSync(validationPath, 'utf8')) : null const authenticationPath = path.join(root, 'authentication/summary.json') const authentication = fs.existsSync(authenticationPath) ? JSON.parse(fs.readFileSync(authenticationPath, 'utf8')) : null const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]) const decode = attachment => { if (!attachment?.body) return null try { return JSON.parse(Buffer.from(attachment.body, 'base64').toString('utf8')) } catch { return null } } const specs = [] function visit(suite) { specs.push(...(suite.specs || [])) for (const child of suite.suites || []) visit(child) } for (const suite of result.suites || []) visit(suite) const checks = specs.flatMap(spec => (spec.tests || []).map(test => { const run = test.results?.at(-1) || {} const screenshots = (run.attachments || []).filter(item => item.contentType === 'image/png' && fs.existsSync(path.join(root, 'screenshots', `${item.name}.png`))) .map(item => ({ title: item.name, path: `screenshots/${item.name}.png` })) const isolation = decode((run.attachments || []).find(item => item.name === 'API 隔离与浏览器错误')) const http = decode((run.attachments || []).find(item => item.name === 'HTTP 合同证据')) const httpBoundaries = decode((run.attachments || []).find(item => item.name === '取消请求与业务原文证据')) return { title: spec.title, status: run.status === 'passed' ? 'PASS' : String(run.status || 'NOT_RUN').toUpperCase(), durationMs: run.duration || 0, screenshots, isolation, ...(http ? { http } : {}), ...(httpBoundaries ? { httpBoundaries } : {}), } })) const passed = checks.filter(item => item.status === 'PASS').length const failed = checks.length - passed const screenshotCount = checks.reduce((sum, item) => sum + item.screenshots.length, 0) const requestCount = checks.reduce((sum, item) => sum + (item.isolation?.requests.length || 0), 0) const pageErrorCount = checks.reduce((sum, item) => sum + (item.isolation?.pageErrors.length || 0), 0) for (const check of checks) { if (check.status === 'PASS' && !check.isolation) throw new Error(`缺少隔离证据:${check.title}`) } const authenticationShots = authentication?.checks?.flatMap(check => check.screenshots || []) || [] for (const shot of authenticationShots) if (!fs.existsSync(path.join(root, shot.path))) throw new Error(`认证报告缺图:${shot.path}`) const stages = [ ['资料', '按本轮“接口错误使用 Message,不保留常驻错误横条”的要求检查。沿用已确认的教学业务边界:离线识别回放、观察模式、步骤反馈和表单校验仍需可见。'], ['原型', '错误发生时顶部短暂提示;可以手动关闭,约 4.5 秒自动消失。同一错误合并展示;失败后仍可重新加载,已成功取得的数据不因后续刷新失败消失。'], ['设计', '共享 Message 处理负责统一展示与缓存页面生命周期;请求层保留业务错误码、HTTP 状态和 requestId,静默请求不会自行弹窗。未取得数据与空结果保持区分。'], ['开发', '代表性覆盖内容制作、系统用户与教学实施,包括训练编辑器加载失败的重试入口。接口异常详情仅在 Message 中展示;表单必填错误、项目编码说明和离线运行模式继续保留。'], ['测试', '使用真实 Vue / Element Plus 页面、Edge 无头浏览器和浏览器级 API mock。覆盖本机与局域网入口。身份及接口数据均为合成测试数据;不登录真实账号、不写业务库,不将本轮测试称为真实服务端或设备联调。'], ] const limitations = [ '这是接口失败展示的聚焦浏览器回归,未逐一遍历所有业务页面。', '页面渲染、Element Plus Message、缓存路由和 Axios 超时均运行于真实浏览器;服务端响应由测试模拟。', '模型 iframe 项为真实宿主桥接的 DOM 合同测试,使用空画布夹具,不代表完整 GLB 模型加载测试。', '保留训练业务状态、表单校验与重试入口。未扩大教学流程、权限或接口合同。', ] const evidence = { title: '接口错误 Message 回归报告', status: failed ? 'FAILED' : authentication?.counts?.legacyBlocked ? 'PASS_WITH_LEGACY_BLOCKERS' : 'PASS', generatedAt: new Date().toISOString(), startedAt: result.stats?.startTime, durationMs: result.stats?.duration, passed, total: checks.length, screenshotCount, requestCount, pageErrorCount, validation, authentication, stages: stages.map(([title, text]) => ({ title, text })), checks, limitations, reproduction: 'pnpm exec playwright test -c api-error-messages.config.ts', } fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2) + '\n', 'utf8') const validationText = validation ? validation.commands.map(item => `${item.command}:${item.status}。${item.details}`).join('\n\n') : '构建与源代码审查由主任务单独记录;本报告统计浏览器回归结果。' const validationHtml = validation ? `

构建与聚焦逻辑验证

${validation.commands.map(item => `

${escape(item.status)} · ${escape(item.scope)}
${escape(item.command)}
${escape(item.details)}${item.log ? ` 日志` : ''}

`).join('')}
` : '' const checkHtml = checks.map((check, index) => `
${escape(check.status)}

${index + 1}. ${escape(check.title)}

${(check.durationMs / 1000).toFixed(1)} 秒
${check.screenshots.map(shot => `
${escape(shot.title)}
${escape(shot.title)}
`).join('')}
`).join('') const authenticationHtml = authentication ? `

认证附加回归(独立统计)

${escape(authentication.result)}

${escape(authentication.environment)}

${authentication.limitations.map(text => `

${escape(text)}

`).join('')}
${authentication.checks.map(check => `
${escape(check.status)}

${escape(check.title)}

${escape(check.result)}

${check.rawStatus ? `

Playwright 原始状态:${escape(check.rawStatus)}。

` : ''}
${check.screenshots.map(shot => `
${escape(shot.title)}
${escape(shot.title)}
`).join('')}
`).join('')}` : '' fs.writeFileSync(path.join(root, 'index.html'), ` 接口错误 Message 回归报告

接口错误 Message 回归报告

资料 → 原型 → 设计 → 开发 → 测试

${failed ? '主回归存在未通过项,详见下方结果。' : '主回归:代表性接口错误按统一 Message 展示;自动消失、重试与业务状态保留已通过浏览器验证。'}

${authentication ? `

认证附加:${authentication.counts.passed} 项通过,${authentication.counts.legacyBlocked} 项旧改密入口受当前路由契约阻断;原始失败保留,未计为通过。

` : ''}
主回归 ${passed} / ${checks.length} 通过主回归 ${screenshotCount} 张截图${requestCount} 次模拟 API 请求${pageErrorCount} 个未捕获页面异常

审查与实施

${stages.map(([title, text]) => `
${title}

${escape(text)}

`).join('')}

浏览器验证(主回归)

${checkHtml}${authenticationHtml}${validationHtml}

范围与复现

${limitations.map(text => `

${escape(text)}

`).join('')}

${escape(evidence.reproduction)}

报告重新生成:node tools/build-api-error-message-report.mjs

`, 'utf8') const md = [ '# 接口错误 Message 回归报告', '', `主回归:**${passed}/${checks.length} 通过**。${screenshotCount} 张截图,${requestCount} 次模拟 API 请求,${pageErrorCount} 个未捕获页面异常。`, '', ...(authentication ? [`认证附加:${authentication.counts.passed} 项通过,${authentication.counts.legacyBlocked} 项旧改密入口受当前路由契约阻断。原始失败保留,未计为通过。总状态:${evidence.status}。`, ''] : []), ...stages.flatMap(([title, text]) => [`## ${title}`, '', text, '']), '## 浏览器结果', '', ...checks.flatMap((check, index) => [`### ${index + 1}. ${check.title}`, '', `状态:${check.status}。耗时 ${(check.durationMs / 1000).toFixed(1)} 秒。`, '', ...check.screenshots.flatMap(shot => [`![${shot.title}](${shot.path})`, ''])]), ...(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)', ''] : []), '## 范围与复现', '', ...limitations.map(text => `- ${text}`), '', validationText, '', '```powershell', evidence.reproduction, 'node tools/build-api-error-message-report.mjs', '```', '', `测试开始:${evidence.startedAt}。报告生成:${evidence.generatedAt}。`, '', ] fs.writeFileSync(path.join(root, 'report.md'), md.join('\n'), 'utf8') console.log(JSON.stringify({ status: evidence.status, passed, total: checks.length, screenshotCount, requestCount, pageErrorCount }))