${html(project.name || `场景 ${project.id}`)}
${figure(coverOf(project), `场景 ${project.id} 保存后封面回读证据`, 'cover-image')}封面持久化与项目证据
${html(pretty(project))}import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Evidence is prepared by the real browser run. This script never changes it.
//
// Expected shape (all fields other than `evidence` are optional):
// {
// date, baseURL, browser, role,
// evidence: [{ title, status: 'PASS' | 'FAIL' | string, checks: [],
// screenshot: 'screenshots/example.png', screenshots: [{ path, caption }], details,
// superseded: true /* historical evidence excluded from current pass counts */ }],
// correction: { title, summary, previousConclusion, rootCauses: [], fixes: [],
// verification: [], screenshots: [{ path, caption }] },
// projects: [{ id, name, finalStatus, finalVersionId, modelId, modelVersionId,
// cover: { image, width, height, bytes, sizeBytes, assetId, sha256, storageUri } }],
// finalListScreenshot, issues, followUpIssues, commands, retainedData, cleanup
// }
// Image values must be report-relative PNG/JPEG/WebP paths. Sensitive fields are removed
// before rendering, so evidence may safely retain raw API responses locally.
const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/scene-live-20260905')
const sourcePath = path.join(reportDir, 'evidence.json')
const sensitiveKey = /password|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key/i
const cleanText = value => String(value ?? '')
.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
.replace(/\b((?:access|refresh)?[_-]?token|password|secret|uploadTicket|signature|api[_-]?key|X-Amz-(?:Signature|Credential|Security-Token))\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&}]+)/gi, '$1=[已隐藏]')
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[已隐藏]')
function sanitize(value) {
if (typeof value === 'string') return cleanText(value)
if (Array.isArray(value)) return value.map(sanitize)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value)
.filter(([key]) => !sensitiveKey.test(key))
.map(([key, item]) => [key, sanitize(item)]))
}
return value
}
const data = sanitize(JSON.parse(fs.readFileSync(sourcePath, 'utf8').replace(/^\uFEFF/, '')))
const asArray = value => Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]
const evidence = asArray(data.evidence)
const currentEvidence = evidence.filter(item => item.superseded !== true)
const historicalCount = evidence.length - currentEvidence.length
const correction = data.correction && typeof data.correction === 'object' ? data.correction : null
const projects = asArray(data.projects)
const commands = asArray(data.commands)
const allIssues = asArray(data.issues)
const issueText = issue => typeof issue === 'string' ? issue : JSON.stringify(issue ?? '')
const fsvcTicketFinding = /(?:\bfsvc\b[\s\S]{0,160}(?:upload[\s_-]*ticket|上传票据|票据)|(?:upload[\s_-]*ticket|上传票据|票据)[\s\S]{0,160}\bfsvc\b)/i
const isIndependent = issue => {
if (!issue) return false
if (typeof issue === 'object'
&& /^(?:existing|pre-existing|independent|follow-up)$/i.test(issue.scope || issue.category || '')) return true
const text = issueText(issue)
return fsvcTicketFinding.test(text)
|| /(?:既存|独立|后续).{0,30}票据|票据.{0,30}(?:既存|独立|后续)/.test(text)
}
const issues = allIssues.filter(issue => !isIndependent(issue))
const independentIssues = [...new Map([...allIssues.filter(isIndependent), ...asArray(data.followUpIssues)]
.map(issue => [JSON.stringify(issue), issue])).values()]
const passCount = currentEvidence.filter(item => item.status === 'PASS').length
const failCount = currentEvidence.filter(item => item.status === 'FAIL').length
const otherCount = currentEvidence.length - passCount - failCount
const html = value => cleanText(value).replace(/[&<>"']/g, char => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[char])
const md = value => html(value).replaceAll('\\', '\\\\').replace(/[|\[\]*_`]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
const url = value => String(value).replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/')
const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const recorded = value => value === undefined || value === null || value === '' ? '未记录' : value
const statusClass = value => value === 'PASS' ? 'pass' : value === 'FAIL' ? 'fail' : 'neutral'
const imageFields = new Set(['screenshot', 'screenshots', 'coverImage', 'image', 'finalListScreenshot'])
const imagePaths = new Set()
function imageRefs(value) {
return asArray(value).flatMap(item => {
if (!item) return []
if (typeof item === 'string') return [{ path: item, caption: '' }]
if (typeof item === 'object') {
const imagePath = item.path || item.src || item.image
if (typeof imagePath === 'string' && imagePath) return [{ path: imagePath, caption: item.caption || item.title || '' }]
}
throw new Error('图片引用应为相对路径字符串或 { path, caption } 对象。')
})
}
function collectImages(value) {
if (Array.isArray(value)) return value.forEach(collectImages)
if (!value || typeof value !== 'object') return
for (const [key, item] of Object.entries(value)) {
if (imageFields.has(key)) imageRefs(item).forEach(ref => imagePaths.add(ref.path))
else collectImages(item)
}
}
collectImages(data)
// Fail before writing either report when any referenced image is missing or unsafe.
const realReportDir = fs.realpathSync(reportDir)
for (const relative of imagePaths) {
if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
|| !/\.(png|jpe?g|webp)$/i.test(relative)) {
throw new Error(`图片必须是报告目录中的 PNG/JPEG/WebP 相对路径:${relative}`)
}
const absolute = path.resolve(reportDir, relative.replaceAll('\\', '/'))
const underRoot = path.relative(reportDir, absolute)
if (underRoot === '..' || underRoot.startsWith(`..${path.sep}`) || path.isAbsolute(underRoot)
|| !fs.existsSync(absolute) || !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
throw new Error(`图片不存在、为空或越出报告目录:${relative}`)
}
const realRelative = path.relative(realReportDir, fs.realpathSync(absolute))
if (realRelative === '..' || realRelative.startsWith(`..${path.sep}`) || path.isAbsolute(realRelative)) {
throw new Error(`图片链接目标越出报告目录:${relative}`)
}
}
function picturesOf(item) {
const refs = [item.screenshot, item.screenshots, item.coverImage].flatMap(imageRefs)
return refs.filter((ref, index) => refs.findIndex(other => other.path === ref.path) === index)
.map(ref => item.superseded === true
? { ...ref, caption: `历史截图(不作为修复后的视觉通过证据):${ref.caption || '原测试现场'}` }
: ref)
}
const coverOf = project => imageRefs(project.cover?.image || project.coverImage || project.cover?.screenshot)[0]
const finalList = imageRefs(data.finalListScreenshot)[0] || [...evidence].reverse()
.filter(item => /(?:最终|倒序|排序).*列表|列表.*(?:最终|倒序|排序)/.test(item.title || ''))
.flatMap(picturesOf)[0]
const figure = (ref, caption, className = '') => ref
? `
尚未记录本地图片。
' const mdImage = (ref, caption) => ref ? `[})](${url(ref.path)})` : '尚未记录本地图片。' const extraOf = item => Object.fromEntries(Object.entries(item) .filter(([key]) => !['title', 'status', 'checks', 'screenshot', 'screenshots', 'coverImage'].includes(key))) const checkText = check => typeof check === 'string' ? check : pretty(check) const evidenceStatus = item => item.superseded === true ? '历史记录(已更正)' : recorded(item.status) const correctionSections = correction ? [ ['原结论与漏验', asArray(correction.previousConclusion)], ['根因', asArray(correction.rootCauses)], ['修复内容', asArray(correction.fixes)], ['修复后验证', asArray(correction.verification)], ].filter(([, items]) => items.length) : [] const correctionPictures = correction ? picturesOf(correction) : [] const correctionHtml = correction ? `${html(correction.summary)}
` : ''}${correctionSections.map(([title, items]) => `下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。
${html(featureText)}
${html(environment)}
Markdown 报告${html(pretty(project))}尚未记录场景项目。
'}通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 用于展示实际断言,不重复累加。故障注入中的预期错误响应不等于用例失败;缺少状态时不推断通过。图片文件存在校验不等于视觉内容验证。
${html(pretty(extra))}尚未记录验证结果。
'}${html(retainedText)}| 场景 | 模型 / 发布版本 | 最终状态 | 当前版本 |
|---|---|---|---|
| ${html(project.id)} · ${html(project.name)} | ${html(recorded(project.modelId))} / ${html(recorded(project.modelVersionId))} | ${html(projectStatus(project))} | ${html(projectVersion(project))} |
${html(cleanupText)}${html(pretty(issue))}`).join('') : '当前证据未登记本次新增的未解决问题。
'}${html(independentText)}
${independentIssues.map(issue => `${html(pretty(issue))}`).join('')}${html(entry.result)}evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。
'}', html(pretty(project)), '', '', '
', html(pretty(extraOf(item))), '', '', '
', html(retainedText), '', '', '| 场景 | 模型 / 发布版本 | 最终状态 | 当前版本 |', '|---|---|---|---|', ...projects.map(project => `| ${md(project.id)} · ${md(project.name)} | ${md(recorded(project.modelId))} / ${md(recorded(project.modelVersionId))} | ${md(projectStatus(project))} | ${md(projectVersion(project))} |`), '', '
', html(cleanupText), '', '', '## 本次问题记录', '', ...(issues.length ? issues.flatMap(issue => ['
', html(pretty(issue)), '', '']) : ['当前证据未登记本次新增的未解决问题。', '']), '## 后续独立事项', '', independentText, '', ...independentIssues.flatMap(issue => ['
', html(pretty(issue)), '', '']), '## 实际命令与结果', '', ...(commands.length ? commands.flatMap(item => { const entry = commandParts(item); return [`**${md(entry.command)}**`, '', '
', html(entry.result), '', ''] }) : ['evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。', '']), '## 当前行为与限制', '', ...limitations.map(item => `- ${item}`), '', '报告来自 evidence.json,未修改原始证据;所有引用图片已校验存在且非空,点击可查看原图。通过数随证据更新。', '', ].join('\n') fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8') fs.writeFileSync(path.join(reportDir, 'report.md'), outputMarkdown, 'utf8') console.log(JSON.stringify({ report: 'reports/scene-live-20260905/index.html', passed: passCount, total: currentEvidence.length, historical: historicalCount, failed: failCount, other: otherCount, imagesValidated: imagePaths.size, projectsRecorded: projects.length, commandsRecorded: commands.length }))