zeno-mobile-runner 0.2.13 → 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,15 @@ 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
+
7
16
  ## 0.2.13 (2026-06-23)
8
17
 
9
18
  ### 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.13`, 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.13` 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.13.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.13"
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.13.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.13"
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.13"
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.13",
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.13","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.13`.
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.13","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.13","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.13","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.13","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.13","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.13","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.13","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.13","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.13","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.13",
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"] }
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" },
@@ -20,6 +20,8 @@ pub const FakeDevice = struct {
20
20
  opened_link: ?[]const u8 = null,
21
21
  settles: usize = 0,
22
22
  last_settle_timeout_ms: u64 = 0,
23
+ location_sets: usize = 0,
24
+ last_location: ?LocationRecord = null,
23
25
 
24
26
  pub fn init(allocator: std.mem.Allocator, snapshots: []types.ObservationSnapshot) FakeDevice {
25
27
  return .{
@@ -71,6 +73,14 @@ pub const FakeDevice = struct {
71
73
  self.opened_link = try self.allocator.dupe(u8, url);
72
74
  }
73
75
 
76
+ pub fn setLocation(self: *FakeDevice, latitude: f64, longitude: f64) !void {
77
+ self.location_sets += 1;
78
+ self.last_location = .{
79
+ .latitude = latitude,
80
+ .longitude = longitude,
81
+ };
82
+ }
83
+
74
84
  pub fn tap(self: *FakeDevice, x: i32, y: i32) !void {
75
85
  _ = x;
76
86
  _ = y;
@@ -127,6 +137,11 @@ pub const SwipeRecord = struct {
127
137
  duration_ms: u32,
128
138
  };
129
139
 
140
+ pub const LocationRecord = struct {
141
+ latitude: f64,
142
+ longitude: f64,
143
+ };
144
+
130
145
  pub fn cloneSnapshot(allocator: std.mem.Allocator, source: types.ObservationSnapshot) !types.ObservationSnapshot {
131
146
  var nodes = try allocator.alloc(types.UiNode, source.nodes.len);
132
147
  errdefer allocator.free(nodes);
package/src/ios.zig CHANGED
@@ -144,6 +144,21 @@ pub const IosDevice = struct {
144
144
  self.acceptOpenURLConfirmationBestEffort(url);
145
145
  }
146
146
 
147
+ pub fn setLocation(self: *IosDevice, latitude: f64, longitude: f64) !void {
148
+ if (self.target_kind == .physical) return error.UnsupportedDeviceCapability;
149
+
150
+ const coordinate = try std.fmt.allocPrint(self.allocator, "{d:.6},{d:.6}", .{ latitude, longitude });
151
+ defer self.allocator.free(coordinate);
152
+
153
+ const grant = try self.runSimctl(&.{ "privacy", self.target(), "grant", "location", self.app_id }, default_max_output);
154
+ defer grant.deinit(self.allocator);
155
+ try grant.ensureSuccess();
156
+
157
+ const result = try self.runSimctl(&.{ "location", self.target(), "set", coordinate }, default_max_output);
158
+ defer result.deinit(self.allocator);
159
+ try result.ensureSuccess();
160
+ }
161
+
147
162
  pub fn tap(self: *IosDevice, x: i32, y: i32) !void {
148
163
  try self.runShimAction(.{ .kind = .tap, .x = x, .y = y });
149
164
  }
@@ -543,6 +558,70 @@ test "ios simulator openLink keeps sweeping delayed XCTest interruptions until a
543
558
  try std.testing.expectEqual(@as(u8, 6), count);
544
559
  }
545
560
 
561
+ test "ios simulator setLocation grants app location permission and sets coordinates" {
562
+ const allocator = std.heap.page_allocator;
563
+ const argv = [_][*:0]const u8{"zmr-ios-test"};
564
+ stdio.initProcess(.{
565
+ .args = .{ .vector = argv[0..] },
566
+ .environ = .empty,
567
+ }, allocator);
568
+ defer stdio.deinitProcess();
569
+
570
+ var tmp = std.testing.tmpDir(.{});
571
+ defer tmp.cleanup();
572
+
573
+ var xcrun = try tmp.dir.createFile(stdio.io(), "fake-xcrun-location.sh", .{ .truncate = true });
574
+ {
575
+ var buffer: [4096]u8 = undefined;
576
+ var writer = xcrun.writerStreaming(stdio.io(), &buffer);
577
+ const script = try std.fmt.allocPrint(allocator,
578
+ \\#!/usr/bin/env bash
579
+ \\set -euo pipefail
580
+ \\printf '%s\n' "$*" >> ".zig-cache/tmp/{s}/xcrun.log"
581
+ \\if [[ "${{1:-}}" != "simctl" ]]; then
582
+ \\ echo "expected simctl" >&2
583
+ \\ exit 2
584
+ \\fi
585
+ \\shift
586
+ \\case "${{1:-}}" in
587
+ \\ privacy)
588
+ \\ [[ "${{2:-}}" == "fake-ios-1" && "${{3:-}}" == "grant" && "${{4:-}}" == "location" && "${{5:-}}" == "com.example.mobiletest" ]] || exit 2
589
+ \\ ;;
590
+ \\ location)
591
+ \\ [[ "${{2:-}}" == "fake-ios-1" && "${{3:-}}" == "set" && "${{4:-}}" == "51.507400,-0.127800" ]] || exit 2
592
+ \\ ;;
593
+ \\ *)
594
+ \\ echo "unsupported simctl command: $*" >&2
595
+ \\ exit 2
596
+ \\ ;;
597
+ \\esac
598
+ \\
599
+ , .{tmp.sub_path});
600
+ defer allocator.free(script);
601
+ try writer.interface.writeAll(script);
602
+ try writer.interface.flush();
603
+ }
604
+ xcrun.close(stdio.io());
605
+
606
+ const xcrun_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/fake-xcrun-location.sh", .{tmp.sub_path});
607
+ defer allocator.free(xcrun_path);
608
+ const xcrun_path_z = try allocator.dupeZ(u8, xcrun_path);
609
+ defer allocator.free(xcrun_path_z);
610
+ if (std.c.chmod(xcrun_path_z, 0o755) != 0) return error.ChmodFailed;
611
+
612
+ var device = try IosDevice.initWithShim(allocator, xcrun_path, "fake-ios-1", "com.example.mobiletest", null);
613
+ defer device.deinit();
614
+
615
+ try device.setLocation(51.5074, -0.1278);
616
+
617
+ const log_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/xcrun.log", .{tmp.sub_path});
618
+ defer allocator.free(log_path);
619
+ const log = try stdio.readFileAlloc(allocator, log_path, 1024);
620
+ defer allocator.free(log);
621
+ try std.testing.expect(std.mem.indexOf(u8, log, "simctl privacy fake-ios-1 grant location com.example.mobiletest") != null);
622
+ try std.testing.expect(std.mem.indexOf(u8, log, "simctl location fake-ios-1 set 51.507400,-0.127800") != null);
623
+ }
624
+
546
625
  pub fn listDevices(allocator: std.mem.Allocator, xcrun_path: []const u8) ![]types.DeviceInfo {
547
626
  return try ios_devices.listSimulators(allocator, xcrun_path);
548
627
  }
@@ -31,6 +31,11 @@ pub fn requiredI32FromObject(object: std.json.ObjectMap, key: []const u8, missin
31
31
  return i32Value(value, type_error);
32
32
  }
33
33
 
34
+ pub fn requiredF64FromObject(object: std.json.ObjectMap, key: []const u8, missing_error: anyerror, type_error: anyerror) !f64 {
35
+ const value = object.get(key) orelse return missing_error;
36
+ return f64Value(value, type_error);
37
+ }
38
+
34
39
  pub fn optionalU64(params: ?std.json.Value, key: []const u8, default_value: u64, type_error: anyerror) !u64 {
35
40
  const value = field(params, key) orelse return default_value;
36
41
  return u64Value(value, type_error);
@@ -72,6 +77,14 @@ fn u64Value(value: std.json.Value, type_error: anyerror) !u64 {
72
77
  };
73
78
  }
74
79
 
80
+ fn f64Value(value: std.json.Value, type_error: anyerror) !f64 {
81
+ return switch (value) {
82
+ .float => |actual| actual,
83
+ .integer => |actual| @as(f64, @floatFromInt(actual)),
84
+ else => type_error,
85
+ };
86
+ }
87
+
75
88
  fn boolValue(value: std.json.Value, type_error: anyerror) !bool {
76
89
  return switch (value) {
77
90
  .bool => |actual| actual,
package/src/runner.zig CHANGED
@@ -8,6 +8,7 @@ const scenario = @import("scenario.zig");
8
8
  const selector = @import("selector.zig");
9
9
  const trace = @import("trace.zig");
10
10
  const types = @import("types.zig");
11
+ const fake_device = @import("fake_device.zig");
11
12
 
12
13
  pub const RunOptions = runner_config.RunOptions;
13
14
 
@@ -86,6 +87,14 @@ pub fn executeStep(
86
87
  if (writer) |tw| try runner_events.recordActionStatus(tw, "app.openLink", "ok", null, url);
87
88
  try settleDevice(device, options);
88
89
  },
90
+ .set_location => |location| {
91
+ device.setLocation(location.latitude, location.longitude) catch |err| {
92
+ if (writer) |tw| try runner_events.recordSetLocation(tw, "failed", err, location.latitude, location.longitude);
93
+ return err;
94
+ };
95
+ if (writer) |tw| try runner_events.recordSetLocation(tw, "ok", null, location.latitude, location.longitude);
96
+ try settleDevice(device, options);
97
+ },
89
98
  .tap => |wanted| try tapSelector(device, wanted, writer, options),
90
99
  .type_text => |input| {
91
100
  if (input.selector) |wanted| return try typeTextSelector(device, wanted, input.text, writer, options);
@@ -309,6 +318,46 @@ fn settleDevice(device: anytype, options: RunOptions) !void {
309
318
  try device.settle(options.settle_ms);
310
319
  }
311
320
 
321
+ test "setLocation dispatches through the device, records trace evidence, and settles" {
322
+ const allocator = std.testing.allocator;
323
+ const dir = "zig-cache-test-runner-set-location";
324
+ std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
325
+ defer std.Io.Dir.cwd().deleteTree(stdio.io(), dir) catch {};
326
+
327
+ const script_json =
328
+ \\{
329
+ \\ "name": "set location",
330
+ \\ "steps": [
331
+ \\ {"action": "setLocation", "latitude": 51.5074, "longitude": -0.1278}
332
+ \\ ]
333
+ \\}
334
+ ;
335
+ const script = try scenario.parseSlice(allocator, script_json);
336
+ defer script.deinit(allocator);
337
+
338
+ var device = fake_device.FakeDevice.init(allocator, &.{});
339
+ defer device.deinit();
340
+ var tw = try trace.TraceWriter.init(allocator, dir);
341
+ defer tw.deinit();
342
+
343
+ try runScenario(allocator, &device, script, &tw, .{ .settle_ms = 25 });
344
+
345
+ try std.testing.expectEqual(@as(usize, 1), device.location_sets);
346
+ try std.testing.expectApproxEqAbs(@as(f64, 51.5074), device.last_location.?.latitude, 0.000001);
347
+ try std.testing.expectApproxEqAbs(@as(f64, -0.1278), device.last_location.?.longitude, 0.000001);
348
+ try std.testing.expectEqual(@as(usize, 1), device.settles);
349
+ try std.testing.expectEqual(@as(u64, 25), device.last_settle_timeout_ms);
350
+
351
+ const events_path = try std.fs.path.join(allocator, &.{ dir, "events.jsonl" });
352
+ defer allocator.free(events_path);
353
+ const events = try stdio.readFileAlloc(allocator, events_path, 1024 * 1024);
354
+ defer allocator.free(events);
355
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"kind\":\"device.setLocation\"") != null);
356
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"status\":\"ok\"") != null);
357
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"latitude\":51.5074") != null);
358
+ try std.testing.expect(std.mem.indexOf(u8, events, "\"longitude\":-0.1278") != null);
359
+ }
360
+
312
361
  test "whenVisible skips the conditional block when the visibility probe command fails" {
313
362
  const allocator = std.testing.allocator;
314
363
  const dir = "zig-cache-test-runner-when-visible-command-failed";
@@ -336,6 +385,12 @@ test "whenVisible skips the conditional block when the visibility probe command
336
385
  _ = url;
337
386
  }
338
387
 
388
+ pub fn setLocation(self: *@This(), latitude: f64, longitude: f64) !void {
389
+ _ = self;
390
+ _ = latitude;
391
+ _ = longitude;
392
+ }
393
+
339
394
  pub fn tap(self: *@This(), x: i32, y: i32) !void {
340
395
  _ = self;
341
396
  _ = x;
@@ -137,6 +137,20 @@ pub fn recordActionStatus(tw: *trace.TraceWriter, kind: []const u8, status: []co
137
137
  try tw.recordEvent(kind, out.buffered());
138
138
  }
139
139
 
140
+ pub fn recordSetLocation(tw: *trace.TraceWriter, status: []const u8, err: ?anyerror, latitude: f64, longitude: f64) !void {
141
+ var payload: std.Io.Writer.Allocating = .init(tw.allocator);
142
+ defer payload.deinit();
143
+ const out = &payload.writer;
144
+ try out.writeAll("{\"status\":");
145
+ try trace.writeJsonString(out, status);
146
+ if (err) |actual| {
147
+ try out.writeAll(",\"error\":");
148
+ try trace.writeJsonString(out, @errorName(actual));
149
+ }
150
+ try out.print(",\"latitude\":{d:.6},\"longitude\":{d:.6}}}", .{ latitude, longitude });
151
+ try tw.recordEvent("device.setLocation", out.buffered());
152
+ }
153
+
140
154
  pub fn recordSwipe(tw: *trace.TraceWriter, x1: i32, y1: i32, x2: i32, y2: i32, duration_ms: u32) !void {
141
155
  const payload = try std.fmt.allocPrint(
142
156
  tw.allocator,
package/src/scenario.zig CHANGED
@@ -11,6 +11,11 @@ pub const Swipe = struct {
11
11
  duration_ms: u32 = 300,
12
12
  };
13
13
 
14
+ pub const Location = struct {
15
+ latitude: f64,
16
+ longitude: f64,
17
+ };
18
+
14
19
  pub const WaitVisible = struct {
15
20
  selector: selector.Selector,
16
21
  timeout_ms: u64 = 5000,
@@ -106,6 +111,7 @@ pub const Step = union(enum) {
106
111
  clear_state,
107
112
  snapshot,
108
113
  open_link: []const u8,
114
+ set_location: Location,
109
115
  tap: selector.Selector,
110
116
  type_text: TypeText,
111
117
  press_back,
@@ -224,6 +230,10 @@ fn parseRawStep(allocator: std.mem.Allocator, object: std.json.ObjectMap) anyerr
224
230
  if (std.mem.eql(u8, action, "hideKeyboard")) return .hide_keyboard;
225
231
  if (std.mem.eql(u8, action, "sleep")) return .{ .sleep_ms = try fields.optionalU64(object, "ms", 500) };
226
232
  if (std.mem.eql(u8, action, "openLink")) return .{ .open_link = try fields.requiredStringOrError(allocator, object, "url", error.StepMissingUrl) };
233
+ if (std.mem.eql(u8, action, "setLocation")) return .{ .set_location = .{
234
+ .latitude = try parseLatitude(object),
235
+ .longitude = try parseLongitude(object),
236
+ } };
227
237
  if (std.mem.eql(u8, action, "tap")) return .{ .tap = try fields.parseSelectorField(allocator, object) };
228
238
  if (std.mem.eql(u8, action, "typeText")) {
229
239
  const wanted = if (object.get("selector")) |selector_value| try selector.parseFromJson(allocator, selector_value) else null;
@@ -342,6 +352,18 @@ fn parseRawStep(allocator: std.mem.Allocator, object: std.json.ObjectMap) anyerr
342
352
  return error.unknownScenarioAction;
343
353
  }
344
354
 
355
+ fn parseLatitude(object: std.json.ObjectMap) !f64 {
356
+ const latitude = try fields.requiredF64OrError(object, "latitude", error.StepMissingLatitude, error.StepLatitudeMustBeNumber);
357
+ if (latitude < -90.0 or latitude > 90.0) return error.StepLatitudeOutOfRange;
358
+ return latitude;
359
+ }
360
+
361
+ fn parseLongitude(object: std.json.ObjectMap) !f64 {
362
+ const longitude = try fields.requiredF64OrError(object, "longitude", error.StepMissingLongitude, error.StepLongitudeMustBeNumber);
363
+ if (longitude < -180.0 or longitude > 180.0) return error.StepLongitudeOutOfRange;
364
+ return longitude;
365
+ }
366
+
345
367
  fn appendParsedSteps(allocator: std.mem.Allocator, steps: *std.ArrayList(Step), value: std.json.Value) anyerror!void {
346
368
  if (value != .array) return error.ScenarioStepsMustBeArray;
347
369
  for (value.array.items) |step_value| {
@@ -373,3 +395,27 @@ fn optionalTimeoutMs(object: std.json.ObjectMap) !?u64 {
373
395
  if (object.get("timeoutMs") == null) return null;
374
396
  return try fields.optionalU64(object, "timeoutMs", 0);
375
397
  }
398
+
399
+ test "parses setLocation with latitude and longitude" {
400
+ const allocator = std.testing.allocator;
401
+ const script_json =
402
+ \\{
403
+ \\ "name": "set location smoke",
404
+ \\ "steps": [
405
+ \\ {"action": "setLocation", "latitude": 51.5074, "longitude": -0.1278}
406
+ \\ ]
407
+ \\}
408
+ ;
409
+
410
+ const script = try parseSlice(allocator, script_json);
411
+ defer script.deinit(allocator);
412
+
413
+ try std.testing.expectEqual(@as(usize, 1), script.steps.len);
414
+ switch (script.steps[0]) {
415
+ .set_location => |location| {
416
+ try std.testing.expectApproxEqAbs(@as(f64, 51.5074), location.latitude, 0.000001);
417
+ try std.testing.expectApproxEqAbs(@as(f64, -0.1278), location.longitude, 0.000001);
418
+ },
419
+ else => return error.ExpectedSetLocationStep,
420
+ }
421
+ }
@@ -21,6 +21,10 @@ pub fn requiredI32OrError(object: std.json.ObjectMap, key: []const u8, missing_e
21
21
  return try json_fields.requiredI32FromObject(object, key, missing_error, error.RequiredFieldMustBeInteger);
22
22
  }
23
23
 
24
+ pub fn requiredF64OrError(object: std.json.ObjectMap, key: []const u8, missing_error: anyerror, type_error: anyerror) !f64 {
25
+ return try json_fields.requiredF64FromObject(object, key, missing_error, type_error);
26
+ }
27
+
24
28
  pub fn optionalU64(object: std.json.ObjectMap, key: []const u8, default_value: u64) !u64 {
25
29
  return try json_fields.optionalU64FromObject(object, key, default_value, error.OptionalFieldMustBeInteger);
26
30
  }
@@ -109,6 +109,14 @@ fn diagnoseFailure(allocator: std.mem.Allocator, content: []const u8, err: anyer
109
109
  => try pathDiagnostic(allocator, content, "$.steps[].x2", "x2"),
110
110
  error.StepMissingY2,
111
111
  => try pathDiagnostic(allocator, content, "$.steps[].y2", "y2"),
112
+ error.StepMissingLatitude,
113
+ error.StepLatitudeMustBeNumber,
114
+ error.StepLatitudeOutOfRange,
115
+ => try pathDiagnostic(allocator, content, "$.steps[].latitude", "latitude"),
116
+ error.StepMissingLongitude,
117
+ error.StepLongitudeMustBeNumber,
118
+ error.StepLongitudeOutOfRange,
119
+ => try pathDiagnostic(allocator, content, "$.steps[].longitude", "longitude"),
112
120
  error.MissingSelector,
113
121
  error.StepMissingSelector,
114
122
  error.SelectorMustNotBeEmpty,
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.2.13";
1
+ pub const runner_version = "0.2.14";
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";