ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

Playwright CI 测试结果分析:基于 DuckDB 的 test-results 数据库查询实战 Playwright CI 测试结果分析基于 DuckDB 的 test-results 数据库查询实战【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwrightPlaywright 项目将每次 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的 artifactretention-days: 7、overwrite: true。对应的 CI 命令来自 update_test_results_db.ymlnode 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-L43Usage: 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 arent 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查询不需要单独安装 DuckDBduckdb/node-api是仓库的 devDependencypackage.json#L83 中为1.5.4-r.1npm ci后即可通过 Node 内联脚本直接查询node --input-typemodule -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.mdColumnMeaningrun_id,run_attemptGitHub Actions run identityrun_started_at该次运行的开始时间workflow_name例如tests 1/tests 2/tests others/MCPeventpush/pull_requesthead_sha,head_branch,pr_number被测对象提交、分支、PR 号bot_name例如chromium-ubuntu-22.04-node20、webkit-macos-15-large即 CI bot。操作系统与架构编码在这个字段里没有单独的 os 列project_nameCI project 浏览器 测试套件例如chromium-page、webkit-library、playwright-testtest_title文件内的标题路径以›连接describe › testfile,line,column_number源码位置file 相对仓库根目录expected_statuspassed/skipped/ ...预期结果status实际结果passed/failed/timedOut/skipped/interruptedretry0 首次尝试result_started_at该次尝试的开始时间duration_ms该次结果的耗时error_message全部错误信息拼接已去除 ANSI 转义无错误时为 NULLtags字符串列表例如[slow, flaky]查询需用 list 函数 /list_containsannotations{type, description}结构体列表例如[{type: skip, description: flaky on CI}]无则为空列表artifact_id该行来自哪个 GitHub artifact去重键CLI 追加ingested_at仅用于调试——该行被导入的时间CLI 追加字段如何被填充Reporter 源码佐证parquetReporter.ts 的onEndL56-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_REFpr_number通过正则从refs/pull/n/merge中提取L193-L197bot_name取PWTEST_BOT_NAME回退到PW_TAG去掉前导L62——这正是操作系统/架构编码在 bot 名里的原因test_title是test.titlePath()去掉 project 后各层标题用›拼接L96、L107file相对config.rootDir并统一转为 POSIX 分隔符L108error_message把result.errors中所有非空 message 去掉 ANSI 颜色后以空行拼接全部为空则存 NULLL136-L144tags与annotations分别以LIST(VARCHAR)和LIST(STRUCT(type, description))类型追加L35-L36这解释了为什么查询tags必须用list_contains而不是LIKE。值得注意Reporter 建表语句里有一行注释test_id is intentionally omitted since its a deterministic hash of (project_name, file, test_title)L82。这直接引出下面的核心查询原则。四条必须记住的查询原则测试标识是(project_name, file, test_title)三元组——聚合、分组都基于它。Playwright 的test_id哈希被刻意不存储因为这三列就是它的原像pre-imageFlakiness 是推导出来的不是存储的。最重要的信号是跨运行cross-run某个测试的最终裁决重试后在不同运行间翻转——有的运行绿、有的运行红。另一种运行内within-runflaky是单次运行中重试救回来的失败failed→passed同样可查但性质不同区分真实失败与故意的失败过滤expected_status passed。被标记test.fail()的测试会记录statusfailed且expected_statusfailed不过滤的话它们会霸占任何失败最多排行榜数据库按运行数量设上限不是按时间而是整批淘汰最老的运行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_containsSELECT 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-typemodule 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_idbot_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-reportaction 完成见 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只导入不在其中的 artifactupdate.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-L182artifact 下载依赖 GitHub API 的 302 重定向到签名 URL跨域跳转时 fetch 自动剥离 Authorization 头以满足签名要求L112-L119。CI 里若存在GITHUB_OUTPUTupdate还会把importedn写回供 workflow 判断是否需要 truncate 和重新上传。GitHub 客户端极简。GitHubClient 只是基于全局fetch的三个端点封装列 artifact、按名查最新、下载 zip认证走GITHUB_TOKENdownload/update必需仓库硬编码为microsoft/playwright。适用前提与限制该工具面向Playwright 自身仓库的 CI 数据GitHub 客户端默认仓库、blob artifact 命名、workflow 名均绑定该仓库的 Actions 配置其他项目不能直接套用需要可访问上述 artifact 的GITHUB_TOKENCI 用 Node LTS 直接以node utils/test-results-db/cli.ts运行 TS 脚本见 update_test_results_db.yml#L29本地运行同等要求 Node 版本支持直接执行 TS查询结果是摘要窗口数据库按运行数上限CI 为 2000 次运行滚动淘汰不是全量历史而逐运行的完整详情步骤树/附件/stdio7 天后随 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 或一段内联脚本完成。【免费下载链接】playwrightPlaywright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.项目地址: https://gitcode.com/GitHub_Trending/pl/playwright创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表