
WidgetFuzzLab 不在手机里跑随机测试。它把 Form Kit 的新增、刷新、路由、删除抽成纯模型交给 fast-check 在构建侧生成序列手机端只展示同一份报告。这样既不把测试库带进 HAP也能把偶现问题压缩成五步可复现样本。一、四十二步之后的错误人工很难复盘桌面卡片的单个回调通常不难。麻烦来自顺序用户刚添加卡片系统触发更新点击卡片拉起应用应用写入新数据桌面又发来刷新紧接着用户删除卡片。每一步单独执行都没错组合起来却可能让已删除的formId再次落盘或者让旧版本数据覆盖新版本。原来的回归脚本只有几条固定流程。线上偶发一次“删除后复活”日志足足 42 步手工删掉任何一段都可能让问题消失。团队花了半天重放最后还不能确定最小触发条件。于是我把目标从“多写几个示例”改成三个可验证的不变量已删除卡片不可更新同一formId的revision只能单调递增同一个待消费路由最多处理一次。本次任务编号为fuzz_20261001_22项目叫 WidgetFuzzLab报告页叫PropertyReportPage。测试使用固定种子203104717总共生成 5,000 条序列、42,680 个命令。fast-check 只用于 Node 侧测试任务不进入 HarmonyOS 运行包ArkTS 运行时仍只包含 Form Kit 业务适配器和一份 JSON 报告读取逻辑。二、先做一个比真实卡片更严格的模型属性测试不是把随机数塞进现有接口。没有“什么结果才算对”随机只会制造噪声。我先写纯内存模型每个卡片记录revision、删除标记和待消费路由模型不会访问 Form Kit也不会写文件。真实适配器和模型接收同一条命令执行后比较可观察状态。下面这段代码解决的是判定标准缺失。WidgetLifecycleModel把三条不变量放在同一处命令执行后都调用assertInvariant()。exportinterfaceWidgetState{revision:numberremoved:booleanpendingRoute?:stringconsumedRoutes:Setstring}exportclassWidgetLifecycleModel{privatestates:Mapstring,WidgetStatenewMap()add(formId:string):void{if(!this.states.has(formId)){this.states.set(formId,{revision:0,removed:false,consumedRoutes:newSetstring()})}}update(formId:string,revision:number):void{conststatethis.states.get(formId)if(!state||state.removed||revisionstate.revision)returnstate.revisionrevision}remove(formId:string):void{conststatethis.states.get(formId)if(state)state.removedtrue}route(formId:string,routeId:string):void{conststatethis.states.get(formId)if(!state||state.removed||state.consumedRoutes.has(routeId))returnstate.pendingRouterouteId state.consumedRoutes.add(routeId)}assertInvariant():void{for(conststateofthis.states.values()){if(state.removedstate.pendingRoute)thrownewError(REMOVED_WITH_ROUTE)if(state.revision0)thrownewError(REVISION_ROLLBACK)}}}模型故意比业务层更保守一旦删除就不允许复活。update()对相同或更小的版本直接忽略这使重复回调天然幂等。数据只在命令被接受时变化拒绝分支不修改状态。测试结束后模型整体丢弃没有资源释放问题但它不能替代真机验证因为系统回调的并发、序列化和进程退出仍属于真实适配器的责任。三、命令生成、收缩与重放是一条链我为AddCommand、UpdateCommand、RemoveCommand和RouteCommand分别实现fc.AsyncCommand。check()控制某条命令当前是否有效run()同时推动模型和真实适配器。生成器偏向重复更新、删除后更新和相同路由重放因为这些正是事故集中区而不是平均分配所有操作。下面的测试入口解决“失败只能在当次随机运行中看到”的问题。固定种子保证序列可追溯fast-check 收缩后输出replayPath下一次可以只重放最小失败样本。importfcfromfast-checkimport{WidgetLifecycleModel}from../model/WidgetLifecycleModelimport{FormRuntimeAdapter}from../adapter/FormRuntimeAdapterimport{widgetCommandArbitrary}from./WidgetCommandsconstseed:number203104717constreplayPath:string|undefinedprocess.env.FC_PATHawaitfc.assert(fc.asyncProperty(fc.commands(widgetCommandArbitrary(),{maxCommands:64}),async(commands){constsetup()({model:newWidgetLifecycleModel(),real:newFormRuntimeAdapter()})awaitfc.asyncModelRun(setup,commands)}),{seed,path:replayPath,numRuns:5000,endOnFailure:true,verbose:2})这里的commands会记录整条执行轨迹失败时尝试删除命令、缩小参数直到找不到更短的仍失败序列。第一次定位到的问题从 42 步收缩成 5 步路径是AAAE:B。FC_PATHAAAE:B只适合与同一 seed、同一生成器版本组合使用改了命令集合后路径可能失效因此报告还会保存可读命令文本。测试进程完成后适配器要显式dispose()关闭临时数据库和定时器否则下一轮属性测试可能被上一轮资源污染。四、真实适配器的修复点是幂等提交最小序列是添加 A、更新到 7、删除 A、收到延迟更新 8、消费旧路由。错误不在 Form Kit而在我们的onUpdateForm()先写数据库、后检查删除墓碑。进程调度改变时延迟更新就可能穿过删除操作。下面这段适配器代码解决“检查与写入分离”的竞态。它把墓碑、当前版本和新数据放进同一个存储事务只有版本更新且未删除才提交。exportclassFormRuntimeAdapter{constructor(privatestore:FormStateStoreFormStateStore.getInstance()){}asyncapplyUpdate(formId:string,revision:number,payload:Recordstring,Object):Promiseboolean{returnthis.store.transaction(async(tx:FormTransaction){constrecordawaittx.get(formId)if(!record||record.removed)returnfalseif(revisionrecord.revision)returnfalseawaittx.put(formId,{...record,revision,payload,updatedAt:Date.now()})returntrue})}asyncremove(formId:string):Promisevoid{awaitthis.store.transaction(async(tx:FormTransaction){constrecordawaittx.get(formId)if(record)awaittx.put(formId,{...record,removed:true,pendingRoute:undefined})})}dispose():void{this.store.closeIdleHandles()}}applyUpdate()返回布尔值让EntryFormAbility决定是否继续调用卡片更新而不是把拒绝当异常。删除时保留墓碑而不是立即删除记录是为了识别晚到回调墓碑可在超过系统可能重试的时间窗后批量清理。正式项目需要用真实的关系型或键值存储事务替换 Demo 抽象不能把先get后put的两个普通 Promise 当成原子操作。FormExtensionAbility 的生命周期也必须纳入判断。系统回调结束后扩展进程并不适合承载长任务耗时计算应交给应用或后台任务准备好数据后再更新卡片。属性测试验证的是业务状态顺序不代表可以绕过系统对扩展存活时间的约束。五、报告页只展示证据不执行测试DevEco Studio 图里左侧目录把test/property/WidgetCommands.test.ts、模型、真实适配器和EntryFormAbility.ets分开中间是固定种子的测试入口右侧模拟器打开PropertyReportPage。底部 HiLog 与报告使用同一份 JSON5,000 条序列、42,680 个命令历史失败 37 个已全部修复最小反例 42 → 5 步11 个曾经不稳定的样本现在都能稳定重放收缩耗时 P95 为 84 ms。状态流转是GENERATING → SHRINKING → REPLAYING → PROPERTY_GREEN。PROPERTY_GREEN只表示当前生成器与不变量下没有失败不表示卡片没有任何缺陷。生成器没有覆盖的权限变化、网络中断和跨设备恢复仍要用专项测试补齐。手机截图顶部显示时间 22:56、电量 86%页面中保留任务编号、种子和AAAE:B是为了让值班同学能从报告直接拼出重放命令。它没有手机外壳也没有把 IDE 截图再嵌进去红色批注只指向最小反例与最终状态。报告生成也做了两层隔离。属性测试只输出结构化结果到build/reports/widget-fuzz/result.json一个轻量构建步骤再挑选摘要写入应用的 rawfile。这样本地调试可以看到最新结果发布构建则可以完全排除测试报告。报告 schema 带版本号页面遇到未知版本只显示“报告不兼容”不会把缺失字段误判成零失败。这个细节避免测试工具反过来成为应用运行时的脆弱依赖。我还给每个命令增加了稳定的文本表示例如Update(A,8)和Remove(A)。fast-check 的 path 很适合机器重放但代码重构后未必长期可读命令文本则能直接贴进缺陷单。最小反例最终被保存为五行而不是一整段序列化对象。值班同学不需要理解生成器内部结构就能看出“删除之后仍接受更新”才是关键转折。性能数据同样需要正确解读。84 ms 是失败样本进入收缩阶段后的 P95不是 5,000 条序列的总耗时42,680 是实际执行命令数不等于生成器尝试次数因为被check()拒绝的命令不会执行。把这些口径写进报告是为了防止后来的人看到数值变化就误判性能回退。工具页展示摘要完整耗时分布仍留在 CI 附件中。六、让随机测试能长期留在工程里属性测试最容易出现两个反效果。一个是序列太随意跑得多却碰不到危险边界另一个是每次失败都不同团队逐渐把它当成“不稳定测试”跳过。我的做法是把线上事故转换成生成权重和不变量并把 seed、path、命令文本、代码版本一起写入报告。任何历史失败都进入固定回归集合随机生成负责继续探路固定集合负责防止倒退。CI 中分成两档提交阶段运行 300 条序列控制反馈时间夜间任务运行 5,000 条并允许完整收缩。发现失败后先把seed path固化确认可重放再修业务代码。不要为了让流水线变绿而只调整生成器也不要无限增加numRuns掩盖一个不可复现的共享状态问题。还有几个工程边界值得保留。命令的check()不能过度过滤否则永远生成不了删除后更新这种非法输入真实适配器必须为每次 property 创建独立实例时间相关逻辑要注入时钟避免Date.now()让重放漂移formId在模型里使用字符串不能隐式转成 Number测试报告落盘要做大小限制不能把 5,000 条完整轨迹全部塞进资源包。模型与真实实现的比较也不能只看最终快照。如果先错误更新、随后又被另一条命令覆盖最终数据可能恰好一致过程中的违规却已经对桌面发出一次错误刷新。因此适配器还记录精简事件流断言每次提交之前都满足墓碑和版本条件。事件流在单条 property 结束后立即清空失败时才写报告既能捕捉瞬时错误也不会无限占用内存。针对并发我没有让随机命令直接开大量线程那会让失败变得难以收缩。当前版本先把逻辑顺序验证干净再用两个受控调度点模拟“删除检查后暂停”和“更新提交前恢复”。如果后续要覆盖更多调度组合应该引入可控 scheduler并把调度选择一并纳入重放数据。盲目增加并发只会得到偶发红灯不会得到可行动的反例。最后第三方库版本必须锁定并接受许可证与依赖审计。fast-check 位于测试依赖构建产物要通过依赖清单确认它没有进入 HAP。升级版本时先用历史 seed 和固定回归集合跑一遍再更新 path如果收缩策略变化导致旧 path 失效仍可用保存的命令文本重建用例。工具可替换失败证据不能随工具升级一起消失。这次还暴露出一个命名问题。过去的refresh()同时承担读取、比较、写入和通知模型无法判断哪一步违反规则。拆成loadRecord()、acceptRevision()、commitPayload()和publishUpdate()后属性测试能够在提交边界检查不变量生产代码的错误语义也清楚了。拒绝旧版本属于正常分支存储事务失败才进入错误日志二者不再混成同一个异常码。当 Form Kit 回调携带未知formId时适配器不会自动创建记录而是记一次受控拒绝。自动补建看似提高容错实际会绕过添加流程需要写入的初始配置。生成器专门提高未知 ID 的比例确认它既不污染存储也不会触发桌面更新。这个边界同样来自最小反例随机工具最有价值的地方往往是逼代码回答过去从未明确过的业务问题。这套工具最终没有改变卡片的视觉效果却改变了团队处理偶现故障的方式从一段长日志里猜测变成一个五步反例从“可能是系统回调乱序”变成可执行的不变量从修完一次就算结束变成把失败样本永久收入回归。fast-check 的价值不在随机本身而在它能把复杂生命周期压缩到人能读懂、机器能重复的最小证据。