zeno-mobile-runner 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to Zeno Mobile Runner are tracked here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.7 (2026-06-15)
8
+
9
+ ### Fixed
10
+
11
+ - `assertHealthy` now uses native iOS selector probes for known crash and error
12
+ overlays before falling back to a broad accessibility snapshot. This avoids
13
+ false `CommandFailed` failures when XCTest broad snapshot enumeration races
14
+ with animated or reloading screens, while preserving direct overlay detection.
15
+
16
+ ## 0.2.6 (2026-06-15)
17
+
18
+ ### Fixed
19
+
20
+ - Snapshot-based waits and `assertHealthy` now retry transient observation
21
+ `CommandFailed` errors within the step timeout, recording the retry in the
22
+ trace instead of failing immediately. This fixes iOS XCTest shim snapshot
23
+ races observed after an app reached the expected screen.
24
+
7
25
  ## 0.2.5 (2026-06-15)
8
26
 
9
27
  ### Fixed
package/FEATURES.md CHANGED
@@ -142,7 +142,7 @@ state, and writes deterministic traces. It does not embed an LLM.
142
142
 
143
143
  ## Current Limitations
144
144
 
145
- - Current release status is `0.2.5`, a public developer preview rather than
145
+ - Current release status is `0.2.7`, a public developer preview rather than
146
146
  a production-stable `1.0.0`.
147
147
  - Physical iOS log capture is still simulator-first. Physical iOS screenshots
148
148
  are available when the XCTest/XCUIAutomation shim is configured.
package/README.md CHANGED
@@ -197,7 +197,7 @@ comparisons against your current E2E tool, and multi-device matrices, see
197
197
  | Cloud device farms | Not included | ZMR focuses on local and self-managed device targets in this preview |
198
198
 
199
199
  Slow CI hardware can extend the iOS shim cold-build timeout with
200
- `ZMR_IOS_SHIM_TIMEOUT_MS`. Current release: `0.2.5` developer preview.
200
+ `ZMR_IOS_SHIM_TIMEOUT_MS`. Current release: `0.2.7` developer preview.
201
201
  Protocol version: `2026-04-28`.
202
202
 
203
203
  ## Optional protocol clients
package/build.zig.zon CHANGED
@@ -1,6 +1,6 @@
1
1
  .{
2
2
  .name = .zeno_mobile_runner,
3
- .version = "0.2.5",
3
+ .version = "0.2.7",
4
4
  .minimum_zig_version = "0.16.0",
5
5
  .paths = .{""},
6
6
  .fingerprint = 0xcc2c8187874868fc,
@@ -27,7 +27,7 @@ gradle -p clients/kotlin runFakeSession \
27
27
  ```
28
28
 
29
29
  ```kotlin
30
- implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.5.jar"))
30
+ implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.7.jar"))
31
31
  ```
32
32
 
33
33
  ```kotlin
@@ -4,7 +4,7 @@ plugins {
4
4
  }
5
5
 
6
6
  group = "dev.zmr"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
 
9
9
  kotlin {
10
10
  jvmToolchain(17)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zmr-client"
7
- version = "0.2.5.dev1"
7
+ version = "0.2.7.dev1"
8
8
  description = "Python JSON-RPC client for Zeno Mobile Runner."
9
9
  requires-python = ">=3.9"
10
10
  license = { text = "MIT" }
@@ -100,7 +100,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
100
100
 
101
101
  [[package]]
102
102
  name = "zmr-client"
103
- version = "0.2.5"
103
+ version = "0.2.7"
104
104
  dependencies = [
105
105
  "serde",
106
106
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "zmr-client"
3
- version = "0.2.5"
3
+ version = "0.2.7"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  description = "Rust JSON-RPC client for Zeno Mobile Runner."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmr/client",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "types": "index.d.ts",
@@ -1,4 +1,4 @@
1
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.5","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
1
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
2
2
  {"jsonrpc":"2.0","id":2,"result":[{"serial":"fake-device-1","state":"device","ready":true}]}
3
3
  {"jsonrpc":"2.0","id":3,"result":{"sessionId":"default"}}
4
4
  {"jsonrpc":"2.0","id":4,"result":true}
package/docs/protocol.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ZMR exposes newline-delimited JSON-RPC 2.0 over stdio or localhost TCP in v1. Each request is one JSON object followed by `\n`. Each response is one JSON object followed by `\n`.
4
4
 
5
- Current runner version: `0.2.5`.
5
+ Current runner version: `0.2.7`.
6
6
 
7
7
  Current protocol version: `2026-04-28`.
8
8
 
@@ -47,7 +47,7 @@ and protocol versions. The response is covered by
47
47
  `schemas/inspect-output.schema.json`:
48
48
 
49
49
  ```json
50
- {"ok":true,"status":"ready","schemaVersion":1,"runnerVersion":"0.2.5","protocolVersion":"2026-04-28","dir":".","configPath":".zmr/config.json","configExists":true,"agentInstructionsPath":".zmr/AGENTS.md","agentInstructionsExists":true,"platforms":[{"name":"android","enabled":true,"defaultDevice":"emulator-5554","smokeScenario":".zmr/android-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-android"},{"name":"ios","enabled":true,"defaultDevice":"booted","smokeScenario":".zmr/ios-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-ios"}],"recommendedCommands":["zmr doctor --strict --json --config .zmr/config.json","zmr schemas --json","zmr validate --json .zmr/android-smoke.json","zmr validate --json .zmr/ios-smoke.json","zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent","zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent"],"claimsPolicy":["verify runs with trace evidence before making readiness claims","do not claim Flutter widget-tree inspection"],"limitations":["inspect is read-only and does not launch devices","autonomous crawling is not shipped; generate or edit scenarios for human review"]}
50
+ {"ok":true,"status":"ready","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","dir":".","configPath":".zmr/config.json","configExists":true,"agentInstructionsPath":".zmr/AGENTS.md","agentInstructionsExists":true,"platforms":[{"name":"android","enabled":true,"defaultDevice":"emulator-5554","smokeScenario":".zmr/android-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-android"},{"name":"ios","enabled":true,"defaultDevice":"booted","smokeScenario":".zmr/ios-smoke.json","smokeScenarioExists":true,"traceDir":"traces/zmr-ios"}],"recommendedCommands":["zmr doctor --strict --json --config .zmr/config.json","zmr schemas --json","zmr validate --json .zmr/android-smoke.json","zmr validate --json .zmr/ios-smoke.json","zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent","zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent"],"claimsPolicy":["verify runs with trace evidence before making readiness claims","do not claim Flutter widget-tree inspection"],"limitations":["inspect is read-only and does not launch devices","autonomous crawling is not shipped; generate or edit scenarios for human review"]}
51
51
  ```
52
52
 
53
53
  `zmr discover --from-trace <trace-dir> --out <scenario.json> --validate --json`
@@ -60,7 +60,7 @@ invent credentials, or commit files. The response is covered by
60
60
  `schemas/discover-output.schema.json`:
61
61
 
62
62
  ```json
63
- {"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.5","protocolVersion":"2026-04-28","out":".zmr/discovered/replay-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/replay-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/replay-smoke.json","zmr run .zmr/discovered/replay-smoke.json --json --trace-dir traces/zmr-agent"]}
63
+ {"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/replay-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/replay-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/replay-smoke.json","zmr run .zmr/discovered/replay-smoke.json --json --trace-dir traces/zmr-agent"]}
64
64
  ```
65
65
 
66
66
  `zmr explore --from-trace <trace-dir> --out <scenario.json> --goal <goal>
@@ -71,7 +71,7 @@ launch devices, crawl the app, invent missing actions, discover credentials, or
71
71
  commit files. The response is covered by `schemas/explore-output.schema.json`:
72
72
 
73
73
  ```json
74
- {"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.5","protocolVersion":"2026-04-28","goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"],"out":".zmr/discovered/login-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/login-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/login-smoke.json","zmr run .zmr/discovered/login-smoke.json --json --trace-dir traces/zmr-agent"]}
74
+ {"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"],"out":".zmr/discovered/login-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":6,"replay":{"enabled":true,"eventCount":4,"stepCount":3,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/login-smoke.json","name":"draft from login smoke","appId":"com.example.mobiletest","stepCount":6},"nextCommands":["zmr validate --json .zmr/discovered/login-smoke.json","zmr run .zmr/discovered/login-smoke.json --json --trace-dir traces/zmr-agent"]}
75
75
  ```
76
76
 
77
77
  `zmr draft --from-trace <trace-dir> --out <scenario.json> --json` is the lower
@@ -84,7 +84,7 @@ into fields, or commit files. The response is covered by
84
84
  `schemas/draft-output.schema.json`:
85
85
 
86
86
  ```json
87
- {"ok":true,"mode":"draft","schemaVersion":1,"runnerVersion":"0.2.5","protocolVersion":"2026-04-28","out":".zmr/discovered/surface-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":4,"replay":{"enabled":false,"eventCount":0,"stepCount":0,"skippedEventCount":0},"warnings":["draft requires human review before commit"],"nextCommands":["zmr validate --json .zmr/discovered/surface-smoke.json","zmr run .zmr/discovered/surface-smoke.json --json --trace-dir traces/zmr-agent"]}
87
+ {"ok":true,"mode":"draft","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/surface-smoke.json","traceDir":"traces/zmr-agent","sourceSnapshot":"traces/zmr-agent/artifacts/snapshot-2.json","name":"draft from login smoke","appId":"com.example.mobiletest","selectorCount":2,"stepCount":4,"replay":{"enabled":false,"eventCount":0,"stepCount":0,"skippedEventCount":0},"warnings":["draft requires human review before commit"],"nextCommands":["zmr validate --json .zmr/discovered/surface-smoke.json","zmr run .zmr/discovered/surface-smoke.json --json --trace-dir traces/zmr-agent"]}
88
88
  ```
89
89
 
90
90
  `zmr draft --include-actions` additionally parses `events.jsonl` and prepends
@@ -214,7 +214,7 @@ installers, setup scripts, and generated clients. The response is covered by
214
214
  `schemas/version-output.schema.json`:
215
215
 
216
216
  ```json
217
- {"name":"zmr","version":"0.2.5","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
217
+ {"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
218
218
  ```
219
219
 
220
220
  ## Capabilities Output Contract
@@ -226,7 +226,7 @@ and method inventory for JSON-RPC clients. The result object is covered by
226
226
  iOS simulator, or physical iOS workflows are available.
227
227
 
228
228
  ```json
229
- {"name":"zmr","version":"0.2.5","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}
229
+ {"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}
230
230
  ```
231
231
 
232
232
  ## Doctor Output Contract
@@ -432,7 +432,7 @@ Request:
432
432
  Response:
433
433
 
434
434
  ```json
435
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.5","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
435
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.7","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
436
436
  ```
437
437
 
438
438
  ### `trace.events`
@@ -514,7 +514,7 @@ Request:
514
514
  Response:
515
515
 
516
516
  ```json
517
- {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.5","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-smoke.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-smoke.json","name":"agent smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-smoke.json","zmr run .zmr/discovered/agent-smoke.json --json --trace-dir traces/agent-session"]}}
517
+ {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-smoke.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-smoke.json","name":"agent smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-smoke.json","zmr run .zmr/discovered/agent-smoke.json --json --trace-dir traces/agent-session"]}}
518
518
  ```
519
519
 
520
520
  Without `--trace-dir`, it returns `ok: false` with `traceDir: null`. Generated
@@ -537,7 +537,7 @@ Request:
537
537
  Response:
538
538
 
539
539
  ```json
540
- {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.5","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-goal.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent goal smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-goal.json","name":"agent goal smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-goal.json","zmr run .zmr/discovered/agent-goal.json --json --trace-dir traces/agent-session"],"goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"]}}
540
+ {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.7","protocolVersion":"2026-04-28","out":".zmr/discovered/agent-goal.json","traceDir":"traces/agent-session","sourceSnapshot":"traces/agent-session/artifacts/snapshot-1.json","name":"agent goal smoke","appId":"com.example.mobiletest","selectorCount":1,"stepCount":4,"replay":{"enabled":true,"eventCount":2,"stepCount":1,"skippedEventCount":1},"warnings":["draft requires human review before commit"],"validated":true,"validation":{"ok":true,"path":".zmr/discovered/agent-goal.json","name":"agent goal smoke","appId":"com.example.mobiletest","stepCount":4},"nextCommands":["zmr validate --json .zmr/discovered/agent-goal.json","zmr run .zmr/discovered/agent-goal.json --json --trace-dir traces/agent-session"],"goal":"find a stable login smoke","autonomous":false,"reviewRequired":true,"guardrails":["writes from existing trace evidence only","does not crawl the app","does not discover credentials or secrets","requires human review before commit"]}}
541
541
  ```
542
542
 
543
543
  Without `--trace-dir`, it returns `ok: false` with `traceDir: null`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Agent-native mobile app test runner for React Native, Expo, Flutter, and native Android/iOS.",
5
5
  "main": "npm/index.mjs",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
package/src/runner.zig CHANGED
@@ -409,3 +409,132 @@ test "whenVisible skips the conditional block when the visibility probe command
409
409
  try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"step.whenVisible.skipped\"") != null);
410
410
  try std.testing.expect(std.mem.indexOf(u8, events, "\"error\":\"CommandFailed\"") != null);
411
411
  }
412
+
413
+ test "assertHealthy retries through a transient observation command failure" {
414
+ const allocator = std.testing.allocator;
415
+ const dir = "zig-cache-test-runner-assert-healthy-command-failed";
416
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
417
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
418
+
419
+ const FlakyHealthDevice = struct {
420
+ allocator: std.mem.Allocator,
421
+ snapshots: usize = 0,
422
+
423
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
424
+ _ = writer;
425
+ self.snapshots += 1;
426
+ if (self.snapshots == 1) return error.CommandFailed;
427
+
428
+ const nodes = try self.allocator.alloc(types.UiNode, 1);
429
+ nodes[0] = .{
430
+ .stable_id = try self.allocator.dupe(u8, "node-ready"),
431
+ .class_name = try self.allocator.dupe(u8, "android.widget.TextView"),
432
+ .text = try self.allocator.dupe(u8, "Probe mode"),
433
+ };
434
+ return .{
435
+ .id = try self.allocator.dupe(u8, "snapshot-ready"),
436
+ .timestamp_ms = 1,
437
+ .nodes = nodes,
438
+ };
439
+ }
440
+ };
441
+
442
+ var device = FlakyHealthDevice{ .allocator = allocator };
443
+ var tw = try trace.TraceWriter.init(allocator, dir);
444
+ defer tw.deinit();
445
+
446
+ try std.testing.expect(try assertHealthy(&device, 100, &tw, .{ .settle_ms = 0, .poll_ms = 0 }));
447
+ try std.testing.expectEqual(@as(usize, 2), device.snapshots);
448
+
449
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
450
+ defer allocator.free(events_path);
451
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
452
+ defer allocator.free(events);
453
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"observe.retry\"") != null);
454
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"error\":\"CommandFailed\"") != null);
455
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"assert.healthy\"") != null);
456
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
457
+ }
458
+
459
+ test "assertHealthy uses native selector probes before broad snapshots" {
460
+ const allocator = std.testing.allocator;
461
+ const dir = "zig-cache-test-runner-assert-healthy-native-selector";
462
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
463
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
464
+
465
+ const NativeHealthDevice = struct {
466
+ allocator: std.mem.Allocator,
467
+ queries: usize = 0,
468
+ snapshots: usize = 0,
469
+
470
+ pub fn visibleBySelector(self: *@This(), wanted: selector.Selector) !?bool {
471
+ _ = wanted;
472
+ self.queries += 1;
473
+ return false;
474
+ }
475
+
476
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
477
+ _ = writer;
478
+ self.snapshots += 1;
479
+ return error.UnexpectedSnapshotFallback;
480
+ }
481
+ };
482
+
483
+ var device = NativeHealthDevice{ .allocator = allocator };
484
+ var tw = try trace.TraceWriter.init(allocator, dir);
485
+ defer tw.deinit();
486
+
487
+ try std.testing.expect(try assertHealthy(&device, 100, &tw, .{ .settle_ms = 0, .poll_ms = 0 }));
488
+ try std.testing.expect(device.queries > 0);
489
+ try std.testing.expectEqual(@as(usize, 0), device.snapshots);
490
+
491
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
492
+ defer allocator.free(events_path);
493
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
494
+ defer allocator.free(events);
495
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"assert.healthy\"") != null);
496
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
497
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"strategy\":\"nativeSelector\"") != null);
498
+ }
499
+
500
+ test "assertHealthy reports unhealthy native selector matches" {
501
+ const allocator = std.testing.allocator;
502
+ const dir = "zig-cache-test-runner-assert-healthy-native-unhealthy";
503
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
504
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
505
+
506
+ const NativeUnhealthyDevice = struct {
507
+ allocator: std.mem.Allocator,
508
+ queries: usize = 0,
509
+ snapshots: usize = 0,
510
+
511
+ pub fn visibleBySelector(self: *@This(), wanted: selector.Selector) !?bool {
512
+ self.queries += 1;
513
+ if (wanted.text_contains) |text| return std.mem.eql(u8, text, "ReferenceError");
514
+ return false;
515
+ }
516
+
517
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
518
+ _ = writer;
519
+ self.snapshots += 1;
520
+ return error.UnexpectedSnapshotFallback;
521
+ }
522
+ };
523
+
524
+ var device = NativeUnhealthyDevice{ .allocator = allocator };
525
+ var tw = try trace.TraceWriter.init(allocator, dir);
526
+ defer tw.deinit();
527
+
528
+ try std.testing.expect(!try assertHealthy(&device, 100, &tw, .{ .settle_ms = 0, .poll_ms = 0 }));
529
+ try std.testing.expect(device.queries > 0);
530
+ try std.testing.expectEqual(@as(usize, 0), device.snapshots);
531
+
532
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
533
+ defer allocator.free(events_path);
534
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
535
+ defer allocator.free(events);
536
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"assert.healthy\"") != null);
537
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"unhealthy\"") != null);
538
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"strategy\":\"nativeSelector\"") != null);
539
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"matchedIndex\"") != null);
540
+ }
@@ -48,6 +48,23 @@ pub fn recordNativeWaitTimeoutWithDiagnostics(device: anytype, tw: *trace.TraceW
48
48
  try recordDiagnosticWithStrategyAndTimeout(tw, kind, "timeout", "nativeSelector", selectors, snap, timeout_ms);
49
49
  }
50
50
 
51
+ pub fn recordNativeSelectorArrayStatus(tw: *trace.TraceWriter, kind: []const u8, status: []const u8, selectors: []const selector.Selector, matched_index: ?usize, timeout_ms: u64) !void {
52
+ var payload: std.Io.Writer.Allocating = .init(tw.allocator);
53
+ defer payload.deinit();
54
+ const out = &payload.writer;
55
+ try out.writeAll("{\"status\":");
56
+ try trace.writeJsonString(out, status);
57
+ try out.writeAll(",\"strategy\":\"nativeSelector\"");
58
+ if (matched_index) |index| try out.print(",\"matchedIndex\":{d}", .{index});
59
+ try out.writeAll(",\"selectors\":[");
60
+ for (selectors, 0..) |wanted, index| {
61
+ if (index > 0) try out.writeAll(",");
62
+ try trace.writeSelectorJson(out, wanted);
63
+ }
64
+ try out.print("],\"timeoutMs\":{d}}}", .{timeout_ms});
65
+ try tw.recordEvent(kind, out.buffered());
66
+ }
67
+
51
68
  pub fn recordSelectorArrayStatus(tw: *trace.TraceWriter, kind: []const u8, status: []const u8, selectors: []const selector.Selector, timeout_ms: u64) !void {
52
69
  var payload: std.Io.Writer.Allocating = .init(tw.allocator);
53
70
  defer payload.deinit();
@@ -251,6 +251,7 @@ pub fn assertHealthy(
251
251
  ) !bool {
252
252
  const health_selectors = health.defaultSelectors();
253
253
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
254
+ if (try nativeAssertHealthy(device, health_selectors, timeout_ms, writer, deadline, options)) |healthy| return healthy;
254
255
  while (true) {
255
256
  var snap = device.snapshot(writer) catch |err| {
256
257
  if (try retryTransientObservation(err, "assert.healthy", writer, deadline, options)) continue;
@@ -276,6 +277,34 @@ pub fn assertHealthy(
276
277
  }
277
278
  }
278
279
 
280
+ fn nativeAssertHealthy(
281
+ device: anytype,
282
+ health_selectors: []const selector.Selector,
283
+ timeout_ms: u64,
284
+ writer: ?*trace.TraceWriter,
285
+ deadline: i64,
286
+ options: RunOptions,
287
+ ) !?bool {
288
+ if (!@hasDecl(@TypeOf(device.*), "visibleBySelector")) return null;
289
+
290
+ probe: while (true) {
291
+ for (health_selectors, 0..) |wanted, index| {
292
+ const result = device.visibleBySelector(wanted) catch |err| {
293
+ if (try retryTransientObservation(err, "assert.healthy", writer, deadline, options)) continue :probe;
294
+ return err;
295
+ };
296
+ const visible = result orelse return null;
297
+ if (visible) {
298
+ if (writer) |tw| try runner_events.recordNativeSelectorArrayStatus(tw, "assert.healthy", "unhealthy", health_selectors, index, timeout_ms);
299
+ return false;
300
+ }
301
+ }
302
+
303
+ if (writer) |tw| try runner_events.recordNativeSelectorArrayStatus(tw, "assert.healthy", "ok", health_selectors, null, timeout_ms);
304
+ return true;
305
+ }
306
+ }
307
+
279
308
  pub fn scrollUntilVisible(
280
309
  device: anytype,
281
310
  wanted: selector.Selector,
@@ -352,7 +381,7 @@ fn retryTransientObservation(
352
381
  deadline: i64,
353
382
  options: RunOptions,
354
383
  ) !bool {
355
- if (err != error.CommandTimedOut) return false;
384
+ if (err != error.CommandTimedOut and err != error.CommandFailed) return false;
356
385
  if (stdio.nowMs() >= deadline) return false;
357
386
  if (writer) |tw| try runner_events.recordObservationRetry(tw, kind, err);
358
387
  try sleepMs(options.poll_ms);
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.5";
1
+ pub const runner_version = "0.2.7";
2
2
  pub const protocol_version = "2026-04-28";
3
3
  pub const protocol_min_compatible_version = "2026-04-28";
4
4
  pub const protocol_stability = "dev-preview";