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)}`).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)}`).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]) => `
`).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 => [``, ''])]),
...(authentication ? ['## 认证附加回归(独立统计)', '', authentication.result, '', ...authentication.checks.flatMap(check => [`### ${check.title}`, '', `状态:${check.status}${check.rawStatus ? `;Playwright 原始状态:${check.rawStatus}` : ''}。${check.result}`, '', ...check.screenshots.flatMap(shot => [``, ''])]), ...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 }))