${html(source.title)} ${badge('PENDING')}
等待 ${html(source.file)}。未计为通过。
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Browser runs own the evidence files. This generator only writes index.html
// and report.md. Missing evidence remains pending, never an inferred pass.
// Each JSON may use { status, title, checks/evidence/results/tests: [...],
// projects: [...], commands: [...], limitations: [...] }. Unknown shapes remain
// available as sanitized JSON details rather than being silently discarded.
const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/training-live-20260905')
fs.mkdirSync(reportDir, { recursive: true })
const array = value => Array.isArray(value) ? value : value == null ? [] : [value]
const sensitiveKey = /password|passwd|secret|token|ticket|authorization|cookie|signature|credential|api[_-]?key|username|loginName|accountName|headers|storageState/i
const cleanText = value => String(value ?? '')
.replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [已隐藏]')
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[已隐藏]@')
.replace(/\b((?:access|refresh)?[_-]?token|password|passwd|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 html = value => cleanText(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
const md = value => cleanText(value).replace(/[|\[\]*_`<>]/g, char => `\\${char}`).replace(/\r?\n/g, ' ')
const pretty = value => typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const recorded = value => value === undefined || value === null || value === '' ? '待最终证据' : value
const status = value => {
const normalized = String(value ?? '').toUpperCase()
return normalized === 'PASS' || normalized === 'PASSED' ? 'PASS'
: normalized === 'FAIL' || normalized === 'FAILED' ? 'FAIL' : 'PENDING'
}
const statusLabel = value => ({ PASS: '通过', FAIL: '失败', PENDING: '待补充证据' })[status(value)]
const statusClass = value => status(value).toLowerCase()
const sourceDefinitions = [
{ file: 'final-evidence.json', title: '真实编排、保存、发布及重新打开' },
{ file: 'readonly-evidence.json', title: '独立浏览器只读重开与真实画面验证' },
{ file: 'error-evidence.json', title: '失败、冲突与并发处理' },
]
const sources = sourceDefinitions.map(definition => {
const absolute = path.join(reportDir, definition.file)
const data = fs.existsSync(absolute)
? sanitize(JSON.parse(fs.readFileSync(absolute, 'utf8').replace(/^\uFEFF/, ''))) : null
const checks = data ? array(data.checks || data.evidence || data.results || data.tests) : []
const assessed = checks.filter(item => item && item.superseded !== true && item.historical !== true)
const sourceStatus = assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL'
: status(data?.status) !== 'PENDING' ? status(data.status)
: assessed.length && assessed.every(item => status(item.status) === 'PASS') ? 'PASS'
: assessed.some(item => status(item.status) === 'FAIL') ? 'FAIL' : 'PENDING'
return { ...definition, data, checks, status: sourceStatus }
})
const final = sources[0].data || {}
const projects = array(final.projects || final.retainedProjects || final.trainingProjects)
const reopenedProjects = array(sources[1].data?.projects)
const versionMismatches = projects.flatMap(project => {
const expected = project.publishedVersionId || project.finalVersionId || project.currentVersionId
if (!expected || !sources[1].data) return []
const reopened = reopenedProjects.find(item => String(item.id || item.projectId) === String(project.id || project.projectId))
const actual = reopened?.publishedVersionId || reopened?.currentVersionId
return String(expected) === String(actual) ? [] : [{ projectId: project.id || project.projectId, expected, actual: actual || '未记录' }]
})
if (versionMismatches.length && sources[1].status !== 'FAIL') sources[1].status = 'PENDING'
const allChecks = sources.flatMap(source => source.checks)
.filter(item => item && item.superseded !== true && item.historical !== true)
const passedChecks = allChecks.filter(item => status(item.status) === 'PASS').length
const failedChecks = allChecks.filter(item => status(item.status) === 'FAIL').length
const pendingChecks = allChecks.length - passedChecks - failedChecks
const overall = sources.some(source => source.status === 'FAIL') || failedChecks ? 'FAIL'
: sources.every(source => source.status === 'PASS') && !pendingChecks ? 'PASS' : 'PENDING'
const commands = sources.flatMap(source => array(source.data?.commands))
const limitations = [...new Map(sources.flatMap(source => array(source.data?.limitations || source.data?.followUpIssues))
.map(item => [pretty(item), item])).values()]
const generatedAt = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })
const history = [
{ path: 'screenshots/01-before-scene-catalog.png', caption: '过程记录:初建训练仍显示默认液压模板,仅记录起点,不作为故障对照或最终通过证据。' },
{ path: 'screenshots/02-published-scenes.png', caption: '过程记录:打开已发布场景目录。最终版本和固定依赖以最终证据为准。' },
{ path: 'screenshots/03-truck-bound.png', caption: '过程记录:首次绑定卡车时仍有 8 个旧模板步骤;不代表最终两步编排通过。' },
{ path: 'screenshots/04-truck-saved.png', caption: '过程记录:卡车训练阶段性保存;最终步骤、状态、版本和封面以最终证据为准。' },
].filter(item => fs.existsSync(path.join(reportDir, item.path)))
const historyPaths = new Set(history.map(item => item.path))
const screenshotPattern = /^screenshots\/[\w\W]+\.(?:png|jpe?g|webp)$/i
const imagePaths = new Set()
function imageRefs(value, inheritedCaption = '') {
if (!value) return []
if (typeof value === 'string') return screenshotPattern.test(value.replaceAll('\\', '/'))
? [{ path: value.replaceAll('\\', '/'), caption: inheritedCaption }] : []
if (Array.isArray(value)) return value.flatMap(item => imageRefs(item, inheritedCaption))
if (typeof value !== 'object') return []
const caption = value.caption || value.title || value.label || value.name || inheritedCaption
return Object.entries(value).flatMap(([key, item]) => imageRefs(item, caption || key))
}
function validateImage(relative) {
const absolute = path.resolve(reportDir, relative)
const relativeToRoot = path.relative(reportDir, absolute)
if (path.isAbsolute(relative) || /^[a-z][a-z\d+.-]*:/i.test(relative)
|| relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)
|| !screenshotPattern.test(relative) || !fs.existsSync(absolute)
|| !fs.statSync(absolute).isFile() || fs.statSync(absolute).size === 0) {
throw new Error(`截图引用不存在、为空或不在报告目录内:${relative}`)
}
const realRelative = path.relative(fs.realpathSync(reportDir), fs.realpathSync(absolute))
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) throw new Error(`截图链接越出报告目录:${relative}`)
imagePaths.add(relative)
}
const uniqueImages = refs => [...new Map(refs.map(ref => [ref.path, ref])).values()]
const evidenceImages = uniqueImages(sources.flatMap(source => imageRefs(source.data)))
evidenceImages.forEach(ref => validateImage(ref.path))
history.forEach(ref => validateImage(ref.path))
const finalImages = evidenceImages.filter(ref => !historyPaths.has(ref.path))
const imageUrl = value => value.split('/').map(encodeURIComponent).join('/')
const figure = ref => `${html(title)}
${html(pretty(value))}
| ${html(value)} | `).join('')}
|---|
| ${html(value)} | `).join('')}
已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json,不以阶段性保存结果代替最终记录。
' const mdProjects = projects.length ? `| ${projectHeaders.map(md).join(' | ')} |\n| ${projectHeaders.map(() => '---').join(' | ')} |\n${projects.map(project => `| ${projectRow(project).map(md).join(' | ')} |`).join('\n')}` : '已开始创建训练 161(狐狸)与 162(卡车);最终状态、版本、封面与场景依赖等待 final-evidence.json。' function sourceHtml(source) { if (!source.data) return `等待 ${html(source.file)}。未计为通过。
历史记录,不计入当前通过数。
' : ''}${details(check)}${html(pretty(source.data))}\n\n${html(command.details || command.result || '')}
狐狸与牛奶卡车 · 场景 → 任务步骤 → 保存与发布
2026-09-05 · ${html(final.baseURL || 'http://127.0.0.1:6180')} · ${html(final.browser || 'Microsoft Edge / Playwright')} · ${html(final.role || '教员编排与独立浏览器只读重开')}
${html(summary)}
报告草稿:最终证据尚不完整。已有过程截图和开发记录不能代替保存、发布及重新打开的最终验证。
' : ''}保留两条真实训练供后续查看;本报告生成器不创建、修改或删除业务数据。
${html(summary)}
过程检查记录执行当时的版本;最终数据以上方保留项目及同版本独立重开记录为准。
${versionMismatches.length ? `独立重开证据尚未覆盖最终发布版本,不能标记完整通过。
${details(versionMismatches, '待重验的版本')}` : ''}${sources.map(sourceHtml).join('')}${commands.length ? `命令与最终构建结果等待证据记录。
'}等待最终证据引用实际截图。尚无最终截图不等于模型已通过可见性检查。
'}这些截图只记录测试中途状态;最终两步、总分、版本和封面以上方证据为准。
${html(pretty(versionMismatches))}\n` : ''}
${sources.map(sourceMd).join('\n\n')}
${commands.length ? `### 执行命令与验证结果\n\n${mdCommands}` : '命令与最终构建结果等待证据记录。'}
## 最终及异常验证截图
${finalImages.length ? finalImages.map(mdFigure).join('\n\n') : '等待最终证据引用实际截图;尚未将模型可见性计为通过。'}
## 过程截图 · 不计入最终通过结论
${history.map(mdFigure).join('\n\n')}
## 范围、限制与功能确认结论
${mdBullets(scopeItems)}
${limitations.length ? `### 实测限制与后续项\n\n${mdBullets(limitations.map(pretty))}\n` : ''}
报告生成:${generatedAt}(Asia/Shanghai)。
`
fs.writeFileSync(path.join(reportDir, 'index.html'), outputHtml, 'utf8')
fs.writeFileSync(path.join(reportDir, 'report.md'), outputMd, 'utf8')
console.log(JSON.stringify({ report: 'reports/training-live-20260905/index.html', status: overall, sources: sources.map(({ file, status: state }) => ({ file, status: state })), checks: { pass: passedChecks, fail: failedChecks, pending: pendingChecks }, images: imagePaths.size }))