首页
/ Playwright CI 测试结果分析:基于 DuckDB 的 test-results 数据库查询实战

Playwright CI 测试结果分析:基于 DuckDB 的 test-results 数据库查询实战

2026-09-06 11:42:06作者:段琳惟

Playwright 项目将每次 CI 运行的测试结果汇总进单个 DuckDB 数据库文件,使得"哪些测试不稳定、哪些测试失败率最高、某个测试最近 N 次运行的表现如何"这类问题可以直接用普通 SQL 回答,而不必翻找 GitHub Actions 的产物文件。本文基于仓库中的技能文档 SKILL.md 展开,完整覆盖数据库的获取与增量更新、test_results 表的结构细节、跨运行 Flaky 检测与慢测试排查的 SQL 写法、可贴入 GitHub 评论的 emoji 运行历史生成方法,并结合 utils/test-results-db/tests/config/parquetReporter.ts 的源码剖析数据管道的实现原理与边界限制。

从 Parquet 到单个 DuckDB 文件:整体数据流

理解这套机制的关键在于分清两个阶段:

  1. 每次 CI 运行产出 Parquet。Playwright 的自定义 Reporter ParquetReporteronTestEnd 收集全部测试结果,onEnd 时把它们写入内存中的 DuckDB 表,再通过 COPY test_results TO '...' (FORMAT parquet) 导出为 Parquet 文件(默认 test-results/test-results.parquet,可由 PWTEST_PARQUET_OUTPUT_FILE 覆盖),并作为 GitHub artifact 上传;
  2. 定时工作流把 Parquet 压实进一个 DuckDB 文件update_test_results_db.yml 每 3 小时(cron: "0 */3 * * *",与文档"refreshed every few hours"对应)执行一次完整管道:download 拉取上一次维护好的数据库 artifact → update 摄取新增的 parquet artifact → truncate 按运行数量裁剪 → 重新上传名为 test-results-db 的 artifact(retention-days: 7overwrite: true)。

对应的 CI 命令(来自 update_test_results_db.yml):

node utils/test-results-db/cli.ts download
node utils/test-results-db/cli.ts update --lookback-days 7 --concurrency 32
node utils/test-results-db/cli.ts truncate --max-runs 2000   # 仅当本次有新增时执行

也就是说,本地下载的快照可能缺少最新几次运行,可以用 update 命令本地补齐。

CLI 命令与参数

入口是 cli.ts,用法如下(摘自该文件的 USAGE 常量,L25-L43):

Usage: node utils/test-results-db/cli.ts <command> [options]

Compacts the per-run parquet CI artifacts into a single queryable DuckDB file.

Commands:
  download                 Fetch the latest maintained database artifact.
                           Starts a fresh database if none exists yet.
  update [options]         Ingest parquet artifacts that aren't in the database yet.
    --lookback-days <n>    How many days back to scan (default 7).
    --concurrency <n>      Parallel downloads per batch (default 16).
    --stop-after-seen <n>  Stop after this many consecutive already-ingested
                           artifacts (default 100). The list is newest-first, so
                           this short-circuits the scan once caught up.
  truncate --max-runs <n>  Keep only the newest <n> runs, delete the rest, compact.

Environment:
  GITHUB_TOKEN             Required for 'download' and 'update'.
  TRDB_DB_PATH             Database file path (default utils/test-results-db/test-results.duckdb).

要点:

  • downloadupdate 都要求 GITHUB_TOKEN(缺失会直接抛错,见 cli.ts#L72-L77);
  • 数据库文件位置默认是 utils/test-results-db/test-results.duckdb,可用环境变量 TRDB_DB_PATH 覆盖;
  • update 的三个选项均有默认值:--lookback-days 7--concurrency 16--stop-after-seen 100,且参数必须是正整数(L62-L70)。

获取并查询数据库

首次使用(从仓库根目录):

npm ci                       # 首次执行,确保 @duckdb/node-api 在 node_modules 中
GITHUB_TOKEN=$(gh auth token) node utils/test-results-db/cli.ts download

快照可能缺少最新的运行,本地增量补齐(例如回看 3 天):

GITHUB_TOKEN=$(gh auth token) node utils/test-results-db/cli.ts update --lookback-days 3

查询不需要单独安装 DuckDB:@duckdb/node-api 是仓库的 devDependency(package.json#L83 中为 1.5.4-r.1),npm ci 后即可通过 Node 内联脚本直接查询:

node --input-type=module -e '
import { DuckDBInstance } from "@duckdb/node-api";
const conn = await (await DuckDBInstance.create("utils/test-results-db/test-results.duckdb")).connect();
console.table((await conn.runAndReadAll(process.argv[1])).getRowObjectsJson());
' "SELECT count(*) FROM test_results"

一个容易踩坑的行为:整型列经 getRowObjectsJson() 返回的是字符串(JSON 安全),因此排序、过滤、比较应当在 SQL 侧完成,而不是在 JS 里对返回值做数值判断。

test_results 表结构详解

单表 test_results一个测试结果一行,一次重试一行(one row per retry)。列分为两部分:主体列由 Reporter 产出的 Parquet 推断而来,CLI 额外追加两个尾列。完整字段表(继承自 SKILL.md):

Column Meaning
run_id, run_attempt GitHub Actions run identity
run_started_at 该次运行的开始时间
workflow_name 例如 tests 1 / tests 2 / tests others / MCP
event push / pull_request
head_sha, head_branch, pr_number 被测对象(提交、分支、PR 号)
bot_name 例如 chromium-ubuntu-22.04-node20webkit-macos-15-large,即 CI bot。操作系统与架构编码在这个字段里,没有单独的 os 列
project_name CI project = 浏览器 + 测试套件,例如 chromium-pagewebkit-libraryplaywright-test
test_title 文件内的标题路径,以 连接(describe › test
file, line, column_number 源码位置(file 相对仓库根目录)
expected_status passed / skipped / ...(预期结果)
status 实际结果:passed / failed / timedOut / skipped / interrupted
retry 0 = 首次尝试
result_started_at 该次尝试的开始时间
duration_ms 该次结果的耗时
error_message 全部错误信息拼接,已去除 ANSI 转义(无错误时为 NULL)
tags 字符串列表,例如 ['@slow', '@flaky'](查询需用 list 函数 / list_contains
annotations {type, description} 结构体列表,例如 [{'type': 'skip', 'description': 'flaky on CI'}](无则为空列表)
artifact_id 该行来自哪个 GitHub artifact(去重键,CLI 追加)
ingested_at 仅用于调试——该行被导入的时间(CLI 追加)

字段如何被填充:Reporter 源码佐证

parquetReporter.tsonEndL56-L128)展示了每个字段的真实来源:

  • run_id / run_attempt / workflow_name / event / head_sha / pr_number 全部解析自 GitHub Actions 注入的环境变量(GITHUB_RUN_IDGITHUB_RUN_ATTEMPTGITHUB_WORKFLOWGITHUB_EVENT_NAMEGITHUB_SHAGITHUB_REF);pr_number 通过正则从 refs/pull/<n>/merge 中提取(L193-L197);
  • bot_namePWTEST_BOT_NAME,回退到 PW_TAG(去掉前导 @)(L62)——这正是"操作系统/架构编码在 bot 名里"的原因;
  • test_titletest.titlePath() 去掉 project 后各层标题用 拼接(L96L107);file 相对 config.rootDir 并统一转为 POSIX 分隔符(L108);
  • error_messageresult.errors 中所有非空 message 去掉 ANSI 颜色后以空行拼接,全部为空则存 NULL(L136-L144);
  • tagsannotations 分别以 LIST(VARCHAR)LIST(STRUCT(type, description)) 类型追加(L35-L36),这解释了为什么查询 tags 必须用 list_contains 而不是 LIKE

值得注意,Reporter 建表语句里有一行注释:test_id is intentionally omitted since it's a deterministic hash of (project_name, file, test_title)L82)。这直接引出下面的核心查询原则。

四条必须记住的查询原则

  1. 测试标识是 (project_name, file, test_title) 三元组——聚合、分组都基于它。Playwright 的 test_id 哈希被刻意不存储,因为这三列就是它的原像(pre-image);
  2. Flakiness 是推导出来的,不是存储的。最重要的信号是跨运行(cross-run):某个测试的最终裁决(重试后)在不同运行间翻转——有的运行绿、有的运行红。另一种运行内(within-run)flaky 是单次运行中重试救回来的失败(failedpassed),同样可查但性质不同;
  3. 区分真实失败与"故意的失败":过滤 expected_status = 'passed'。被标记 test.fail() 的测试会记录 status='failed'expected_status='failed',不过滤的话它们会霸占任何"失败最多"排行榜;
  4. 数据库按运行数量设上限:不是按时间,而是整批淘汰最老的运行(oldest whole runs),因此它保存的是"最近窗口"而非完整历史。CI 中该上限为 2000 次运行(update_test_results_db.yml#L38)。裁剪的实现见 db.ts 的 truncateToRuns:先 DELETE 掉不在最新 N 个 (run_id, run_attempt) 组内的行,再执行 _compact——由于 DuckDB 的 DELETE 不回收磁盘,实现是把现存行 ATTACH 到全新数据库文件再整体换回(L116-L134),这样文件才能真正缩小。

示例查询

通用写法:按 (project_name, file, test_title) 分组;涉及失败率/Flaky 时限定 expected_status = 'passed',避免 test.fail() 测试干扰结果。重试会产生多行,取"最终裁决"的标准手段是 arg_max(status, retry)

跨运行 Flaky 测试排名

这是让"红色 CI 运行含义模糊"的元凶——最终裁决在不同运行间翻转的测试。排序技巧 least(failed_runs, passed_runs) 能真正把双峰(bimodal)的测试排在"一直坏"和"偶发失败"之前:

WITH per_run AS (
  SELECT project_name, file, test_title, run_id, run_attempt,
         arg_max(status, retry) AS final_status,
         any_value(expected_status) AS expected
  FROM test_results
  GROUP BY project_name, file, test_title, run_id, run_attempt)
SELECT project_name, test_title,
       count(*) AS runs,
       count(*) FILTER (WHERE final_status IN ('failed','timedOut')) AS failed_runs,
       count(*) FILTER (WHERE final_status = 'passed') AS passed_runs,
       round(100.0 * count(*) FILTER (WHERE final_status IN ('failed','timedOut'))
             / count(*), 1) AS fail_pct
FROM per_run
WHERE expected = 'passed'
GROUP BY project_name, test_title
HAVING failed_runs > 0 AND passed_runs > 0 AND runs >= 10
ORDER BY least(failed_runs, passed_runs) DESC, failed_runs DESC
LIMIT 20;

逐句解读:

  • per_run CTE 先用 arg_max(status, retry) 把"同一测试在同一次运行里的多次重试"收敛为最终状态;
  • FILTER (WHERE ...) 是 DuckDB 的条件计数,避免写 SUM(CASE WHEN ...)
  • HAVING 中的 runs >= 10 过滤样本量不足的小样本,failed_runs > 0 AND passed_runs > 0 保证结果既失败过也通过过——这才是 Flaky 的定义;
  • timedOutfailed 同等对待,因为超时同样让 CI 变红。

按标签过滤

tags 是列表列而非字符串,必须用 list_contains

SELECT project_name, test_title, count(*) AS runs
FROM test_results
WHERE list_contains(tags, '@slow')
GROUP BY project_name, test_title
ORDER BY runs DESC
LIMIT 20;

同类模式可以扩展到技能文档 description 中提到的其他问题类型。例如查慢测试,直接基于 duration_ms 排序即可(记得用 arg_max 思路或取单次尝试,并同样注意整型过滤放在 SQL 侧):

SELECT project_name, file, test_title, duration_ms
FROM test_results
WHERE expected_status = 'passed' AND retry = 0
ORDER BY duration_ms DESC
LIMIT 20;

生成可贴入 GitHub 评论的 emoji 运行历史

为了得到一份紧凑、可直接放进 GitHub 评论的结果,可以把某测试每次运行的最终裁决渲染成一个带链接的方块。修改四个测试标识字段后执行(注意这里用了 DuckDB 的命名参数绑定 $projectName 等,避免手工拼接字符串):

node --input-type=module <<'EOF'
import { DuckDBInstance } from "@duckdb/node-api";

const repository = "microsoft/playwright";
const test = {
  projectName: "firefox-library",
  file: "library/proxy.spec.ts",
  testTitle: "should exclude patterns",
  botName: "firefox-macos-15-large",
};

const conn = await (await DuckDBInstance.create(
  "utils/test-results-db/test-results.duckdb"
)).connect();
const result = await conn.runAndReadAll(`
  WITH per_run AS (
    SELECT run_id, run_attempt,
           any_value(run_started_at) AS run_started_at,
           arg_max(status, retry) AS final_status,
           arg_max(expected_status, retry) AS expected_status,
           list(status ORDER BY retry) AS attempt_statuses
    FROM test_results
    WHERE project_name = $projectName
      AND file = $file
      AND test_title = $testTitle
      AND bot_name = $botName
    GROUP BY run_id, run_attempt
  )
  SELECT run_id, run_attempt, final_status, attempt_statuses
  FROM per_run
  WHERE expected_status = 'passed'
    AND final_status IN ('passed', 'failed', 'timedOut')
  ORDER BY run_started_at, run_id, run_attempt
`, test);

const markdown = result.getRowObjectsJson().map(row => {
  const rescued = row.final_status === "passed" &&
    row.attempt_statuses.some(status => status === "failed" || status === "timedOut");
  const emoji = rescued ? "🟧" : row.final_status === "passed" ? "🟩" : "🟥";
  const url = `https://github.com/${repository}/actions/runs/${row.run_id}/attempts/${row.run_attempt}`;
  return `${emoji}`;
}).join("");

console.log(markdown);
EOF

输出是一段 Markdown:

🟩🟧🟥

每个方块对应一个 workflow 运行尝试(run attempt),按时间从旧到新排列,链接指向 /attempts/<n> 精确锚点:

  • 绿 🟩:最终通过;
  • 橙 🟧:重试救回了先前失败(within-run flake)——rescued 判定是"最终 passedattempt_statuses 里出现过 failed/timedOut";
  • 红 🟥:最终失败或超时。

两个关键 SQL 细节:arg_max(status, retry) 取的是重试后的最终裁决;GROUP BY (run_id, run_attempt) 保证重试不会变成额外的方块。list(status ORDER BY retry) 则保留完整尝试序列供橙色判定使用。

获取完整详情:回源 blob-report artifact

数据库里存的是按结果粒度的摘要。要查看完整的步骤树 / 附件 / stdio,需要拉取该次运行的原始 blob report(前提是当时上传了)。一行数据通过 run_id + bot_name 定位到它:运行的 blob artifact 命名为 blob-report-<bot_name>

# 列出该运行的 blob artifact,找到匹配本 bot_name 的那个:
gh api /repos/microsoft/playwright/actions/runs/<run_id>/artifacts \
  --jq '.artifacts[] | select(.name | startswith("blob-report")) | {id, name}'

# 下载它(name == "blob-report-<bot_name>"):
gh api /repos/microsoft/playwright/actions/artifacts/<artifact_id>/zip > blob.zip

仓库中对应的上传逻辑在 tests_primary.yml 等 workflow 中通过 upload-blob-report action 完成(见 create_test_report.ymlnamePrefix: 'blob-report' 的合并配置)。

保留期差异是设计要点:blob 与 parquet artifact 都只有 7 天保留期,所以回源只适用于近期运行;而数据库本身保存摘要更久(直到被运行数上限淘汰)。这正是"DB 存摘要 + artifact 存全文"两层存储的分工。

数据管道实现细节(源码级)

以下几处实现解释了这套机制为什么可靠、以及边界在哪,均出自 utils/test-results-db/

表结构懒创建与按列名插入db.ts 刻意不再次声明 Reporter 的列:第一个 parquet 到达时执行 CREATE TABLE test_results AS SELECT *, $id AS artifact_id, now() AS ingested_at FROM read_parquet($file) LIMIT 0,表结构随之推断,自动追加 artifact_id(去重键)与 ingested_at 两个尾列;后续导入用 INSERT INTO ... BY NAME,因此 Reporter 端调整/扩展列顺序都能正确落位。

幂等摄取靠 artifact_id 去重ingestedArtifactIds() 返回已导入的 artifact id 集合;update 只导入不在其中的 artifact(update.ts#L40-L70),重复执行不会造成重复行。

增量扫描的"垫层"策略github.ts 的 listArtifacts 从最新的 artifact 开始遍历,核心是一个精巧的早停逻辑:artifact id 按创建顺序单调递增(源码注释说明在 1000 个样本上验证过无乱序),因此"已摄取区"连续出现在列表头部;当连续看到 stopAfterSeen(默认 100)个已摄取的 artifact 就停止扫描,而一旦遇到新的未摄取 artifact 就重置计数。--lookback-days 只在首次运行(尚无任何摄取记录、垫层永远不触发)时作为绝对兜底。这样既避免全量翻页,又不会因为"上传中晚于上次扫描完成"的竞态漏掉 artifact。

下载并发与磁盘受限update 把待导入 artifact 按 concurrency 分批:批内并行下载 zip(网络是瓶颈),批内摄取则在单连接上串行完成,随后删除临时文件——磁盘占用被限制在一个批次的规模(update.ts#L32-L35)。zip 解包用 yauzl 提取首个匹配扩展名的条目(github.ts#L148-L182);artifact 下载依赖 GitHub API 的 302 重定向到签名 URL,跨域跳转时 fetch 自动剥离 Authorization 头以满足签名要求(L112-L119)。CI 里若存在 GITHUB_OUTPUTupdate 还会把 imported=<n> 写回,供 workflow 判断是否需要 truncate 和重新上传。

GitHub 客户端极简GitHubClient 只是基于全局 fetch 的三个端点封装(列 artifact、按名查最新、下载 zip),认证走 GITHUB_TOKENdownload/update 必需),仓库硬编码为 microsoft/playwright

适用前提与限制

  • 该工具面向 Playwright 自身仓库的 CI 数据:GitHub 客户端默认仓库、blob artifact 命名、workflow 名均绑定该仓库的 Actions 配置,其他项目不能直接套用;
  • 需要可访问上述 artifact 的 GITHUB_TOKEN
  • CI 用 Node LTS 直接以 node utils/test-results-db/cli.ts 运行 TS 脚本(见 update_test_results_db.yml#L29),本地运行同等要求 Node 版本支持直接执行 TS;
  • 查询结果是摘要窗口:数据库按运行数上限(CI 为 2000 次运行)滚动淘汰,不是全量历史;而逐运行的完整详情(步骤树/附件/stdio)7 天后随 artifact 过期消失;
  • 整型列经 JSON 返回为字符串——一切数值比较、排序放在 SQL 里做;
  • 查询"失败最多/最 Flaky"时务必带 expected_status = 'passed',否则 test.fail() 的故意失败会污染榜单。

小结

这套机制的本质是一条"每次运行一份 Parquet → 定时压实为单文件 DuckDB → 本地下载即查"的管道:tests/config/parquetReporter.ts 负责标准化每次运行的结果(含 tagsannotations 等列表/结构体列),utils/test-results-db/ 负责幂等摄取、按运行数裁剪与磁盘压实,.github/workflows/update_test_results_db.yml 每 3 小时驱动一次。掌握 (project_name, file, test_title) 三元组、arg_max(status, retry) 取最终裁决、expected_status = 'passed' 排除故意失败这三个模式后,Flaky 排名、慢测试定位、单测试运行历史、回源完整报告等查询都可以用几行 SQL 或一段内联脚本完成。

登录后查看全文
热门项目推荐
相关项目推荐