"""Build the portable report from completed test evidence, without credentials.""" import html import json import re import zipfile from pathlib import Path import markdown from markdown.extensions.toc import slugify_unicode ROOT = Path(__file__).resolve().parents[1] REPORT = ROOT / 'reports/review-20260905' def main(): live = json.loads((REPORT / 'live/results.json').read_text(encoding='utf-8'))['stats'] front = json.loads((REPORT / 'frontend-e2e/results.json').read_text(encoding='utf-8'))['stats'] cleanup = json.loads((REPORT / 'cleanup.json').read_text(encoding='utf-8')) if any(value['unexpected'] or value['skipped'] or value['flaky'] for value in (live, front)): raise RuntimeError('Final browser evidence is not fully passing') if not all(cleanup['originalRowsUnchanged'].values()) or cleanup['remainingActiveTestUsers']: raise RuntimeError('Cleanup evidence is not passing') source = (REPORT / 'review-report.md').read_text(encoding='utf-8') source = source.replace('状态:最终浏览器复核中', '状态:审查与验证完成,4 项功能差异和 1 项素材选择待确认') summary = f'''| 验证 | 最终结果 | 证据与范围 | |---|---|---| | 后端全量测试 | 402/402 通过,0 失败、错误、跳过 | Gateway 1、Auth 86、Tran 315;含新增迟到事件回归 | | 后端构建 | 通过 | Java 17 / Maven;三个服务打包完成 | | 前端 Node 回归 | 25/25 通过 | 原 24 项加视频资产、候选与抓拍合同 | | 前端构建 | 通过 | vue-tsc、Vite、旧模型 iframe 校验 | | 真实服务 E2E(Edge) | {live['expected']}/4 通过,0 失败/跳过/重试 | {live['duration']/1000:.1f} 秒;开始时间 {live['startTime']}(UTC) | | 浏览器模拟接口回归 | {front['expected']}/15 通过,0 失败/跳过/重试 | {front['duration']/1000:.1f} 秒;编辑保存、状态组合、军工绿/暗色主题 | | 数据库迁移 | 通过 | 033、035;首次、重跑和指定中断点恢复;开发库检查值均为 0 | | 测试脚本类型检查、Git 空白检查 | 通过 | 两份专用 Playwright 测试与配置;API/Web/E2E 仓库 | 最终通过结果来自各自一次完整运行,未启用自动重试。补入三次完整场景加载与截图后,真实虚拟用例曾超过原 120 秒测试总预算,断言和评定已完成但截图收尾超时;保留 [该轮记录](diagnostics/live-resource-wait-timeout.json),随后将该用例预算调到 240 秒,并在其他浏览器回归结束后重新完整运行。单次资源就绪仍有 60 秒上限。 实装视频新增检查还发现本机 Playwright 自带 Chromium 不支持 H.264(`canPlayType` 返回空字符串);[失败轮次](diagnostics/live-chromium-h264-unsupported.json)与[Edge 播放探针](diagnostics/video-probe-msedge.json)保留。最终真实 E2E 使用已安装 Microsoft Edge,模拟接口回归继续使用 Chromium。另独立修复了实际素材与候选配置不匹配的 WEB-06;最终用例验证默认视频可播、片长覆盖末尾候选,并在结尾得到 12 条候选。测试开发阶段的权限夹具、请求拦截范围与 UI 选择器错误已修正,不计为产品缺陷。''' source = source.replace('', summary) cleared = ', '.join(f"{table.removeprefix('ut_teaching_')} {count}" for table, count in cleanup['deleted'].items() if count) source = source.replace('', f"最终清理时间:`{cleanup['time']}`。删除本轮临时任务及关联记录({cleared}),测试账号已停用/软删除,会话已撤销,临时管理员密码文件已移除。四张原始教学表的既有行逐行对照均未变化,剩余活动测试账号为 0。审计与软删除记录按系统机制保留。见 [最终清理记录](cleanup.json);较早轮次见 `diagnostics/cleanup-*.json`。") titles = { '01-login-three-roles': '真实服务:登录仅展示教员、学员、管理员', '02-virtual-formal-task-center': '真实服务:正式虚拟任务中心', '03-virtual-1920': '真实服务:1920 宽度任务中心', '04-teacher-content': '真实服务:教员训练编排列表已加载', '05-virtual-started-no-exam-overlay': '真实服务:开始训练、模型就绪,无错误考核遮罩', '06-virtual-all-steps-completed': '真实服务:8/8 步骤完成,真实模型已加载', '07-virtual-reviewed': '真实服务:提交评定后刷新', '08-physical-api-task-list': '真实服务:实装任务清单', '09-physical-offline-replay-boundary': '真实页面:离线视频就绪,并标明不推进正式进度、不计分', '10-physical-twelve-candidates': '真实页面:匹配视频到达末尾,12 条离线候选可达、等待复核', '01-saved-step-move': '模拟接口:移动已保存步骤后保持正确顺序', '01-saved-step-delete': '模拟接口:删除步骤后末步进度 100%', '01-saved-step-duplicate': '模拟接口:复制步骤后重新编号', '02-multichannel-virtual-save': '模拟接口:虚拟渠道配置保存与重开', '03-multichannel-confrontation-save': '模拟接口:对抗渠道配置保存与重开', '05-failed-before-retry': '模拟接口:失败步骤出现重试入口', '06-failed-after-retry': '模拟接口:重试后尝试 2 次、失败 1 次', '07-no-retry-SUBMITTED': '模拟接口:已提交不出现重试按钮', '07-no-retry-TERMINATED': '模拟接口:已终止不出现重试按钮', '08-visual-safety-military': '模拟接口:军工绿主题安全区,对比度 12.00', '08-visual-safety-dark': '模拟接口:暗色主题安全区,对比度 15.30', } gallery = [] count = 0 for folder, caption in [('live', '真实服务与页面'), ('frontend-e2e', '模拟接口浏览器回归')]: gallery.append(f'

{caption}

') source = source.replace('', f'共 {count} 张最终 E2E 截图。\n\n' + '\n'.join(gallery)) (REPORT / 'review-report.md').write_text(source, encoding='utf-8') style = '''*{box-sizing:border-box}body{margin:0;background:#edf2f7;color:#203248;font:16px/1.75 "Microsoft YaHei","Segoe UI",sans-serif}header{background:#0c2b47;color:white;padding:22px max(24px,calc((100vw - 1160px)/2))}header a{color:#c1e6ff;margin-right:24px}main{max-width:1200px;margin:24px auto 60px;padding:36px;background:white;border-radius:12px;box-shadow:0 6px 24px #1028400c}h1{font-size:30px;line-height:1.4;margin-top:0}h2{font-size:22px;margin:36px 0 14px;border-bottom:2px solid #dbe8f2;padding-bottom:8px}h3{font-size:18px}p{margin:13px 0}a{color:#126fba;text-decoration:none}a:hover{text-decoration:underline}table{border-collapse:collapse;width:100%;margin:18px 0;font-size:14px;table-layout:auto}th,td{padding:11px 13px;border:1px solid #dce5ed;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#edf5fc}tr:nth-child(even){background:#f9fbfd}code{font:13px/1.5 Consolas,monospace;background:#edf2f7;padding:2px 5px;border-radius:4px;overflow-wrap:anywhere}pre{padding:18px;overflow:auto;background:#edf2f7;border-radius:8px}pre code{padding:0}img{max-width:100%;height:auto}details{padding:12px;background:#f7f9fc;margin:12px 0}.gallery{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}figure{margin:0;border:1px solid #dce5ed;border-radius:8px;overflow:hidden;background:#f8fafc}figure img{display:block;width:100%;height:300px;object-fit:contain;background:#e8eef3}figcaption{padding:12px;font-size:14px;color:#37526b}footer{color:#657c91;font-size:13px;margin-top:36px}li{margin:8px 0}@media(max-width:760px){main{margin:0;padding:20px;border-radius:0}.gallery{grid-template-columns:1fr}table{display:block;overflow:auto}h1{font-size:25px}header{padding:16px}}@media print{body{background:white}main{margin:0;box-shadow:none;max-width:none}header{display:none}figure{break-inside:avoid}h2{break-after:avoid}}''' for item in REPORT.glob('*.md'): content = item.read_text(encoding='utf-8') content = re.sub(r'\(([^()]+)\.md(#[^()]*)?\)', lambda m: '(' + m[1] + '.html' + (m[2] or '') + ')' if (REPORT / (m[1] + '.md')).exists() else m[0], content) content = content.replace('
', '
') body = markdown.markdown(content, extensions=['markdown.extensions.tables', 'markdown.extensions.fenced_code', 'markdown.extensions.md_in_html', 'markdown.extensions.toc'], extension_configs={'markdown.extensions.toc': {'slugify': slugify_unicode}}) document = f'{html.escape(item.stem)} · Code Review
审查总报告真实 E2E模拟接口回归
{body}
' item.with_suffix('.html').write_text(document, encoding='utf-8') if item.name == 'review-report.md': (REPORT / 'index.html').write_text(document, encoding='utf-8') archive = REPORT.parent / 'review-20260905.zip' with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as zipped: for item in REPORT.rglob('*'): if not item.is_file() or item.name.startswith('failure-') or 'test-results' in item.parts or 'results' in item.parts: continue zipped.write(item, item.relative_to(REPORT.parent).as_posix()) print(json.dumps({'screenshots': count, 'live': live, 'frontend': front, 'archiveBytes': archive.stat().st_size}, ensure_ascii=False)) if __name__ == '__main__': main()