zeno-mobile-runner 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -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/protocol-fixtures/core-session.responses.jsonl +1 -1
- package/docs/protocol.md +11 -10
- package/docs/scenario-authoring.md +12 -0
- package/package.json +1 -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/shims/ios/ZMRShim.swift +2 -0
- package/shims/ios/ZMRShimUITestCase.swift +112 -40
- package/shims/ios/protocol.md +8 -0
- package/src/android.zig +20 -0
- package/src/errors.zig +8 -0
- package/src/fake_device.zig +15 -0
- package/src/ios.zig +104 -5
- package/src/ios_shim.zig +24 -0
- package/src/json_fields.zig +13 -0
- package/src/runner.zig +55 -0
- package/src/runner_events.zig +31 -0
- package/src/runner_waits.zig +173 -54
- 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,30 @@ All notable changes to Zeno Mobile Runner are tracked here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.2.14 (2026-06-23)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added a first-class `setLocation` scenario action for simulator/emulator
|
|
12
|
+
location control. iOS simulators grant the target app location permission and
|
|
13
|
+
set coordinates through `simctl`; Android emulators best-effort grant runtime
|
|
14
|
+
location permissions before setting emulator geolocation.
|
|
15
|
+
|
|
16
|
+
## 0.2.13 (2026-06-23)
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- iOS native selector waits now cap each XCTest query to the action timeout,
|
|
21
|
+
retry one transient native command failure, and then fall back to semantic
|
|
22
|
+
snapshots with diagnostics. This prevents one stuck XCTest selector query from
|
|
23
|
+
consuming the whole wait budget while still allowing transient native queries
|
|
24
|
+
to recover.
|
|
25
|
+
- iOS native selector scrolling now reads the app-frame viewport from the XCTest
|
|
26
|
+
shim before generating swipe coordinates, so native scrolls use iOS point
|
|
27
|
+
dimensions instead of Android fallback dimensions.
|
|
28
|
+
- Expo dev-client URL opening now uses URL-aware fallback handling and avoids
|
|
29
|
+
broad static-text enumeration while accepting deep-link chooser prompts.
|
|
30
|
+
|
|
7
31
|
## 0.2.12 (2026-06-22)
|
|
8
32
|
|
|
9
33
|
### Fixed
|
package/FEATURES.md
CHANGED
|
@@ -142,7 +142,7 @@ state, and writes deterministic traces. It does not embed an LLM.
|
|
|
142
142
|
|
|
143
143
|
## Current Limitations
|
|
144
144
|
|
|
145
|
-
- Current release status is `0.2.
|
|
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.
|
|
202
|
+
process ceiling. Current release: `0.2.14` 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.14.jar"))
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
```kotlin
|
package/clients/rust/Cargo.lock
CHANGED
package/clients/rust/Cargo.toml
CHANGED
|
@@ -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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
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"] }
|
package/shims/ios/ZMRShim.swift
CHANGED
|
@@ -116,6 +116,11 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
116
116
|
"viewport": ZMRShim.viewport(app: app).json,
|
|
117
117
|
"nodes": ZMRShim.snapshot(app: app).map { $0.json }
|
|
118
118
|
]
|
|
119
|
+
case "viewport":
|
|
120
|
+
return [
|
|
121
|
+
"status": "ok",
|
|
122
|
+
"viewport": ZMRShim.viewport(app: app).json
|
|
123
|
+
]
|
|
119
124
|
case "screenshot":
|
|
120
125
|
let screenshot = XCUIScreen.main.screenshot()
|
|
121
126
|
return [
|
|
@@ -187,7 +192,12 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
187
192
|
case "appState":
|
|
188
193
|
return ["status": "ok", "state": app.state.rawValue]
|
|
189
194
|
case "acceptSystemAlert":
|
|
190
|
-
return acceptSystemAlert(
|
|
195
|
+
return acceptSystemAlert(
|
|
196
|
+
buttonText: command.text ?? "Open",
|
|
197
|
+
openedURL: command.url,
|
|
198
|
+
expoDevClientFallback: command.expoDevClientFallback ?? false,
|
|
199
|
+
app: app
|
|
200
|
+
)
|
|
191
201
|
default:
|
|
192
202
|
return error("unknown.command", "unsupported command: \(command.cmd)")
|
|
193
203
|
}
|
|
@@ -195,7 +205,7 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
195
205
|
|
|
196
206
|
private func commandRequiresForeground(_ command: ZMRShimCommand) -> Bool {
|
|
197
207
|
switch command.cmd {
|
|
198
|
-
case "snapshot", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
|
|
208
|
+
case "snapshot", "viewport", "query", "tap", "type", "eraseText", "hideKeyboard", "swipe", "settle":
|
|
199
209
|
return true
|
|
200
210
|
default:
|
|
201
211
|
return false
|
|
@@ -229,7 +239,12 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
229
239
|
["status": "error", "code": code, "message": message]
|
|
230
240
|
}
|
|
231
241
|
|
|
232
|
-
private func acceptSystemAlert(
|
|
242
|
+
private func acceptSystemAlert(
|
|
243
|
+
buttonText: String,
|
|
244
|
+
openedURL: String?,
|
|
245
|
+
expoDevClientFallback: Bool,
|
|
246
|
+
app: XCUIApplication
|
|
247
|
+
) -> [String: Any] {
|
|
233
248
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
234
249
|
var labels = [buttonText, "Open", "Allow", "OK", "Continue"]
|
|
235
250
|
labels = labels.reduce(into: [String]()) { unique, label in
|
|
@@ -264,7 +279,11 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
264
279
|
}
|
|
265
280
|
}
|
|
266
281
|
|
|
267
|
-
let expoDeepLinkSelection = acceptExpoDevClientDeepLink(
|
|
282
|
+
let expoDeepLinkSelection = acceptExpoDevClientDeepLink(
|
|
283
|
+
openedURL: openedURL,
|
|
284
|
+
expoDevClientFallback: expoDevClientFallback,
|
|
285
|
+
app: app
|
|
286
|
+
)
|
|
268
287
|
if expoDeepLinkSelection.accepted {
|
|
269
288
|
acceptedCount += 1
|
|
270
289
|
lastAcceptedLabel = expoDeepLinkSelection.label
|
|
@@ -282,27 +301,38 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
282
301
|
return ["status": "ok", "accepted": false, "count": 0]
|
|
283
302
|
}
|
|
284
303
|
|
|
285
|
-
private func acceptExpoDevClientDeepLink(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
let
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
app
|
|
294
|
-
|
|
304
|
+
private func acceptExpoDevClientDeepLink(
|
|
305
|
+
openedURL: String?,
|
|
306
|
+
expoDevClientFallback: Bool,
|
|
307
|
+
app: XCUIApplication
|
|
308
|
+
) -> (accepted: Bool, label: String) {
|
|
309
|
+
let predicate = NSPredicate(
|
|
310
|
+
format: "label != '' AND label != %@ AND label != %@ AND label != %@ AND NOT label CONTAINS[c] %@ AND NOT label BEGINSWITH[c] %@ AND NOT label CONTAINS[c] %@",
|
|
311
|
+
"Deep link received:",
|
|
312
|
+
"Select an app to open it:",
|
|
313
|
+
"Go back",
|
|
314
|
+
"://",
|
|
315
|
+
"Note:",
|
|
316
|
+
"next app you open"
|
|
317
|
+
)
|
|
295
318
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
319
|
+
if app.staticTexts["Deep link received:"].waitForExistence(timeout: 1) {
|
|
320
|
+
if tapFirstMatchingExpoCandidate(
|
|
321
|
+
queries: [app.buttons, app.cells, app.staticTexts],
|
|
322
|
+
predicate: predicate
|
|
323
|
+
) {
|
|
324
|
+
return (true, "expo-dev-client-deep-link")
|
|
325
|
+
}
|
|
326
|
+
}
|
|
302
327
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
328
|
+
if expoDevClientFallback,
|
|
329
|
+
isCustomSchemeURL(openedURL),
|
|
330
|
+
!isExpoDevClientURL(openedURL) {
|
|
331
|
+
if tapExpoDevClientDeepLinkCoordinateFallback(app: app) {
|
|
332
|
+
return (true, "expo-dev-client-deep-link-coordinate")
|
|
333
|
+
}
|
|
334
|
+
if tapExpoDevClientDeepLinkCandidateFallback(app: app, predicate: predicate) {
|
|
335
|
+
return (true, "expo-dev-client-deep-link-candidate")
|
|
306
336
|
}
|
|
307
337
|
}
|
|
308
338
|
|
|
@@ -335,23 +365,12 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
335
365
|
return (false, "")
|
|
336
366
|
}
|
|
337
367
|
|
|
338
|
-
let
|
|
339
|
-
|
|
340
|
-
app.cells.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
for elements in candidateQueries {
|
|
345
|
-
for element in elements {
|
|
346
|
-
let label = element.label.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
347
|
-
guard isExpoDevClientProjectTarget(label: label), element.exists else {
|
|
348
|
-
continue
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
352
|
-
Thread.sleep(forTimeInterval: 1.0)
|
|
353
|
-
return (true, label)
|
|
354
|
-
}
|
|
368
|
+
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR label CONTAINS[c] %@", " http://", " https://")
|
|
369
|
+
if tapFirstMatchingExpoCandidate(
|
|
370
|
+
queries: [app.buttons, app.cells, app.staticTexts],
|
|
371
|
+
predicate: predicate
|
|
372
|
+
) {
|
|
373
|
+
return (true, "expo-dev-client-home")
|
|
355
374
|
}
|
|
356
375
|
|
|
357
376
|
return (false, "")
|
|
@@ -379,6 +398,59 @@ final class ZMRShimUITestCase: XCTestCase {
|
|
|
379
398
|
return label.contains(" http://") || label.contains(" https://")
|
|
380
399
|
}
|
|
381
400
|
|
|
401
|
+
private func tapFirstMatchingExpoCandidate(
|
|
402
|
+
queries: [XCUIElementQuery],
|
|
403
|
+
predicate: NSPredicate
|
|
404
|
+
) -> Bool {
|
|
405
|
+
for query in queries {
|
|
406
|
+
let matching = query.matching(predicate)
|
|
407
|
+
for candidateIndex in 0..<6 {
|
|
408
|
+
let element = matching.element(boundBy: candidateIndex)
|
|
409
|
+
guard element.exists else {
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
guard element.isHittable else {
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
|
418
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
419
|
+
return true
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private func isCustomSchemeURL(_ value: String?) -> Bool {
|
|
427
|
+
guard let value else {
|
|
428
|
+
return false
|
|
429
|
+
}
|
|
430
|
+
return value.contains("://") && !value.hasPrefix("http://") && !value.hasPrefix("https://")
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private func isExpoDevClientURL(_ value: String?) -> Bool {
|
|
434
|
+
guard let value else {
|
|
435
|
+
return false
|
|
436
|
+
}
|
|
437
|
+
return value.hasPrefix("exp+") && value.contains("://expo-development-client/")
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private func tapExpoDevClientDeepLinkCoordinateFallback(app: XCUIApplication) -> Bool {
|
|
441
|
+
Thread.sleep(forTimeInterval: 1.5)
|
|
442
|
+
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)).tap()
|
|
443
|
+
Thread.sleep(forTimeInterval: 1.0)
|
|
444
|
+
return true
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private func tapExpoDevClientDeepLinkCandidateFallback(app: XCUIApplication, predicate: NSPredicate) -> Bool {
|
|
448
|
+
tapFirstMatchingExpoCandidate(
|
|
449
|
+
queries: [app.buttons, app.cells, app.staticTexts],
|
|
450
|
+
predicate: predicate
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
382
454
|
private func hideKeyboard(app: XCUIApplication) -> [String: Any] {
|
|
383
455
|
guard app.keyboards.firstMatch.exists else {
|
|
384
456
|
return ok()
|
package/shims/ios/protocol.md
CHANGED
|
@@ -6,6 +6,7 @@ Commands are newline-delimited JSON objects:
|
|
|
6
6
|
|
|
7
7
|
```json
|
|
8
8
|
{"cmd":"snapshot"}
|
|
9
|
+
{"cmd":"viewport"}
|
|
9
10
|
{"cmd":"tap","selector":"text=Continue","x":20,"y":40}
|
|
10
11
|
{"cmd":"type","selector":"identifier=email","text":"hello"}
|
|
11
12
|
{"cmd":"eraseText","selector":"identifier=email","maxChars":20}
|
|
@@ -43,6 +44,13 @@ that XCTest can evaluate natively. It returns:
|
|
|
43
44
|
{"status":"ok","format":"png","base64":"..."}
|
|
44
45
|
```
|
|
45
46
|
|
|
47
|
+
`viewport` returns the target application frame in XCTest point coordinates
|
|
48
|
+
without crawling the element hierarchy:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{"status":"ok","viewport":{"width":390,"height":844}}
|
|
52
|
+
```
|
|
53
|
+
|
|
46
54
|
Snapshot responses return bounded XCTest element data in a shape Zig can map
|
|
47
55
|
into `UiNode`. The shim captures common interactive and readable element
|
|
48
56
|
families and caps the response at 256 nodes so large application trees do not
|
package/src/android.zig
CHANGED
|
@@ -100,6 +100,20 @@ pub const AndroidDevice = struct {
|
|
|
100
100
|
return error.AppDidNotOpen;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
pub fn setLocation(self: *AndroidDevice, latitude: f64, longitude: f64) !void {
|
|
104
|
+
const latitude_arg = try std.fmt.allocPrint(self.allocator, "{d:.6}", .{latitude});
|
|
105
|
+
defer self.allocator.free(latitude_arg);
|
|
106
|
+
const longitude_arg = try std.fmt.allocPrint(self.allocator, "{d:.6}", .{longitude});
|
|
107
|
+
defer self.allocator.free(longitude_arg);
|
|
108
|
+
|
|
109
|
+
try self.grantRuntimePermissionBestEffort("android.permission.ACCESS_FINE_LOCATION");
|
|
110
|
+
try self.grantRuntimePermissionBestEffort("android.permission.ACCESS_COARSE_LOCATION");
|
|
111
|
+
|
|
112
|
+
const result = try self.runAdb(&.{ "emu", "geo", "fix", longitude_arg, latitude_arg }, default_max_output);
|
|
113
|
+
defer result.deinit(self.allocator);
|
|
114
|
+
try result.ensureSuccess();
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
pub fn tap(self: *AndroidDevice, x: i32, y: i32) !void {
|
|
104
118
|
if (self.shim_path != null) return try self.runShimAction(.{ .kind = .tap, .x = x, .y = y });
|
|
105
119
|
var args = try android_shell.tap(self.allocator, x, y);
|
|
@@ -284,6 +298,12 @@ pub const AndroidDevice = struct {
|
|
|
284
298
|
return try ios_shim.parseSnapshotNodes(self.allocator, response);
|
|
285
299
|
}
|
|
286
300
|
|
|
301
|
+
fn grantRuntimePermissionBestEffort(self: *AndroidDevice, permission: []const u8) !void {
|
|
302
|
+
const result = self.runAdb(&.{ "shell", "pm", "grant", self.app_id, permission }, 64 * 1024) catch return;
|
|
303
|
+
defer result.deinit(self.allocator);
|
|
304
|
+
result.ensureSuccess() catch {};
|
|
305
|
+
}
|
|
306
|
+
|
|
287
307
|
fn runShimAction(self: *AndroidDevice, shim_command: ios_shim.Command) !void {
|
|
288
308
|
const response = try self.runShim(shim_command);
|
|
289
309
|
defer self.allocator.free(response);
|
package/src/errors.zig
CHANGED
|
@@ -45,6 +45,13 @@ pub fn classify(err: anyerror) PublicError {
|
|
|
45
45
|
error.StepMissingY1,
|
|
46
46
|
error.StepMissingX2,
|
|
47
47
|
error.StepMissingY2,
|
|
48
|
+
error.StepMissingLatitude,
|
|
49
|
+
error.StepMissingLongitude,
|
|
50
|
+
error.StepLatitudeMustBeNumber,
|
|
51
|
+
error.StepLongitudeMustBeNumber,
|
|
52
|
+
error.StepLatitudeOutOfRange,
|
|
53
|
+
error.StepLongitudeOutOfRange,
|
|
54
|
+
error.RequiredFieldMustBeNumber,
|
|
48
55
|
=> .{ .code = "scenario.invalid", .message = "scenario is invalid" },
|
|
49
56
|
error.SelectorMustNotBeEmpty,
|
|
50
57
|
error.MissingSelector,
|
|
@@ -59,6 +66,7 @@ pub fn classify(err: anyerror) PublicError {
|
|
|
59
66
|
error.SelectorNotFound => .{ .code = "runner.selector_not_found", .message = "selector not found" },
|
|
60
67
|
error.CommandFailed => .{ .code = "device.command_failed", .message = "device command failed" },
|
|
61
68
|
error.CommandTimedOut => .{ .code = "device.command_timed_out", .message = "device command timed out" },
|
|
69
|
+
error.UnsupportedDeviceCapability => .{ .code = "device.unsupported_capability", .message = "device capability is unsupported" },
|
|
62
70
|
error.IosXCTestShimRequired => .{ .code = "ios.xctest_shim_required", .message = "iOS selector interaction requires the XCTest shim" },
|
|
63
71
|
error.IosXCTestShimResponseTimedOut => .{ .code = "ios.xctest_shim_response_timeout", .message = "iOS XCTest shim response timed out" },
|
|
64
72
|
error.IosXCTestShimStartTimedOut => .{ .code = "ios.xctest_shim_start_timeout", .message = "iOS XCTest shim server startup timed out" },
|