在 Flutter 仓库中新增 CI 测试分片(Test Shard)完整指南:test.dart 与 .ci.yaml 的配置实践
在 Flutter/flutter 主仓库的日常开发中,flutter test 的全量回归会拆分到多个并行执行的“测试分片”(test shard)上,而新增一套独立的测试分组(例如把某个包或某类测试从现有 shard 中拆出)需要同时修改三处:LUCI builder 定义、执行分片的 Dart runner(dev/bots/test.dart)以及 CI 编排文件(.ci.yaml)。本篇以 docs/infra/Adding-a-new-Test-Shard.md 为主线,结合仓库内真实的分片注册表与 target 配置,说明“新增分片应当遵循的落地顺序、配置键语义与 bringup 灰度流程”,读完后你将能够在 Flutter 源码树中独立完成一次新测试分片的添加与上线。
分片机制概览:一次构建如何在 CI 上被“切分”
Flutter 的主仓库回归体量很大(框架单测、工具链测试、示例构建、Web/桌面集成等),无法靠单台机器在规定时间内跑完,因此 CI 采用两层拆分:
- shard(分片):指某一类测试的集合,例如
framework_tests、tool_tests、analyze、web_canvaskit_tests等,由 dev/bots/test.dart 中的注册表定义。 - subshard(子分片):在同一 shard 内部再按主题或按数量切分,例如
framework_tests下又分为widgets、libraries、slow、misc、impeller(见 dev/bots/suite_runners/run_framework_tests.dart)。
从执行链路看,LUCI 上的每个“builder”会运行一段 recipe,而 Framework 侧大多使用 flutter/flutter_drone recipe——它本身不做测试逻辑,只是把 shard/subshard 键透传给仓库根目录下的 dev/bots/test.dart 去执行真正的测试。也就是说,“分片”对 recipe 是无感知的,真正的分片调度与测试发现逻辑全部发生在仓库内部的 Dart 脚本中。关于这套 build 基础设施的整体说明,可先阅读 dev/bots/README.md。
一个 Flutter/LUCI 构建所需的四个要素
原文档把“一个 Flutter CI 测试分片”应具备的要素归纳为以下四项,每一项都对应仓库中的具体文件:
- 一个独立的 LUCI builder。在 LUCI 中测试分片与 builder 一一对应,通常同时需要 pre-submit(LUCI 术语中的 “try”)与 post-submit(“prod”)两套。Framework 侧的 builder 清单定义在 infra 仓库的
config/framework_config.star(仓库外部托管,不在当前源码树内)。 - 一条被执行 recipe。builder 会指定一个 recipe(基于 Starlark 的脚本),决定真正执行的 CI 步骤。绝大多数 Framework 测试使用
flutter/flutter_drone.pyrecipe。如何编辑 recipe 以及本地联调方式(led get-builder ...等命令)见 dev/bots/README.md 的 “Editing a recipe” 一节——该节完整说明了从克隆 recipes 仓库、修改、recipes.py test train更新期望输出、到用led在指定 PR 上试跑并提交 CL 的整个迭代周期。 - 在 .ci.yaml 中登记 builder。该文件被 Flutter 的 build dashboard 读取并用于调度构建,是仓库内可直接查看与修改的一环。
- 一个真正能运行该分片的 Dart 入口。这一点对应 dev/bots/test.dart 中注册的 shard 名——它是第 3 步
.ci.yaml中shard键的“契约实现”。
其中前两项定义在仓库之外(由 infra/recipes 团队维护),需要在源码树内完成的工作集中在第 3、4 步以及它们的顺序编排上。
深入 .ci.yaml:一个 target 条目的字段语义
仓库根目录的 .ci.yaml(共 7800 余行)是 Framework 全部 CI target 的“单一事实来源”,其头部注释明确写道:Flutter infra 依据该文件为每次提交生成待执行任务清单,且 flutter_drone recipe 会依据本文件中的 shard 键把分片委托给仓库内的 dev/bots/test.dart。文件由三个顶层块组成:
enabled_branches:允许调度这些 target 的分支(如master与flutter-x.y-candidate.z发布候选分支)。platform_properties:可复用的平台属性模板(如linux、mac_arm64、linux_android_emu等),定义了操作系统、CPU、依赖项(dependencies,包括 android_sdk、open_jdk、gradle 等及版本)、device_type、KVM 等机器环境。targets:真正被调度执行的 builder 列表。
以 Linux 上真实的 framework_tests 拆分结果为例,.ci.yaml 中可以看到 4 个 target 共享同一个 shard,但通过不同的 subshard 区分:
- name: Linux framework_tests_libraries
recipe: flutter/flutter_drone
timeout: 60
properties:
dependencies: >-
[
{"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}
]
shard: framework_tests
subshard: libraries
tags: >
["framework","hostonly","shard", "linux"]
runIf:
- dev/**
- packages/flutter/**
- packages/flutter_driver/**
...
name:builder 名称,需全局唯一,在 dashboard 上以该名称呈现。recipe:要执行的 recipe,Framework 测试统一写flutter/flutter_drone;devicelab 任务则写devicelab/devicelab_drone。timeout:单位为分钟,该 builder 的整体超时上限(如上述示例为 60 分钟)。properties:传给 recipe 的自定义属性,其中shard与subshard/subshards必须与 dev/bots/test.dart 中注册的名称完全一致,否则脚本会报 “Invalid shard/subshard” 错误;dependencies声明机器上需要预装的工具链;tags供 dashboard 分类展示(如["framework", "hostonly", "shard", "linux"])。runIf:路径过滤白名单,只有提交触及这些路径时才调度该 builder,用来节省无谓的 CI 消耗。注意:framework_tests_*系列都列入了dev/**、packages/flutter/**、.ci.yaml、engine/**、DEPS等关键路径。bringup:灰度开关,见下文“bringup 上线流程”。presubmit:是否参与 PR(pre-submit)验证。默认参与;设置为false则只在 post-submit(合入 master 后)运行,例如仅用于发布分支或后置缓存的 target(可参考 .ci.yaml 中fuchsia_precache的写法)。enabled_branches:仅在个别分支生效时使用。
仓库中也存在同时标记 bringup: true 与 presubmit: false 的 target(例如 .ci.yaml 中的 Linux flavors_test_linux),说明二者相互独立:bringup 决定是否允许失败/阻塞,presubmit 决定是否进入 PR 门禁。
test.dart:shard 注册表与 SHARD/SUBSHARD 契约
dev/bots/test.dart 是所有 Framework CI 测试的 Dart 入口。其头部注释给出了关键约定:测试默认支持按 shard 与 subshard 拆分,可通过 --dry-run 预览将要执行的测试列表;本地调试时只需设置 SHARD 与 SUBSHARD 两个环境变量。
脚本通过 selectShard 将 SHARD 环境变量映射到具体的 runner:
await selectShard(<String, ShardRunner>{
'add_to_app_life_cycle_tests': addToAppLifeCycleRunner,
'build_tests': _runBuildTests,
'framework_coverage': frameworkCoverageRunner,
'framework_tests': frameworkTestsRunner,
'tool_tests': _runToolTests,
'tool_tests_commands': _runCommandsToolTests,
'web_canvaskit_tests': webTestsSuite.runWebCanvasKitUnitTests,
'analyze': analyzeRunner,
'docs': docsRunner,
...
});
这段注册表就是 .ci.yaml 中 shard 键的合法取值集合。分片的实际调度逻辑在 dev/bots/utils.dart 的 selectShard/selectSubshard 中实现:它们通过常量 kShardKey = 'SHARD'、kSubshardKey = 'SUBSHARD' 读取环境变量,若变量为空则按注册表顺序逐个执行所有分片;若变量值不在注册表中,则调用 foundError 报错并列出所有可选值。
在 shard 内部,各测试单元既可以被继续切分为“命名 subshard”(如 framework_tests 下的 widgets/libraries/slow/misc/impeller),也可以被切分为“编号 subshard”。后者由 dev/bots/utils.dart 的 selectIndexOfTotalSubshard 支持,SUBSHARD 采用 "{index}_{total}" 格式(如 1_3 表示共三份中的第一份),调度器无需仓库提交即可增减总份数;若格式非法会打印 Invalid subshard name ... Expected format "[int]_[int]" 的错误提示。用于均匀分配测试的区间算法见 selectTestsForSubShard(dev/bots/utils.dart):先把测试数按份数取整均分,再将余数逐个放入前几个桶中。
以 framework_tests 为例,dev/bots/suite_runners/run_framework_tests.dart 通过 selectSubshard 注册了五个命名子分片,分别承担:widgets 运行 packages/flutter/test/widgets 及其 track-widget-creation 变体、release/profile 模式测试;libraries 运行除 widgets 目录外的其余 packages/flutter/test 子目录;slow 运行编译量大、耗时的平台无关测试(tracing 测试、dart fix golden 对比、test_private 等);misc 汇总 devicelab/analysis 之外各包的测试;impeller 以 --enable-impeller 打开 Impeller 后端跑全量测试。
新增 Framework 测试分片的落地步骤(按序执行)
原文档强调:必须按下述顺序合入修改,否则迁移期间会出现失败构建。顺序的核心逻辑是——先让 Dart runner 认识新 shard,再让 CI 编排引用它,最后用 bringup 灰度到可以阻塞主树。
第一步:在 test.dart 中注册新 shard
任何全新 shard 必须首先加入 dev/bots/test.dart 的分片注册表,并在本 Framework 仓库中合入该修改。实现时通常还会在对应的 suite_runners/ 文件中补充具体的 runner 逻辑(例如新建 run_xxx_tests.dart),并在 dev/bots/test.dart 头部 import。合入后,任何 .ci.yaml 的 target 若把 shard 指到该名称,才能被 runner 正确解析。
需要区分两类“新增”:
- 新增一套此前不存在的 shard(例如新引入一个大型测试目录),必须改 test.dart;
- 仅将已有测试从某 shard 拆分到更多 subshard(复用同一个 shard 名),无需改动 test.dart——因为 subshard 只是测试集合的再分配,runner 逻辑并未变化。
第二步:在 .ci.yaml 中添加 builder 并标记 bringup
在仓库根目录的 .ci.yaml 的 targets 块中追加新的 target,命名建议沿用平台前缀约定(如 Linux、Mac、Mac_arm64、Windows),recipe 使用 flutter/flutter_drone。务必满足三点:
properties.shard以及subshard/subshards与第一步 test.dart 中的注册严格一致;runIf按新分片实际覆盖的源码范围设置,避免无关提交触发;- 必须标记
bringup: true。新 shard 一律先进 bringup,先在 master 上验证通过后,才允许其阻塞主树。
bringup: true 的效果是:该 target 的结果暂不会因失败而标红阻止树(也不会在 PR pre-submit 阶段运行),从而避免把未经灰度验证的新增分片直接变成全体开发者的门禁。仓库中可随时找到处于 bringup 状态的 target 作为范本,例如 .ci.yaml 中的 Linux snippets。
第三步:在 build dashboard 上观察并等待自动摘除 bringup
合入第二步后,持续在 Flutter build dashboard 上监控新 shard 的 CI 结果。当满足 连续 50 次构建通过且无 flakes 时,flake bot 会自动创建一条 PR,将 .ci.yaml 中该 target 的 bringup: true 参数移除。移除后:
- 该分片开始具备阻塞主树的能力,任何回归都会在 post-submit 被拦截;
- 除非显式设置了
presubmit: false,否则新 shard 会自动开始参与 pre-submit(PR 验证)。
flake bot 每周运行一次(通常在周三),因此摘除 bringup 的节奏以周为单位。
特殊情况:重命名豁免
文档末尾给出了一条重要豁免:若新的 post-submit target 是由现有 target 重命名而来,则无需走 bringup 流程。因为重命名前后的测试集合与稳定性记录是连续的,直接以正式状态上线不会引入未经验证的失败风险。
bringup 流程背后的工程意图
将 bringup: true 作为新分片上线必经之路,本质上是把 CI 变更视为与产品代码同等重要的“灰度发布”:
- 分阶段:先在 post-submit 静默运行并积累信心(50 次全绿无 flake),再开放阻塞与 pre-submit 门禁;
- 可回退:一旦灰度期出现不稳定,可直接在
.ci.yaml中保留/恢复bringup: true而无需改动任何测试代码; - 自动化收敛:摘除动作由 flake bot 依据客观阈值(连续 50 次)自动发起,避免人工遗忘导致新分片长期游离于门禁之外。
同时,标记顺序也防止了中间态:若先合入 .ci.yaml 而 test.dart 尚未识别该 shard,runner 会在 selectShard 阶段报 Invalid shard,产生必然失败的构建;若先摘除 bringup 而未经 50 次验证,则可能把不稳定分片直接压到主树上。这就是原文档要求“按顺序合入”的原因。
本地验证与调试建议
在把改动交给 CI 之前,可完全在本地模拟分片执行。以运行 framework_tests 的 widgets 子分片为例,在仓库根目录执行:
SHARD=framework_tests SUBSHARD=widgets bin/cache/dart-sdk/bin/dart dev/bots/test.dart
更稳妥的做法是先做 dry-run 预览(不会真正执行测试,只打印将要运行的命令与测试清单):
SHARD=framework_tests SUBSHARD=widgets bin/cache/dart-sdk/bin/dart dev/bots/test.dart --dry-run
对于按数量切分的编号 subshard,可验证分片区间分配是否符合预期(示例为三分片中的第一份):
SHARD=build_tests SUBSHARD=1_3 bin/cache/dart-sdk/bin/dart dev/bots/test.dart --dry-run
其他常用参数还包括:--test-randomize-ordering-seed=<n> 固定乱序种子以便复现问题、--verbose 关闭输出静默、--abort-on-error 遇到首个错误即退出。若想用本地自编译的引擎跑测试,可追加 --local-engine=host_debug_unopt --local-engine-host=host_debug_unopt(示例见 dev/bots/test.dart 的注释与参数解析逻辑 dev/bots/test.dart)。
相关文档索引
- 分片调度与 runner 实现:dev/bots/test.dart、dev/bots/utils.dart、dev/bots/suite_runners/run_framework_tests.dart
- CI target 定义:.ci.yaml(引擎仓库侧为 engine/src/flutter/.ci.yaml)
- 构建基础设施总览(含 recipe 编辑流程、dashboard 权限申请、
led联调等):dev/bots/README.md - 各测试套件说明:dev/integration_tests/README.md、dev/devicelab/README.md
需要说明的是:LUCI builder 的机器清单(framework_config.star)、recipe 定义(flutter/flutter_drone.py)以及 .ci.yaml 的完整字段规范文档(cocoon 仓库的 CI_YAML.md)均托管在 Flutter 的独立 infra/recipes 仓库中,不在本源码树内;本文已用仓库内可验证的 .ci.yaml 与 dev/bots/test.dart 覆盖了源码侧的全部改动面,剩余环节请在 infra 侧文档指引下配合完成。
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 StartedRust0624
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