第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 新增渲染测试的流程

  1. 在 metrics/integration/render-tests/<属性名>/<用例名>/ 下新建目录。
  2. 写 style.json,可以直接复制邻近用例再改属性值。
  3. 用 -u default 跑一次,生成首版 expected.png。
  4. 人工检查生成的图。这一步不能省——机器只会把“第一次跑出来的结果”当正确答案,如果渲染本身有 bug,基线会把 bug 固化下来。
  5. 提交 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 都做这些事:

  1. cmake --preset linux-<renderer>,加 -DMLN_WITH_CLANG_TIDY=ON、-DMLN_WITH_COVERAGE=ON、Vulkan 变体加 -DMLN_WITH_SANITIZER=undefined。
  2. 构建 6 个目标:mbgl-core mbgl-test-runner mbgl-render-test-runner mbgl-expression-test mbgl-render mbgl-benchmark-runner。
  3. 跑 CodeQL(languages: cpp)。
  4. xvfb-run -a build-linux-<renderer>/mbgl-test-runner 跑单元测试。
  5. 跑渲染测试(mbgl-render-test-runner)。
  6. 上传覆盖率、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 很短,核心只有几条:

  1. 先熟悉目标平台的构建流程(platform/<平台>/README.md 或 INSTALL 文档)。
  2. 提交前检索已有 PR 与 issue,避免重复。
  3. 面向用户的变更要更新对应 CHANGELOG.md 的 main 段,用 [core]、[android]、[ios]、[node] 之类的前缀标注影响面。
  4. 使用 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,回移需自建分支