Playwright CI 测试结果分析:基于 DuckDB 的 test-results 数据库查询实战
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 文件:整体数据流
理解这套机制的关键在于分清两个阶段:
- 每次 CI 运行产出 Parquet。Playwright 的自定义 Reporter ParquetReporter 在
onTestEnd收集全部测试结果,onEnd时把它们写入内存中的 DuckDB 表,再通过COPY test_results TO '...' (FORMAT parquet)导出为 Parquet 文件(默认test-results/test-results.parquet,可由PWTEST_PARQUET_OUTPUT_FILE覆盖),并作为 GitHub artifact 上传; - 定时工作流把 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: 7、overwrite: 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).
要点:
download与update都要求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-node20、webkit-macos-15-large,即 CI bot。操作系统与架构编码在这个字段里,没有单独的 os 列 |
project_name |
CI project = 浏览器 + 测试套件,例如 chromium-page、webkit-library、playwright-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.ts 的 onEnd(L56-L128)展示了每个字段的真实来源:
run_id/run_attempt/workflow_name/event/head_sha/pr_number全部解析自 GitHub Actions 注入的环境变量(GITHUB_RUN_ID、GITHUB_RUN_ATTEMPT、GITHUB_WORKFLOW、GITHUB_EVENT_NAME、GITHUB_SHA、GITHUB_REF);pr_number通过正则从refs/pull/<n>/merge中提取(L193-L197);bot_name取PWTEST_BOT_NAME,回退到PW_TAG(去掉前导@)(L62)——这正是"操作系统/架构编码在 bot 名里"的原因;test_title是test.titlePath()去掉 project 后各层标题用›拼接(L96、L107);file相对config.rootDir并统一转为 POSIX 分隔符(L108);error_message把result.errors中所有非空 message 去掉 ANSI 颜色后以空行拼接,全部为空则存 NULL(L136-L144);tags与annotations分别以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)。这直接引出下面的核心查询原则。
四条必须记住的查询原则
- 测试标识是
(project_name, file, test_title)三元组——聚合、分组都基于它。Playwright 的test_id哈希被刻意不存储,因为这三列就是它的原像(pre-image); - Flakiness 是推导出来的,不是存储的。最重要的信号是跨运行(cross-run):某个测试的最终裁决(重试后)在不同运行间翻转——有的运行绿、有的运行红。另一种运行内(within-run)flaky 是单次运行中重试救回来的失败(
failed→passed),同样可查但性质不同; - 区分真实失败与"故意的失败":过滤
expected_status = 'passed'。被标记test.fail()的测试会记录status='failed'且expected_status='failed',不过滤的话它们会霸占任何"失败最多"排行榜; - 数据库按运行数量设上限:不是按时间,而是整批淘汰最老的运行(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_runCTE 先用arg_max(status, retry)把"同一测试在同一次运行里的多次重试"收敛为最终状态;FILTER (WHERE ...)是 DuckDB 的条件计数,避免写SUM(CASE WHEN ...);HAVING中的runs >= 10过滤样本量不足的小样本,failed_runs > 0 AND passed_runs > 0保证结果既失败过也通过过——这才是 Flaky 的定义;timedOut与failed同等对待,因为超时同样让 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判定是"最终passed且attempt_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.yml 里 namePrefix: '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_OUTPUT,update 还会把 imported=<n> 写回,供 workflow 判断是否需要 truncate 和重新上传。
GitHub 客户端极简。GitHubClient 只是基于全局 fetch 的三个端点封装(列 artifact、按名查最新、下载 zip),认证走 GITHUB_TOKEN(download/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 负责标准化每次运行的结果(含 tags、annotations 等列表/结构体列),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 或一段内联脚本完成。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00