把 Flutter 测试结果接进 Jenkins,本来是一件很成熟的事:测试跑完,用 junitreport_maintained 把 flutter test 的 JSON 输出转成标准 JUnit XML,JUnit 插件解析 XML,趋势图、失败列表、历史对比全都有了。可当这批测试要迁到鸿蒙设备上跑的时候,流程突然卡住了——用例确实执行完了,结果也拿到了,但 XML 这一步总出问题,Jenkins 上始终是一片空白。
问题出在哪儿?当时我们只知道 junitreport_maintained 是纯 Dart 库,按理说在鸿蒙化的 Flutter 运行时里应该能跑。真正动起手来才发现,这类“看起来能跑”的三方库,鸿蒙化适配的难点全藏在运行环境的边界上:文件系统布局不一样、命令行管道不能直接依赖、沙箱目录和大文件读写的行为也和标准 Flutter 环境不同。这篇博客就是整个适配过程的记录:从拆解 junitreport_maintained 的数据链路开始,做端侧适配,再把报告拉到宿主机接入 Jenkins,最后给测试结果做增强。如果你正在做 Flutter 鸿蒙化,或者只是想把移动端测试报告低成本接进 CI,这篇应该能帮你少走不少弯路。
1. 为什么坚持“端侧生成”JUnit XML:一条卡住的 CI 链路
1.1 传统流程里测试报告是怎么流动的
在标准 Flutter 项目里,报告链路通常长这样:开发者在模拟器或真机上执行flutter test --machine,Flutter 测试框架会把每个用例的开始、结束、失败信息以逐行 JSON 事件流的形式打印出来。这个 JSON 不是给人看的,也不是给 CI 插件看的,它只是中间产物。接着 junitreport_maintained 读取这些事件,归类、统计、转义,最终输出一个标准的 JUnit XML 文件。Jenkins 的 JUnit 插件拿到 XML 后,就能解析出用例数、失败数、跳过数、耗时,并画出趋势图。
这条链路在普通 Flutter 项目里没什么问题,因为它依赖一个前提:宿主机上有完整的 Flutter/Dart 工具链,可以用管道把flutter test --machine的输出直接喂给 junitreport_maintained。鸿蒙化之后,前提变了。鸿蒙设备上的 Flutter 运行时来自社区维护的鸿蒙化 Flutter SDK,它能让 Flutter 应用在鸿蒙系统上跑起来,但设备上并没有一个可以直接调用的 dart 命令行,也不会提供 bash 管道环境。你在标准流程里那套“命令管道 + 临时文件”的玩法,在端侧根本走不通。
1.2 junitreport_maintained 在生态里到底扮演什么角色
junitreport_maintained 本质上是原 junitreport 包的维护分支,负责在 Flutter 测试输出和 JUnit XML 之间做桥接。它做的事情非常聚焦:读入flutter test --machine的事件流,按套件、用例、断言结果组织成 XML 节点,再序列化成文件。整个包的核心依赖是 xml、path、args 这类纯 Dart 库,不涉及任何原生代码。
这一点是鸿蒙化适配的幸运之处。很多 Flutter 三方库在鸿蒙化时挂在原生插件上,要么找不到 so 库,要么原生接口不兼容。junitreport_maintained 没有这个负担,理论上只要是支持 Dart VM 的运行环境,它都能执行。问题在于它虽然不碰原生代码,却要碰文件系统、环境变量、进程入口,这些地方恰恰是鸿蒙化 Flutter 运行时和标准 Flutter 差异最大的部分。
1.3 真正的断裂点集中在 I/O 边界
适配过程中我反复确认过一件事:事件解析、数值统计、XML 序列化这些纯计算逻辑在鸿蒙端侧跑起来没有任何异常,异常全出在 I/O 边界上。
第一个断裂点是命令行入口。标准用法里 junitreport_maintained 是一个 CLI 工具,靠管道接收数据。鸿蒙设备上没有管道,也没有全局 dart 命令,所以不能再把它当外部命令用,只能把它的转换逻辑作为库嵌入到测试 runner 进程里,在测试结束的钩子里直接调用。第二个断裂点是路径。库内部会默认使用Directory.systemTemp或当前工作目录来写文件,但鸿蒙沙箱对应用可访问的目录有严格限制,/tmp之类的位置根本不存在或不可写,随便写路径就是一路 FileSystemException。第三个断裂点是报告怎么“拉回”宿主机。标准流程里宿主机可以直接读本地文件,鸿蒙设备上则必须通过 hdc 等设备管理工具把文件拷贝出来。
所以这个项目的核心不是改业务逻辑,而是把运行环境差异一点点补齐。
2. 动手之前先拆库:依赖链路与数据模型
2.1 依赖构成决定了适配方式
在改任何代码之前,我先检查了 junitreport_maintained 的 pubspec,确认依赖边界长什么样。大致是这样:
| 依赖包 | 作用 | 是否纯 Dart |
|---|---|---|
| xml | 构建 XML 树、序列化、处理转义 | 是 |
| args | 解析命令行参数 | 是 |
| path | 跨平台路径拼接与规范化 | 是 |
| collection / meta | 集合操作、注解支持 | 是 |
这个表格说明一个关键结论:junitreport_maintained 不依赖任何原生插件,所以鸿蒙化适配不需要处理 Android/Linux 的 so 库,也不需要重写平台通道。所有改动都能在 Dart 层完成。
因为它是纯 Dart 库,我可以直接修改它内部的转换逻辑来适配端侧环境,不需要动引擎。但相应的,一旦引入本地修改版本,后续升级原包就要重新合并补丁,这是得提前接受的成本。
2.2 flutter test --machine 到底吐了什么事件
要把转换逻辑吃透,得先搞明白输入格式。flutter test --machine输出的是一串 JSON 事件,每一行代表一个事件。最关键的是这几类:
| 事件名 | 关键字段 | 用途 |
|---|---|---|
| suite | id, name, platform, path | 定位套件元信息,生成 testsuite 节点 |
| testStart | id, name, suiteID, groupName | 确定 testcase 的 name、classname |
| testDone | id, result, duration, stackTrace, skipped | 决定 testcase 的 time、failure、skipped 节点 |
| done | success, ... | 整个测试流程结束,触发 XML 落盘 |
看到这个结构就能明白,junitreport_maintained 的工作方式是一边读事件流,一边维护一个“当前未结束的 testcase”映射。testStart 到达时创建 case 对象,testDone 到达时回填 duration 和结果。如果事件乱序或者某个 testcase 的 start 和 done 没配对,最后生成的 XML 就会丢失用例或时间统计变成 0。
2.3 从事件流到 JUnit XML 的字段映射逻辑
JUnit XML 不是随便生成的,它有一套约定俗成的 schema。junitreport_maintained 在生成节点时,字段来源大致如下:
| XML 节点 | 关键属性 | 数据来源 |
|---|---|---|
| testsuite | name, tests, failures, errors, skipped, time | suite.name,以及各 testcase 状态计数 |
| testcase | name, classname, time | testStart.name、suite/group 组合、testDone.duration 换算 |
| failure | message, type | stackTrace 首行信息 |
| skipped | / | testDone.skipped 为 true 时生成 |
| system-out | 文本 | 测试日志或输出捕获 |
| properties | 键值对 | 设备信息、SDK 版本等自定义参数 |
最容易被忽略的是 classname。很多人以为 classname 不重要,实际上 Jenkins 的 JUnit 插件展示测试结果时,会把 name 和 classname 拼成“类名.方法名”的结构。如果 classname 为空,所有用例会归到同一个匿名类下,趋势图里的失败归类就废了。
2.4 四个藏在背后的隐式依赖
除了依赖列表,适配时还要注意几个不写在 pubspec 里的隐式依赖。
第一是文件编码。XML 必须是 UTF-8 无 BOM,如果在写入文件时用了默认编码或者带了 BOM 头,Jenkins 解析时可能报告非法字符。第二是换行符。Windows 宿主机上拉回 XML 后如果被转成 CRLF,一般没问题,但某些解析器会把 \r 当字符处理,导致校验报错。第三是时间单位换算。testDone.duration 的原始单位可能是毫秒或微秒,而 JUnit XML 的 time 属性要求秒,换算时少除一个数量级,所有用例耗时都会虚高。第四是工作目录。库要写输出文件时,如果依赖相对路径,在端侧可能会写到不可写目录。这四个问题都不起眼,但任何一个都能让整条 CI 链路静默失败。
3. 鸿蒙化落地三步走:本地依赖、路径修正、最小验证
3.1 第一步:把 junitreport_maintained 改成 path 依赖
鸿蒙化适配必然要改库的某些行为,如果继续走 pub 远程依赖,每次改动都要发版本、重新拉取,效率太低。我的做法是先把整个库拷贝到工程内的第三方目录,用 path 依赖引入:
dev_dependencies: junitreport_maintained: path: third_party/junitreport_maintained这样的好处是改动即时生效,IDE 里能直接跳到源码打断点。坏处也明显:升级时要手动合并 diff。我的建议是不要在 fork 里堆私有逻辑,只改动跟鸿蒙环境相关的路径处理、字符过滤、文件写入方式。纯 Dart 的逻辑保持和上游一致,以后合并补丁能省很多事。
3.2 第二步:把 I/O 边界统一收口到应用沙箱目录
这是整个适配里最关键的一步。junitreport_maintained 默认用系统的临时目录来写中间文件,这在鸿蒙沙箱下不可行。我统一改成一个可注入的输出目录解析函数,优先使用应用自己的文件目录:
Future<String> resolveOutputDir() async { // 优先使用当前工程提供的可写目录 final override = const String.fromEnvironment('TEST_REPORT_DIR'); if (override.isNotEmpty) { final dir = Directory(override); if (!await dir.exists()) { await dir.create(recursive: true); } return dir.path; } // 兜底:使用 app support 目录 // 这里用 path_provider 或鸿蒙侧封装的目录获取能力都可以 final supportDir = await getApplicationSupportDirectory(); final reportDir = Directory(p.join(supportDir.path, 'test_reports')); if (!await reportDir.exists()) { await reportDir.create(recursive: true); } return reportDir.path; }这里有个原则:不要在业务代码里做“当前是鸿蒙还是 Android”的判断,而是把所有目录获取收敛到一个函数里,让上层只关心拿到的是不是可写路径。沙箱规则以后变了,改这一个函数就够了。
3.3 第三步:用最小验证用例把“库逻辑”和“环境问题”分开
改完目录逻辑,先别急着跑全量测试。我写了一个最小验证脚本,用假数据喂给转换器,确认 XML 生成没有基础错误:
import 'package:junitreport_maintained/junitreport_maintained.dart'; Future<void> main() async { final outDir = Directory('build/tmp_validate'); await outDir.create(recursive: true); final generator = JunitReportGenerator( outputDirectory: outDir.path, ); // 模拟一个最简单的事件流:一个套件、两个用例,一个失败 await generator.handleEvent({ 'type': 'suite', 'id': 1, 'name': 'sample_suite', }); await generator.handleEvent({ 'type': 'testStart', 'id': 1, 'name': 'should_pass', 'suiteID': 1, }); await generator.handleEvent({ 'type': 'testDone', 'id': 1, 'result': 'success', 'duration': 120, }); await generator.handleEvent({ 'type': 'testStart', 'id': 2, 'name': 'should_fail', 'suiteID': 1, }); await generator.handleEvent({ 'type': 'testDone', 'id': 2, 'result': 'failure', 'duration': 80, 'stackTrace': 'AssertionError: expected true but got false', }); await generator.finish(); final xmlFile = File(p.join(outDir.path, 'junit.xml')); if (!await xmlFile.exists()) { throw Exception('XML was not generated'); } final content = await xmlFile.readAsString(); if (!content.contains('testsuite') || !content.contains('failure')) { throw Exception('XML content is invalid: $content'); } print('minimal validation passed'); }这一步非常值得做。它能在 5 分钟内告诉你问题到底出在 junitreport_maintained 自身,还是出在鸿蒙环境的路径和权限,省掉后面在 CI 里反复试错的成本。
4. 实测里的四类踩坑与完整排查链路
4.1 坑一:输出目录断言失败,真实原因是沙箱路径
现象是测试跑完后报FileSystemException: Cannot open file, path = '/tmp/junit.xml' (OS Error: No such file or directory),一开始我以为是 junitreport_maintained 的代码问题,但翻代码看不出问题。
排查链路是这样的:
- 在鸿蒙设备上打印
Directory.systemTemp.path,发现它确实映射到了/tmp,但这个目录在当前进程里根本不存在。 - 再用
Directory.current.path打印工作目录,发现 Flutter 引擎把当前目录设置到了应用安装路径,而这个路径是只读的。 - 最后确认:必须显式获取应用沙箱下的可写目录,不能依赖任何隐含约定。
解决方法就是前面说的收口目录函数。这里的关键教训是:FileSystemException 的报错路径会骗人,你要查的是当前进程允许写哪里,而不是报错说哪里缺。
4.2 坑二:XML 里有非法控制字符导致 Jenkins 解析失败
有一次用例名和日志里包含了一些不可见字符,Jenkins 插件解析时报reference to invalid character number,但本地的 & < > 转义看起来都没问题。
排查链路:
- 先在宿主机上用 xmllint 校验:
xmllint --noout junit.xml,报错指向某个 PCDATA 区域。 - 把该区域的原始字节打印出来,发现是
\u000B这类 XML 1.0 不允许的控制字符。 - 确认 junitreport_maintained 的转义逻辑基于 xml 包,只会对 & < > 做实体化,不会过滤控制字符。
解决手段是在转换器的文本输入处加一个 sanitize 函数,把 XML 1.0 不允许的字符替换成 \uFFFD:
String sanitizeXmlChars(String input) { if (input.isEmpty) return input; final buffer = StringBuffer(); for (final code in input.runes) { final allowed = code == 0x9 || code == 0xA || code == 0xD || (code >= 0x20 && code <= 0xD7FF) || (code >= 0xE000 && code <= 0xFFFD) || (code >= 0x10000 && code <= 0x10FFFF); if (allowed) { buffer.writeCharCode(code); } else { buffer.writeCharCode(0xFFFD); } } return buffer.toString(); }这个函数在标准 Flutter 环境里可能永远用不上,但鸿蒙端侧因为被测应用的日志更“野生”,各种异常字符都能进来,必须有这层兜底。
4.3 坑三:时间字段出现负数、套件耗时全为 0
某次查看报告,发现所有 testcase 的 time 都是 0,个别还出现了负数。这不是数据造假,是单位换算和事件配对出了问题。
排查时我用测试事件里的 duration 字段做了一次打印,发现原文给的是毫秒,而 JUnit XML 的 time 属性需要秒。换算逻辑如果少了除以 1000 的步骤,或者反过来把秒当毫秒写,结果都会失真。
另一个隐蔽问题是事件乱序。某个 testcase 的 testDone 先于它的 testStart 到达,映射表里找不到对应 case,这个用例就会被当成孤儿丢弃,最终统计里的 tests 数也变少。我用按 id 索引的 Map 代替了原来的顺序列表,并在 testDone 到达时做幂等处理,问题就消失了。
换算代码可以这样写:
double durationToSeconds(dynamic durationMs) { final ms = durationMs is num ? durationMs.toDouble() : 0.0; return double.parse((ms / 1000).toStringAsFixed(3)); }如果上游 event 给的是微秒,就把除数改成 1000000。先确认单位,再决定除法,别猜。
4.4 坑四:真机与模拟器行为不一致
最头疼的问题是真机上报告生成失败,模拟器却一切正常。一开始怀疑是权限差异,但排查后发现更隐蔽:junitreport_maintained 内部某个分支用Platform.isAndroid来判断路径策略,鸿蒙的 Flutter 运行时在某些版本里把自己识别成了 android,于是走了 Android 内部存储路径分支,但鸿蒙沙箱的权限模型和 Android 不完全一致,某些目录根本没权限访问。
排查链路是给库里的平台判断位置加日志,打印Platform.operatingSystem,发现上面明明跑的是鸿蒙,却返回了 android。再次确认这不是业务逻辑问题,而是运行时对平台类型的映射差异。
解决方法是把平台判断从“类型依赖”改成“能力依赖”:不关心它说自己是什么平台,只关心最终拿到的目录能否写入。统一走前面那个 recompose 后的输出目录函数,真机和模拟器的行为就一致了。
5. Jenkins/CI 集成实战:从设备拉回 XML 到流水线解析
5.1 用 hdc 把端侧 XML 拉回宿主机
测试 runner 在鸿蒙设备上生成 XML 之后,不能指望 Jenkins 直接读设备文件。鸿蒙开发工具链里带了 hdc 命令,可以操作设备文件系统。我的做法是先在设备上准备一个共享目录,再把应用生成的 XML 拷贝过去,最后拉回宿主机:
# 在设备上准备共享目录 hdc shell "mkdir -p /data/local/tmp/harmony_reports && chmod 777 /data/local/tmp/harmony_reports" # 将应用生成的文件拷贝到共享目录 hdc shell "cp /data/data/你的应用包名/files/test_reports/junit.xml /data/local/tmp/harmony_reports/" # 拉回宿主机 hdc file recv /data/local/tmp/harmony_reports/junit.xml "$WORKSPACE/build/test-results/device_01_junit.xml"如果应用目录权限拿不到,还可以用hdc shell cat的方式直接读回文本:
hdc shell cat /data/local/tmp/harmony_reports/junit.xml > "$WORKSPACE/build/test-results/device_01_junit.xml"注意在 Windows 宿主机上做 cat 重定向,可能把 LF 换行转成 CRLF,虽然 Jenkins 一般不受影响,但建议统一用 hdc file recv 这种二进制安全的命令。
5.2 Jenkins pipeline 里的 JUnit 聚合
Jenkins 的 junit 插件只要拿到 XML 文件路径,就能自动统计用例、画趋势图、归档失败详情。流水线里我分成两个 stage,一个是执行测试并拉取报告,一个是发布报告:
stage('Run Tests on Harmony') { steps { withEnv([ 'DEVICE_SN=xxxx', 'OUT_DIR=build/test-results' ]) { sh ''' set -euo pipefail mkdir -p build/test-results hdc shell "mkdir -p /data/local/tmp/harmony_reports && chmod 777 /data/local/tmp/harmony_reports" # 触发鸿蒙设备上的测试 runner,生成 junit.xml # 假设有一个脚本负责启动测试并等待结束 ./scripts/run_harmony_tests.sh hdc file recv /data/local/tmp/harmony_reports/junit.xml build/test-results/merged.xml ''' } } } stage('Publish JUnit Report') { steps { junit testResults: 'build/test-results/**/*.xml' } }需要强调一点:junit 插件收的是 XML 文件集合,不是单个文件。所以即使只跑一台设备,也建议放在build/test-results/目录下用通配符匹配,后面加第二台设备时不需要改流水线。
5.3 多设备/多模块结果汇总策略
如果团队有多台鸿蒙设备或模拟器并行回归,XML 汇总有几种做法:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 通配符多文件 | 每台设备生成独立 XML,Jenkins 用**/*.xml收集 | 最简单,零额外脚本 | 设备多时归档文件分散,单次趋势图仍能自动汇总 |
| 合并单文件 | 脚本把多个 testsuite 合并进一个 testsuites 根节点 | 归档文件少,适合上传到其他测试平台 | 多一份脚本要维护 |
| 端侧聚合 | 测试 runner 内汇总多个模块 | 最终产物最少 | 跨设备聚合不适合在端侧做 |
实际项目里我建议无脑选第一种,让 Jenkins 自己聚合。它内部会把所有 XML 的 testsuite 累加,生成全局趋势图,不需要自己写合并逻辑。
如果你确实需要单文件,可以用一个简单的 Python 脚本合并:
import glob import xml.etree.ElementTree as ET root = ET.Element('testsuites') for path in glob.glob('build/test-results/*.xml'): tree = ET.parse(path) for node in tree.getroot(): if node.tag == 'testsuite': root.append(node) xml_bytes = ET.tostring(root, encoding='unicode') with open('build/merged.xml', 'w', encoding='utf-8') as f: f.write(xml_bytes)合并后的文件记得先做一次 xmllint 校验再给 Jenkins,避免某个设备端侧生成的文件有问题拖垮整个报告。
5.4 稳定性兜底:超时、重试与失败保留
设备端生成报告这件事,最大的风险不是逻辑错,而是设备不稳定。hdc 命令可能因为设备断连、USB 权限、服务进程被杀而挂起,所以拉取命令一定要套超时:
timeout 60 hdc file recv /data/local/tmp/harmony_reports/junit.xml build/test-results/merged.xml || true同时,如果拉取失败,不要让流水线直接标红。更好的做法是保留设备端的原始日志路径,在流水线里打印出hdc shell ls的输出,方便人工到时去查。我用|| true的原因是:测试本身已经跑完了,拉取失败属于报告归档失败,应该作为告警处理,而不是让整个构建失败。当然,这取决于你的团队是“报告缺失必须失败”还是“测试结果优先展示”,两种策略可以再权衡。
6. 再往前一步:面向团队的 JUnit XML 增强实践
6.1 失败信息附带日志:system-out 与 properties
JUnit XML 的失败节点如果只有 message 和 stackTrace,Jenkins 展示时能干的事有限。把关键日志塞进 system-out,排查问题会快很多。
我在转换器里加了这样的处理:当 testDone 的结果是 failure 或 error 时,把事件流中该用例相关的 print 日志、崩溃堆栈、甚至截图路径都写进 system-out 节点。这样在 Jenkins 的测试详情页里,点开失败用例就能直接看到设备侧的原始输出,不必再翻设备日志。
properties 节点也值得利用。在 testsuite 级 properties 写入设备型号、系统版本、测试 runner 版本,配合 Jenkins 的矩阵展示能力,可以快速看出某个设备型号是不是更容易出问题。
final props = XmlElement(XmlName('properties'), [], [ XmlElement(XmlName('property'), [ XmlAttribute(XmlName('name'), 'device_model'), XmlAttribute(XmlName('value'), deviceModel), ]), XmlElement(XmlName('property'), [ XmlAttribute(XmlName('name'), 'os_version'), XmlAttribute(XmlName('value'), osVersion), ]), ]);这个增强的成本很低,但对测试数据的价值提升很大。
6.2 按 suite 拆分报告,避免单文件过大
当用例数量上万时,单个 XML 文件可能涨到几十 MB,Jenkins 解析会变慢,归档也不方便。我的建议是按 suite 名拆分文件,每个 testsuite 一个 XML:
build/test-results/ login_module.xml order_module.xml payment_module.xmlJenkins 用通配符收集所有 XML,展示时按文件维度区分模块,一目了然。拆分逻辑很简单:在 runner 的 finish 阶段不要只调用一次转换器,而是按已缓存的 suite 批量生成多份文件。
6.3 把整条链路沉淀成一条命令
适配做完之后,最理想的状态是开发者在本地也能一键复现 CI 里的行为,而不是只能在流水线里跑。我把“启动测试 runner + 生成 XML + hdc 拉回 + 合并校验”封装成了一个 wrapper 脚本:
#!/usr/bin/env bash set -euo pipefail DEVICE_ID="${1:-}" OUT_DIR="${2:-build/test-results}" mkdir -p "$OUT_DIR" hdc shell "mkdir -p /data/local/tmp/harmony_reports && chmod 777 /data/local/tmp/harmony_reports" # 触发端侧测试,并指定输出目录 # 这里的细节取决于你的 runner 如何启动 ./scripts/trigger_harmony_tests.sh --device-id "$DEVICE_ID" hdc file recv /data/local/tmp/harmony_reports/junit.xml "$OUT_DIR/${DEVICE_ID}_junit.xml" xmllint --noout "$OUT_DIR/${DEVICE_ID}_junit.xml"这样一来,CI 里只留一行./scripts/harmonytest.sh $DEVICE_ID build/test-results,本地开发也能跑同一套逻辑,减少环境差异带来的“我本机好好的,CI 挂了”类问题。
适配完这套链路之后,我又在项目里跑了两周,最大的体会是:junitreport_maintained 本身没有问题,问题都出在它依赖的外部环境上。鸿蒙化 Flutter 和标准 Flutter 对纯 Dart 代码的执行能力基本一致,但文件系统、进程模型、路径规则这些“看不见的边界”会静默地改变行为。所以如果你也要做类似适配,我建议第一步不是改代码,而是先写好最小验证用例,把问题范围精确到“环境”还是“逻辑”。另外一个小技巧:接入 Jenkins 之前,先在本地用 xmllint --noout 校验一份 XML,能省掉很多在流水线里来回试的功夫。