把 Flutter 测试结果接进 Jenkins本来是一件很成熟的事测试跑完用 junitreport_maintained 把 flutter test 的 JSON 输出转成标准 JUnit XMLJUnit 插件解析 XML趋势图、失败列表、历史对比全都有了。可当这批测试要迁到鸿蒙设备上跑的时候流程突然卡住了——用例确实执行完了结果也拿到了但 XML 这一步总出问题Jenkins 上始终是一片空白。问题出在哪儿当时我们只知道 junitreport_maintained 是纯 Dart 库按理说在鸿蒙化的 Flutter 运行时里应该能跑。真正动起手来才发现这类“看起来能跑”的三方库鸿蒙化适配的难点全藏在运行环境的边界上文件系统布局不一样、命令行管道不能直接依赖、沙箱目录和大文件读写的行为也和标准 Flutter 环境不同。这篇博客就是整个适配过程的记录从拆解 junitreport_maintained 的数据链路开始做端侧适配再把报告拉到宿主机接入 Jenkins最后给测试结果做增强。如果你正在做 Flutter 鸿蒙化或者只是想把移动端测试报告低成本接进 CI这篇应该能帮你少走不少弯路。1. 为什么坚持“端侧生成”JUnit XML一条卡住的 CI 链路1.1 传统流程里测试报告是怎么流动的在标准 Flutter 项目里报告链路通常长这样开发者在模拟器或真机上执行flutter test --machineFlutter 测试框架会把每个用例的开始、结束、失败信息以逐行 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确认依赖边界长什么样。大致是这样依赖包作用是否纯 Dartxml构建 XML 树、序列化、处理转义是args解析命令行参数是path跨平台路径拼接与规范化是collection / meta集合操作、注解支持是这个表格说明一个关键结论junitreport_maintained 不依赖任何原生插件所以鸿蒙化适配不需要处理 Android/Linux 的 so 库也不需要重写平台通道。所有改动都能在 Dart 层完成。因为它是纯 Dart 库我可以直接修改它内部的转换逻辑来适配端侧环境不需要动引擎。但相应的一旦引入本地修改版本后续升级原包就要重新合并补丁这是得提前接受的成本。2.2 flutter test --machine 到底吐了什么事件要把转换逻辑吃透得先搞明白输入格式。flutter test --machine输出的是一串 JSON 事件每一行代表一个事件。最关键的是这几类事件名关键字段用途suiteid, name, platform, path定位套件元信息生成 testsuite 节点testStartid, name, suiteID, groupName确定 testcase 的 name、classnametestDoneid, result, duration, stackTrace, skipped决定 testcase 的 time、failure、skipped 节点donesuccess, ...整个测试流程结束触发 XML 落盘看到这个结构就能明白junitreport_maintained 的工作方式是一边读事件流一边维护一个“当前未结束的 testcase”映射。testStart 到达时创建 case 对象testDone 到达时回填 duration 和结果。如果事件乱序或者某个 testcase 的 start 和 done 没配对最后生成的 XML 就会丢失用例或时间统计变成 0。2.3 从事件流到 JUnit XML 的字段映射逻辑JUnit XML 不是随便生成的它有一套约定俗成的 schema。junitreport_maintained 在生成节点时字段来源大致如下XML 节点关键属性数据来源testsuitename, tests, failures, errors, skipped, timesuite.name以及各 testcase 状态计数testcasename, classname, timetestStart.name、suite/group 组合、testDone.duration 换算failuremessage, typestackTrace 首行信息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 默认用系统的临时目录来写中间文件这在鸿蒙沙箱下不可行。我统一改成一个可注入的输出目录解析函数优先使用应用自己的文件目录FutureString 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; Futurevoid 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 不允许的字符替换成 \uFFFDString 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_SNxxxx, OUT_DIRbuild/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 汇总有几种做法方案做法优点缺点通配符多文件每台设备生成独立 XMLJenkins 用**/*.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, encodingunicode) with open(build/merged.xml, w, encodingutf-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 与 propertiesJUnit XML 的失败节点如果只有 message 和 stackTraceJenkins 展示时能干的事有限。把关键日志塞进 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 文件可能涨到几十 MBJenkins 解析会变慢归档也不方便。我的建议是按 suite 名拆分文件每个 testsuite 一个 XMLbuild/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能省掉很多在流水线里来回试的功夫。