zeno-mobile-runner 0.2.12 → 0.2.14

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,30 @@ All notable changes to Zeno Mobile Runner are tracked here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.2.14 (2026-06-23)
8
+
9
+ ### Added
10
+
11
+ - Added a first-class `setLocation` scenario action for simulator/emulator
12
+ location control. iOS simulators grant the target app location permission and
13
+ set coordinates through `simctl`; Android emulators best-effort grant runtime
14
+ location permissions before setting emulator geolocation.
15
+
16
+ ## 0.2.13 (2026-06-23)
17
+
18
+ ### Fixed
19
+
20
+ - iOS native selector waits now cap each XCTest query to the action timeout,
21
+ retry one transient native command failure, and then fall back to semantic
22
+ snapshots with diagnostics. This prevents one stuck XCTest selector query from
23
+ consuming the whole wait budget while still allowing transient native queries
24
+ to recover.
25
+ - iOS native selector scrolling now reads the app-frame viewport from the XCTest
26
+ shim before generating swipe coordinates, so native scrolls use iOS point
27
+ dimensions instead of Android fallback dimensions.
28
+ - Expo dev-client URL opening now uses URL-aware fallback handling and avoids
29
+ broad static-text enumeration while accepting deep-link chooser prompts.
30
+
7
31
  ## 0.2.12 (2026-06-22)
8
32
 
9
33
  ### 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.12`, a public developer preview rather than
145
+ - Current release status is `0.2.14`, 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
@@ -199,7 +199,7 @@ comparisons against your current E2E tool, and multi-device matrices, see
199
199
  Slow CI hardware can extend the generated iOS shim build timeout with
200
200
  `ZMR_IOS_SHIM_BUILD_TIMEOUT_SECONDS`; `ZMR_IOS_SHIM_RESPONSE_TIMEOUT_SECONDS`
201
201
  bounds each in-flight request, and `ZMR_IOS_SHIM_TIMEOUT_MS` remains the outer
202
- process ceiling. Current release: `0.2.12` developer preview.
202
+ process ceiling. Current release: `0.2.14` developer preview.
203
203
  Protocol version: `2026-04-28`.
204
204
 
205
205
  ## 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.12.jar"))
30
+ implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.14.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.12"
7
+ version = "0.2.14"
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.12.dev1"
7
+ version = "0.2.14.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.12"
103
+ version = "0.2.14"
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.12"
3
+ version = "0.2.14"
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.12",
3
+ "version": "0.2.14",
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.12","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.14","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.12`.
5
+ Current runner version: `0.2.14`.
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.12","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.14","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.12","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.14","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.12","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.14","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.12","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.14","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.12","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
217
+ {"name":"zmr","version":"0.2.14","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.12","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.14","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
@@ -373,6 +373,7 @@ zmr mcp --config .zmr/config.json --trace-dir traces/mcp-agent-session
373
373
  - `app.stop`
374
374
  - `app.openLink` with `{ "url": "exampleapp://e2e-auth?probe=1" }`
375
375
  - `app.clearState`
376
+ - Scenario action `setLocation` with `{ "latitude": 51.5074, "longitude": -0.1278 }` for simulator/emulator geolocation
376
377
  - `observe.snapshot`
377
378
  - `observe.semanticSnapshot`
378
379
  - `ui.tap` with `{ "selector": { "text": "Sign in" } }`
@@ -432,7 +433,7 @@ Request:
432
433
  Response:
433
434
 
434
435
  ```json
435
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.12","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
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.14","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
437
  ```
437
438
 
438
439
  ### `trace.events`
@@ -514,7 +515,7 @@ Request:
514
515
  Response:
515
516
 
516
517
  ```json
517
- {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.12","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
+ {"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.14","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
519
  ```
519
520
 
520
521
  Without `--trace-dir`, it returns `ok: false` with `traceDir: null`. Generated
@@ -537,7 +538,7 @@ Request:
537
538
  Response:
538
539
 
539
540
  ```json
540
- {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.12","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
+ {"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.14","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
542
  ```
542
543
 
543
544
  Without `--trace-dir`, it returns `ok: false` with `traceDir: null`.
@@ -84,6 +84,18 @@ The importer supports the common subset needed for smoke scenarios:
84
84
  generated JSON before committing it; native `.zmr/*.json` scenarios remain the
85
85
  runtime contract for agents and CI.
86
86
 
87
+ Use `setLocation` before location-dependent assertions to set simulator or
88
+ emulator coordinates through the runner instead of shelling out from the app
89
+ test:
90
+
91
+ ```json
92
+ { "action": "setLocation", "latitude": 51.5074, "longitude": -0.1278 }
93
+ ```
94
+
95
+ On iOS simulators, ZMR grants the target app location permission before setting
96
+ the coordinate. On Android emulators, ZMR grants runtime location permissions
97
+ best-effort and then uses emulator geolocation.
98
+
87
99
  `assertVisible` and `assertNotVisible` accept the same `timeoutMs` field as
88
100
  waits when a scenario needs assertion-specific timing.
89
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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
@@ -40,6 +40,7 @@
40
40
  "clearState",
41
41
  "snapshot",
42
42
  "openLink",
43
+ "setLocation",
43
44
  "tap",
44
45
  "typeText",
45
46
  "eraseText",
@@ -62,6 +63,8 @@
62
63
  },
63
64
  "optional": { "type": "boolean" },
64
65
  "url": { "type": "string", "minLength": 1 },
66
+ "latitude": { "type": "number", "minimum": -90, "maximum": 90 },
67
+ "longitude": { "type": "number", "minimum": -180, "maximum": 180 },
65
68
  "selector": { "$ref": "#/$defs/selector" },
66
69
  "selectors": {
67
70
  "type": "array",
@@ -90,6 +93,10 @@
90
93
  "if": { "properties": { "action": { "const": "openLink" } } },
91
94
  "then": { "required": ["url"] }
92
95
  },
96
+ {
97
+ "if": { "properties": { "action": { "const": "setLocation" } } },
98
+ "then": { "required": ["latitude", "longitude"] }
99
+ },
93
100
  {
94
101
  "if": { "properties": { "action": { "enum": ["tap", "waitVisible", "waitNotVisible", "assertVisible", "assertNotVisible", "scrollUntilVisible"] } } },
95
102
  "then": { "required": ["selector"] }
@@ -5,6 +5,8 @@ struct ZMRShimCommand: Decodable {
5
5
  let cmd: String
6
6
  let selector: String?
7
7
  let text: String?
8
+ let url: String?
9
+ let expoDevClientFallback: Bool?
8
10
  let x: Int?
9
11
  let y: Int?
10
12
  let x1: Int?
@@ -116,6 +116,11 @@ final class ZMRShimUITestCase: XCTestCase {
116
116
  "viewport": ZMRShim.viewport(app: app).json,
117
117
  "nodes": ZMRShim.snapshot(app: app).map { $0.json }
118
118
  ]
119
+ case "viewport":
120
+ return [
121
+ "status": "ok",
122
+ "viewport": ZMRShim.viewport(app: app).json
123
+ ]
119
124
  case "screenshot":
120
125
  let screenshot = XCUIScreen.main.screenshot()
121
126
  return [
@@ -187,7 +192,12 @@ final class ZMRShimUITestCase: XCTestCase {
187
192
  case "appState":
188
193
  return ["status": "ok", "state": app.state.rawValue]
189
194
  case "acceptSystemAlert":
190
- return acceptSystemAlert(buttonText: command.text ?? "Open", app: app)
195
+ return acceptSystemAlert(
196
+ buttonText: command.text ?? "Open",
197
+ openedURL: command.url,
198
+ expoDevClientFallback: command.expoDevClientFallback ?? false,
199
+ app: app
200
+ )
191
201
  default:
192
202
  return error("unknown.command", "unsupported command: \(command.cmd)")
193
203
  }
@@ -195,7 +205,7 @@ final class ZMRShimUITestCase: XCTestCase {
195
205
 
196
206
  private func commandRequiresForeground(_ command: ZMRShimCommand) -> Bool {
197
207
  switch command.cmd {
198
- case "snapshot", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
208
+ case "snapshot", "viewport", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
199
209
  return true
200
210
  default:
201
211
  return false
@@ -229,7 +239,12 @@ final class ZMRShimUITestCase: XCTestCase {
229
239
  ["status": "error", "code": code, "message": message]
230
240
  }
231
241
 
232
- private func acceptSystemAlert(buttonText: String, app: XCUIApplication) -> [String: Any] {
242
+ private func acceptSystemAlert(
243
+ buttonText: String,
244
+ openedURL: String?,
245
+ expoDevClientFallback: Bool,
246
+ app: XCUIApplication
247
+ ) -> [String: Any] {
233
248
  let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
234
249
  var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
235
250
  labels = labels.reduce(into: [String]()) { unique, label in
@@ -264,7 +279,11 @@ final class ZMRShimUITestCase: XCTestCase {
264
279
  }
265
280
  }
266
281
 
267
- let expoDeepLinkSelection = acceptExpoDevClientDeepLink(app: app)
282
+ let expoDeepLinkSelection = acceptExpoDevClientDeepLink(
283
+ openedURL: openedURL,
284
+ expoDevClientFallback: expoDevClientFallback,
285
+ app: app
286
+ )
268
287
  if expoDeepLinkSelection.accepted {
269
288
  acceptedCount += 1
270
289
  lastAcceptedLabel = expoDeepLinkSelection.label
@@ -282,27 +301,38 @@ final class ZMRShimUITestCase: XCTestCase {
282
301
  return ["status": "ok", "accepted": false, "count": 0]
283
302
  }
284
303
 
285
- private func acceptExpoDevClientDeepLink(app: XCUIApplication) -> (accepted: Bool, label: String) {
286
- guard app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) else {
287
- return (false, "")
288
- }
289
-
290
- let candidateQueries = [
291
- app.buttons.allElementsBoundByIndex,
292
- app.cells.allElementsBoundByIndex,
293
- app.staticTexts.allElementsBoundByIndex
294
- ]
304
+ private func acceptExpoDevClientDeepLink(
305
+ openedURL: String?,
306
+ expoDevClientFallback: Bool,
307
+ app: XCUIApplication
308
+ ) -> (accepted: Bool, label: String) {
309
+ let predicate = NSPredicate(
310
+ format: "label != '' AND label != %@ AND label != %@ AND label != %@ AND NOT label CONTAINS[c] %@ AND NOT label BEGINSWITH[c] %@ AND NOT label CONTAINS[c] %@",
311
+ "Deep link received:",
312
+ "Select an app to open it:",
313
+ "Go back",
314
+ "://",
315
+ "Note:",
316
+ "next app you open"
317
+ )
295
318
 
296
- for elements in candidateQueries {
297
- for element in elements {
298
- let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
299
- guard isExpoDevClientDeepLinkTarget(label: label), element.isHittable else {
300
- continue
301
- }
319
+ if app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) {
320
+ if tapFirstMatchingExpoCandidate(
321
+ queries: [app.buttons, app.cells, app.staticTexts],
322
+ predicate: predicate
323
+ ) {
324
+ return (true, "expo-dev-client-deep-link")
325
+ }
326
+ }
302
327
 
303
- element.tap()
304
- Thread.sleep(forTimeInterval: 1.0)
305
- return (true, label)
328
+ if expoDevClientFallback,
329
+ isCustomSchemeURL(openedURL),
330
+ !isExpoDevClientURL(openedURL) {
331
+ if tapExpoDevClientDeepLinkCoordinateFallback(app: app) {
332
+ return (true, "expo-dev-client-deep-link-coordinate")
333
+ }
334
+ if tapExpoDevClientDeepLinkCandidateFallback(app: app, predicate: predicate) {
335
+ return (true, "expo-dev-client-deep-link-candidate")
306
336
  }
307
337
  }
308
338
 
@@ -335,23 +365,12 @@ final class ZMRShimUITestCase: XCTestCase {
335
365
  return (false, "")
336
366
  }
337
367
 
338
- let candidateQueries = [
339
- app.buttons.allElementsBoundByIndex,
340
- app.cells.allElementsBoundByIndex,
341
- app.staticTexts.allElementsBoundByIndex
342
- ]
343
-
344
- for elements in candidateQueries {
345
- for element in elements {
346
- let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
347
- guard isExpoDevClientProjectTarget(label: label), element.exists else {
348
- continue
349
- }
350
-
351
- element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
352
- Thread.sleep(forTimeInterval: 1.0)
353
- return (true, label)
354
- }
368
+ let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR label CONTAINS[c] %@", " http://", " https://")
369
+ if tapFirstMatchingExpoCandidate(
370
+ queries: [app.buttons, app.cells, app.staticTexts],
371
+ predicate: predicate
372
+ ) {
373
+ return (true, "expo-dev-client-home")
355
374
  }
356
375
 
357
376
  return (false, "")
@@ -379,6 +398,59 @@ final class ZMRShimUITestCase: XCTestCase {
379
398
  return label.contains(" http://") || label.contains(" https://")
380
399
  }
381
400
 
401
+ private func tapFirstMatchingExpoCandidate(
402
+ queries: [XCUIElementQuery],
403
+ predicate: NSPredicate
404
+ ) -> Bool {
405
+ for query in queries {
406
+ let matching = query.matching(predicate)
407
+ for candidateIndex in 0..<6 {
408
+ let element = matching.element(boundBy: candidateIndex)
409
+ guard element.exists else {
410
+ break
411
+ }
412
+
413
+ guard element.isHittable else {
414
+ continue
415
+ }
416
+
417
+ element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
418
+ Thread.sleep(forTimeInterval: 1.0)
419
+ return true
420
+ }
421
+ }
422
+
423
+ return false
424
+ }
425
+
426
+ private func isCustomSchemeURL(_ value: String?) -> Bool {
427
+ guard let value else {
428
+ return false
429
+ }
430
+ return value.contains("://") && !value.hasPrefix("http://") && !value.hasPrefix("https://")
431
+ }
432
+
433
+ private func isExpoDevClientURL(_ value: String?) -> Bool {
434
+ guard let value else {
435
+ return false
436
+ }
437
+ return value.hasPrefix("exp+") && value.contains("://expo-development-client/")
438
+ }
439
+
440
+ private func tapExpoDevClientDeepLinkCoordinateFallback(app: XCUIApplication) -> Bool {
441
+ Thread.sleep(forTimeInterval: 1.5)
442
+ app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
443
+ Thread.sleep(forTimeInterval: 1.0)
444
+ return true
445
+ }
446
+
447
+ private func tapExpoDevClientDeepLinkCandidateFallback(app: XCUIApplication, predicate: NSPredicate) -> Bool {
448
+ tapFirstMatchingExpoCandidate(
449
+ queries: [app.buttons, app.cells, app.staticTexts],
450
+ predicate: predicate
451
+ )
452
+ }
453
+
382
454
  private func hideKeyboard(app: XCUIApplication) -> [String: Any] {
383
455
  guard app.keyboards.firstMatch.exists else {
384
456
  return ok()
@@ -6,6 +6,7 @@ Commands are newline-delimited JSON objects:
6
6
 
7
7
  ```json
8
8
  {"cmd":"snapshot"}
9
+ {"cmd":"viewport"}
9
10
  {"cmd":"tap","selector":"text=Continue","x":20,"y":40}
10
11
  {"cmd":"type","selector":"identifier=email","text":"hello"}
11
12
  {"cmd":"eraseText","selector":"identifier=email","maxChars":20}
@@ -43,6 +44,13 @@ that XCTest can evaluate natively. It returns:
43
44
  {"status":"ok","format":"png","base64":"..."}
44
45
  ```
45
46
 
47
+ `viewport` returns the target application frame in XCTest point coordinates
48
+ without crawling the element hierarchy:
49
+
50
+ ```json
51
+ {"status":"ok","viewport":{"width":390,"height":844}}
52
+ ```
53
+
46
54
  Snapshot responses return bounded XCTest element data in a shape Zig can map
47
55
  into `UiNode`. The shim captures common interactive and readable element
48
56
  families and caps the response at 256 nodes so large application trees do not
package/src/android.zig CHANGED
@@ -100,6 +100,20 @@ pub const AndroidDevice = struct {
100
100
  return error.AppDidNotOpen;
101
101
  }
102
102
 
103
+ pub fn setLocation(self: *AndroidDevice, latitude: f64, longitude: f64) !void {
104
+ const latitude_arg = try std.fmt.allocPrint(self.allocator, "{d:.6}", .{latitude});
105
+ defer self.allocator.free(latitude_arg);
106
+ const longitude_arg = try std.fmt.allocPrint(self.allocator, "{d:.6}", .{longitude});
107
+ defer self.allocator.free(longitude_arg);
108
+
109
+ try self.grantRuntimePermissionBestEffort("android.permission.ACCESS_FINE_LOCATION");
110
+ try self.grantRuntimePermissionBestEffort("android.permission.ACCESS_COARSE_LOCATION");
111
+
112
+ const result = try self.runAdb(&.{ "emu", "geo", "fix", longitude_arg, latitude_arg }, default_max_output);
113
+ defer result.deinit(self.allocator);
114
+ try result.ensureSuccess();
115
+ }
116
+
103
117
  pub fn tap(self: *AndroidDevice, x: i32, y: i32) !void {
104
118
  if (self.shim_path != null) return try self.runShimAction(.{ .kind = .tap, .x = x, .y = y });
105
119
  var args = try android_shell.tap(self.allocator, x, y);
@@ -284,6 +298,12 @@ pub const AndroidDevice = struct {
284
298
  return try ios_shim.parseSnapshotNodes(self.allocator, response);
285
299
  }
286
300
 
301
+ fn grantRuntimePermissionBestEffort(self: *AndroidDevice, permission: []const u8) !void {
302
+ const result = self.runAdb(&.{ "shell", "pm", "grant", self.app_id, permission }, 64 * 1024) catch return;
303
+ defer result.deinit(self.allocator);
304
+ result.ensureSuccess() catch {};
305
+ }
306
+
287
307
  fn runShimAction(self: *AndroidDevice, shim_command: ios_shim.Command) !void {
288
308
  const response = try self.runShim(shim_command);
289
309
  defer self.allocator.free(response);
package/src/errors.zig CHANGED
@@ -45,6 +45,13 @@ pub fn classify(err: anyerror) PublicError {
45
45
  error.StepMissingY1,
46
46
  error.StepMissingX2,
47
47
  error.StepMissingY2,
48
+ error.StepMissingLatitude,
49
+ error.StepMissingLongitude,
50
+ error.StepLatitudeMustBeNumber,
51
+ error.StepLongitudeMustBeNumber,
52
+ error.StepLatitudeOutOfRange,
53
+ error.StepLongitudeOutOfRange,
54
+ error.RequiredFieldMustBeNumber,
48
55
  => .{ .code = "scenario.invalid", .message = "scenario is invalid" },
49
56
  error.SelectorMustNotBeEmpty,
50
57
  error.MissingSelector,
@@ -59,6 +66,7 @@ pub fn classify(err: anyerror) PublicError {
59
66
  error.SelectorNotFound => .{ .code = "runner.selector_not_found", .message = "selector not found" },
60
67
  error.CommandFailed => .{ .code = "device.command_failed", .message = "device command failed" },
61
68
  error.CommandTimedOut => .{ .code = "device.command_timed_out", .message = "device command timed out" },
69
+ error.UnsupportedDeviceCapability => .{ .code = "device.unsupported_capability", .message = "device capability is unsupported" },
62
70
  error.IosXCTestShimRequired => .{ .code = "ios.xctest_shim_required", .message = "iOS selector interaction requires the XCTest shim" },
63
71
  error.IosXCTestShimResponseTimedOut => .{ .code = "ios.xctest_shim_response_timeout", .message = "iOS XCTest shim response timed out" },
64
72
  error.IosXCTestShimStartTimedOut => .{ .code = "ios.xctest_shim_start_timeout", .message = "iOS XCTest shim server startup timed out" },