zeno-mobile-runner 0.2.13 → 0.2.15
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 +20 -0
- package/FEATURES.md +1 -1
- package/README.md +1 -1
- package/clients/kotlin/README.md +1 -1
- package/clients/kotlin/build.gradle.kts +1 -1
- package/clients/python/pyproject.toml +1 -1
- package/clients/rust/Cargo.lock +1 -1
- package/clients/rust/Cargo.toml +1 -1
- package/clients/typescript/package.json +1 -1
- package/docs/app-integration.md +4 -0
- package/docs/npm.md +4 -1
- package/docs/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol.md +11 -10
- package/docs/scenario-authoring.md +12 -0
- package/docs/troubleshooting.md +4 -2
- package/package.json +2 -1
- package/prebuilds/darwin-arm64/zmr +0 -0
- package/prebuilds/darwin-x64/zmr +0 -0
- package/prebuilds/linux-arm64/zmr +0 -0
- package/prebuilds/linux-x64/zmr +0 -0
- package/schemas/scenario.schema.json +7 -0
- package/scripts/install-ios-shim.sh +27 -0
- package/shims/ios/README.md +4 -1
- package/src/android.zig +20 -0
- package/src/errors.zig +8 -0
- package/src/fake_device.zig +15 -0
- package/src/ios.zig +79 -0
- package/src/json_fields.zig +13 -0
- package/src/runner.zig +55 -0
- package/src/runner_events.zig +14 -0
- package/src/scenario.zig +46 -0
- package/src/scenario_fields.zig +4 -0
- package/src/validation.zig +8 -0
- package/src/version.zig +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to Zeno Mobile Runner are tracked here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.2.15 (2026-06-24)
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Generated iOS shim commands now remove app-local ZMR-owned derived data paths
|
|
12
|
+
ending in `ZMRDerivedData` before `build-for-testing` refreshes. This avoids
|
|
13
|
+
reusing stale Xcode absolute paths when app checkouts are copied, while
|
|
14
|
+
refusing to delete arbitrary shared DerivedData locations.
|
|
15
|
+
- Release gates now verify that `ZMR_VERSION`, `package.json`, `src/version.zig`,
|
|
16
|
+
archive names, and release-smoked binaries agree before publishing.
|
|
17
|
+
|
|
18
|
+
## 0.2.14 (2026-06-23)
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added a first-class `setLocation` scenario action for simulator/emulator
|
|
23
|
+
location control. iOS simulators grant the target app location permission and
|
|
24
|
+
set coordinates through `simctl`; Android emulators best-effort grant runtime
|
|
25
|
+
location permissions before setting emulator geolocation.
|
|
26
|
+
|
|
7
27
|
## 0.2.13 (2026-06-23)
|
|
8
28
|
|
|
9
29
|
### 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.
|
|
145
|
+
- Current release status is `0.2.15`, 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.
|
|
202
|
+
process ceiling. Current release: `0.2.15` developer preview.
|
|
203
203
|
Protocol version: `2026-04-28`.
|
|
204
204
|
|
|
205
205
|
## Optional protocol clients
|
package/clients/kotlin/README.md
CHANGED
|
@@ -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.
|
|
30
|
+
implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.2.15.jar"))
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```kotlin
|
package/clients/rust/Cargo.lock
CHANGED
package/clients/rust/Cargo.toml
CHANGED
package/docs/app-integration.md
CHANGED
|
@@ -101,6 +101,10 @@ with `--ios-shim`. It caches `build-for-testing` output and uses
|
|
|
101
101
|
`test-without-building` for selector commands through `.zmr/ios-shim-state/`.
|
|
102
102
|
Set `ZMR_IOS_SHIM_FORCE_REBUILD=1` after app-side target changes, or
|
|
103
103
|
`ZMR_IOS_SHIM_ONESHOT=1` when you need to debug the slower cold-start path.
|
|
104
|
+
When `--derived-data-path` points at a ZMR-owned path ending in
|
|
105
|
+
`ZMRDerivedData`, the generated shim removes that directory before each
|
|
106
|
+
`build-for-testing` refresh so copied app checkouts do not reuse stale absolute
|
|
107
|
+
Xcode paths. It refuses to delete arbitrary shared DerivedData locations.
|
|
104
108
|
|
|
105
109
|
## Recommended App Repo Layout
|
|
106
110
|
|
package/docs/npm.md
CHANGED
|
@@ -333,7 +333,10 @@ The generated command caches `build-for-testing` output under
|
|
|
333
333
|
prints the last Xcode log lines when XCTest fails. Set
|
|
334
334
|
`ZMR_IOS_SHIM_FORCE_REBUILD=1` after app-side target changes, or
|
|
335
335
|
`ZMR_IOS_SHIM_ONESHOT=1` for a cold-start fallback while debugging app-side Xcode
|
|
336
|
-
wiring.
|
|
336
|
+
wiring. When `--derived-data-path` points at a ZMR-owned path ending in
|
|
337
|
+
`ZMRDerivedData`, the generated command removes that directory before each
|
|
338
|
+
`build-for-testing` refresh so copied app checkouts do not reuse stale absolute
|
|
339
|
+
Xcode paths. It refuses to delete arbitrary shared DerivedData locations.
|
|
337
340
|
|
|
338
341
|
## Native Binary Resolution
|
|
339
342
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
{"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.
|
|
1
|
+
{"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.15","protocolVersion":"2026-04-28","protocol":{"version":"2026-04-28","minimumCompatibleVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"},"platforms":["android","ios"],"platformSupport":{"android":{"status":"supported","deviceTypes":["emulator","physical"],"automation":["adb","uiautomator","android-shim"]},"ios":{"status":"supported","deviceTypes":["simulator","physical"],"automation":["simctl","devicectl","xctest-shim"],"physicalDevices":true}},"iosPreview":false,"transports":["stdio","tcp"],"methods":["runner.capabilities","device.list","session.create","session.close","app.install","app.launch","app.stop","app.openLink","app.clearState","observe.snapshot","observe.semanticSnapshot","ui.tap","ui.type","ui.eraseText","ui.hideKeyboard","ui.swipe","ui.pressBack","ui.scrollUntilVisible","wait.until","wait.any","wait.gone","assert.visible","assert.notVisible","assert.healthy","scenario.validate","trace.events","trace.explore","trace.discover","trace.explain","trace.export"]}}
|
|
2
2
|
{"jsonrpc":"2.0","id":2,"result":[{"serial":"fake-device-1","state":"device","ready":true}]}
|
|
3
3
|
{"jsonrpc":"2.0","id":3,"result":{"sessionId":"default"}}
|
|
4
4
|
{"jsonrpc":"2.0","id":4,"result":true}
|
package/docs/protocol.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
ZMR exposes newline-delimited JSON-RPC 2.0 over stdio or localhost TCP in v1. Each request is one JSON object followed by `\n`. Each response is one JSON object followed by `\n`.
|
|
4
4
|
|
|
5
|
-
Current runner version: `0.2.
|
|
5
|
+
Current runner version: `0.2.15`.
|
|
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.
|
|
50
|
+
{"ok":true,"status":"ready","schemaVersion":1,"runnerVersion":"0.2.15","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.
|
|
63
|
+
{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.15","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.
|
|
74
|
+
{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.15","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.
|
|
87
|
+
{"ok":true,"mode":"draft","schemaVersion":1,"runnerVersion":"0.2.15","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.
|
|
217
|
+
{"name":"zmr","version":"0.2.15","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.
|
|
229
|
+
{"name":"zmr","version":"0.2.15","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.
|
|
436
|
+
{"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.2.15","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.
|
|
518
|
+
{"jsonrpc":"2.0","id":25,"result":{"ok":true,"mode":"discover","schemaVersion":1,"runnerVersion":"0.2.15","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.
|
|
541
|
+
{"jsonrpc":"2.0","id":27,"result":{"ok":true,"mode":"explore","schemaVersion":1,"runnerVersion":"0.2.15","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/docs/troubleshooting.md
CHANGED
|
@@ -208,8 +208,10 @@ This avoids failing the whole demo when one local simulator cannot start
|
|
|
208
208
|
`launchd_sim`, while still surfacing a setup error if every available simulator
|
|
209
209
|
fails to boot.
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
The generated shim now removes app-local ZMR derived data paths ending in
|
|
212
|
+
`ZMRDerivedData` before each `build-for-testing` refresh. If you intentionally
|
|
213
|
+
configured a different shared DerivedData path, clean only the app-local ZMR
|
|
214
|
+
cache you own, then rerun the shim once to prewarm it:
|
|
213
215
|
|
|
214
216
|
```bash
|
|
215
217
|
rm -rf ios/build/ZMRDerivedData
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zeno-mobile-runner",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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": {
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"!scripts/release-candidate.sh",
|
|
83
83
|
"!scripts/release-gate.sh",
|
|
84
84
|
"!scripts/release-smoke.sh",
|
|
85
|
+
"!scripts/verify-release-version.sh",
|
|
85
86
|
"!scripts/sign-macos-release.sh",
|
|
86
87
|
"!scripts/verify-release-artifacts.sh",
|
|
87
88
|
"!scripts/__pycache__/",
|
|
Binary file
|
package/prebuilds/darwin-x64/zmr
CHANGED
|
Binary file
|
|
Binary file
|
package/prebuilds/linux-x64/zmr
CHANGED
|
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"] }
|
|
@@ -223,6 +223,7 @@ READY_FILE="\$SERVER_DIR/ready"
|
|
|
223
223
|
DESTINATION_ID_FILE="\$STATE_DIR/destination.id"
|
|
224
224
|
BUILD_READY_FILE="\$STATE_DIR/build-for-testing.ready"
|
|
225
225
|
LOG_FILE="\$STATE_DIR/xcodebuild.log"
|
|
226
|
+
DERIVED_DATA_PATH_VALUE="$DERIVED_DATA_PATH"
|
|
226
227
|
STDIN_FILE="\$(mktemp)"
|
|
227
228
|
trap 'rm -f "\$STDIN_FILE"' EXIT
|
|
228
229
|
|
|
@@ -332,6 +333,31 @@ destination_spec() {
|
|
|
332
333
|
printf 'platform=%s,id=%s' "\$platform_name" "\$destination_id"
|
|
333
334
|
}
|
|
334
335
|
|
|
336
|
+
clean_zmr_derived_data() {
|
|
337
|
+
if [[ -z "\$DERIVED_DATA_PATH_VALUE" ]]; then
|
|
338
|
+
return 0
|
|
339
|
+
fi
|
|
340
|
+
|
|
341
|
+
local derived_data_abs
|
|
342
|
+
if [[ "\$DERIVED_DATA_PATH_VALUE" == /* ]]; then
|
|
343
|
+
derived_data_abs="\$DERIVED_DATA_PATH_VALUE"
|
|
344
|
+
else
|
|
345
|
+
derived_data_abs="$APP_ROOT/\$DERIVED_DATA_PATH_VALUE"
|
|
346
|
+
fi
|
|
347
|
+
while [[ "\$derived_data_abs" == */ && "\$derived_data_abs" != "/" ]]; do
|
|
348
|
+
derived_data_abs="\${derived_data_abs%/}"
|
|
349
|
+
done
|
|
350
|
+
|
|
351
|
+
case "\$derived_data_abs" in
|
|
352
|
+
"$APP_ROOT/ZMRDerivedData"|"$APP_ROOT"/*/ZMRDerivedData)
|
|
353
|
+
rm -rf "\$derived_data_abs"
|
|
354
|
+
;;
|
|
355
|
+
*)
|
|
356
|
+
echo "warning: refusing to delete non-ZMR derived data path: \$DERIVED_DATA_PATH_VALUE" >&2
|
|
357
|
+
;;
|
|
358
|
+
esac
|
|
359
|
+
}
|
|
360
|
+
|
|
335
361
|
is_server_running() {
|
|
336
362
|
if [[ ! -f "\$PID_FILE" ]]; then
|
|
337
363
|
return 1
|
|
@@ -399,6 +425,7 @@ build_for_testing() {
|
|
|
399
425
|
local destination_id build_log
|
|
400
426
|
destination_id="\$(destination_spec)"
|
|
401
427
|
build_log="\$STATE_DIR/xcodebuild.build.log"
|
|
428
|
+
clean_zmr_derived_data
|
|
402
429
|
|
|
403
430
|
run_xcodebuild_with_timeout "iOS shim build-for-testing" "\${ZMR_IOS_SHIM_BUILD_TIMEOUT_SECONDS:-5400}" "\$build_log" \\
|
|
404
431
|
xcodebuild build-for-testing \\
|
package/shims/ios/README.md
CHANGED
|
@@ -19,7 +19,10 @@ Current status:
|
|
|
19
19
|
selector commands through `test-without-building`, exchanging per-command
|
|
20
20
|
files under `.zmr/ios-shim-state/`. Set `ZMR_IOS_SHIM_FORCE_REBUILD=1` to
|
|
21
21
|
refresh the cached test bundle, or `ZMR_IOS_SHIM_ONESHOT=1` to force the
|
|
22
|
-
slower one-command XCTest fallback for debugging.
|
|
22
|
+
slower one-command XCTest fallback for debugging. When configured with a
|
|
23
|
+
ZMR-owned derived data path ending in `ZMRDerivedData`, the command removes
|
|
24
|
+
that directory before each `build-for-testing` refresh and refuses to delete
|
|
25
|
+
arbitrary shared DerivedData paths.
|
|
23
26
|
- The iOS adapter still uses `xcrun simctl` for simulator install, launch,
|
|
24
27
|
terminate, open link, screenshots, and logs. It uses `xcrun devicectl` for
|
|
25
28
|
physical-device lifecycle where Apple exposes a supported local command, and
|
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" },
|
package/src/fake_device.zig
CHANGED
|
@@ -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
|
}
|
package/src/json_fields.zig
CHANGED
|
@@ -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;
|
package/src/runner_events.zig
CHANGED
|
@@ -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
|
+
}
|
package/src/scenario_fields.zig
CHANGED
|
@@ -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
|
}
|
package/src/validation.zig
CHANGED
|
@@ -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