第17章 - 测试CI与贡献流程
17.1 三层质量闸门
MapLibre Native 是 C++ monorepo,一次改动可能同时影响 core、Android、iOS、Node、Qt 五套绑定。仓库用三层测试把回归拦在不同阶段:
| 层级 | 位置 | 入口 | 验证内容 |
|---|---|---|---|
| 单元测试 | test/ |
mbgl-test-runner |
C++ 内部逻辑:tile、style、text、storage、util |
| 渲染测试 | render-test/ + metrics/ |
mbgl-render-test-runner |
实际 GPU 渲染结果与期望图逐像素比对 |
| 表达式测试 | expression-test/ |
mbgl-expression-test |
MapLibre Style Spec 表达式求值 |
三层都由 CMake 构建,CI 在 Linux、macOS、Windows、Android、iOS 上分别跑对应子集。理解这套结构,本地调试时就不会在错误的方向上找问题——单元测试失败与渲染测试失败的排查路径完全不同。
17.2 单元测试的组织方式
test/CMakeLists.txt 里没有 file(GLOB),而是逐个列出源文件,构建一个 EXCLUDE_FROM_ALL 的静态库:
add_library(
mbgl-test STATIC EXCLUDE_FROM_ALL
${PROJECT_SOURCE_DIR}/test/actor/actor.test.cpp
${PROJECT_SOURCE_DIR}/test/algorithm/update_renderables.test.cpp
${PROJECT_SOURCE_DIR}/test/map/transform.test.cpp
${PROJECT_SOURCE_DIR}/test/style/style_parser.test.cpp
...
)
目录划分直接对应 core 的模块边界:
| 目录 | 覆盖内容 | 典型用例 |
|---|---|---|
test/map/ |
Map、Transform、prefetch | transform.test.cpp、prefetch.test.cpp |
test/style/ |
Style 解析、表达式、属性 | style_parser.test.cpp、expression/expression.test.cpp |
test/tile/ |
瓦片类型与缓存 | tile_cache.test.cpp、vector_tile.test.cpp |
test/storage/ |
离线、HTTP、MBTiles、PMTiles | offline_database.test.cpp、pmtiles_file_source.test.cpp |
test/renderer/ |
bucket、atlas、shader 注册 | fill_bucket.test.cpp、shader_registry.test.cpp |
test/text/ |
字形、断行、跨瓦片索引 | glyph_manager.test.cpp、cross_tile_symbol_index.test.cpp |
test/util/ |
几何、矩阵、投影、颜色 | geo.test.cpp、projection.test.cpp |
测试基础设施放在 test/src/mln/test/,提供了一组 stub:stub_file_source.hpp、stub_map_observer.hpp、stub_style_observer.hpp、stub_tile_observer.hpp。写新用例时优先复用这些 stub,而不是启动真实后端——一次真实 GPU 初始化的成本是几百毫秒,单元测试里叠几十个就不可接受了。
框架是 GoogleTest。CMake 里对 Windows 加了 MBGL_BUILDING_LIB 定义,在 Windows/Linux/WebGPU 下定义 USE_CPP_TEST_SERVER(测试用 cpp-httplib 起本地 HTTP 服务,验证网络请求与超时逻辑)。
17.3 渲染测试:长什么样、怎么跑
渲染测试的本质是“给定 style.json,渲染出图,与 expected.png 比对”。用例目录在 metrics/integration/render-tests/ 下按 Style Spec 属性分目录,每个用例一个文件夹:
metrics/integration/render-tests/
└── circle-radius/
├── default/
│ ├── style.json
│ └── expected.png
├── property-function/
│ ├── style.json
│ └── expected.png
└── ...
还有 expression-tests/(表达式求值)与 query-tests/(queryRenderedFeatures 结果断言),它们共用同一个 runner,这也是官方文档把 render test 称为“名不副实”的原因——它同时也测内存、网络请求数、GPU 分配。
运行入口是 mbgl-render-test-runner,必须传 manifest:
./build-linux-opengl/mbgl-render-test-runner \
--manifestPath metrics/linux-clang8-release-style.json
manifest 的字段不多,但每一项都影响结果:
{
"base_test_path": "integration",
"cache_path": "cache-style.db",
"expectation_paths": [],
"ignore_paths": [
"ignores/platform-all.json",
"ignores/platform-linux.json"
],
"metric_path": "linux-clang8-release",
"probes": ["probeGFX", "probeNetwork"]
}
cache_path指向预置的 SQLite 缓存metrics/cache-style.db。默认离线模式下,所有瓦片、字形、sprite 都从这里读,保证结果可复现且不依赖网络。ignore_paths列出忽略清单,格式是“测试路径: 原因”,例如"expression-tests/collator/accent-equals-de": "Locale-specific behavior changes based on platform."。平台差异(字体、GPU 精度)都靠它屏蔽。metric_path决定期望值/结果文件名前缀,所以同一份用例在不同平台各有独立基线。
常用参数:
| 参数 | 作用 |
|---|---|
-p, --manifestPath |
必填,manifest 路径 |
-f, --filter |
正则过滤测试 ID,如 -f 'circle-radius/.*' |
-u, --update |
更新期望值:default、platform、metrics、rebaseline |
-o, --online |
允许联网(默认离线,只读缓存) |
-r, --recycle-map |
复用地图对象,减少初始化开销 |
-s, --shuffle |
打乱用例顺序,用于暴露状态泄漏 |
--seed |
打乱种子,默认 1 |
典型输出:
* passed query-tests/circle-radius/feature-state
* passed render-tests/circle-radius/zoom-and-property-function
* passed render-tests/circle-radius/property-function
13 passed (100.0%)
Results at: metrics/macos-xcode11-release-style.html
最后那行 HTML 是每个用例的并排对比图,失败时先看它,比读日志快得多。
17.3.1 新增渲染测试的流程
- 在
metrics/integration/render-tests/<属性名>/<用例名>/下新建目录。 - 写
style.json,可以直接复制邻近用例再改属性值。 - 用
-u default跑一次,生成首版expected.png。 - 人工检查生成的图。这一步不能省——机器只会把“第一次跑出来的结果”当正确答案,如果渲染本身有 bug,基线会把 bug 固化下来。
- 提交
style.json与expected.png,CI 之后就会开始保护这个行为。
跨平台结果不一致时,用 -u platform 生成平台专属期望,或把用例加进对应 ignores/platform-*.json 并写明原因。
17.4 CI 工作流全景
.github/workflows/ 下有 30 多个工作流,按触发方式可分四类:
| 类别 | 工作流 | 触发 |
|---|---|---|
| 核心 CI | linux-ci.yml、macos-ci.yml、windows-ci.yml |
push 到 main、PR |
| 平台 CI | android-ci.yml、ios-ci.yml、node-ci.yml、qt-ci.yml、ohos-ci.yml、rust-ci.yaml |
对应路径变更 |
| 度量 | pr-linux-tests.yml、upload-coverage.yml |
linux-ci 完成后 |
| 发布与文档 | *-release.yml、gh-pages-*.yml、pr-bloaty-ios.yml |
tag 或手动 |
linux-ci.yml 是最完整的一条,值得逐段读:
strategy:
matrix:
variant:
- renderer: vulkan
rust: false
- renderer: opengl
rust: false
- renderer: opengl
rust: true
runs-on: ubuntu-26.04
timeout-minutes: 240
三个矩阵覆盖 Vulkan 后端、OpenGL 后端、OpenGL + Rust 组件(rustutils,编译时需 cargo install cxxbridge-cmd --version 1.0.202)。每个 job 都做这些事:
cmake --preset linux-<renderer>,加-DMLN_WITH_CLANG_TIDY=ON、-DMLN_WITH_COVERAGE=ON、Vulkan 变体加-DMLN_WITH_SANITIZER=undefined。- 构建 6 个目标:
mbgl-core mbgl-test-runner mbgl-render-test-runner mbgl-expression-test mbgl-render mbgl-benchmark-runner。 - 跑 CodeQL(
languages: cpp)。 xvfb-run -a build-linux-<renderer>/mbgl-test-runner跑单元测试。- 跑渲染测试(
mbgl-render-test-runner)。 - 上传覆盖率、
mbgl-render与mbgl-benchmark-runner产物给下游 job。
几个环境变量决定了无头环境能不能跑起来:
| 变量 | 值 | 原因 |
|---|---|---|
EGL_PLATFORM |
surfaceless |
没有显示器时用无表面 EGL |
VK_DRIVER_FILES |
/usr/share/vulkan/icd.d/lvp_icd.json |
用 Mesa lavapipe 软件 Vulkan 实现 |
UBSAN_OPTIONS |
print_stacktrace=1 |
UBSan 命中时打印调用栈 |
注释里还有一条经验:Mesa 26 的 gfxstream ICD 每次 vkCreateInstance 泄漏一个 pthread key,会把 glibc 的 1024 个 key 耗尽,所以 Vulkan 渲染测试强制指定 lvp_icd.json。这类环境问题在本地很难复现,读 workflow 能省下大量时间。
pr-linux-tests.yml 不是独立触发的,而是监听 linux-ci 的 workflow_run 完成事件,然后做两件 CI 主流程不适合做的事:
- Bloaty 体积对比:把 PR 构建出的
mbgl-render与 main、与一个 legacy 版本(legacy_maplibre_sha)分别做 size 分解,用评论贴出差异。 - Google Benchmark:跑
mbgl-benchmark-runner并与 main 对比,输出性能回归表。产物通过 S3(https://maplibre-native.s3.eu-central-1.amazonaws.com)传递。
17.5 本地怎么跑
17.5.1 从零构建
git clone --recurse-submodules https://github.com/maplibre/maplibre-native.git
cd maplibre-native
cmake --preset linux-opengl
cmake --build build-linux-opengl --target mbgl-test-runner mbgl-render-test-runner
--recurse-submodules 不能省,vendor 依赖走 submodule。预设支持渲染后端组合(linux-opengl、linux-vulkan、linux-webgpu-dawn、linux-webgpu-wgpu),也与 MLN_WITH_* 选项一一对应。
17.5.2 跑单元测试
# 全部
./build-linux-opengl/mbgl-test-runner
# 按 GoogleTest 过滤,只看瓦片缓存与样式解析
./build-linux-opengl/mbgl-test-runner --gtest_filter='TileCache.*:StyleParser.*'
# 列出所有用例
./build-linux-opengl/mbgl-test-runner --gtest_list_tests
无头 CI 用 xvfb-run -a 包一层;本地有桌面环境时不需要。遇到 EGL not initialized 或 Vulkan 设备枚举失败,先按 17.4 的三个环境变量排查。
17.5.3 跑渲染测试
# 单条链路
./build-linux-opengl/mbgl-render-test-runner -p metrics/linux-clang8-release-style.json
# 只看 circle-radius 相关
./build-linux-opengl/mbgl-render-test-runner \
-p metrics/linux-clang8-release-style.json -f 'circle-radius/.*'
# 复现随机顺序下才出现的状态泄漏
./build-linux-opengl/mbgl-render-test-runner \
-p metrics/linux-clang8-release-style.json -s --seed 42
结果 HTML 默认写在 metrics/<metric_path>.html,用浏览器打开即可看逐用例对比。失败用例会给出像素差异比例,阈值内的差异(各平台 GPU 舍入不同)默认容忍,超出即为失败。
17.6 贡献流程
17.6.1 常规 PR
官方的 CONTRIBUTING.md 很短,核心只有几条:
- 先熟悉目标平台的构建流程(
platform/<平台>/README.md或 INSTALL 文档)。 - 提交前检索已有 PR 与 issue,避免重复。
- 面向用户的变更要更新对应
CHANGELOG.md的main段,用[core]、[android]、[ios]、[node]之类的前缀标注影响面。 - 使用 AI 辅助的贡献必须阅读 MapLibre 的 AI Policy,并在 PR 中披露 AI 使用;生成内容要自己验证,不能让 AI 代你发言。
仓库内没有独立的 CLA 文件;对贡献者来说实际门槛是“通过对应平台 CI + 至少一次 maintainer 评审 + CHANGELOG 更新”。
17.6.2 设计提案(Design Proposal)
架构级改动(拆 monorepo、引入 Metal 着色器这类)不走普通 PR,先走 design-proposals/ 流程:
1. 复制 design-proposals/ 下的模板
2. 文件名带日期与关键词,如 2022-09-15-metal.md
3. 填完模板,提交 PR
4. 在 PR 中讨论、修订
5. 讨论收敛后发起投票(赞成 approve,反对评论 "Rejected")
6. 至少留 72 小时投票期,多数赞成则合并
投票结果与提案本身一起进仓库,作为后续实现的依据。这套流程的价值在于“先达成共识再写代码”,避免大改动的 PR 在评审阶段才暴露方向性分歧。
17.6.3 分支与发布
发布策略的几条硬规则:
- 严格遵守语义化版本,破坏性变更必然升 major。
main分支原则上始终处于可发布状态。- 没有 LTS,也不承诺固定发布节奏。
- 老版本的修复不做自动回移,需要为
platform-x.x.x分支(例如android-10.x.x)单独开 issue 与 PR。
平台 tag 的形态是 android-v13.5.4、ios-v7.0.0、node-v6.4.1、qt-v2.0.1 这类“平台 + 版本”的组合,根目录工具链版本与平台发布版本不是一回事——升级依赖时不要按根 package.json 的版本号推断各 SDK 的版本。
17.6.4 提交前钩子
仓库配置了 pre-commit:
pip install pre-commit
pre-commit install
钩子覆盖 clang-format、YAML/JSON 校验等。C++ 格式由根目录 .clang-format 定义,CI 用 .clang-tidy 做静态检查;本地跑一遍 pre-commit 能避免“格式不对被打回”的往返。
17.7 排错
mbgl-render-test-runner: --manifestPath is required。 runner 没有默认 manifest,必须显式传 -p。路径相对当前工作目录,不是相对可执行文件。
渲染测试全部失败,差异图全花。 先确认 manifest 的 metric_path 与平台匹配(Linux 用 linux-*,macOS 用 macos-*),再确认 GPU 后端一致。同一个用例在 OpenGL 与 Vulkan 下会有像素级差异,期望图不通用。
离线模式下一步都加载不出来。 默认不联网,所有资源必须能从 cache_path 指向的 SQLite 读出来。新增用例依赖了新字体或新瓦片时,要么更新 cache-style.db,要么临时加 -o 联网验证——但不要把联网结果当基线提交。
CI 里单元测试挂在 eglInitialize 或 vkCreateInstance。 检查 EGL_PLATFORM=surfaceless 与 VK_DRIVER_FILES 是否生效;本地则先跑 xvfb-run -a 包一层。
UBSan 命中但本地不复现。 CI 的 Vulkan job 开了 -DMLN_WITH_SANITIZER=undefined,本地默认不开。用同一 preset 加该选项重跑,UBSAN_OPTIONS=print_stacktrace=1 能给出完整栈。
测试顺序相关(单独跑过、全量跑挂)。 用 -s --seed <n> 固定打乱顺序复现,多数是全局单例(FileSourceManager、Scheduler、字体缓存)没在用例间复位。
PR 的 Bloaty/benchmark 评论没出现。 这两个 job 监听的是 linux-ci 的完成事件,且仅当主流程构建成功并上传了 mbgl-render、mbgl-benchmark-runner 产物时才跑。fork 的 PR 拿不到写权限的 secrets,会静默跳过 S3 上传。
17.8 本章小结
| 要点 | 结论 |
|---|---|
| 测试分层 | test/(gtest 单测)、render-test/(GPU 比对)、expression-test/ |
| 单测入口 | mbgl-test-runner,支持 --gtest_filter,CI 里用 xvfb-run |
| 渲染测试 | mbgl-render-test-runner -p <manifest>,默认离线读 cache-style.db |
| 过滤与更新 | -f 正则筛用例,-u 取 default/platform/metrics/rebaseline 更新基线 |
| 用例结构 | metrics/integration/render-tests/<属性>/<用例>/{style.json, expected.png} |
| CI 矩阵 | Linux 覆盖 vulkan / opengl / opengl+rust 三变体,构建 6 个目标 |
| 无头环境 | EGL_PLATFORM=surfaceless、VK_DRIVER_FILES=lvp_icd.json |
| 度量 | pr-linux-tests 跑 Bloaty 体积与 benchmark,结果贴 PR 评论 |
| 贡献 | PR + CHANGELOG main 段 + AI 使用披露;架构改动走 Design Proposal(72 小时投票) |
| 发布 | 平台独立 tag(android-v*、ios-v*),无 LTS,回移需自建分支 |