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 ? `
${html(ref.caption || caption)}
${html(ref.caption || caption)} · 点击查看原图
` : '

尚未记录本地图片。

' const mdImage = (ref, caption) => ref ? `[![${md(ref.caption || caption)}](${url(ref.path)})](${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.title || '狐狸场景验证更正')}

${correction.summary ? `

${html(correction.summary)}

` : ''}${correctionSections.map(([title, items]) => `

${html(title)}

`).join('')}
${correctionPictures.map(ref => figure(ref, '修复后视觉验证')).join('')}

下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。

` : '' const correctionMarkdown = correction ? [ `## ${md(correction.title || '狐狸场景验证更正')}`, '', ...(correction.summary ? [`> **测试结论更正:** ${md(correction.summary)}`, ''] : []), ...correctionSections.flatMap(([title, items]) => [`### ${md(title)}`, '', ...items.map(item => `- ${md(checkText(item))}`), '']), ...correctionPictures.flatMap(ref => [mdImage(ref, '修复后视觉验证'), '']), '下方标为“历史记录(已更正)”的检查及截图保留用于追溯,不计入当前通过率,也不作为狐狸已经可见的证据。', '', ] : [] const projectStatus = project => recorded(project.finalStatus || project.status) const projectVersion = project => recorded(project.finalVersionId || project.currentVersionId || project.cover?.versionId) const commandParts = item => typeof item === 'string' ? { command: item, result: '未记录执行结果' } : { command: item.command || item.cmd || item.name || '命令未记录', result: pretty(Object.fromEntries(Object.entries(item).filter(([key]) => !['command', 'cmd', 'name'].includes(key)))) } const featureText = '场景保存截图封面及列表按创建时间倒序的功能已实施。本报告记录使用现有模型创建真实场景、保存当前三维视口封面及异常回退的实际验证结果。' const environment = `${recorded(data.date)} · ${recorded(data.baseURL)} · ${recorded(data.browser)} · ${recorded(data.role)}` const retainedText = data.retainedData ? pretty(data.retainedData) : projects.length ? `本次创建的 ${projects.length} 条场景保留在开发环境:${projects.map(project => `${project.id}(${project.name || '未命名'})`).join('、')}。模型引用固定到下表记录的已发布版本。` : '尚未记录保留场景。' const cleanupText = data.cleanup ? pretty(data.cleanup) : '未记录额外清理操作;报告不据此宣称测试文件或历史版本已删除。' const limitations = [ '手动保存从当前三维视口生成封面;新封面与场景内容、模型引用在同一次工程更新中关联保存。发布前的保存沿用该流程。', '截图或封面上传失败时保留旧封面;工程是否保存成功以实际更新请求为准。版本冲突不覆盖其他已保存的内容。', '自动恢复及重新打开工程不生成或上传新封面;恢复后的修改需由用户保存后更新封面。', '列表按创建时间倒序展示。再次保存不会改变创建时间,也不应因此改变创建时间排序。', ...asArray(data.limitations).map(checkText), ] const independentText = '既存受控文件资产的 fsvc URI 分支未完整覆盖上传票据绑定校验,属于本次场景封面变更前已存在的问题。本次保留现有流程,后续应独立补齐校验并验证历史资产、版本复制与引用兼容性;不计入本次场景功能失败数。' const countText = `当前证据项通过 ${passCount} / ${currentEvidence.length};失败 ${failCount};其他或未标注 ${otherCount};已更正历史记录 ${historicalCount};已校验图片文件 ${imagePaths.size}。` const outputHtml = ` 真实场景与截图封面验证报告 · ${html(data.date)}
SCENE LIVE · 功能已实施

真实场景与截图封面验证报告

${html(featureText)}

${html(environment)}

Markdown 报告
${correctionHtml}
${passCount} / ${currentEvidence.length}当前证据项通过
${failCount}失败
${otherCount}其他或未标注
${historicalCount ? `
${historicalCount}已更正历史记录
` : ''}
${imagePaths.size}图片文件已校验

保留场景与封面证据

${projects.map(project => { const cover = project.cover || {} return `

${html(project.name || `场景 ${project.id}`)}

${figure(coverOf(project), `场景 ${project.id} 保存后封面回读证据`, 'cover-image')}
场景 / 状态
${html(project.id)} / ${html(projectStatus(project))}
当前工程版本
${html(projectVersion(project))}
引用模型 / 版本
${html(recorded(project.modelId))} / ${html(recorded(project.modelVersionId))}
封面大小
${html(recorded(cover.sizeBytes ?? cover.bytes))} 字节${cover.width && cover.height ? ` · ${html(cover.width)} × ${html(cover.height)}` : ''}
封面持久化与项目证据
${html(pretty(project))}
` }).join('') || '

尚未记录场景项目。

'}

最终列表与创建时间倒序

${figure(finalList, '保留场景的实际封面与创建时间倒序列表')}

逐项验证记录

通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 用于展示实际断言,不重复累加。故障注入中的预期错误响应不等于用例失败;缺少状态时不推断通过。图片文件存在校验不等于视觉内容验证。

${evidence.map((item, index) => { const pictures = picturesOf(item) const extra = extraOf(item) return `

${index + 1}. ${html(item.title || '未命名检查')}

${html(evidenceStatus(item))}
${asArray(item.checks).length ? `
    ${asArray(item.checks).map(check => `
  • ${html(checkText(check))}
  • `).join('')}
` : ''}${Object.keys(extra).length ? `
检查详情
${html(pretty(extra))}
` : ''}
${pictures.map(ref => figure(ref, /\.jpe?g$/i.test(ref.path) ? '实际生成的场景封面' : '浏览器现场')).join('')}
` }).join('') || '

尚未记录验证结果。

'}

数据保留与清理

${html(retainedText)}
${projects.map(project => ``).join('')}
场景模型 / 发布版本最终状态当前版本
${html(project.id)} · ${html(project.name)}${html(recorded(project.modelId))} / ${html(recorded(project.modelVersionId))}${html(projectStatus(project))}${html(projectVersion(project))}
${html(cleanupText)}

本次问题记录

${issues.length ? issues.map(issue => `
${html(pretty(issue))}
`).join('') : '

当前证据未登记本次新增的未解决问题。

'}

后续独立事项

${html(independentText)}

${independentIssues.map(issue => `
${html(pretty(issue))}
`).join('')}

实际命令与结果

${commands.length ? commands.map(item => { const entry = commandParts(item); return `

${html(entry.command)}

${html(entry.result)}
` }).join('') : '

evidence.json 尚未记录命令结果;不据此宣称构建或命令行测试通过。

'}

当前行为与限制

` const outputMarkdown = [ '# 真实场景与截图封面验证报告', '', featureText, '', md(environment), '', ...correctionMarkdown, countText, '', '## 保留场景与封面证据', '', ...projects.flatMap(project => [ `### ${md(project.name || `场景 ${project.id}`)}`, '', `场景 ${md(project.id)};状态 ${md(projectStatus(project))};当前版本 ${md(projectVersion(project))};引用模型 ${md(recorded(project.modelId))} / 发布版本 ${md(recorded(project.modelVersionId))}。`, '', mdImage(coverOf(project), `场景 ${project.id} 保存后封面回读证据`), '', '
封面持久化与项目证据', '', '
', html(pretty(project)), '
', '', '
', '', ]), '## 最终列表与创建时间倒序', '', mdImage(finalList, '保留场景的封面与创建时间倒序列表'), '', '## 逐项验证记录', '', '通过数仅统计未被更正且 status 明确为 PASS 的当前证据项;历史记录单独标注。checks 不重复累加。预期故障响应不等于用例失败,缺少状态不推断通过。图片文件存在校验不等于视觉内容验证。', '', '| 序号 | 检查项 | 结果 | 图片 |', '|---|---|---|---|', ...evidence.map((item, index) => `| ${index + 1} | ${md(item.title || '未命名检查')} | ${md(evidenceStatus(item))} | ${picturesOf(item).map((ref, i) => `[图片 ${i + 1}](${url(ref.path)})`).join(' · ') || '—'} |`), '', ...evidence.flatMap((item, index) => [ `### ${index + 1}. ${md(item.title || '未命名检查')} · ${md(evidenceStatus(item))}`, '', ...asArray(item.checks).map(check => `- ${md(checkText(check))}`), '', ...(Object.keys(extraOf(item)).length ? ['
检查详情', '', '
', html(pretty(extraOf(item))), '
', '', '
', ''] : []), ...picturesOf(item).flatMap(ref => [mdImage(ref, '验证现场'), '']), ]), '## 数据保留与清理', '', '
', 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 }))