zeno-mobile-runner 0.2.7 → 0.2.9

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,27 @@ All notable changes to Zeno Mobile Runner are tracked here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.9 (2026-06-17)
8
+
9
+ ### Fixed
10
+
11
+ - iOS `assertHealthy` now bounds each native crash/error selector probe
12
+ independently and falls back to the broad snapshot path after transient
13
+ native query timeouts. This prevents healthy but heavy screens from failing
14
+ when one absent overlay selector consumes the whole observation budget.
15
+ - The iOS XCTest shim `query` command now uses the fast selector resolver it
16
+ already validates, avoiding broad element enumeration for exact selector
17
+ checks.
18
+
19
+ ## 0.2.8 (2026-06-17)
20
+
21
+ ### Fixed
22
+
23
+ - iOS native selector waits now cap each XCTest query to the remaining scenario
24
+ step timeout and retry transient query command timeouts. Missing selectors can
25
+ no longer outlive the scenario `timeoutMs` by waiting on the shim's longer
26
+ cold-start command timeout.
27
+
7
28
  ## 0.2.7 (2026-06-15)
8
29
 
9
30
  ### Fixed
@@ -39,7 +60,7 @@ All notable changes to Zeno Mobile Runner are tracked here.
39
60
  - Extended the iOS simulator `openLink` interruption sweep to cover Expo
40
61
  dev-client deep-link chooser sheets that appear more than six seconds after
41
62
  `simctl openurl` returns. The sweep remains bounded, but now covers the
42
- delayed chooser timing observed in the Rently auth smoke.
63
+ delayed chooser timing observed in app auth smoke runs.
43
64
 
44
65
  ## 0.2.3 (2026-06-15)
45
66
 
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.7`, a public developer preview rather than
145
+ - Current release status is `0.2.9`, 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.7` developer preview.
200
+ `ZMR_IOS_SHIM_TIMEOUT_MS`. Current release: `0.2.9` developer preview.
201
201
  Protocol version: `2026-04-28`.
202
202
 
203
203
  ## Optional protocol clients
@@ -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.7.jar"))
30
+ implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.9.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.7"
7
+ version = "0.2.9"
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.7.dev1"
7
+ version = "0.2.9.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.7"
103
+ version = "0.2.9"
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.7"
3
+ version = "0.2.9"
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.7",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "types": "index.d.ts",
package/docs/npm.md CHANGED
@@ -431,6 +431,14 @@ Publish the generated tarball from `dist/`:
431
431
  npm publish ./dist/zeno-mobile-runner-<version>.tgz --access public
432
432
  ```
433
433
 
434
+ If npm returns `EOTP`, the account or organization requires a TOTP-style
435
+ one-time password for this publish command. The browser/passkey login flow can
436
+ authenticate the local CLI session, but `npm publish` itself only accepts the
437
+ publish-time second factor through `--otp`. Prefer the tagged trusted-publishing
438
+ workflow for normal releases; otherwise enter the TOTP locally or use a granular
439
+ automation token configured to bypass 2FA. Do not send OTPs or tokens through
440
+ issue comments, chat, or commit history.
441
+
434
442
  If npm returns `E403` with a two-factor authentication message, the account or
435
443
  organization requires either a current interactive 2FA challenge or a granular
436
444
  automation token configured to bypass 2FA. For local passkey accounts, rerun
@@ -1,4 +1,4 @@
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"]}}
1
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.9","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.7`.
5
+ Current runner version: `0.2.9`.
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.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"]}
50
+ {"ok":true,"status":"ready","schemaVersion":1,"runnerVersion":"0.2.9","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.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"]}
63
+ {"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.9","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.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"]}
74
+ {"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.9","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.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"]}
87
+ {"ok":true,"mode":"draft","schemaVersion":1,"runnerVersion":"0.2.9","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.7","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
217
+ {"name":"zmr","version":"0.2.9","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.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"]}
229
+ {"name":"zmr","version":"0.2.9","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.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"]}}
435
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.9","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.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"]}}
517
+ {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.9","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.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"]}}
540
+ {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.9","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.7",
3
+ "version": "0.2.9",
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
@@ -133,7 +133,7 @@ final class ZMRShimUITestCase: XCTestCase {
133
133
  guard isFastQueryable(parts: parts) else {
134
134
  return error("selector.unsupported", "unsupported query selector: \(selector)")
135
135
  }
136
- let element = resolveElement(selector: selector, app: app, preferredTypes: [])
136
+ let element = resolveFastElement(selector: selector, app: app, preferredTypes: [])
137
137
  return [
138
138
  "status": "ok",
139
139
  "exists": element?.exists ?? false,
@@ -48,11 +48,16 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
48
48
  stdout_io.init(.stdout());
49
49
  defer stdout_io.deinit();
50
50
  const stdout = stdout_io.writer();
51
- if (json) return try device_registry.writeJson(stdout, registryPlatform(platform), devices);
52
- if (devices.len == 0) return try stdout.print("No {s} devices found.\n", .{@tagName(platform)});
53
- for (devices) |device| {
54
- try stdout.print("{s}\t{s}\n", .{ device.serial, device.state });
51
+ if (json) {
52
+ try device_registry.writeJson(stdout, registryPlatform(platform), devices);
53
+ } else if (devices.len == 0) {
54
+ try stdout.print("No {s} devices found.\n", .{@tagName(platform)});
55
+ } else {
56
+ for (devices) |device| {
57
+ try stdout.print("{s}\t{s}\n", .{ device.serial, device.state });
58
+ }
55
59
  }
60
+ try stdout_io.flush();
56
61
  }
57
62
 
58
63
  fn parsePlatform(value: []const u8) !run_options.Platform {
@@ -106,6 +106,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
106
106
  try cli_output.writeShellArg(stdout, discovered.summary.draft.out_path);
107
107
  try stdout.writeAll("\n");
108
108
  }
109
+ try stdout_io.flush();
109
110
  if (!discovered.summary.ok) std.process.exit(1);
110
111
  }
111
112
 
@@ -114,5 +114,6 @@ fn runParsed(allocator: std.mem.Allocator, parsed: ParsedArgs) !void {
114
114
  } else {
115
115
  try cli_output.writeDoctorText(stdout, config_check, checks);
116
116
  }
117
+ try stdout_io.flush();
117
118
  if (parsed.strict and !cli_output.doctorChecksHealthy(config_check, checks)) std.process.exit(1);
118
119
  }
package/src/cli_draft.zig CHANGED
@@ -125,13 +125,13 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
125
125
  const stdout = stdout_io.writer();
126
126
  if (parsed.json) {
127
127
  try writeJson(stdout, draft.summary);
128
- return;
128
+ } else {
129
+ try stdout.print("wrote {s}\n", .{draft.summary.out_path});
130
+ try stdout.writeAll("next: zmr validate --json ");
131
+ try cli_output.writeShellArg(stdout, draft.summary.out_path);
132
+ try stdout.writeAll("\n");
129
133
  }
130
-
131
- try stdout.print("wrote {s}\n", .{draft.summary.out_path});
132
- try stdout.writeAll("next: zmr validate --json ");
133
- try cli_output.writeShellArg(stdout, draft.summary.out_path);
134
- try stdout.writeAll("\n");
134
+ try stdout_io.flush();
135
135
  }
136
136
 
137
137
  pub fn draftFromTrace(allocator: std.mem.Allocator, parsed: ParsedArgs) !OwnedDraft {
@@ -99,6 +99,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
99
99
  try cli_output.writeShellArg(stdout, explored.discovered.summary.draft.out_path);
100
100
  try stdout.writeAll("\n");
101
101
  }
102
+ try stdout_io.flush();
102
103
  if (!explored.summary.ok) std.process.exit(1);
103
104
  }
104
105
 
@@ -82,9 +82,13 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
82
82
  stdout_io.init(.stdout());
83
83
  defer stdout_io.deinit();
84
84
  const stdout = stdout_io.writer();
85
- if (parsed.json) return try cli_output.writeImportJson(stdout, parsed.format, parsed.source_path, result);
86
- try stdout.print("wrote {s}\n", .{result.out_path});
87
- try stdout.writeAll("next: zmr validate ");
88
- try cli_output.writeShellArg(stdout, result.out_path);
89
- try stdout.writeAll("\n");
85
+ if (parsed.json) {
86
+ try cli_output.writeImportJson(stdout, parsed.format, parsed.source_path, result);
87
+ } else {
88
+ try stdout.print("wrote {s}\n", .{result.out_path});
89
+ try stdout.writeAll("next: zmr validate ");
90
+ try cli_output.writeShellArg(stdout, result.out_path);
91
+ try stdout.writeAll("\n");
92
+ }
93
+ try stdout_io.flush();
90
94
  }
package/src/cli_info.zig CHANGED
@@ -22,8 +22,12 @@ pub fn runVersion(allocator: std.mem.Allocator, args: *std.process.Args.Iterator
22
22
  stdout_io.init(.stdout());
23
23
  defer stdout_io.deinit();
24
24
  const stdout = stdout_io.writer();
25
- if (json) return try version.writeJson(stdout);
26
- try version.writePlain(stdout);
25
+ if (json) {
26
+ try version.writeJson(stdout);
27
+ } else {
28
+ try version.writePlain(stdout);
29
+ }
30
+ try stdout_io.flush();
27
31
  }
28
32
 
29
33
  pub fn runSchemas(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
@@ -32,10 +36,14 @@ pub fn runSchemas(allocator: std.mem.Allocator, args: *std.process.Args.Iterator
32
36
  stdout_io.init(.stdout());
33
37
  defer stdout_io.deinit();
34
38
  const stdout = stdout_io.writer();
35
- if (json) return try schema_registry.writeJson(stdout);
36
- for (schema_registry.all()) |schema_info| {
37
- try stdout.print("{s}\t{s}\n", .{ schema_info.name, schema_info.path });
39
+ if (json) {
40
+ try schema_registry.writeJson(stdout);
41
+ } else {
42
+ for (schema_registry.all()) |schema_info| {
43
+ try stdout.print("{s}\t{s}\n", .{ schema_info.name, schema_info.path });
44
+ }
38
45
  }
46
+ try stdout_io.flush();
39
47
  }
40
48
 
41
49
  fn parseArgIterator(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !bool {
package/src/cli_init.zig CHANGED
@@ -57,20 +57,28 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
57
57
  const stdout = stdout_io.writer();
58
58
  if (parsed.app_scaffold) {
59
59
  try scaffold.writeAppScaffold(allocator, parsed.dir, parsed.app_id, parsed.force);
60
- if (parsed.json) return try cli_output.writeInitAppJson(stdout, parsed.dir, parsed.app_id);
61
- for (scaffold.app_created_files) |path| {
62
- try stdout.print("created {s}/{s}\n", .{ parsed.dir, path });
60
+ if (parsed.json) {
61
+ try cli_output.writeInitAppJson(stdout, parsed.dir, parsed.app_id);
62
+ } else {
63
+ for (scaffold.app_created_files) |path| {
64
+ try stdout.print("created {s}/{s}\n", .{ parsed.dir, path });
65
+ }
66
+ try stdout.writeAll("next: zmr doctor --strict --json --config ");
67
+ try cli_output.writeJoinedPathShellArg(stdout, parsed.dir, scaffold.app_config_file);
68
+ try stdout.writeAll("\n");
63
69
  }
64
- try stdout.writeAll("next: zmr doctor --strict --json --config ");
65
- try cli_output.writeJoinedPathShellArg(stdout, parsed.dir, scaffold.app_config_file);
66
- try stdout.writeAll("\n");
70
+ try stdout_io.flush();
67
71
  return;
68
72
  }
69
73
 
70
74
  try scaffold.writeStarterScenario(allocator, parsed.path, parsed.app_id, parsed.force);
71
- if (parsed.json) return try cli_output.writeInitScenarioJson(stdout, parsed.path, parsed.app_id);
72
- try stdout.print("created {s}\n", .{parsed.path});
73
- try stdout.writeAll("next: zmr validate ");
74
- try cli_output.writeShellArg(stdout, parsed.path);
75
- try stdout.writeAll("\n");
75
+ if (parsed.json) {
76
+ try cli_output.writeInitScenarioJson(stdout, parsed.path, parsed.app_id);
77
+ } else {
78
+ try stdout.print("created {s}\n", .{parsed.path});
79
+ try stdout.writeAll("next: zmr validate ");
80
+ try cli_output.writeShellArg(stdout, parsed.path);
81
+ try stdout.writeAll("\n");
82
+ }
83
+ try stdout_io.flush();
76
84
  }
@@ -84,6 +84,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
84
84
  } else {
85
85
  try writeText(stdout, owned.inspection);
86
86
  }
87
+ try stdout_io.flush();
87
88
  }
88
89
 
89
90
  fn inspect(allocator: std.mem.Allocator, parsed: ParsedArgs) !OwnedInspection {
package/src/cli_run.zig CHANGED
@@ -226,6 +226,7 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
226
226
  run_error,
227
227
  run_discovery,
228
228
  );
229
+ try stdout_io.flush();
229
230
  }
230
231
  if (run_error) |err| return err;
231
232
  }
package/src/cli_trace.zig CHANGED
@@ -120,6 +120,7 @@ pub fn runReport(allocator: std.mem.Allocator, args: *std.process.Args.Iterator)
120
120
  try report.writeJUnitReport(allocator, parsed.input_path, junit_path);
121
121
  try stdout.print("wrote {s}\n", .{junit_path});
122
122
  }
123
+ try stdout_io.flush();
123
124
  }
124
125
 
125
126
  pub fn runExplain(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
@@ -132,8 +133,12 @@ pub fn runExplain(allocator: std.mem.Allocator, args: *std.process.Args.Iterator
132
133
  stdout_io.init(.stdout());
133
134
  defer stdout_io.deinit();
134
135
  const stdout = stdout_io.writer();
135
- if (parsed.json) return try report.writeTraceExplanationJson(allocator, parsed.trace_dir.?, stdout);
136
- try report.writeTraceExplanation(allocator, parsed.trace_dir.?, stdout);
136
+ if (parsed.json) {
137
+ try report.writeTraceExplanationJson(allocator, parsed.trace_dir.?, stdout);
138
+ } else {
139
+ try report.writeTraceExplanation(allocator, parsed.trace_dir.?, stdout);
140
+ }
141
+ try stdout_io.flush();
137
142
  }
138
143
 
139
144
  pub fn runExport(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void {
@@ -151,4 +156,5 @@ pub fn runExport(allocator: std.mem.Allocator, args: *std.process.Args.Iterator)
151
156
  defer stdout_io.deinit();
152
157
  const stdout = stdout_io.writer();
153
158
  try stdout.print("wrote {s}\n", .{parsed.out_path.?});
159
+ try stdout_io.flush();
154
160
  }
@@ -47,5 +47,6 @@ pub fn run(allocator: std.mem.Allocator, args: *std.process.Args.Iterator) !void
47
47
  } else {
48
48
  try cli_output.writeValidationText(stdout, parsed.path, result);
49
49
  }
50
+ try stdout_io.flush();
50
51
  if (!result.ok) std.process.exit(1);
51
52
  }
package/src/command.zig CHANGED
@@ -1,3 +1,4 @@
1
+ const builtin = @import("builtin");
1
2
  const std = @import("std");
2
3
  const stdio = @import("stdio.zig");
3
4
 
@@ -61,6 +62,7 @@ pub fn runWithInputTimeout(
61
62
  .stdin = .pipe,
62
63
  .stdout = .pipe,
63
64
  .stderr = .pipe,
65
+ .pgid = processGroupForTimeout(timeout_ms),
64
66
  });
65
67
  defer if (child.id != null) child.kill(stdio.io());
66
68
 
@@ -84,21 +86,16 @@ pub fn runWithTimeout(
84
86
  ) !ExecResult {
85
87
  if (timeout_ms == 0) return run(allocator, argv, max_output_bytes);
86
88
 
87
- const result = std.process.run(allocator, stdio.io(), .{
89
+ var child = try std.process.spawn(stdio.io(), .{
88
90
  .argv = argv,
89
- .stdout_limit = .limited(max_output_bytes),
90
- .stderr_limit = .limited(max_output_bytes),
91
- .timeout = timeoutForMs(timeout_ms),
92
- }) catch |err| switch (err) {
93
- error.Timeout => return timedOutResult(allocator),
94
- else => |actual| return actual,
95
- };
96
- return .{
97
- .stdout = result.stdout,
98
- .stderr = result.stderr,
99
- .term = result.term,
100
- .timed_out = false,
101
- };
91
+ .stdin = .ignore,
92
+ .stdout = .pipe,
93
+ .stderr = .pipe,
94
+ .pgid = processGroupForTimeout(timeout_ms),
95
+ });
96
+ defer if (child.id != null) child.kill(stdio.io());
97
+
98
+ return try collectSpawnedOutput(allocator, &child, max_output_bytes, timeoutForMs(timeout_ms));
102
99
  }
103
100
 
104
101
  fn collectSpawnedOutput(
@@ -121,7 +118,7 @@ fn collectSpawnedOutput(
121
118
  } else |err| switch (err) {
122
119
  error.EndOfStream => {},
123
120
  error.Timeout => {
124
- child.kill(stdio.io());
121
+ terminateTimedOutChild(child);
125
122
  return timedOutResult(allocator);
126
123
  },
127
124
  else => |actual| return actual,
@@ -143,6 +140,34 @@ fn collectSpawnedOutput(
143
140
  };
144
141
  }
145
142
 
143
+ fn processGroupForTimeout(timeout_ms: u64) ?std.posix.pid_t {
144
+ if (timeout_ms == 0) return null;
145
+ return switch (builtin.os.tag) {
146
+ .linux,
147
+ .macos,
148
+ .ios,
149
+ .tvos,
150
+ .watchos,
151
+ .visionos,
152
+ .freebsd,
153
+ .netbsd,
154
+ .openbsd,
155
+ .haiku,
156
+ .illumos,
157
+ => 0,
158
+ else => null,
159
+ };
160
+ }
161
+
162
+ fn terminateTimedOutChild(child: *std.process.Child) void {
163
+ if (child.id) |pid| {
164
+ if (processGroupForTimeout(1) != null and pid > 0) {
165
+ std.posix.kill(-pid, .TERM) catch {};
166
+ }
167
+ }
168
+ child.kill(stdio.io());
169
+ }
170
+
146
171
  fn timedOutResult(allocator: std.mem.Allocator) !ExecResult {
147
172
  return .{
148
173
  .stdout = try allocator.dupe(u8, ""),
package/src/ios.zig CHANGED
@@ -149,11 +149,18 @@ pub const IosDevice = struct {
149
149
  }
150
150
 
151
151
  pub fn visibleBySelector(self: *IosDevice, wanted: selector.Selector) !?bool {
152
+ return try self.visibleBySelectorWithTimeout(wanted, shimTimeoutMs());
153
+ }
154
+
155
+ pub fn visibleBySelectorWithTimeout(self: *IosDevice, wanted: selector.Selector, timeout_ms: u64) !?bool {
152
156
  if (self.shim_path == null) return null;
153
157
  const shim_selector = try ios_shim.selectorString(self.allocator, wanted) orelse return null;
154
158
  defer self.allocator.free(shim_selector);
155
159
 
156
- const response = try self.runShim(.{ .kind = .query, .selector = shim_selector });
160
+ const response = try self.runShimWithTimeout(.{
161
+ .kind = .query,
162
+ .selector = shim_selector,
163
+ }, @max(timeout_ms, 1));
157
164
  defer self.allocator.free(response);
158
165
  return try ios_shim.parseQueryResponse(response);
159
166
  }
@@ -460,7 +467,7 @@ test "ios simulator openLink keeps sweeping delayed XCTest interruptions until a
460
467
  \\if [[ "$count" -lt 6 ]]; then
461
468
  \\ printf '{{"status":"ok","accepted":false,"count":0}}\n'
462
469
  \\else
463
- \\ printf '{{"status":"ok","accepted":true,"label":"Brick Rewards Test","count":1}}\n'
470
+ \\ printf '{{"status":"ok","accepted":true,"label":"Demo App","count":1}}\n'
464
471
  \\fi
465
472
  \\
466
473
  , .{ tmp.sub_path, tmp.sub_path });
package/src/main.zig CHANGED
@@ -165,4 +165,5 @@ fn usage() !void {
165
165
  \\assertNotVisible, assertNoneVisible, assertHealthy, snapshot, sleep. Any step may use "optional": true.
166
166
  \\
167
167
  );
168
+ try stdout_io.flush();
168
169
  }
package/src/runner.zig CHANGED
@@ -456,6 +456,106 @@ test "assertHealthy retries through a transient observation command failure" {
456
456
  try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
457
457
  }
458
458
 
459
+ test "native selector waits pass bounded query timeouts instead of legacy blocking queries" {
460
+ const allocator = std.testing.allocator;
461
+ const dir = "zig-cache-test-runner-native-wait-bounded-query-timeout";
462
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
463
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
464
+
465
+ const NativeBoundedWaitDevice = struct {
466
+ allocator: std.mem.Allocator,
467
+ legacy_queries: usize = 0,
468
+ bounded_queries: usize = 0,
469
+ largest_query_timeout_ms: u64 = 0,
470
+ snapshots: usize = 0,
471
+
472
+ pub fn visibleBySelector(self: *@This(), wanted: selector.Selector) !?bool {
473
+ _ = wanted;
474
+ self.legacy_queries += 1;
475
+ return false;
476
+ }
477
+
478
+ pub fn visibleBySelectorWithTimeout(self: *@This(), wanted: selector.Selector, timeout_ms: u64) !?bool {
479
+ _ = wanted;
480
+ self.bounded_queries += 1;
481
+ self.largest_query_timeout_ms = @max(self.largest_query_timeout_ms, timeout_ms);
482
+ return false;
483
+ }
484
+
485
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
486
+ _ = writer;
487
+ self.snapshots += 1;
488
+ const nodes = try self.allocator.alloc(types.UiNode, 0);
489
+ return .{
490
+ .id = try self.allocator.dupe(u8, "native-bounded-timeout-final"),
491
+ .timestamp_ms = 1,
492
+ .nodes = nodes,
493
+ };
494
+ }
495
+ };
496
+
497
+ var device = NativeBoundedWaitDevice{ .allocator = allocator };
498
+ var tw = try trace.TraceWriter.init(allocator, dir);
499
+ defer tw.deinit();
500
+
501
+ try std.testing.expect(!try waitUntilVisible(&device, .{ .id = "never-visible" }, 25, &tw, .{ .poll_ms = 0 }));
502
+ try std.testing.expect(device.bounded_queries > 0);
503
+ try std.testing.expectEqual(@as(usize, 0), device.legacy_queries);
504
+ try std.testing.expect(device.largest_query_timeout_ms > 0);
505
+ try std.testing.expect(device.largest_query_timeout_ms <= 25);
506
+ try std.testing.expectEqual(@as(usize, 1), device.snapshots);
507
+ }
508
+
509
+ test "native selector wait retries bounded query command timeouts before falling back" {
510
+ const allocator = std.testing.allocator;
511
+ const dir = "zig-cache-test-runner-native-wait-retries-bounded-query-timeout";
512
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
513
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
514
+
515
+ const NativeFlakyBoundedWaitDevice = struct {
516
+ allocator: std.mem.Allocator,
517
+ legacy_queries: usize = 0,
518
+ bounded_queries: usize = 0,
519
+ snapshots: usize = 0,
520
+
521
+ pub fn visibleBySelector(self: *@This(), wanted: selector.Selector) !?bool {
522
+ _ = wanted;
523
+ self.legacy_queries += 1;
524
+ return error.LegacyNativeQueryUsed;
525
+ }
526
+
527
+ pub fn visibleBySelectorWithTimeout(self: *@This(), wanted: selector.Selector, timeout_ms: u64) !?bool {
528
+ _ = wanted;
529
+ try std.testing.expect(timeout_ms > 0);
530
+ self.bounded_queries += 1;
531
+ if (self.bounded_queries == 1) return error.CommandTimedOut;
532
+ return true;
533
+ }
534
+
535
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
536
+ _ = writer;
537
+ self.snapshots += 1;
538
+ return error.UnexpectedSnapshotFallback;
539
+ }
540
+ };
541
+
542
+ var device = NativeFlakyBoundedWaitDevice{ .allocator = allocator };
543
+ var tw = try trace.TraceWriter.init(allocator, dir);
544
+ defer tw.deinit();
545
+
546
+ try std.testing.expect(try waitUntilVisible(&device, .{ .text = "Ready" }, 100, &tw, .{ .poll_ms = 0 }));
547
+ try std.testing.expectEqual(@as(usize, 0), device.legacy_queries);
548
+ try std.testing.expectEqual(@as(usize, 2), device.bounded_queries);
549
+ try std.testing.expectEqual(@as(usize, 0), device.snapshots);
550
+
551
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
552
+ defer allocator.free(events_path);
553
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
554
+ defer allocator.free(events);
555
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"observe.retry\"") != null);
556
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"error\":\"CommandTimedOut\"") != null);
557
+ }
558
+
459
559
  test "assertHealthy uses native selector probes before broad snapshots" {
460
560
  const allocator = std.testing.allocator;
461
561
  const dir = "zig-cache-test-runner-assert-healthy-native-selector";
@@ -497,6 +597,51 @@ test "assertHealthy uses native selector probes before broad snapshots" {
497
597
  try std.testing.expect(std.mem.indexOf(u8, events, "\"strategy\":\"nativeSelector\"") != null);
498
598
  }
499
599
 
600
+ test "assertHealthy bounds each native selector probe independently" {
601
+ const allocator = std.testing.allocator;
602
+ const dir = "zig-cache-test-runner-assert-healthy-native-probe-budget";
603
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
604
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
605
+
606
+ const NativeSlowAbsentHealthDevice = struct {
607
+ allocator: std.mem.Allocator,
608
+ bounded_queries: usize = 0,
609
+ largest_query_timeout_ms: u64 = 0,
610
+ snapshots: usize = 0,
611
+
612
+ pub fn visibleBySelectorWithTimeout(self: *@This(), wanted: selector.Selector, timeout_ms: u64) !?bool {
613
+ _ = wanted;
614
+ self.bounded_queries += 1;
615
+ self.largest_query_timeout_ms = @max(self.largest_query_timeout_ms, timeout_ms);
616
+ if (timeout_ms > 0) stdio.sleepNs(timeout_ms * std.time.ns_per_ms);
617
+ return false;
618
+ }
619
+
620
+ pub fn snapshot(self: *@This(), writer: anytype) !types.ObservationSnapshot {
621
+ _ = writer;
622
+ self.snapshots += 1;
623
+ return error.UnexpectedSnapshotFallback;
624
+ }
625
+ };
626
+
627
+ var device = NativeSlowAbsentHealthDevice{ .allocator = allocator };
628
+ var tw = try trace.TraceWriter.init(allocator, dir);
629
+ defer tw.deinit();
630
+
631
+ try std.testing.expect(try assertHealthy(&device, 1, &tw, .{ .settle_ms = 0, .poll_ms = 0, .action_timeout_ms = 1 }));
632
+ try std.testing.expect(device.bounded_queries > 1);
633
+ try std.testing.expect(device.largest_query_timeout_ms <= 1);
634
+ try std.testing.expectEqual(@as(usize, 0), device.snapshots);
635
+
636
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
637
+ defer allocator.free(events_path);
638
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
639
+ defer allocator.free(events);
640
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"assert.healthy\"") != null);
641
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
642
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"strategy\":\"nativeSelector\"") != null);
643
+ }
644
+
500
645
  test "assertHealthy reports unhealthy native selector matches" {
501
646
  const allocator = std.testing.allocator;
502
647
  const dir = "zig-cache-test-runner-assert-healthy-native-unhealthy";
@@ -3,12 +3,12 @@ const stdio = @import("stdio.zig");
3
3
  const health = @import("health.zig");
4
4
  const runner_config = @import("runner_config.zig");
5
5
  const runner_events = @import("runner_events.zig");
6
- const runner_native = @import("runner_native.zig");
7
6
  const scenario = @import("scenario.zig");
8
7
  const selector = @import("selector.zig");
9
8
  const trace = @import("trace.zig");
10
9
 
11
10
  const RunOptions = runner_config.RunOptions;
11
+ const native_health_probe_timeout_ms: u64 = 250;
12
12
 
13
13
  pub fn waitUntilVisible(
14
14
  device: anytype,
@@ -40,18 +40,28 @@ fn untilVisibleKind(
40
40
  ) !bool {
41
41
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
42
42
  while (true) {
43
- if (try nativeVisibleBySelector(device, wanted)) |visible| {
44
- if (visible) {
45
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
46
- return true;
47
- }
48
- if (stdio.nowMs() >= deadline) {
49
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
50
- return false;
43
+ if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
44
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
45
+ if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
46
+ return err;
47
+ };
48
+ if (native_result) |visible| {
49
+ if (visible) {
50
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
51
+ return true;
52
+ }
53
+ if (stdio.nowMs() >= deadline) {
54
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
55
+ return false;
56
+ }
57
+ try sleepMs(options.poll_ms);
58
+ continue;
51
59
  }
52
- try sleepMs(options.poll_ms);
53
- continue;
60
+ } else if (hasNativeSelectorQuery(device)) {
61
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
62
+ return false;
54
63
  }
64
+
55
65
  var snap = device.snapshot(writer) catch |err| {
56
66
  if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
57
67
  return err;
@@ -110,18 +120,28 @@ fn untilNotVisibleKind(
110
120
  ) !bool {
111
121
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
112
122
  while (true) {
113
- if (try nativeVisibleBySelector(device, wanted)) |visible| {
114
- if (!visible) {
115
- if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
116
- return true;
117
- }
118
- if (stdio.nowMs() >= deadline) {
119
- if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
120
- return false;
123
+ if (nativeSelectorQueryTimeoutMs(deadline)) |query_timeout_ms| {
124
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
125
+ if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
126
+ return err;
127
+ };
128
+ if (native_result) |visible| {
129
+ if (!visible) {
130
+ if (writer) |tw| try runner_events.recordNativeWait(tw, kind, wanted, null, timeout_ms);
131
+ return true;
132
+ }
133
+ if (stdio.nowMs() >= deadline) {
134
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
135
+ return false;
136
+ }
137
+ try sleepMs(options.poll_ms);
138
+ continue;
121
139
  }
122
- try sleepMs(options.poll_ms);
123
- continue;
140
+ } else if (hasNativeSelectorQuery(device)) {
141
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, kind, &[_]selector.Selector{wanted}, timeout_ms);
142
+ return false;
124
143
  }
144
+
125
145
  var snap = device.snapshot(writer) catch |err| {
126
146
  if (try retryTransientObservation(err, kind, writer, deadline, options)) continue;
127
147
  return err;
@@ -161,7 +181,19 @@ pub fn waitUntilAnyVisible(
161
181
  while (true) {
162
182
  var all_native = true;
163
183
  for (selectors, 0..) |wanted, index| {
164
- if (try nativeVisibleBySelector(device, wanted)) |visible| {
184
+ const query_timeout_ms = nativeSelectorQueryTimeoutMs(deadline) orelse {
185
+ if (hasNativeSelectorQuery(device)) {
186
+ if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
187
+ return null;
188
+ }
189
+ all_native = false;
190
+ break;
191
+ };
192
+ const native_result = nativeVisibleBySelector(device, wanted, query_timeout_ms) catch |err| {
193
+ if (try retryTransientObservation(err, "wait.any", writer, deadline, options)) continue;
194
+ return err;
195
+ };
196
+ if (native_result) |visible| {
165
197
  if (visible) {
166
198
  if (writer) |tw| try runner_events.recordNativeWait(tw, "wait.any", wanted, index, timeout_ms);
167
199
  return index;
@@ -171,6 +203,7 @@ pub fn waitUntilAnyVisible(
171
203
  break;
172
204
  }
173
205
  }
206
+
174
207
  if (all_native) {
175
208
  if (stdio.nowMs() >= deadline) {
176
209
  if (writer) |tw| try runner_events.recordNativeWaitTimeoutWithDiagnostics(device, tw, "wait.any", selectors, timeout_ms);
@@ -179,6 +212,7 @@ pub fn waitUntilAnyVisible(
179
212
  try sleepMs(options.poll_ms);
180
213
  continue;
181
214
  }
215
+
182
216
  var snap = device.snapshot(writer) catch |err| {
183
217
  if (try retryTransientObservation(err, "wait.any", writer, deadline, options)) continue;
184
218
  return err;
@@ -251,7 +285,7 @@ pub fn assertHealthy(
251
285
  ) !bool {
252
286
  const health_selectors = health.defaultSelectors();
253
287
  const deadline = stdio.nowMs() + @as(i64, @intCast(timeout_ms));
254
- if (try nativeAssertHealthy(device, health_selectors, timeout_ms, writer, deadline, options)) |healthy| return healthy;
288
+ if (try nativeAssertHealthy(device, health_selectors, timeout_ms, writer, options)) |healthy| return healthy;
255
289
  while (true) {
256
290
  var snap = device.snapshot(writer) catch |err| {
257
291
  if (try retryTransientObservation(err, "assert.healthy", writer, deadline, options)) continue;
@@ -282,27 +316,27 @@ fn nativeAssertHealthy(
282
316
  health_selectors: []const selector.Selector,
283
317
  timeout_ms: u64,
284
318
  writer: ?*trace.TraceWriter,
285
- deadline: i64,
286
319
  options: RunOptions,
287
320
  ) !?bool {
288
- if (!@hasDecl(@TypeOf(device.*), "visibleBySelector")) return null;
321
+ if (!hasNativeSelectorQuery(device)) return null;
289
322
 
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;
323
+ for (health_selectors, 0..) |wanted, index| {
324
+ const result = nativeVisibleBySelector(device, wanted, nativeHealthProbeTimeoutMs(timeout_ms, options)) catch |err| {
325
+ if (err == error.CommandTimedOut or err == error.CommandFailed) {
326
+ if (writer) |tw| try runner_events.recordObservationRetry(tw, "assert.healthy", err);
327
+ return null;
300
328
  }
329
+ return err;
330
+ };
331
+ const visible = result orelse return null;
332
+ if (visible) {
333
+ if (writer) |tw| try runner_events.recordNativeSelectorArrayStatus(tw, "assert.healthy", "unhealthy", health_selectors, index, timeout_ms);
334
+ return false;
301
335
  }
302
-
303
- if (writer) |tw| try runner_events.recordNativeSelectorArrayStatus(tw, "assert.healthy", "ok", health_selectors, null, timeout_ms);
304
- return true;
305
336
  }
337
+
338
+ if (writer) |tw| try runner_events.recordNativeSelectorArrayStatus(tw, "assert.healthy", "ok", health_selectors, null, timeout_ms);
339
+ return true;
306
340
  }
307
341
 
308
342
  pub fn scrollUntilVisible(
@@ -369,11 +403,29 @@ pub fn scrollUntilVisible(
369
403
  }
370
404
  }
371
405
 
372
- fn nativeVisibleBySelector(device: anytype, wanted: selector.Selector) !?bool {
406
+ fn hasNativeSelectorQuery(device: anytype) bool {
407
+ return @hasDecl(@TypeOf(device.*), "visibleBySelectorWithTimeout") or @hasDecl(@TypeOf(device.*), "visibleBySelector");
408
+ }
409
+
410
+ fn nativeVisibleBySelector(device: anytype, wanted: selector.Selector, timeout_ms: u64) !?bool {
411
+ if (@hasDecl(@TypeOf(device.*), "visibleBySelectorWithTimeout")) return try device.visibleBySelectorWithTimeout(wanted, timeout_ms);
373
412
  if (!@hasDecl(@TypeOf(device.*), "visibleBySelector")) return null;
374
413
  return try device.visibleBySelector(wanted);
375
414
  }
376
415
 
416
+ fn nativeSelectorQueryTimeoutMs(deadline: i64) ?u64 {
417
+ const now = stdio.nowMs();
418
+ if (now >= deadline) return null;
419
+ return @as(u64, @intCast(deadline - now));
420
+ }
421
+
422
+ fn nativeHealthProbeTimeoutMs(timeout_ms: u64, options: RunOptions) u64 {
423
+ var probe_timeout_ms = if (timeout_ms == 0) @as(u64, 1) else timeout_ms;
424
+ probe_timeout_ms = @min(probe_timeout_ms, native_health_probe_timeout_ms);
425
+ if (options.action_timeout_ms > 0) probe_timeout_ms = @min(probe_timeout_ms, options.action_timeout_ms);
426
+ return @max(probe_timeout_ms, 1);
427
+ }
428
+
377
429
  fn retryTransientObservation(
378
430
  err: anyerror,
379
431
  kind: []const u8,
package/src/stdio.zig CHANGED
@@ -1,3 +1,4 @@
1
+ const builtin = @import("builtin");
1
2
  const std = @import("std");
2
3
 
3
4
  const default_buffer_size = 8192;
@@ -6,6 +7,7 @@ var process_threaded: ?std.Io.Threaded = null;
6
7
 
7
8
  fn processIo() std.Io {
8
9
  if (process_threaded) |*threaded| return threaded.io();
10
+ if (builtin.is_test) return std.testing.io;
9
11
  return std.Io.Threaded.global_single_threaded.io();
10
12
  }
11
13
 
@@ -46,7 +48,12 @@ pub fn nowMs() i64 {
46
48
  }
47
49
 
48
50
  pub fn getenv(name: []const u8) ?[]const u8 {
49
- const environ = process_environ orelse return null;
51
+ const environ = process_environ orelse {
52
+ if (builtin.is_test) {
53
+ return std.process.Environ.getPosix(std.testing.environ, name);
54
+ }
55
+ return null;
56
+ };
50
57
  const block = environ.block;
51
58
  const Block = @TypeOf(block);
52
59
  if (Block != std.process.Environ.PosixBlock) return null;
@@ -86,7 +93,15 @@ pub const Output = struct {
86
93
  }
87
94
 
88
95
  pub fn flush(self: *Output) !void {
89
- if (self.initialized) try self.file_writer.interface.flush();
96
+ if (!self.initialized) return;
97
+ self.file_writer.interface.flush() catch |err| switch (err) {
98
+ error.WriteFailed => {
99
+ if (self.file_writer.err) |actual| return actual;
100
+ if (self.file_writer.write_file_err) |actual| return actual;
101
+ return err;
102
+ },
103
+ else => |actual| return actual,
104
+ };
90
105
  }
91
106
 
92
107
  pub fn deinit(self: *Output) void {
@@ -0,0 +1,60 @@
1
+ const std = @import("std");
2
+ const stdio = @import("stdio.zig");
3
+
4
+ pub fn cwd() Cwd {
5
+ return .{};
6
+ }
7
+
8
+ pub const Cwd = struct {
9
+ pub fn makePath(_: Cwd, path: []const u8) !void {
10
+ return std.Io.Dir.cwd().createDirPath(stdio.io(), path);
11
+ }
12
+
13
+ pub fn writeFile(_: Cwd, options: std.Io.Dir.WriteFileOptions) !void {
14
+ return std.Io.Dir.cwd().writeFile(stdio.io(), options);
15
+ }
16
+
17
+ pub fn readFileAlloc(_: Cwd, allocator: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
18
+ return std.Io.Dir.cwd().readFileAlloc(stdio.io(), path, allocator, .limited(limit));
19
+ }
20
+
21
+ pub fn createFile(_: Cwd, path: []const u8, flags: std.Io.Dir.CreateFileOptions) !File {
22
+ return createFileIn(std.Io.Dir.cwd(), path, flags);
23
+ }
24
+
25
+ pub fn deleteTree(_: Cwd, path: []const u8) !void {
26
+ return std.Io.Dir.cwd().deleteTree(stdio.io(), path);
27
+ }
28
+
29
+ pub fn deleteFile(_: Cwd, path: []const u8) !void {
30
+ return std.Io.Dir.cwd().deleteFile(stdio.io(), path);
31
+ }
32
+
33
+ pub fn access(_: Cwd, path: []const u8, options: std.Io.Dir.AccessOptions) !void {
34
+ return std.Io.Dir.cwd().access(stdio.io(), path, options);
35
+ }
36
+ };
37
+
38
+ pub const File = struct {
39
+ inner: std.Io.File,
40
+
41
+ pub fn writeAll(self: *File, bytes: []const u8) !void {
42
+ return std.Io.File.writeStreamingAll(self.inner, stdio.io(), bytes);
43
+ }
44
+
45
+ pub fn chmod(self: *File, mode: std.posix.mode_t) !void {
46
+ return self.inner.setPermissions(stdio.io(), .fromMode(mode));
47
+ }
48
+
49
+ pub fn close(self: *File) void {
50
+ self.inner.close(stdio.io());
51
+ }
52
+ };
53
+
54
+ pub fn createFileIn(dir: std.Io.Dir, path: []const u8, flags: std.Io.Dir.CreateFileOptions) !File {
55
+ return .{ .inner = try dir.createFile(stdio.io(), path, flags) };
56
+ }
57
+
58
+ pub fn readFileAllocIn(dir: std.Io.Dir, allocator: std.mem.Allocator, path: []const u8, limit: usize) ![]u8 {
59
+ return dir.readFileAlloc(stdio.io(), path, allocator, .limited(limit));
60
+ }
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.7";
1
+ pub const runner_version = "0.2.9";
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";