zeno-mobile-runner 0.1.2 → 0.1.3

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,24 @@ All notable changes to Zeno Mobile Runner are tracked here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.1.3 (2026-06-03)
8
+
9
+ ### Fixed
10
+
11
+ - Hardened the iOS XCTest shim for clean Expo/RN prebuilds by giving cold
12
+ `build-for-testing` runs a 90-minute default timeout with explicit progress
13
+ logging.
14
+ - Made iOS simulator `app.stop` idempotent when `simctl terminate` reports the
15
+ app is already stopped.
16
+ - Improved selector actions so native `selector.not_found` and
17
+ `selector.not_hittable` responses return a typed unavailable result instead
18
+ of failing the command parser.
19
+ - Reused a resolved booted simulator UDID across `build-for-testing` and
20
+ `test-without-building`, and cleared stale shim PID/server/destination state
21
+ when reinstalling the app-local shim.
22
+ - Avoided stale XCTest snapshot elements and replaced broad selector scans with
23
+ native predicate-based fallback queries.
24
+
7
25
  ## 0.1.2 (2026-05-28)
8
26
 
9
27
  ### Fixed
package/FEATURES.md CHANGED
@@ -99,7 +99,7 @@ state, and writes deterministic traces. It does not embed an LLM.
99
99
 
100
100
  ## Current Limitations
101
101
 
102
- - Current release status is `0.1.2`, a public developer preview rather than
102
+ - Current release status is `0.1.3`, a public developer preview rather than
103
103
  a production-stable `1.0.0`.
104
104
  - Physical iOS log capture is still simulator-first. Physical iOS screenshots
105
105
  are available when the XCTest/XCUIAutomation shim is configured.
package/README.md CHANGED
@@ -121,6 +121,11 @@ zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent
121
121
  The MCP server exposes mobile-specific tools such as `semantic_snapshot`, `tap`,
122
122
  `type`, `wait_visible`, `trace_events`, and `trace_export`.
123
123
 
124
+ For agent-led discovery and test authoring, see
125
+ [docs/agent-discovery.md](docs/agent-discovery.md). ZMR supports that loop
126
+ through MCP and JSON-RPC today; a built-in autonomous crawler is not shipped in
127
+ this preview.
128
+
124
129
  ## Optional Protocol Clients
125
130
 
126
131
  Clients are thin wrappers around `zmr serve --transport stdio`. They do not
@@ -153,7 +158,7 @@ and [docs/client-installation.md](docs/client-installation.md).
153
158
  | iOS physical device | Supported, validate locally | `devicectl` lifecycle plus app-local XCTest/XCUIAutomation shim; run pilots on your own app/device before relying on it in CI |
154
159
  | Cloud device farms | Not included | ZMR is focused on local and self-managed device targets in this preview |
155
160
 
156
- Current release: `0.1.2` developer preview. Protocol version:
161
+ Current release: `0.1.3` developer preview. Protocol version:
157
162
  `2026-04-28`.
158
163
 
159
164
  ## Documentation
@@ -161,8 +166,10 @@ Current release: `0.1.2` developer preview. Protocol version:
161
166
  - [FEATURES.md](FEATURES.md): complete feature list and limitations
162
167
  - [docs/install.md](docs/install.md): source, npm, Homebrew, and app setup
163
168
  - [docs/frameworks.md](docs/frameworks.md): React Native, Expo, Flutter, and native app guidance
169
+ - [docs/expo-smoke.md](docs/expo-smoke.md): reproducible Expo and iOS smoke test
164
170
  - [docs/app-integration.md](docs/app-integration.md): app-side Android/iOS shims
165
171
  - [docs/scenario-authoring.md](docs/scenario-authoring.md): selectors, waits, and scenario design
172
+ - [docs/agent-discovery.md](docs/agent-discovery.md): agent-led discovery and scenario authoring loop
166
173
  - [docs/protocol.md](docs/protocol.md): JSON-RPC methods and schemas
167
174
  - [docs/ai-agents.md](docs/ai-agents.md): JSON-RPC and MCP agent workflows
168
175
  - [docs/clients.md](docs/clients.md): language client guide
@@ -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.1.2.jar"))
30
+ implementation(files("path/to/zeno-mobile-runner/clients/kotlin/build/libs/zmr-client-0.1.3.jar"))
31
31
  ```
32
32
 
33
33
  The Kotlin client is host-side. It is useful for Android teams that want test
@@ -4,7 +4,7 @@ plugins {
4
4
  }
5
5
 
6
6
  group = "dev.zmr"
7
- version = "0.1.2"
7
+ version = "0.1.3"
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.1.2.dev1"
7
+ version = "0.1.3.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.1.2"
103
+ version = "0.1.3"
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.1.2"
3
+ version = "0.1.3"
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.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "index.mjs",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,83 @@
1
+ # Agent Discovery
2
+
3
+ ZMR supports agent-led discovery today through its JSON-RPC and MCP interfaces.
4
+ An external agent can observe the app, choose typed actions, inspect trace
5
+ events, and write a repeatable scenario file as it learns a flow.
6
+
7
+ ZMR does not include a built-in autonomous crawler or test writer in this
8
+ developer preview. Keep the planning loop in the agent, and keep ZMR as the
9
+ deterministic mobile control plane.
10
+
11
+ ## Recommended Loop
12
+
13
+ 1. Validate local setup:
14
+
15
+ ```bash
16
+ zmr doctor --json --config .zmr/config.json
17
+ zmr validate --json .zmr/ios-smoke.json
18
+ ```
19
+
20
+ 2. Start a live session:
21
+
22
+ ```bash
23
+ zmr serve --transport stdio --config .zmr/config.json --trace-dir traces/zmr-agent
24
+ ```
25
+
26
+ Agents that speak MCP can use:
27
+
28
+ ```bash
29
+ zmr mcp --config .zmr/config.json --trace-dir traces/zmr-agent
30
+ ```
31
+
32
+ 3. Call `runner.capabilities`, then `session.create`.
33
+ 4. Call `observe.semanticSnapshot` before choosing an action.
34
+ 5. Choose one typed action, such as `ui.tap`, `ui.type`, `app.openLink`, or
35
+ `wait.until`.
36
+ 6. Observe again and inspect `trace.events`.
37
+ 7. Write successful steps into a candidate scenario, for example
38
+ `.zmr/discovered/login-smoke.json`.
39
+ 8. Validate the candidate scenario:
40
+
41
+ ```bash
42
+ zmr validate --json .zmr/discovered/login-smoke.json
43
+ ```
44
+
45
+ 9. Re-run it deterministically:
46
+
47
+ ```bash
48
+ zmr run .zmr/discovered/login-smoke.json \
49
+ --platform ios \
50
+ --device booted \
51
+ --trace-dir traces/zmr-login-smoke \
52
+ --json
53
+ ```
54
+
55
+ 10. Export a redacted bundle before sharing artifacts:
56
+
57
+ ```bash
58
+ zmr export traces/zmr-login-smoke \
59
+ --out traces/zmr-login-smoke-redacted.zmrtrace \
60
+ --redact
61
+ ```
62
+
63
+ ## Guardrails
64
+
65
+ - Set a step budget and a time budget before discovery starts.
66
+ - Restrict discovery to known app ids, deep-link schemes, and test accounts.
67
+ - Do not ask an agent to discover credentials or secrets.
68
+ - Prefer accessibility identifiers, resource ids, stable labels, and exact text
69
+ over coordinates.
70
+ - Require human review before committing generated tests.
71
+ - Redact traces before sharing them outside the local team.
72
+
73
+ ## Future Shape
74
+
75
+ A future command could wrap this loop:
76
+
77
+ ```bash
78
+ zmr explore --goal "find the login flow" --out .zmr/discovered/login-smoke.json
79
+ ```
80
+
81
+ That command is not shipped today. The safer product direction is to make
82
+ scenario discovery explicit, reviewable, and trace-backed before it becomes a
83
+ one-command workflow.
package/docs/ai-agents.md CHANGED
@@ -68,6 +68,17 @@ The MCP server exposes mobile-specific tools:
68
68
  Prefer `semantic_snapshot` for action planning. It avoids forcing an agent to
69
69
  infer intent from platform-specific Android/UI Automator or XCTest class names.
70
70
 
71
+ ## Agent-Led Discovery
72
+
73
+ Agents can use ZMR to discover flows and draft scenarios by looping over
74
+ `observe.semanticSnapshot`, one typed action, trace events, and scenario
75
+ validation. See [Agent Discovery](agent-discovery.md) for the recommended
76
+ reviewable loop.
77
+
78
+ ZMR does not ship a built-in autonomous crawler or test writer in this developer
79
+ preview. Keep autonomous planning outside the runner, then commit only reviewed
80
+ scenario JSON.
81
+
71
82
  ## Scenario File Workflow
72
83
 
73
84
  For repeatable tests, generate or edit `.zmr/*.json` scenarios:
@@ -0,0 +1,79 @@
1
+ # Expo Smoke Test
2
+
3
+ This is the quickest public smoke path for an Expo app. It proves that the npm
4
+ package installs, the wizard scaffolds a scenario, ZMR can launch an iOS app,
5
+ and the runner can produce screenshots, traces, HTML reports, and redacted trace
6
+ bundles.
7
+
8
+ The flow below was verified locally with `zeno-mobile-runner@0.1.3` on an iOS
9
+ simulator.
10
+
11
+ ```bash
12
+ npx create-expo-app@latest /tmp/zmr-expo-smoke --template blank --yes
13
+ cd /tmp/zmr-expo-smoke
14
+ npm install --save-dev zeno-mobile-runner
15
+
16
+ npx zmr-wizard --yes --dir . \
17
+ --app-id com.example.zenoexposmoke \
18
+ --ios \
19
+ --package-json
20
+ ```
21
+
22
+ Boot a simulator, then build and launch the app:
23
+
24
+ ```bash
25
+ xcrun simctl boot <simulator-udid>
26
+ npx expo run:ios --device <simulator-name>
27
+ ```
28
+
29
+ Run the generated ZMR scenario:
30
+
31
+ ```bash
32
+ npx zmr run .zmr/ios-smoke.json \
33
+ --platform ios \
34
+ --device booted \
35
+ --trace-dir traces/zmr-ios \
36
+ --json
37
+
38
+ npx zmr report traces/zmr-ios --out traces/zmr-ios/report.html
39
+ npx zmr export traces/zmr-ios --out traces/zmr-ios-redacted.zmrtrace --redact
40
+ ```
41
+
42
+ Expected result shape:
43
+
44
+ ```json
45
+ {
46
+ "ok": true,
47
+ "status": "passed",
48
+ "scenario": "iOS smoke",
49
+ "appId": "com.example.zenoexposmoke",
50
+ "traceDir": "traces/zmr-ios",
51
+ "eventCount": 8,
52
+ "snapshotCount": 2,
53
+ "partialFailureCount": 0
54
+ }
55
+ ```
56
+
57
+ This smoke validates the platform-level loop: app launch, health check,
58
+ screenshot capture, trace collection, report generation, and redacted export.
59
+ For selector-grade React Native or Expo assertions on iOS, add the XCTest shim
60
+ described in [app integration](app-integration.md).
61
+
62
+ Android follows the same pattern with a connected emulator or device:
63
+
64
+ ```bash
65
+ npx zmr-wizard --yes --dir . \
66
+ --app-id com.example.zenoexposmoke \
67
+ --android \
68
+ --package-json
69
+
70
+ npx zmr run .zmr/android-smoke.json \
71
+ --platform android \
72
+ --device emulator-5554 \
73
+ --trace-dir traces/zmr-android \
74
+ --json
75
+ ```
76
+
77
+ Do not commit traces, screenshots, bundle identifiers, private app names, or
78
+ credentials from a real product app. Use `zmr export --redact`, and add
79
+ `--omit-screenshots` when visual artifacts may contain sensitive data.
@@ -1,4 +1,4 @@
1
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.1.2","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","trace.events","trace.export"]}}
1
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.1.3","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","trace.events","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.1.2`.
5
+ Current runner version: `0.1.3`.
6
6
 
7
7
  Current protocol version: `2026-04-28`.
8
8
 
@@ -133,7 +133,7 @@ installers, setup scripts, and generated clients. The response is covered by
133
133
  `schemas/version-output.schema.json`:
134
134
 
135
135
  ```json
136
- {"name":"zmr","version":"0.1.2","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
136
+ {"name":"zmr","version":"0.1.3","protocolVersion":"2026-04-28","minimumCompatibleProtocolVersion":"2026-04-28","stability":"dev-preview","breakingChangePolicy":"version-and-changelog"}
137
137
  ```
138
138
 
139
139
  ## Capabilities Output Contract
@@ -145,7 +145,7 @@ and method inventory for JSON-RPC clients. The result object is covered by
145
145
  iOS simulator, or physical iOS workflows are available.
146
146
 
147
147
  ```json
148
- {"name":"zmr","version":"0.1.2","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","observe.snapshot","observe.semanticSnapshot"]}
148
+ {"name":"zmr","version":"0.1.3","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","observe.snapshot","observe.semanticSnapshot"]}
149
149
  ```
150
150
 
151
151
  ## Doctor Output Contract
@@ -347,7 +347,7 @@ Request:
347
347
  Response:
348
348
 
349
349
  ```json
350
- {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.1.2","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","trace.events","trace.export"]}}
350
+ {"jsonrpc":"2.0","id":1,"result":{"name":"zmr","version":"0.1.3","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","trace.events","trace.export"]}}
351
351
  ```
352
352
 
353
353
  ### `trace.events`
package/npm/build-zmr.mjs CHANGED
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
- const out = path.join(root, "zig-out", "bin", process.platform === "win32" ? "zmr.exe" : "zmr");
8
+ const out = process.env.ZMR_BUILD_OUT || path.join(root, "zig-out", "bin", process.platform === "win32" ? "zmr.exe" : "zmr");
9
9
  fs.mkdirSync(path.dirname(out), { recursive: true });
10
10
 
11
11
  const args = ["build-exe", "src/main.zig", "-O", "ReleaseSafe", `-femit-bin=${out}`];
@@ -1,21 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
3
4
  import { resolveBinary } from "./index.mjs";
4
5
 
5
- if (resolveBinary()) process.exit(0);
6
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
7
+ const binary = resolveBinary();
8
+
9
+ if (binary && binaryMatchesPackageVersion(binary, pkg.version)) process.exit(0);
10
+ if (binary && process.env.ZMR_BIN) {
11
+ console.warn(`zeno-mobile-runner: ZMR_BIN points to a zmr binary that does not report package version ${pkg.version}: ${binary}`);
12
+ process.exit(0);
13
+ }
6
14
  if (process.env.ZMR_SKIP_POSTINSTALL_BUILD === "1") process.exit(0);
7
15
 
8
16
  const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0;
9
17
  if (!hasZig) {
10
- console.warn("zeno-mobile-runner: no prebuilt zmr binary found and Zig is not installed.");
18
+ if (binary) {
19
+ console.warn(`zeno-mobile-runner: prebuilt zmr binary does not report package version ${pkg.version}: ${binary}`);
20
+ } else {
21
+ console.warn("zeno-mobile-runner: no prebuilt zmr binary found and Zig is not installed.");
22
+ }
11
23
  console.warn("zeno-mobile-runner: install a release package with prebuilds, install Zig and run `npm run build:zmr`, or set ZMR_BIN.");
12
24
  process.exit(0);
13
25
  }
14
26
 
15
27
  const result = spawnSync(process.execPath, [new URL("./build-zmr.mjs", import.meta.url).pathname], {
16
28
  stdio: "inherit",
29
+ env: {
30
+ ...process.env,
31
+ ...(binary ? { ZMR_BUILD_OUT: binary } : {}),
32
+ },
17
33
  });
18
34
 
19
35
  if (result.status !== 0) {
20
36
  console.warn("zeno-mobile-runner: postinstall build failed; set ZMR_BIN or run `npm run build:zmr` after fixing Zig.");
21
37
  }
38
+
39
+ function binaryMatchesPackageVersion(binaryPath, expectedVersion) {
40
+ const result = spawnSync(binaryPath, ["version", "--json"], { encoding: "utf8" });
41
+ if (result.status !== 0) return false;
42
+ try {
43
+ return JSON.parse(result.stdout).version === expectedVersion;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
9
+ const exe = process.platform === "win32" ? "zmr.exe" : "zmr";
10
+ const hostPrebuild = path.join(root, "prebuilds", `${process.platform}-${process.arch}`, exe);
11
+
12
+ if (!fs.existsSync(hostPrebuild)) {
13
+ fail(`missing host prebuild for ${process.platform}-${process.arch}: ${hostPrebuild}`);
14
+ }
15
+
16
+ const version = spawnSync(hostPrebuild, ["version", "--json"], { encoding: "utf8" });
17
+ if (version.status !== 0) {
18
+ fail(`host prebuild is not runnable: ${hostPrebuild}`);
19
+ }
20
+
21
+ let actual;
22
+ try {
23
+ actual = JSON.parse(version.stdout).version;
24
+ } catch {
25
+ fail(`host prebuild did not return valid version JSON: ${hostPrebuild}`);
26
+ }
27
+
28
+ if (actual !== pkg.version) {
29
+ fail(`host prebuild reports ${actual}, expected ${pkg.version}: ${hostPrebuild}`);
30
+ }
31
+
32
+ function fail(message) {
33
+ console.error(`zeno-mobile-runner: ${message}`);
34
+ console.error("zeno-mobile-runner: run `npm run pack:npm` before publishing so prebuilds match package.json.");
35
+ process.exit(1);
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zeno-mobile-runner",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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": {
@@ -92,6 +92,7 @@
92
92
  ],
93
93
  "scripts": {
94
94
  "postinstall": "node npm/postinstall.mjs",
95
+ "prepublishOnly": "node npm/verify-publish.mjs",
95
96
  "build:zmr": "node npm/build-zmr.mjs",
96
97
  "pack:npm": "bash scripts/build-npm-package.sh",
97
98
  "zmr:demo": "node npm/zmr.mjs validate examples/demo-fake.json",
Binary file
Binary file
Binary file
Binary file
@@ -167,6 +167,10 @@ mkdir -p "$APP_ROOT"
167
167
  APP_ROOT="$(cd "$APP_ROOT" && pwd)"
168
168
  mkdir -p "$APP_ROOT/.zmr" "$APP_ROOT/.zmr/shims/ios"
169
169
  rm -f "$APP_ROOT/.zmr/ios-shim-state/build-for-testing.ready"
170
+ rm -f "$APP_ROOT/.zmr/ios-shim-state/destination.id"
171
+ rm -f "$APP_ROOT/.zmr/ios-shim-state/xcodebuild.pid"
172
+ rm -f "$APP_ROOT/.zmr/ios-shim-state/xcodebuild.log"
173
+ rm -rf "$APP_ROOT/.zmr/ios-shim-state/server"
170
174
  cp "$ROOT/shims/ios/ZMRShim.swift" "$APP_ROOT/.zmr/shims/ios/ZMRShim.swift"
171
175
  cp "$ROOT/shims/ios/ZMRShimUITestCase.swift" "$APP_ROOT/.zmr/ZMRShimUITestCase.swift"
172
176
  cp "$ROOT/scripts/ensure-ios-shim-target.rb" "$APP_ROOT/.zmr/ensure-ios-shim-target.rb"
@@ -216,6 +220,7 @@ STATE_DIR="$APP_ROOT/.zmr/ios-shim-state"
216
220
  SERVER_DIR="\$STATE_DIR/server"
217
221
  PID_FILE="\$STATE_DIR/xcodebuild.pid"
218
222
  READY_FILE="\$SERVER_DIR/ready"
223
+ DESTINATION_ID_FILE="\$STATE_DIR/destination.id"
219
224
  BUILD_READY_FILE="\$STATE_DIR/build-for-testing.ready"
220
225
  LOG_FILE="\$STATE_DIR/xcodebuild.log"
221
226
  STDIN_FILE="\$(mktemp)"
@@ -241,6 +246,52 @@ tail_log() {
241
246
  fi
242
247
  }
243
248
 
249
+ tail_log_path() {
250
+ local log_file="\$1"
251
+ if [[ -f "\$log_file" ]]; then
252
+ tail -120 "\$log_file" >&2
253
+ fi
254
+ }
255
+
256
+ run_xcodebuild_with_timeout() {
257
+ local label="\$1"
258
+ local timeout_seconds="\$2"
259
+ local log_file="\$3"
260
+ shift 3
261
+
262
+ local pid started_at next_progress progress_seconds elapsed
263
+ progress_seconds="\${ZMR_IOS_SHIM_PROGRESS_SECONDS:-30}"
264
+ "\$@" >"\$log_file" 2>&1 &
265
+ pid="\$!"
266
+ started_at="\$SECONDS"
267
+ next_progress=\$((started_at + progress_seconds))
268
+
269
+ while kill -0 "\$pid" 2>/dev/null; do
270
+ elapsed=\$((SECONDS - started_at))
271
+ if (( elapsed >= timeout_seconds )); then
272
+ echo "timed out waiting for \$label after \${timeout_seconds}s" >&2
273
+ kill -TERM "\$pid" 2>/dev/null || true
274
+ sleep 2
275
+ if kill -0 "\$pid" 2>/dev/null; then
276
+ kill -KILL "\$pid" 2>/dev/null || true
277
+ fi
278
+ wait "\$pid" 2>/dev/null || true
279
+ tail_log_path "\$log_file"
280
+ exit 1
281
+ fi
282
+ if (( SECONDS >= next_progress )); then
283
+ echo "still waiting for \$label after \${elapsed}s; log: \$log_file" >&2
284
+ next_progress=\$((SECONDS + progress_seconds))
285
+ fi
286
+ sleep 1
287
+ done
288
+
289
+ if ! wait "\$pid"; then
290
+ tail_log_path "\$log_file"
291
+ exit 1
292
+ fi
293
+ }
294
+
244
295
  resolve_destination() {
245
296
  local destination_id="$DEVICE"
246
297
  if [[ "\$destination_id" == "booted" ]]; then
@@ -248,7 +299,20 @@ resolve_destination() {
248
299
  echo "physical iOS shim requires an explicit --device id" >&2
249
300
  exit 2
250
301
  fi
302
+ if [[ -f "\$DESTINATION_ID_FILE" ]]; then
303
+ destination_id="\$(cat "\$DESTINATION_ID_FILE" 2>/dev/null || true)"
304
+ if [[ -n "\$destination_id" ]] && xcrun simctl list devices available | grep -F "\$destination_id" >/dev/null 2>&1; then
305
+ # Reuse the first resolved booted simulator so build-for-testing and
306
+ # test-without-building target the same simulator even if it shuts down
307
+ # while Xcode is compiling the shim target.
308
+ printf '%s' "\$destination_id"
309
+ return 0
310
+ fi
311
+ fi
251
312
  destination_id="\$(xcrun simctl list devices booted | sed -n 's/.*(\([0-9A-Fa-f-][0-9A-Fa-f-]*\)) (Booted).*/\1/p' | head -n 1)"
313
+ if [[ -n "\$destination_id" ]]; then
314
+ printf '%s' "\$destination_id" > "\$DESTINATION_ID_FILE"
315
+ fi
252
316
  fi
253
317
  if [[ -z "\$destination_id" ]]; then
254
318
  echo "no booted iOS simulator found" >&2
@@ -272,9 +336,13 @@ is_server_running() {
272
336
  if [[ ! -f "\$PID_FILE" ]]; then
273
337
  return 1
274
338
  fi
275
- local pid
339
+ local pid command
276
340
  pid="\$(cat "\$PID_FILE" 2>/dev/null || true)"
277
- [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null
341
+ if [[ -z "\$pid" ]] || ! kill -0 "\$pid" 2>/dev/null; then
342
+ return 1
343
+ fi
344
+ command="\$(ps -p "\$pid" -o command= 2>/dev/null || true)"
345
+ [[ "\$command" == *xcodebuild* && "\$command" == *ZMRShimUITests* ]]
278
346
  }
279
347
 
280
348
  run_oneshot() {
@@ -332,18 +400,15 @@ build_for_testing() {
332
400
  destination_id="\$(destination_spec)"
333
401
  build_log="\$STATE_DIR/xcodebuild.build.log"
334
402
 
335
- if ! xcodebuild build-for-testing \\
336
- "\${XCODEBUILD_ARGS[@]}" \\
337
- -scheme "$SCHEME" \\
338
- -configuration "$CONFIGURATION" \\
339
- -destination "\$destination_id" \\
340
- ZMR_SHIM_MODE="server" \\
341
- ZMR_SHIM_SERVER_DIR="\$SERVER_DIR" \\
342
- ZMR_APP_BUNDLE_ID="$BUNDLE_ID" \\
343
- >"\$build_log" 2>&1; then
344
- tail -120 "\$build_log" >&2
345
- exit 1
346
- fi
403
+ run_xcodebuild_with_timeout "iOS shim build-for-testing" "\${ZMR_IOS_SHIM_BUILD_TIMEOUT_SECONDS:-5400}" "\$build_log" \\
404
+ xcodebuild build-for-testing \\
405
+ "\${XCODEBUILD_ARGS[@]}" \\
406
+ -scheme "$SCHEME" \\
407
+ -configuration "$CONFIGURATION" \\
408
+ -destination "\$destination_id" \\
409
+ ZMR_SHIM_MODE="server" \\
410
+ ZMR_SHIM_SERVER_DIR="\$SERVER_DIR" \\
411
+ ZMR_APP_BUNDLE_ID="$BUNDLE_ID"
347
412
 
348
413
  touch "\$BUILD_READY_FILE"
349
414
  }
@@ -58,6 +58,9 @@ enum ZMRShim {
58
58
  guard nodes.count < 256 else {
59
59
  return nodes
60
60
  }
61
+ guard element.exists else {
62
+ continue
63
+ }
61
64
  nodes.append(node(index: nodes.count, type: type, element: element))
62
65
  }
63
66
  }
@@ -128,7 +128,7 @@ final class ZMRShimUITestCase: XCTestCase {
128
128
  guard isFastQueryable(parts: parts) else {
129
129
  return error("selector.unsupported", "unsupported query selector: \(selector)")
130
130
  }
131
- let element = resolveFastElement(selector: selector, app: app, preferredTypes: [])
131
+ let element = resolveElement(selector: selector, app: app, preferredTypes: [])
132
132
  return [
133
133
  "status": "ok",
134
134
  "exists": element?.exists ?? false,
@@ -328,19 +328,49 @@ final class ZMRShimUITestCase: XCTestCase {
328
328
  return fast
329
329
  }
330
330
 
331
- let matchedElements = app.descendants(matching: .any).allElementsBoundByIndex.filter { element in
332
- matches(selector: selector, element: element)
333
- }
334
- if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) && $0.isHittable }) {
335
- return preferred
331
+ return resolveBroadElement(selector: selector, app: app)
332
+ }
333
+
334
+ private func resolveBroadElement(selector: String, app: XCUIApplication) -> XCUIElement? {
335
+ guard let parts = selectorParts(selector) else {
336
+ return nil
336
337
  }
337
- if let preferred = matchedElements.first(where: { preferredTypes.contains($0.elementType) }) {
338
- return preferred
338
+
339
+ let query: XCUIElementQuery?
340
+ switch parts.field {
341
+ case "text", "label":
342
+ let predicate = parts.contains
343
+ ? NSPredicate(format: "label CONTAINS[c] %@", parts.value)
344
+ : NSPredicate(format: "label == %@", parts.value)
345
+ query = app.descendants(matching: .any).matching(predicate)
346
+ case "identifier", "resourceId":
347
+ let predicate = parts.contains
348
+ ? NSPredicate(format: "identifier CONTAINS[c] %@", parts.value)
349
+ : NSPredicate(format: "identifier == %@", parts.value)
350
+ query = app.descendants(matching: .any).matching(predicate)
351
+ case "value":
352
+ let predicate = parts.contains
353
+ ? NSPredicate(format: "value CONTAINS[c] %@", parts.value)
354
+ : NSPredicate(format: "value == %@", parts.value)
355
+ query = app.descendants(matching: .any).matching(predicate)
356
+ case "id":
357
+ if parts.value.hasPrefix("id:") {
358
+ let identifier = String(parts.value.dropFirst("id:".count))
359
+ query = app.descendants(matching: .any).matching(identifier: identifier)
360
+ } else if parts.value.hasPrefix("label:") {
361
+ let label = String(parts.value.dropFirst("label:".count))
362
+ query = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label))
363
+ } else {
364
+ query = nil
365
+ }
366
+ default:
367
+ query = nil
339
368
  }
340
- if let hittable = matchedElements.first(where: { $0.isHittable }) {
341
- return hittable
369
+
370
+ guard let element = query?.firstMatch, element.exists else {
371
+ return nil
342
372
  }
343
- return matchedElements.first
373
+ return element
344
374
  }
345
375
 
346
376
  private func resolveFastElement(selector: String, app: XCUIApplication, preferredTypes: [XCUIElement.ElementType]) -> XCUIElement? {
package/src/ios.zig CHANGED
@@ -9,7 +9,9 @@ const trace = @import("trace.zig");
9
9
  const types = @import("types.zig");
10
10
 
11
11
  const default_max_output = 32 * 1024 * 1024;
12
- const shim_timeout_ms = 600_000;
12
+ // Clean iOS prebuilds can force the XCTest shim script through a full native
13
+ // dependency build before it can answer the first selector query.
14
+ const shim_timeout_ms = 5_400_000;
13
15
  const shim_best_effort_timeout_ms = 10_000;
14
16
  const shim_command_attempts = 2;
15
17
  const shim_bootstrap_retry_delay_ms = 500;
@@ -107,6 +109,7 @@ pub const IosDevice = struct {
107
109
  if (self.target_kind == .physical) return try self.stopPhysicalBestEffort();
108
110
  const result = try self.runSimctl(&.{ "terminate", self.target(), self.app_id }, default_max_output);
109
111
  defer result.deinit(self.allocator);
112
+ if (ios_lifecycle.isAppNotRunning(result)) return;
110
113
  try result.ensureSuccess();
111
114
  }
112
115
 
@@ -307,8 +310,12 @@ pub const IosDevice = struct {
307
310
 
308
311
  var command_with_selector = shim_command;
309
312
  command_with_selector.selector = shim_selector;
310
- try self.runShimAction(command_with_selector);
311
- return true;
313
+ const response = try self.runShim(command_with_selector);
314
+ defer self.allocator.free(response);
315
+ return switch (try ios_shim.parseSelectorActionResponse(response)) {
316
+ .ok => true,
317
+ .selector_unavailable => false,
318
+ };
312
319
  }
313
320
 
314
321
  fn runShim(self: *IosDevice, shim_command: ios_shim.Command) ![]u8 {
@@ -394,6 +401,6 @@ pub fn parsePhysicalDevicesJson(allocator: std.mem.Allocator, content: []const u
394
401
  }
395
402
 
396
403
  test "ios xctest shim timeout allows cold xcodebuild startup" {
397
- try std.testing.expect(shim_timeout_ms >= 300_000);
404
+ try std.testing.expect(shim_timeout_ms >= 5_400_000);
398
405
  try std.testing.expect(shim_best_effort_timeout_ms <= 15_000);
399
406
  }
@@ -70,3 +70,39 @@ pub fn isMissingInstalledApp(result: command.ExecResult) bool {
70
70
  }
71
71
  return std.mem.indexOf(u8, result.stderr, "No installed application with bundle identifier") != null;
72
72
  }
73
+
74
+ pub fn isAppNotRunning(result: command.ExecResult) bool {
75
+ switch (result.term) {
76
+ .Exited => |code| if (code == 0) return false,
77
+ else => return false,
78
+ }
79
+ return std.mem.indexOf(u8, result.stderr, "found nothing to terminate") != null;
80
+ }
81
+
82
+ test "simctl terminate missing running app is best-effort" {
83
+ const allocator = std.testing.allocator;
84
+ const stdout = try allocator.dupe(u8, "");
85
+ defer allocator.free(stdout);
86
+ const stderr = try allocator.dupe(u8, "The request to terminate \"com.example.mobiletest\" failed. found nothing to terminate");
87
+ defer allocator.free(stderr);
88
+
89
+ try std.testing.expect(isAppNotRunning(.{
90
+ .stdout = stdout,
91
+ .stderr = stderr,
92
+ .term = .{ .Exited = 3 },
93
+ }));
94
+ }
95
+
96
+ test "simctl terminate success is not classified as already stopped" {
97
+ const allocator = std.testing.allocator;
98
+ const stdout = try allocator.dupe(u8, "");
99
+ defer allocator.free(stdout);
100
+ const stderr = try allocator.dupe(u8, "");
101
+ defer allocator.free(stderr);
102
+
103
+ try std.testing.expect(!isAppNotRunning(.{
104
+ .stdout = stdout,
105
+ .stderr = stderr,
106
+ .term = .{ .Exited = 0 },
107
+ }));
108
+ }
package/src/ios_shim.zig CHANGED
@@ -109,6 +109,48 @@ pub fn parseOkResponse(content: []const u8) !void {
109
109
  if (!std.mem.eql(u8, status, "ok")) return error.IosShimResponseNotOk;
110
110
  }
111
111
 
112
+ pub const SelectorActionResponse = enum {
113
+ ok,
114
+ selector_unavailable,
115
+ };
116
+
117
+ pub fn parseSelectorActionResponse(content: []const u8) !SelectorActionResponse {
118
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
119
+ defer arena.deinit();
120
+ const parsed = try std.json.parseFromSlice(std.json.Value, arena.allocator(), content, .{});
121
+ if (parsed.value != .object) return error.IosShimResponseMustBeObject;
122
+ const status = fieldString(parsed.value.object, "status") orelse return error.IosShimMissingStatus;
123
+ if (std.mem.eql(u8, status, "ok")) return .ok;
124
+
125
+ const code = fieldString(parsed.value.object, "code") orelse return error.IosShimResponseNotOk;
126
+ if (std.mem.eql(u8, code, "selector.not_found") or
127
+ std.mem.eql(u8, code, "selector.not_hittable"))
128
+ {
129
+ return .selector_unavailable;
130
+ }
131
+
132
+ return error.IosShimResponseNotOk;
133
+ }
134
+
135
+ test "selector action response maps native selector misses to unavailable" {
136
+ try std.testing.expectEqual(SelectorActionResponse.ok, try parseSelectorActionResponse("{\"status\":\"ok\"}"));
137
+ try std.testing.expectEqual(
138
+ SelectorActionResponse.selector_unavailable,
139
+ try parseSelectorActionResponse("{\"status\":\"error\",\"code\":\"selector.not_found\",\"message\":\"missing\"}"),
140
+ );
141
+ try std.testing.expectEqual(
142
+ SelectorActionResponse.selector_unavailable,
143
+ try parseSelectorActionResponse("{\"status\":\"error\",\"code\":\"selector.not_hittable\",\"message\":\"covered\"}"),
144
+ );
145
+ }
146
+
147
+ test "selector action response keeps unsupported selectors as hard errors" {
148
+ try std.testing.expectError(
149
+ error.IosShimResponseNotOk,
150
+ parseSelectorActionResponse("{\"status\":\"error\",\"code\":\"selector.unsupported\",\"message\":\"bad\"}"),
151
+ );
152
+ }
153
+
112
154
  pub fn parseQueryResponse(content: []const u8) !bool {
113
155
  var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
114
156
  defer arena.deinit();
package/src/version.zig CHANGED
@@ -1,4 +1,4 @@
1
- pub const runner_version = "0.1.2";
1
+ pub const runner_version = "0.1.3";
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";