Midscene 报告生成管线源码分析¶
分析版本:Midscene 1.10.5,基线提交
cfc7c100分析日期:2026-07-16 范围:Core 任务快照、截图持久化、增量 HTML 报告、Report App 数据恢复,以及 Playground 中的报告返回
1. 结论摘要¶
Midscene 报告不是“任务结束后把日志一次性渲染成页面”,而是一条持续更新的增量管线:
TaskRunner的任务状态变化触发 snapshot change。Agent调用runner.dump()得到当前完整ExecutionDump。ReportGenerator把截图先持久化,再把 execution JSON 作为<script type="midscene_web_dump">追加到报告 HTML。- 同一个 execution 会被追加多次;Report App 按 execution id 去重,只保留最后一个快照。
- 报告页面本身是预先构建的 React App,构建后被注入
@midscene/core,运行时不需要另外部署后端。 - 页面打开后扫描 DOM 中的 dump/image script tag,恢复截图引用,再渲染侧栏、时间线、任务详情和 Player 回放。
默认 single-html 模式可以生成一个可独立打开的 HTML;html-and-external-assets 模式把截图放到同级 screenshots/,更适合大报告和 HTTP 托管。
2. 端到端流程¶
flowchart TD
Action["AI / actionSpace 执行动作"]
Runner["TaskRunner 更新 task 状态和 recorder"]
Signal["onSnapshotChange"]
Dump["runner.dump() -> ExecutionDump"]
Agent["Agent appendExecutionDump + usage"]
Generator["ReportGenerator.onExecutionUpdate"]
Queue["串行异步 writeQueue"]
Screenshots["ScreenshotStore.persist"]
ImageStore{"截图模式"}
InlineImage["midscene-image script tag"]
Files["screenshots/id.png 或 jpeg"]
DumpTag["midscene_web_dump script tag"]
HTML["Report React 模板 + 增量数据"]
Browser["Report App 扫描 DOM"]
Restore["恢复 screenshot refs"]
Dedupe["按 execution id 保留最新快照"]
Views["Sidebar / Timeline / Detail / Player"]
Action --> Runner --> Signal --> Dump --> Agent --> Generator --> Queue
Queue --> Screenshots --> ImageStore
ImageStore -->|inline| InlineImage --> HTML
ImageStore -->|directory| Files
Queue --> DumpTag --> HTML
Files --> Browser
HTML --> Browser --> Restore --> Dedupe --> Views
关键入口:
TaskRunner.emitSnapshotChange():任务快照变化信号。Agent的 TaskExecutor hook:dump、报告写入和监听器通知。ReportGenerator:增量报告文件写入。ScreenshotStore:inline/file 截图持久化。Report App:浏览器恢复和渲染数据。
3. 报告数据模型¶
3.1 层级¶
classDiagram
class ReportActionDump {
sdkVersion
groupName
groupDescription
modelBriefs
deviceType
executions[]
}
class ExecutionDump {
id
logTime
name
description
tasks[]
}
class ExecutionTask {
taskId
type
subType
status
param
output
timing
usage
recorder[]
}
class ScreenshotItem {
id
base64
capturedAt
mimeType
persistenceState
}
ReportActionDump "1" o-- "many" ExecutionDump
ExecutionDump "1" o-- "many" ExecutionTask
ExecutionTask "1" o-- "many" ScreenshotItem : uiContext / recorder
ReportActionDump 是报告顶层;ExecutionDump 是一次 Agent 调用或一组任务;ExecutionTask 保存规划、定位、设备动作、错误、耗时和模型 usage。
截图通常出现在:
task.uiContext.screenshot:任务读取的当前 UI。task.recorder[].screenshot:动作后截图、观察序列或日志截图。
screenshotSequence 是临时模型输入,不会直接序列化。需要出现在报告时间线的观察帧会先转成 recorder item;这样避免生成指向未持久化图片的悬空引用。
3.2 ScreenshotItem¶
ScreenshotItem 不只是 Base64 字符串,还保存:
- 稳定 id。
- 捕获时间。
- MIME type 和扩展名。
- 原始 Base64/data URI。
- 尚未持久化、inline 或 file 等状态。
- 可选的已落盘副本引用。
序列化时,ScreenshotItem 被替换成轻量 screenshot ref;真正图片由 ScreenshotStore 单独保存。这个设计避免每次 execution 快照都在 JSON 中复制同一张大图。
4. 快照何时产生¶
4.1 TaskRunner 信号¶
TaskRunner 在 task append、状态切换、完成或错误时发出粗粒度 onSnapshotChange。它与逐 task 的 event stream 不同:snapshot listener 每次收到整个 runner,可以重新生成当前完整 execution。
runner.dump() 创建:
同一个 runner 的 id 在整个执行过程中保持不变,这是后续浏览器去重的基础。
4.2 Agent hook¶
Agent 的 onSnapshotChange 严格按以下顺序执行:
runner.dump()。appendExecutionDump():同一个 runner 更新原 execution,不重复增加逻辑 execution。collectUsageMetrics():按 task/字段去重累计 token usage。writeOutActionDumps(executionDump):把更新提交给 ReportGenerator。await reportGenerator.flush():等截图和 dump 已持久化。- 生成 dump string 并通知
onDumpUpdatelistener。
“先写文件再通知 listener”很重要:写入后 ScreenshotItem 可以安全转换为引用并释放大块内存,监听器看到的也是可恢复数据。
4.3 手工日志¶
除 AI task 外,Agent 还支持:
recordToReport():记录日志、文本和一组截图。recordErrorToReport():记录错误、stack 和现场截图。
两者都会构造一个完整 ExecutionDump,进入同一 ReportGenerator 管线,所以 UI 不需要区分“AI 自动生成”和“业务主动写入”的报告数据。
5. ReportGenerator 增量写入¶
5.1 创建路径和输出模式¶
ReportGenerator.create() 根据运行目录和输出格式计算路径:
outputFormat |
报告路径 | 截图模式 |
|---|---|---|
single-html 或未指定 |
midscene_run/report/<name>.html |
inline |
html-and-external-assets |
midscene_run/report/<name>/index.html |
directory |
generateReport=false 时返回 nullReportGenerator。generateReport=false 与 persistExecutionDump=true 不能同时使用,因为 execution dump 的截图引用依赖报告持久化流程。
浏览器环境没有 Node 文件系统,也会使用 no-op generator;此时需要通过 reportHTMLString() 在内存中生成 HTML。
5.2 串行异步写队列¶
sequenceDiagram
participant Agent
participant Generator as ReportGenerator
participant Queue as writeQueue
participant Store as ScreenshotStore
participant FS as fs/promises
Agent->>Generator: onExecutionUpdate(execution, meta)
Generator->>Queue: append doWriteExecution()
Agent->>Generator: flush()
Queue->>Store: persist each unique screenshot
Store->>FS: append image tag / write image file
Queue->>FS: append dump script tag
FS-->>Queue: write complete
Queue-->>Generator: flush resolved
Generator-->>Agent: persistence complete
onExecutionUpdate() 不直接同步写文件,而是把操作链到 writeQueue。这样同时满足:
- 多次 snapshot 按调用顺序写入。
- 图片一定先于引用它的 dump 可用。
- 不阻塞 Electron/Node 主事件循环。
源码顶部把异步 I/O 标为性能不变量:过去同步追加数 MB dump 会让 Electron 主循环单次冻结 20 秒以上,连带阻塞 IPC、scrcpy 和 renderer。当前 write path 使用 fs/promises;后续不能为了代码简单重新换成 writeFileSync/appendFileSync。
5.3 append-only 与去重¶
每次 snapshot 都追加一个新的 dump tag,不回头修改旧 tag:
append-only 的优点:
- 不需要读取并重写越来越大的 HTML。
- 写入中断时,之前完成的快照仍可读取。
- 多个 ReportGenerator 可以被合并。
代价是同一个 execution 的旧 JSON 仍留在文件中。Report App 在加载时按 id 保留最后一个;长时间运行的报告文件会包含历史快照,文件大小不等于最终逻辑数据大小。
6. 截图持久化¶
6.1 ScreenshotStore¶
ScreenshotStore.persist() 使用两个 Set 分别去重 inline 和 file 写入。同一个 screenshot id 在一个 generator 生命周期中只写一次。
flowchart LR
Item["ScreenshotItem"] --> Mode{"mode"}
Mode -->|inline| ImageTag["script type=midscene-image"]
Mode -->|directory| File["screenshots/id.ext"]
Item --> Extra{"persistExecutionDump?"}
Extra -->|是| FileCopy["额外文件副本"]
ImageTag --> InlineRef["storage=inline ref"]
File --> FileRef["storage=file + relative path"]
6.2 inline 模式¶
图片以独立 script tag 追加到 HTML:
dump JSON 只保存 id/MIME/storage 引用。打开文件时,Report App 根据 data-id 查找图片 script 并恢复 data URI。
单 HTML 最便于直接发送和归档,但大量截图会让 HTML 变大,浏览器加载和 DOM 扫描成本更高。
6.3 directory 模式¶
目录结构示意:
dump 使用 ./screenshots/<id>.<ext> 相对路径。HTML 会加入 base URL 修复脚本,确保经 HTTP server 打开时相对资源基于 index.html 所在目录解析。
目录模式不适合直接用 file:// 在所有浏览器中验证;生成器提示使用 npx serve <dir>。
6.4 可选 execution JSON¶
persistExecutionDump=true 时,persistExecutionDumpToFile() 额外生成 <index>.execution.json。同一 execution 的后续 snapshot 覆盖同一个 JSON 文件,而不是增加新编号;它适合机器分析和恢复最终状态。
为了让 JSON 中的 screenshot refs 可独立解析,即使报告是 inline 模式,也会额外写一份 screenshot file copy。
7. HTML 模板如何进入 Core¶
报告 UI 源码位于 apps/report,但运行时报告由 @midscene/core 生成。两者通过构建时模板注入连接:
flowchart LR
React["apps/report React 源码"] --> Build["构建 dist/index.html"]
Build --> Inject["inject-report-template.mjs"]
Marker["Core: REPLACE_ME_WITH_REPORT_HTML"] --> Inject
Inject --> CoreDist["@midscene/core dist 内嵌模板"]
CoreDist --> Runtime["getReportTpl()"]
Runtime --> Report["生成的报告 HTML"]
getReportTpl() 在源码中保留 REPLACE_ME_WITH_REPORT_HTML。构建 Report App 后,inject-report-template.mjs 读取 apps/report/dist/index.html,清理模板并替换 Core dist 中的唯一 marker。
开发环境可以通过 __DEV_REPORT_PATH__ 直接读取本地模板。发布构建如果跳过注入,生成器会得到 marker 字符串而不是可用页面,因此 Report App build/inject 是发布链路的一部分。
8. Report App 如何加载数据¶
8.1 DOM 扫描¶
App.getDumpElements() 查询所有:
然后按 data-group-id 分组。老报告没有 group id 时,每个 tag 被当作独立组,保持向后兼容。
8.2 延迟解析与恢复¶
每组数据包装成带 get() 的 lazy object,只有真正展示时才:
- 反转义 script 内容。
JSON.parse()。- 调用
restoreImageReferences()。 - inline ref 从
midscene-imagetag 读取并缓存。 - file ref 保留相对路径。
- 构造
GroupedActionDump。 - 合并所有 execution。
- 调用
dedupeExecutionsKeepLatest()。
没有稳定 id 的老 execution 不参与去重,因为同名 execution 可能是不同测试。
8.3 UI 展示¶
恢复后的 dump 进入全局 store,主要视图包括:
- Sidebar:execution/测试用例列表和报告模式。
- Timeline:task 执行顺序、状态和截图节点。
- DetailPanel/DetailSide:参数、思考、输出、模型调用、错误和耗时。
- Player:根据 recorder screenshot 和 task 数据重放流程。
- Markdown View:把同一 dump 导出为 Markdown 和图片附件。
Player 展示的是截图/动作序列重放,不是原始设备视频。player-only=1 可以只显示回放画面,play-control=1 显示控制条,auto-play=0 禁用自动播放。
9. Playground 中的报告¶
Playground /execute 完成后同时返回 dump 和 reportHTML:
flowchart LR
Execute["POST /execute"] --> Agent["Agent 执行动作"]
Agent --> Dump["dumpDataString inlineScreenshots"]
Dump --> MemoryHTML["reportHTMLContent(dump)"]
Agent --> FileGenerator["ReportGenerator 增量落盘"]
MemoryHTML --> Response["HTTP response.reportHTML"]
FileGenerator --> RunDir["midscene_run/report"]
Agent.reportHTMLString() 调用 reportHTMLContent(),把当前 dump 和模板在内存中组合成独立 HTML。Playground 使用 inlineScreenshots=true,所以 response 不依赖 Server 文件系统路径。
这与 ReportGenerator 的长期增量报告是两个输出:
response.reportHTML:当前 Playground 请求立刻可展示/下载的快照。agent.reportFile:Agent 生命周期内持续增量更新的正式报告。
取消任务时 Server 会先读取当前 dump/report HTML,再销毁并重建 Agent,因此失败或中止也可以保留已有执行证据。
10. finalize、销毁和文件完整性¶
- 再写一次最后 execution,捕获最终状态。
- 等待 write queue 清空。
- 标记 generator destroyed,忽略之后更新。
- 追加面向 Agent 分析的 HTML comment。
- 返回报告路径。
Agent.destroy() 先停止 observer 和设备 interface,再 flush/finalize generator,最后 reset dump 释放内存。即使 interface destroy 报错,也会尽量完成报告落盘,然后再抛出原始错误。
Agent comment 记录报告名、SDK、设备、execution/task 数量和模型信息,并提示自动分析工具去读取 dump/image tag。它不参与 UI 渲染,是给后续 Agent/CLI 分析的轻量索引。
11. 报告合并和维护 API¶
report.ts 还提供报告文件级操作:
- 识别 inline/directory screenshot mode。
- 流式读取 dump/image tag,避免一次把大 HTML 全部载入内存。
- 合并多个报告。
- 删除指定 case/execution。
- 将 directory 图片转入目标报告。
- 从报告恢复 screenshot source。
大文件扫描使用固定 64 KB chunk;读取标签时的内存复杂度主要取决于单个 tag 大小,而不是整个报告文件大小。
合并时必须同时处理 dump tag、image tag/file、data-group-id 和 screenshot mode,不能只拼接 JSON。
12. 失败模式与调试¶
| 表现 | 可能原因 | 检查位置 |
|---|---|---|
| 报告文件不存在 | 没有 execution、generateReport=false、浏览器环境 |
ReportGenerator.create/finalize |
| HTML 只有 marker/空壳 | Report App 未构建或模板未注入 Core dist | inject-report-template.mjs |
| 页面显示无 dump | 缺少/空 midscene_web_dump tag |
HTML 源码、App DOM 扫描 |
| 截图不显示 | ref id/path、MIME、image tag 或 screenshots 目录不匹配 | ScreenshotStore, resolveImageFromDom |
| 同一任务出现多次 | execution 缺少稳定 id 或 group id 不一致 | TaskRunner.id, dump tag attributes |
| 最终状态不是 finished | destroy/finalize 前 write queue 未完成 | flush(), Agent 生命周期 |
| 执行时 UI/scrcpy 卡顿 | write path 引入同步大文件 I/O | report-generator.ts 性能不变量 |
| 报告越来越大 | append-only 保存了历史快照 | dump tag 数量和 snapshot 频率 |
12.1 最小人工检查¶
13. 性能、安全与存储边界¶
13.1 性能¶
- 报告写入必须保持异步和串行。
- screenshot id 去重避免每个 snapshot 重复图片,但 dump JSON 仍会重复。
- inline 报告便携但 DOM 和 HTML 更大;长流程优先 directory 模式。
- Report App 对 dump 使用 lazy parsing,对图片使用全局 id cache。
persistExecutionDump增加磁盘文件和截图副本,只在机器分析/恢复确有需求时开启。
13.2 安全¶
报告可能包含:
- 登录态页面和个人信息截图。
- 输入值、自然语言 prompt 和模型输出。
- 错误 stack、URL 和设备 metadata。
- 模型名称和 token usage。
报告 HTML 是可执行页面,不应把未知来源的报告当普通文本在高权限域名直接托管。公司平台应使用独立只读域名、严格 CSP、访问控制、过期删除和敏感截图脱敏。
13.3 完整性¶
单 HTML 并不意味着原子写入:模板和数据是逐步追加的。运行中复制文件可能只拿到中间快照。CI 上传 artifact 前应确保 Agent 已销毁/finalize,或显式等待 generator flush。
14. 测试覆盖¶
核心测试包括:
report-generator.test.ts:append-only、inline/directory、复用、截图和 finalize。report-generator-async-contract.test.ts:写路径必须保持 async 的性能守卫。report.test.ts:报告读取、合并、截图恢复和清理。report-merge-count.test.ts:多报告、多 execution 合并数量。merge-browser-parse.test.ts:生成、合并后仍可按浏览器格式解析。html-utils.test.ts:script tag 转义、扫描和 Agent comment。
建议新增/保持的端到端覆盖:
- Android 执行中持续打开报告,确认增量数据可加载。
- 失败、取消、超时和 Agent destroy 错误均能得到最终报告。
- 100+ screenshot 的 inline 与 directory 报告加载时间和内存基线。
- 合并包含同名但不同 id 的 execution,不误去重。
- CI 上传 artifact 前验证最后一个 execution 已结束且截图引用全部可解析。
15. 关键源码索引¶
| 模块 | 文件 | 责任 |
|---|---|---|
| 快照生产 | task-runner.ts |
task 状态变化与 ExecutionDump |
| Agent 接线 | agent.ts |
dump、usage、generator、listener |
| 增量生成器 | report-generator.ts |
write queue、HTML 和 execution JSON |
| dump 模型 | report-action-dump.ts |
序列化、截图收集和恢复 |
| 截图存储 | screenshot-store.ts |
inline/file 持久化和 ref 解析 |
| 截图对象 | screenshot-item.ts |
截图 id、MIME 和状态 |
| HTML 工具 | html-utils.ts |
image/dump tag、流式扫描和 comment |
| 报告维护 | report.ts |
合并、删除、模式和截图迁移 |
| 模板注入 | inject-report-template.mjs |
React build -> Core dist |
| 报告前端 | App.tsx |
DOM 扫描、恢复、去重和页面组合 |