Flutter customer_testing 工具深度解析:如何用“客户视角”的测试守住 tip-of-tree 稳定性
customer_testing 是 Flutter 仓库中的一个回归验证工具,它通过检出 flutter/tests 仓库中注册的“客户测试”(即模拟真实用户应用与库的测试套件),在 Flutter 当前主干(tip-of-tree)上运行,以验证最新代码不会破坏真实世界的用户应用。读完本文,你将掌握如何在本地运行指定 SHA 的客户测试、如何更新 CI 使用的测试仓库版本、.test 测试注册文件的完整指令格式,以及底层执行器(分片、临时目录、fetch/setup/update/test 阶段)的实现细节。
工具定位与核心职责
按照 dev/customer_testing/README.md 的说明,该工具的职责非常明确:
- 在 tests.version 指定的提交 SHA 处检出 flutter/tests 仓库;
- 运行其中注册的测试,以验证“当前 Flutter 主干上的端到端用户应用和库仍然可以正常工作”。
这与 Flutter 自身的单元测试或框架测试不同:它模拟的是下游消费者的视角——用户应用升级 Flutter 后能否继续编译和通过测试。当前 tests.version 中记录的 SHA 为 9853d386f609dc41aa22648131756f4eb69fee70。pubspec.yaml 中也对该包有一句话描述:“Tool to run the tests listed in the flutter/tests repository”,并要求 Dart SDK ^3.11.0-0。
本地运行:ci.dart 与 ci.sh 两种入口
README 给出的本地运行方式是:
cd dev/customer_testing
dart ci.dart [sha]
其中 [sha] 是可选参数:不传时默认读取 tests.version 中的 SHA,传入时则测试任意指定版本。这一点可以从 ci.dart 的参数处理逻辑中得到印证——args.isEmpty 时从 tests.version 读取,args.length == 1 时使用传入值,其他情况打印 Usage: dart ci.dart [sha] 并以退出码 1 结束。
ci.dart 的完整执行链如下:
- 清理缓存目录:定位 Flutter 根目录(
p.canonicalize('../../')),若bin/cache/pkg/tests存在则先删除; - 浅克隆 flutter/tests:
git clone --depth 1到bin/cache/pkg/tests; - 检出目标 SHA:在克隆出的仓库中执行
git fetch origin <sha>和git checkout <sha>; - 运行测试:
dart --enable-asserts run_tests.dart \
--skip-on-fetch-failure \
--skip-template \
<flutter-root>/bin/cache/pkg/tests/registry/*.test
注意两个固定附加的参数:--skip-on-fetch-failure 表示某个客户测试仓库拉取失败时跳过而不是判失败;--skip-template 表示跳过名为 template.test 的文件(那是给贡献者用的注册文件模板,不是真实测试)。测试文件路径使用了 p.posix.joinAll 拼接,以 registry/*.test 通配该仓库中所有已注册的测试。
除了直接 dart ci.dart,仓库还提供了一个跨平台的 Shell 入口 ci.sh(以及 Windows 版 ci.bat)。ci.sh 的逻辑是:
set -e
# 先切换到脚本自身所在目录,保证用户可以从哪里调用都能工作
cd "$(script_location)"
dart pub get
../../bin/dart run ci.dart
文件头注释中明确说明:该脚本由 LUCI recipes 调用(对应 dev/bots/suite_runners/run_customer_testing_tests.dart),并且不要求先运行 flutter update-packages——因为运行 flutter/tests 测试基本不需要,CI 可以借此省去这一步;但它确实需要对本目录执行 dart pub get。
更新 CI 使用的测试版本:修改 tests.version
README 中给出的第二条操作路径是:要更新 CI 所使用的 SHA,直接编辑 tests.version 并提交 PR。该文件内容极其简单——只有一行 40 位的提交哈希:
9853d386f609dc41aa22648131756f4eb69fee70
由于 ci.dart 在无参数时正是读取这一行(io.File('tests.version').readAsStringSync().trim()),因此修改该文件并合并 PR,就等价于让所有 CI 和后续本地无参运行都切换到新的 flutter/tests 版本。
测试注册文件格式:.test 指令语法
flutter/tests 仓库中每个客户测试由一个 registry/*.test 文件描述,这些文件的解析逻辑全部集中在 lib/customer_test.dart 的 CustomerTest 类中。文件逐行解析,空行和 # 开头的注释被忽略,每行必须以下列指令之一开头(否则会抛出 FormatException: Unexpected directive):
| 指令 | 是否可重复 | 说明 |
|---|---|---|
contact= |
是(至少 1 个) | 联系人邮箱,必须是合法邮箱且不能是 @example.com;测试失败时输出器会打印这些联系人 |
fetch= |
是(至少 2 行) | 拉取客户仓库的命令;第 1 行必须匹配 git clone https://github.com/<user>/<repo>.git tests 模式,第 2 行必须匹配 git -C tests checkout <hash> 模式(两行均允许可选的 -c core.longPaths=true 参数) |
setup= |
是(可选) | fetch 成功后在客户仓库根目录(tests/)执行的准备命令;支持平台限定后缀 setup.windows=、setup.macos=、setup.linux=、setup.posix= |
update= |
是(至少 1 个) | 需要“升级依赖”的目录;. 表示仓库根。运行器会在该目录执行 flutter packages get 和 dart fix --apply,该目录必须包含 pubspec.yaml |
test= |
是(至少 1 个) | 实际执行的测试命令;同样支持 test.windows=、test.posix= 等平台限定变体 |
iterations= |
否(至多 1 个) | 限定该测试最多重复执行的轮数,必须为正整数;与命令行 --repeat 取较小值 |
一个符合上述语法的完整注册文件示例(取自 test/customer_test_test.dart 的单元测试):
contact=abc@gmail.com
fetch=git clone https://github.com/flutter/cocoon.git tests
fetch=git -C tests checkout abc123
setup=flutter --version
setup.windows=flutter doctor
setup.posix=flutter -h
setup.linux=flutter analyze -h
setup.macos=flutter build -h
update=.
# Runs flutter analyze, flutter test, and builds web platform
test.posix=./test_utilities/bin/flutter_test_runner.sh app_flutter
test.posix=./test_utilities/bin/flutter_test_runner.sh repo_dashboard
test.windows=.\test_utilities\bin\flutter_test_runner.bat repo_dashboard
平台限定的实现见 _PlatformType 枚举:windows 对应 Platform.isWindows,posix 对应 Linux 或 macOS 任一成立时生效;无后缀的指令则视为全平台生效。校验规则同样有对应的自动化测试兜底:缺少 contact=、缺少 test=、只给一行 fetch=、出现未知指令等场景都会在 test/customer_test_test.dart 中触发 throwsFormatException 断言。
run_tests.dart:命令行参数全解
run_tests.dart 是真正的测试执行入口(ci.dart 最终调用的就是它)。使用格式为 run_tests.dart [options...] path/to/file1.test ...,所有路径参数支持 glob 通配(内部用 package:glob 展开)。完整参数表:
| 参数 | 默认值 | 说明 |
|---|---|---|
--repeat <count> |
1 |
每个测试重复执行的次数;设为较大值可用于挖掘 flaky test。若测试自身声明了 iterations=,取两者中的较小值 |
--shards <count> |
1 |
将测试拆分成多少个分片,用于 CI 并行 |
--shard-index <count> |
0 |
当前分片的下标,取值范围 [0 .. shards - 1] |
--skip-on-fetch-failure |
关 | 拉取仓库失败时跳过对应测试而不是判为失败 |
--skip-template |
关 | 跳过名为 template.test 的文件 |
--verbose |
关 | 打印执行细节(临时目录、每条命令、输出日志等) |
--help |
关 | 打印帮助信息 |
参数校验也比较严格:shards 必须大于 0,shard-index 必须落在 [0, shards-1] 区间内,否则报错退出;当分片数多于测试文件数时会打印告警“Some shards will not run any tests”。
单个测试的执行生命周期
真正的执行逻辑在 lib/runner.dart 的 runTests 中,每个 .test 文件的处理流程是:
- 创建临时目录:在系统临时目录下创建
flutter_customer_testing.<测试名>.XXXX目录,作为该测试的隔离工作区; - fetch 阶段:依次执行注册文件中的每条
fetch=命令(即 clone + checkout)。若某条失败且开启了skipOnFetchFailure,则打印 “Skipping ... (fetch failed)” 并跳过该测试; - setup 阶段:在
tests/子目录(即 clone 下来的客户仓库)中依次执行setup=命令,任何一条失败即判该测试失败; - update 阶段:对每个
update=目录先检查pubspec.yaml是否存在,然后执行flutter packages get,再执行dart fix --apply。这一步正是工具“模拟用户升级 Flutter”的关键动作——把客户仓库的依赖刷新到当前主干的 Flutter; - test 阶段:按
repeat(受iterations=上限约束)轮次执行所有test=命令,任何一条命令非零退出即判失败并停止后续轮次; - 计时与清理:无论成败,最终用
finally块删除临时目录,并打印平均每轮耗时(Tests finished in X.XX seconds per iteration); - 失败归因:测试失败时会额外打印注册文件中的
contact=联系人列表,方便 Flutter 团队第一时间找到对应客户仓库的负责人。
命令执行本身由 shell 辅助函数完成:Windows 上通过 CMD.EXE /S /C 执行,其他平台按空白分割后直接 Process.start;非 verbose 模式下会先缓冲子进程输出,仅在命令失败时把完整输出打印出来,以避免噪音淹没主日志。
分片策略是简单的交错取模:for (i = shardIndex; i < files.length; i += numberShards) 让各分片均匀交错地拿到测试文件,而不是顺序切块。
CI 集成与整体调用链
把各环节串起来,完整的调用链是:
LUCI recipe
-> dev/bots/suite_runners/run_customer_testing_tests.dart
-> dev/customer_testing/ci.sh (或 ci.bat)
-> dart pub get && ../../bin/dart run ci.dart
-> git clone/checkout flutter/tests @ SHA
-> dart --enable-asserts run_tests.dart --skip-on-fetch-failure --skip-template registry/*.test
-> 逐个解析 .test 注册文件 (lib/customer_test.dart)
-> 按 fetch / setup / update / test 阶段执行 (lib/runner.dart)
其中 ci.sh 顶部注释直接注明了它与 LUCI recipes 的对接关系,并指向 dev/bots/suite_runners/run_customer_testing_tests.dart 作为 CI 侧入口。这也解释了 --shards/--shard-index 参数的用途:CI 把同一批客户测试拆成多个并行 shard 执行,每个 shard 只跑交错分配给自己的那部分。
小结
- 两个操作入口:本地验证任意 SHA 用
dart ci.dart [sha](或ci.sh/ci.bat);更新 CI 基线则编辑 tests.version 提 PR。 - 注册文件格式:
contact=/fetch=/setup=/update=/test=/iterations=六类指令,fetch固定为“clone 到 tests 目录 + checkout 指定哈希”两行,setup与test支持.windows/.macos/.linux/.posix平台后缀,完整规则由 lib/customer_test.dart 解析并强校验。 - 执行器特性:
--repeat/iterations双重控制重复轮次、--shards/--shard-index交错分片、--skip-on-fetch-failure容忍拉取失败、每个测试独立临时目录并在结束后清理。 - 失败可追溯:注册文件中的联系人会在失败时自动打印,配合 update 阶段的
flutter packages get+dart fix --apply,构成了“模拟真实用户升级 Flutter”这一完整验证闭环。
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