跳转至

Midscene 报告生成管线源码分析

分析版本:Midscene 1.10.5,基线提交 cfc7c100 分析日期:2026-07-16 范围:Core 任务快照、截图持久化、增量 HTML 报告、Report App 数据恢复,以及 Playground 中的报告返回

1. 结论摘要

Midscene 报告不是“任务结束后把日志一次性渲染成页面”,而是一条持续更新的增量管线:

  1. TaskRunner 的任务状态变化触发 snapshot change。
  2. Agent 调用 runner.dump() 得到当前完整 ExecutionDump
  3. ReportGenerator 把截图先持久化,再把 execution JSON 作为 <script type="midscene_web_dump"> 追加到报告 HTML。
  4. 同一个 execution 会被追加多次;Report App 按 execution id 去重,只保留最后一个快照。
  5. 报告页面本身是预先构建的 React App,构建后被注入 @midscene/core,运行时不需要另外部署后端。
  6. 页面打开后扫描 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

关键入口:

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() 创建:

1
2
3
4
5
6
ExecutionDump {
  id: runner.id,
  logTime: execution start time,
  name: runner.name,
  tasks: runner.tasks
}

同一个 runner 的 id 在整个执行过程中保持不变,这是后续浏览器去重的基础。

4.2 Agent hook

AgentonSnapshotChange 严格按以下顺序执行:

  1. runner.dump()
  2. appendExecutionDump():同一个 runner 更新原 execution,不重复增加逻辑 execution。
  3. collectUsageMetrics():按 task/字段去重累计 token usage。
  4. writeOutActionDumps(executionDump):把更新提交给 ReportGenerator。
  5. await reportGenerator.flush():等截图和 dump 已持久化。
  6. 生成 dump string 并通知 onDumpUpdate listener。

“先写文件再通知 listener”很重要:写入后 ScreenshotItem 可以安全转换为引用并释放大块内存,监听器看到的也是可恢复数据。

4.3 手工日志

除 AI task 外,Agent 还支持:

两者都会构造一个完整 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 时返回 nullReportGeneratorgenerateReport=falsepersistExecutionDump=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:

1
2
3
<script type="midscene_web_dump" data-group-id="stream-id">
  { "executions": [{ "id": "execution-1", "tasks": [...] }] }
</script>

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:

1
2
3
<script type="midscene-image" data-id="screenshot-id">
  data:image/png;base64,...
</script>

dump JSON 只保存 id/MIME/storage 引用。打开文件时,Report App 根据 data-id 查找图片 script 并恢复 data URI。

单 HTML 最便于直接发送和归档,但大量截图会让 HTML 变大,浏览器加载和 DOM 扫描成本更高。

6.3 directory 模式

目录结构示意:

1
2
3
4
5
6
7
report-name/
  index.html
  screenshots/
    <id>.png
    <id>.jpeg
  1.execution.json       # 可选
  2.execution.json       # 可选

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() 查询所有:

script[type="midscene_web_dump"]

然后按 data-group-id 分组。老报告没有 group id 时,每个 tag 被当作独立组,保持向后兼容。

8.2 延迟解析与恢复

每组数据包装成带 get() 的 lazy object,只有真正展示时才:

  1. 反转义 script 内容。
  2. JSON.parse()
  3. 调用 restoreImageReferences()
  4. inline ref 从 midscene-image tag 读取并缓存。
  5. file ref 保留相对路径。
  6. 构造 GroupedActionDump
  7. 合并所有 execution。
  8. 调用 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、销毁和文件完整性

ReportGenerator.finalize() 会:

  1. 再写一次最后 execution,捕获最终状态。
  2. 等待 write queue 清空。
  3. 标记 generator destroyed,忽略之后更新。
  4. 追加面向 Agent 分析的 HTML comment。
  5. 返回报告路径。

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 最小人工检查

1
2
3
4
5
6
1. 在 HTML 源码中搜索 type="midscene_web_dump"
2. 检查最后一个同 group/id execution 的 status 和 tasks
3. 搜索 screenshot ref 的 id
4. inline: 查找 type="midscene-image" data-id="..."
5. directory: 检查 screenshots/<id>.<ext>
6. 浏览器控制台查看 JSON parse / image restore 错误

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. 测试覆盖

核心测试包括:

建议新增/保持的端到端覆盖:

  1. Android 执行中持续打开报告,确认增量数据可加载。
  2. 失败、取消、超时和 Agent destroy 错误均能得到最终报告。
  3. 100+ screenshot 的 inline 与 directory 报告加载时间和内存基线。
  4. 合并包含同名但不同 id 的 execution,不误去重。
  5. 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 扫描、恢复、去重和页面组合