@unotest/mobile 0.8.0 → 0.8.2

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,86 @@ All notable changes to `@unotest/mobile` will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.8.2] — 2026-05-18
8
+
9
+ ### Fixed — `type()` no longer drops 1-3 characters mid-string
10
+
11
+ - **What broke.** `WdaDriver.type` issued `XCUIElement.typeText` at WDA's
12
+ default typing frequency (~60 letters/sec) into the simulator's iOS
13
+ keyboard. Under host contention (Claude Code + WDA build + Simulator
14
+ all running on the same Mac), the iOS keyboard layer couldn't keep
15
+ up and randomly dropped 1-3 characters from the middle of the string
16
+ — observed live as `petr@volkov.io` → `pr@volkov.io`, `Qwerty34##`
17
+ → `Qwery34##`. P4 / S8 had only addressed the unrelated *wrong-
18
+ keyboard-layout* failure (Cyrillic keyboard eating Latin chars
19
+ wholesale); this race is a separate failure mode that survived.
20
+ - **Fix 1 — `ConnectHardwareKeyboard` pinned OFF on install.**
21
+ The `pinEnglishKeyboard` step (S8) now also writes
22
+ `ConnectHardwareKeyboard = false` into the Simulator.app preferences
23
+ on every install. When the toggle is ON (the default on a freshly
24
+ created sim, controlled by ⌘K inside the sim), iOS suppresses the
25
+ soft keyboard and routes keys through the host's physical keyboard
26
+ path — typeText then races against keyboard presentation. Pinning
27
+ OFF guarantees the soft keyboard renders and stays visible.
28
+ Idempotent. Reuses the existing `pinKeyboard: false` opt-out flag.
29
+ - **Fix 2 — `WDA_TYPING_FREQUENCY` env (default `8`).** `WdaDriver.type`
30
+ now passes `frequency: <env>` in the POST `/element/{id}/value`
31
+ body, which WDA forwards to `fb_typeText:withFrequency:`. 8 cps is
32
+ conservative-but-still-usable: typical credentials (`petr@volkov.io`,
33
+ `Qwerty34##`) type in ~2s instead of the unreliable ~0.3s. Bump
34
+ higher if you trust your host's headroom; lower if you're seeing
35
+ drops in CI under heavy load. Validated as a positive integer at
36
+ `loadEnv` time.
37
+ - **Why not pasteboard / per-char paste.** Researched approaches the
38
+ XCUITest community has tried (`UIPasteboard` + double-tap-menu paste,
39
+ per-char `typeKey:` calls, `setValue` direct-set): pasteboard paste
40
+ is **43% slower and flaky** (per iammike.org / 2022); per-char
41
+ `typeKey:` is only 3% faster than throttled `typeText`, complex to
42
+ wire reliably; `setValue` on native iOS text fields routes back to
43
+ `typeText` internally — there is no Apple-blessed "set text without
44
+ keyboard" API. WDA's own `maxTypingFrequency` is the documented knob.
45
+ - **Tests:** +0 net. Modified 1 existing assertion to verify
46
+ `frequency: 8` lands in the request body. The hardware-keyboard
47
+ flip rides on the same `pinEnglishKeyboard` call site, so existing
48
+ call-sequence assertions still hold. Total 570.
49
+
50
+ ### Added — `WDA_TYPING_FREQUENCY` env var
51
+
52
+ - Letters-per-second passed to WDA's `fb_typeText:withFrequency:` on
53
+ every `type(...)` call. Default `8`. See the fix entry above for
54
+ rationale on the conservative default.
55
+
56
+ ## [0.8.1] — 2026-05-18
57
+
58
+ ### Fixed — `init` pins the exact `@unotest/mobile` version in `.mcp.json`
59
+
60
+ - **What broke.** The generated `.mcp.json` entry used a bare
61
+ `"args": ["-y", "@unotest/mobile"]` with no version pin. `npx` then
62
+ resolved that name against any **globally installed** copy first
63
+ (e.g. an old `npm i -g @unotest/mobile@0.1.x` from earlier
64
+ experimentation), bypassing the version the user just installed via
65
+ `npx @unotest/mobile@latest install ...`. The stale global frequently
66
+ had the pre-0.1.4 strict env schema, so the MCP server crashed at
67
+ startup with `Invalid environment. ... INVITE_DEEPLINK_PREFIX:
68
+ Required, API_BASE_URL: Required, ...`. Claude Code then surfaced
69
+ `-32000 Connection closed` and the agent fell back to draft-mode
70
+ scenario authoring — defeating the entire MCP-driven flow the
71
+ install just bootstrapped.
72
+ - **Fix.** `init` now reads its own `package.json:version` and writes
73
+ the pinned form `"args": ["-y", "@unotest/mobile@<version>"]` into
74
+ `.mcp.json`. The MCP server Claude Code launches always matches the
75
+ `init`-running copy, so a stale global can no longer ambush startup.
76
+ Pre-release versions (e.g. `1.0.0-rc.3`) pass through verbatim.
77
+ - **Migration.** Existing projects generated by `init` ≤ 0.8.0 keep
78
+ the bare entry. Run `npx @unotest/mobile@latest init --force` to
79
+ rewrite `.mcp.json` with the pinned form (preserves `.env`, only
80
+ rewrites templates).
81
+ - Tests: +3 (`mcpServerEntry: pins to passed version`,
82
+ `pre-release versions pass through verbatim`,
83
+ `never emits a bare unversioned reference`). The third is the
84
+ belt-and-braces regression guard that fails loudly if anyone
85
+ reintroduces a bare `@unotest/mobile` arg in the template.
86
+
7
87
  ## [0.8.0] — 2026-05-18
8
88
 
9
89
  ### Fixed — WdaDriver auto-recovers from stale WDA sessions (B6)
package/README.md CHANGED
@@ -1,123 +1,90 @@
1
1
  # `@unotest/mobile`
2
2
 
3
- **AI-native E2E testing for iOS React Native apps.** MCP server + CLI
4
- runner + JS-DSL scenarios. Project-agnostic works with any RN/Expo app,
5
- not tied to a specific backend stack.
3
+ **AI-native E2E testing for iOS apps.** An MCP server + CLI + JS-DSL
4
+ that lets Claude (or any MCP client) drive your iOS Simulator and write
5
+ real test scenarios for your app — works with any backend stack
6
+ (Node/Python/Rails/Go/Java), no JS expertise required from you.
6
7
 
7
- Part of the `unotest` family. The web counterpart is a separate product.
8
-
9
- ## Install
8
+ ## Quick start
10
9
 
11
10
  ```bash
12
- npm install --save-dev @unotest/mobile # or yarn / pnpm same effect
13
- npx @unotest/mobile init # bootstrap your project
14
- ```
11
+ # 1. Point us at your .app build. Auto-detects bundle ID, URL scheme,
12
+ # and required permissions from Info.plist; offers to bootstrap
13
+ # unotest/ and .mcp.json on first run.
14
+ npx @unotest/mobile@latest install /path/to/Your.app --update-env
15
15
 
16
- `init` first runs an environment check (macOS / Xcode / iOS Simulator /
17
- Node 20+) and hard-fails on a non-macOS host before touching the
18
- filesystem. It then drops:
16
+ # 2. Open Claude Code in this project. Ask: "write an e2e test for
17
+ # sign-in". The agent uses the MCP server to explore your app,
18
+ # record a scenario, and verify it runs end-to-end.
19
19
 
20
- - `unotest/e2e/` with a starter scenario, template, and `_helpers/` dir
21
- - `unotest/AGENTS.md` short pointer for AI coding agents
22
- - `unotest/.env.example` (committed) and `unotest/.env` (gitignored)
23
- - `.claude/skills/write-e2e-test.md` — the Claude Code skill the agent
24
- uses when asked to write a test
25
- - `.mcp.json` — project-scoped MCP registration (merges with existing)
26
- - `.gitignore` updates
20
+ # 3. (Optional) Re-run any saved test from the CLI:
21
+ npx @unotest/mobile@latest e2e <test-name>
22
+ ```
27
23
 
28
- `--force` overwrites existing files (except `unotest/.env`).
29
- `--allow-non-macos` lets you scaffold on a CI prep stage.
24
+ That's it. You never edit DSL by hand — the agent writes the scenarios,
25
+ you review the resulting `.js` files in `unotest/e2e/`.
30
26
 
31
- ## Quick start
27
+ ## How it works
32
28
 
33
- ```bash
34
- # 1. Edit unotest/.env — fill in DATABASE_URL, SIM_A_NAME, APP_BUNDLE_ID, ...
35
- # 2. Boot iOS simulators matching SIM_A_NAME / SIM_B_NAME (Xcode → Devices).
36
- # 3. Open Claude Code in this project — MCP server auto-registers via .mcp.json.
37
- # Ask the agent to write a test; it follows the `write-e2e-test` skill.
38
-
39
- npx @unotest/mobile doctor # re-check environment
40
- npx @unotest/mobile e2e smoke-welcome # run the starter scenario
41
- npx @unotest/mobile lint # static check of all scenarios
42
29
  ```
43
-
44
- ## What this gives you
45
-
46
- - **AI-driven authoring.** Claude (via MCP) sees your screen and
47
- accessibility tree — it doesn't guess testIDs, it resolves them.
48
- - **Pause-on-failure debugger.** When a step fails, the runtime pauses;
49
- use `inspect_runtime` / `step` / `resume` to fix and retry without
50
- rerunning setup.
51
- - **Multi-device flows.** Two simulators in parallel — useful for
52
- invite/share/handoff flows where one user's action must surface on
53
- another user's device.
54
- - **Engine-agnostic.** Today's driver is WebDriverAgent (HTTP, no JVM);
55
- swap to your own XCTest / idb backend without rewriting scenarios.
56
-
57
- ## DSL — strict JS subset
58
-
59
- Scenarios are JS files (`unotest/e2e/*.js`) with one entry function per
60
- file. The full reference is bundled with the package as the
61
- `write-e2e-test` Claude Code skill — open
62
- `.claude/skills/write-e2e-test.md` after `init` for the cheat sheet.
63
-
64
- ```js
65
- // id-smoke-welcome
66
- // First-run sanity: harness reaches sim, WDA, and app launch handshake
67
- // #00aa00
68
- function test_smoke_welcome() {
69
- setDevice("A");
70
- appLaunch(true);
71
- waitFor(getByTestId("screen-welcome"), 15000);
72
- assertVisible(getByTestId("btn-start"));
73
- }
30
+ ┌─────────────┐ MCP (stdio) ┌──────────────────┐
31
+ Claude Code ◀────────────▶ │ @unotest/mobile │
32
+ └─────────────┘ tool calls │ (this package) │
33
+ └────────┬─────────┘
34
+ HTTP
35
+ ┌────────▼──────────┐
36
+ WebDriverAgent │
37
+ (Apple's XCUI │
38
+ │ driver, on sim)
39
+ └────────┬──────────┘
40
+
41
+ ┌────────▼──────────┐
42
+ iOS Simulator │
43
+ │ Your .app │
44
+ └───────────────────┘
74
45
  ```
75
46
 
76
- ## MCP tools
77
-
78
- After `init`, Claude Code (or any MCP-aware client) gets:
47
+ - **The agent sees the accessibility tree, not screenshots.** WDA
48
+ returns a structured tree (roles, testID, text, bounds); we render it
49
+ as a token-cheap text outline. Works on RN/Expo and on plain Swift
50
+ apps too, as long as they expose accessibility.
51
+ - **Tests are plain `.js` files in your repo.** `unotest/e2e/*.js`.
52
+ Goes into git, into code review, into CI. No proprietary format, no
53
+ binary blob.
54
+ - **The DSL is a sandboxed JS subset.** Scenarios run in a vendored AST
55
+ interpreter — they can't `require`, `fetch`, touch the filesystem, or
56
+ import anything. AI-generated tests are safe to run blindly.
57
+ - **Pause-on-failure debugger.** When a step throws, the runtime
58
+ freezes mid-scenario. The agent (or you) calls `inspect_runtime`,
59
+ patches the scenario, and `resume`s from the same step — no full
60
+ simulator restart, no rerunning setup.
61
+ - **Local-only.** Everything runs on your Mac. Your `.app` never leaves
62
+ the machine.
63
+
64
+ **Life of a test:** the agent explores your app live (taps real
65
+ buttons, reads the live a11y tree) → records the actions → emits a JS
66
+ scenario → runs it through our interpreter → on failure, pauses,
67
+ patches, resumes. Every step goes through one of ~15 MCP tools the
68
+ agent already knows; you don't wire anything.
79
69
 
80
- **Discovery + lifecycle:**
81
- `devices_list`, `screenshot`, `a11y_tree`, `resolve_selector`,
82
- `app_install`, `session_reset`.
83
-
84
- **Exploration recording (P2):**
85
- `explore_start`, `explore_step`, `explore_record`, `explore_remove_step`,
86
- `explore_state`, `explore_stop`, `generate_dsl_from_exploration`,
87
- `save_exploration_as_test`. `explore_step` is the single execute-and-
88
- optionally-record entry point for all UI actions (tap, type, press_key,
89
- swipe, wait_for, app_launch, open_deeplink, accept_alert, dismiss_alert).
90
- Pass `explorationId` to record into a session, omit it for ad-hoc.
70
+ ## Requirements
91
71
 
92
- **Pause-on-failure debugger:**
93
- `run_test` (with `pauseOnFailure: true`), `step`, `resume`,
94
- `inspect_runtime`, `abort_runtime`, `list_runtimes`.
72
+ - **macOS** with Xcode + iOS Simulator (Apple licensing — iOS
73
+ simulators don't run on Linux/Windows).
74
+ - **Node 20+** (for `npx`; you don't need a Node project).
75
+ - First run of WebDriverAgent compiles once and caches under
76
+ `~/.cache/unotest/mobile/wda/` — ~5–15 min, subsequent runs reuse it.
95
77
 
96
78
  ## CLI
97
79
 
98
80
  | Command | What it does |
99
81
  |---|---|
100
- | `npx @unotest/mobile init` | Bootstrap a consumer project. Idempotent. |
101
- | `npx @unotest/mobile doctor` | Re-run environment checks. |
102
- | `npx @unotest/mobile e2e <name>` | Run `unotest/e2e/<name>.js`. Supports nested paths. |
103
- | `npx @unotest/mobile lint` | Static check of all scenarios + helpers. |
104
- | `npx @unotest/mobile` (no args) | Run as MCP stdio server. |
105
-
106
- ## Requirements
107
-
108
- - **macOS** with Xcode + iOS Simulator (Apple licensing — iOS simulators
109
- cannot run on Linux/Windows hosts).
110
- - **Node 20+**.
111
- - **Xcode command-line tools** for the on-demand WebDriverAgent build.
112
- WDA is compiled once and cached in
113
- `~/.cache/unotest/mobile/wda/<version>/` — first build is ~5–15
114
- minutes, subsequent runs reuse the cache.
115
-
116
- ## Status
117
-
118
- `0.1.0` — initial public release. iOS-focused (RN, Expo, native Swift).
119
- Android driver is on the roadmap. Local execution only; cloud runners
120
- will follow.
82
+ | `npx @unotest/mobile@latest install <path>` | Install a `.app` on the configured sim; auto-detect bundle ID + permissions; `--update-env` persists. |
83
+ | `npx @unotest/mobile@latest init` | Just bootstrap `unotest/` + `.mcp.json` without installing an app. |
84
+ | `npx @unotest/mobile@latest doctor` | Re-check the environment (Xcode, sim, Node, WDA cache). |
85
+ | `npx @unotest/mobile@latest e2e <name>` | Run `unotest/e2e/<name>.js`. |
86
+ | `npx @unotest/mobile@latest lint` | Static check of all scenarios + helpers. |
87
+ | `npx @unotest/mobile@latest` (no args) | Run as MCP stdio server (this is what Claude Code launches). |
121
88
 
122
89
  ## License
123
90
 
@@ -240,6 +240,15 @@ var EnvSchema = z.object({
240
240
  // Implicit auto-wait on selector-bearing actions (D-18).
241
241
  WDA_DEFAULT_ACTION_WAIT_MS: z.string().default("2000").transform((v) => Number.parseInt(v, 10)),
242
242
  WDA_DEFAULT_WAITFOR_TIMEOUT_MS: z.string().default("10000").transform((v) => Number.parseInt(v, 10)),
243
+ // Typing frequency (letters/sec) passed through to WDA's
244
+ // `fb_typeText:withFrequency:` on every `type(...)` call. WDA's own
245
+ // default is its `maxTypingFrequency` (typically 60 cps) which under
246
+ // contention from the host (Claude Code + WDA build + sim) races
247
+ // against the iOS keyboard presentation and randomly drops 1-3 chars
248
+ // mid-string. 8 cps is the conservative-but-still-usable value that
249
+ // empirically eliminates drops without making typing visibly slow
250
+ // (`petr@volkov.io` types in ~2s instead of ~0.3s).
251
+ WDA_TYPING_FREQUENCY: z.string().default("8").transform((v) => Number.parseInt(v, 10)),
243
252
  // TTL for paused-failed runtimes before auto-abort (D-17). Default 30 min.
244
253
  PAUSED_RUNTIME_TTL_MS: z.string().default("1800000").transform((v) => Number.parseInt(v, 10))
245
254
  });
@@ -287,6 +296,11 @@ ${issues}`
287
296
  );
288
297
  }
289
298
  }
299
+ if (!Number.isFinite(raw.WDA_TYPING_FREQUENCY) || raw.WDA_TYPING_FREQUENCY <= 0) {
300
+ throw new Error(
301
+ `WDA_TYPING_FREQUENCY must be a positive integer (letters/sec). Got "${process.env.WDA_TYPING_FREQUENCY}".`
302
+ );
303
+ }
290
304
  cached = {
291
305
  ...raw,
292
306
  simPool,
@@ -294,6 +308,7 @@ ${issues}`
294
308
  wdaPortBySlot,
295
309
  defaultActionWaitMs: raw.WDA_DEFAULT_ACTION_WAIT_MS,
296
310
  defaultWaitForTimeoutMs: raw.WDA_DEFAULT_WAITFOR_TIMEOUT_MS,
311
+ wdaTypingFrequency: raw.WDA_TYPING_FREQUENCY,
297
312
  pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS,
298
313
  explorationsDir: raw.EXPLORATIONS_DIR ?? `${raw.ARTIFACTS_DIR}/explorations`
299
314
  };
@@ -636,6 +651,17 @@ async function pinEnglishKeyboardSim(udid) {
636
651
  "-string",
637
652
  "en_US"
638
653
  ]);
654
+ await exec2("xcrun", [
655
+ "simctl",
656
+ "spawn",
657
+ udid,
658
+ "defaults",
659
+ "write",
660
+ "com.apple.iphonesimulator.SimulatorApp",
661
+ "ConnectHardwareKeyboard",
662
+ "-bool",
663
+ "false"
664
+ ]);
639
665
  }
640
666
  __name(pinEnglishKeyboardSim, "pinEnglishKeyboardSim");
641
667
 
@@ -690,8 +716,11 @@ var SimctlAdapter = class {
690
716
  async privacyGrant(udid, service, bundleId) {
691
717
  return privacyGrantSim(udid, service, bundleId);
692
718
  }
693
- /** S8 — pin the sim's keyboard to en_US@QWERTY so WDA's typeText
694
- * doesn't drop Latin chars when the sim was last on a Cyrillic layout. */
719
+ /** S8 — prepare the sim's keyboard for reliable `typeText`. Pins
720
+ * layout to en_US@QWERTY (otherwise a leftover Cyrillic layout
721
+ * drops Latin chars) and forces "Connect Hardware Keyboard" OFF
722
+ * (otherwise the soft keyboard is suppressed and `typeText` races
723
+ * against keyboard presentation, dropping 1-3 chars mid-string). */
695
724
  async pinEnglishKeyboard(udid) {
696
725
  return pinEnglishKeyboardSim(udid);
697
726
  }
@@ -1277,7 +1306,8 @@ Install it first: \`npx unotest-mobile install <path-to-.app>\` (or set APP_PATH
1277
1306
  await this.withFreshSession(slot, async (session) => {
1278
1307
  const elementId = await this.resolveElementId(slot, selector);
1279
1308
  await session.client.setElementValue(session.getSessionId(), elementId, {
1280
- value: Array.from(text)
1309
+ value: Array.from(text),
1310
+ frequency: this.deps.typingFrequency
1281
1311
  });
1282
1312
  });
1283
1313
  }
@@ -1578,6 +1608,7 @@ function createDriver(cfg) {
1578
1608
  simBySlot: cfg.env.simBySlot,
1579
1609
  wdaPortBySlot: cfg.env.wdaPortBySlot,
1580
1610
  appBundleId: cfg.env.APP_BUNDLE_ID,
1611
+ typingFrequency: cfg.env.wdaTypingFrequency,
1581
1612
  binaryProvider
1582
1613
  });
1583
1614
  }
@@ -6819,7 +6850,7 @@ async function installApp2(opts, deps) {
6819
6850
  }
6820
6851
  }
6821
6852
  if (opts.pinKeyboard !== false) {
6822
- deps.logger.info(`[${slot}] pinning keyboard to en_US@QWERTY`);
6853
+ deps.logger.info(`[${slot}] pinning keyboard (en_US@QWERTY, soft only)`);
6823
6854
  await deps.simctl.pinEnglishKeyboard(sim.udid);
6824
6855
  }
6825
6856
  let launched = false;
@@ -236,6 +236,15 @@ var EnvSchema = z.object({
236
236
  // Implicit auto-wait on selector-bearing actions (D-18).
237
237
  WDA_DEFAULT_ACTION_WAIT_MS: z.string().default("2000").transform((v) => Number.parseInt(v, 10)),
238
238
  WDA_DEFAULT_WAITFOR_TIMEOUT_MS: z.string().default("10000").transform((v) => Number.parseInt(v, 10)),
239
+ // Typing frequency (letters/sec) passed through to WDA's
240
+ // `fb_typeText:withFrequency:` on every `type(...)` call. WDA's own
241
+ // default is its `maxTypingFrequency` (typically 60 cps) which under
242
+ // contention from the host (Claude Code + WDA build + sim) races
243
+ // against the iOS keyboard presentation and randomly drops 1-3 chars
244
+ // mid-string. 8 cps is the conservative-but-still-usable value that
245
+ // empirically eliminates drops without making typing visibly slow
246
+ // (`petr@volkov.io` types in ~2s instead of ~0.3s).
247
+ WDA_TYPING_FREQUENCY: z.string().default("8").transform((v) => Number.parseInt(v, 10)),
239
248
  // TTL for paused-failed runtimes before auto-abort (D-17). Default 30 min.
240
249
  PAUSED_RUNTIME_TTL_MS: z.string().default("1800000").transform((v) => Number.parseInt(v, 10))
241
250
  });
@@ -283,6 +292,11 @@ ${issues}`
283
292
  );
284
293
  }
285
294
  }
295
+ if (!Number.isFinite(raw.WDA_TYPING_FREQUENCY) || raw.WDA_TYPING_FREQUENCY <= 0) {
296
+ throw new Error(
297
+ `WDA_TYPING_FREQUENCY must be a positive integer (letters/sec). Got "${process.env.WDA_TYPING_FREQUENCY}".`
298
+ );
299
+ }
286
300
  cached = {
287
301
  ...raw,
288
302
  simPool,
@@ -290,6 +304,7 @@ ${issues}`
290
304
  wdaPortBySlot,
291
305
  defaultActionWaitMs: raw.WDA_DEFAULT_ACTION_WAIT_MS,
292
306
  defaultWaitForTimeoutMs: raw.WDA_DEFAULT_WAITFOR_TIMEOUT_MS,
307
+ wdaTypingFrequency: raw.WDA_TYPING_FREQUENCY,
293
308
  pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS,
294
309
  explorationsDir: raw.EXPLORATIONS_DIR ?? `${raw.ARTIFACTS_DIR}/explorations`
295
310
  };
@@ -632,6 +647,17 @@ async function pinEnglishKeyboardSim(udid) {
632
647
  "-string",
633
648
  "en_US"
634
649
  ]);
650
+ await exec2("xcrun", [
651
+ "simctl",
652
+ "spawn",
653
+ udid,
654
+ "defaults",
655
+ "write",
656
+ "com.apple.iphonesimulator.SimulatorApp",
657
+ "ConnectHardwareKeyboard",
658
+ "-bool",
659
+ "false"
660
+ ]);
635
661
  }
636
662
  __name(pinEnglishKeyboardSim, "pinEnglishKeyboardSim");
637
663
 
@@ -686,8 +712,11 @@ var SimctlAdapter = class {
686
712
  async privacyGrant(udid, service, bundleId) {
687
713
  return privacyGrantSim(udid, service, bundleId);
688
714
  }
689
- /** S8 — pin the sim's keyboard to en_US@QWERTY so WDA's typeText
690
- * doesn't drop Latin chars when the sim was last on a Cyrillic layout. */
715
+ /** S8 — prepare the sim's keyboard for reliable `typeText`. Pins
716
+ * layout to en_US@QWERTY (otherwise a leftover Cyrillic layout
717
+ * drops Latin chars) and forces "Connect Hardware Keyboard" OFF
718
+ * (otherwise the soft keyboard is suppressed and `typeText` races
719
+ * against keyboard presentation, dropping 1-3 chars mid-string). */
691
720
  async pinEnglishKeyboard(udid) {
692
721
  return pinEnglishKeyboardSim(udid);
693
722
  }
@@ -1273,7 +1302,8 @@ Install it first: \`npx unotest-mobile install <path-to-.app>\` (or set APP_PATH
1273
1302
  await this.withFreshSession(slot, async (session) => {
1274
1303
  const elementId = await this.resolveElementId(slot, selector);
1275
1304
  await session.client.setElementValue(session.getSessionId(), elementId, {
1276
- value: Array.from(text)
1305
+ value: Array.from(text),
1306
+ frequency: this.deps.typingFrequency
1277
1307
  });
1278
1308
  });
1279
1309
  }
@@ -1574,6 +1604,7 @@ function createDriver(cfg) {
1574
1604
  simBySlot: cfg.env.simBySlot,
1575
1605
  wdaPortBySlot: cfg.env.wdaPortBySlot,
1576
1606
  appBundleId: cfg.env.APP_BUNDLE_ID,
1607
+ typingFrequency: cfg.env.wdaTypingFrequency,
1577
1608
  binaryProvider
1578
1609
  });
1579
1610
  }
@@ -272,6 +272,11 @@ APP_BUNDLE_ID=com.example.myapp
272
272
  WDA_PORTS=A=8100,B=8101
273
273
  # WDA_DEFAULT_ACTION_WAIT_MS=2000
274
274
  # WDA_DEFAULT_WAITFOR_TIMEOUT_MS=10000
275
+ # Letters/sec for type() \u2014 WDA's fb_typeText:withFrequency:. Conservative
276
+ # default avoids char-drop races against the iOS keyboard layer when the
277
+ # host is under load. Bump higher (e.g. 20) on a quiet machine; lower
278
+ # (e.g. 4) if you still see drops in CI.
279
+ # WDA_TYPING_FREQUENCY=8
275
280
 
276
281
  # --- Artifacts / sessions / paused-runtime TTL -----------------------------
277
282
  # Defaults are sensible \u2014 uncomment only to override.
@@ -290,10 +295,20 @@ WDA_PORTS=A=8100,B=8101
290
295
  "unotest/artifacts/",
291
296
  "unotest/sessions/"
292
297
  ],
293
- mcpServerEntry: {
298
+ /**
299
+ * Build the `.mcp.json` entry for this package, pinned to the supplied
300
+ * version. Caller (`runInit`) reads its own `package.json:version` and
301
+ * passes it in, so the entry written into the consumer's project
302
+ * always matches the `init`-running copy. Pinning side-steps the
303
+ * `npx` stale-global / stale-cache ambush: a bare `@unotest/mobile`
304
+ * arg lets `npx` resolve to whatever globally-installed (often very
305
+ * old) copy a developer happens to have, which then crashes at
306
+ * startup against the current env schema.
307
+ */
308
+ mcpServerEntry: /* @__PURE__ */ __name((version) => ({
294
309
  command: "npx",
295
- args: ["-y", "@unotest/mobile"]
296
- }
310
+ args: ["-y", `@unotest/mobile@${version}`]
311
+ }), "mcpServerEntry")
297
312
  };
298
313
 
299
314
  // src/runner/init/mcp-config-merger.ts
@@ -391,6 +406,20 @@ function readPackageFile(relativePath) {
391
406
  return readFileSync(abs, "utf8");
392
407
  }
393
408
  __name(readPackageFile, "readPackageFile");
409
+ function readOwnVersion() {
410
+ const pkgRaw = readPackageFile("package.json");
411
+ if (pkgRaw === null) {
412
+ throw new Error(
413
+ `package.json missing at ${packageRoot}. This is a packaging bug \u2014 reinstall \`@unotest/mobile\` or report the issue.`
414
+ );
415
+ }
416
+ const parsed = JSON.parse(pkgRaw);
417
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
418
+ throw new Error(`package.json:version is not a non-empty string`);
419
+ }
420
+ return parsed.version;
421
+ }
422
+ __name(readOwnVersion, "readOwnVersion");
394
423
  function runInit(argv = process.argv.slice(2)) {
395
424
  const opts = parseInitArgs(argv);
396
425
  const target = process.cwd();
@@ -446,10 +475,7 @@ function runInit(argv = process.argv.slice(2)) {
446
475
  const merge = mergeMcpConfig(
447
476
  existing,
448
477
  "unotest-mobile",
449
- {
450
- command: templates.mcpServerEntry.command,
451
- args: [...templates.mcpServerEntry.args]
452
- },
478
+ templates.mcpServerEntry(readOwnVersion()),
453
479
  { force: opts.force }
454
480
  );
455
481
  if (merge.action !== "already-present") {
@@ -84,6 +84,15 @@ var EnvSchema = z.object({
84
84
  // Implicit auto-wait on selector-bearing actions (D-18).
85
85
  WDA_DEFAULT_ACTION_WAIT_MS: z.string().default("2000").transform((v) => Number.parseInt(v, 10)),
86
86
  WDA_DEFAULT_WAITFOR_TIMEOUT_MS: z.string().default("10000").transform((v) => Number.parseInt(v, 10)),
87
+ // Typing frequency (letters/sec) passed through to WDA's
88
+ // `fb_typeText:withFrequency:` on every `type(...)` call. WDA's own
89
+ // default is its `maxTypingFrequency` (typically 60 cps) which under
90
+ // contention from the host (Claude Code + WDA build + sim) races
91
+ // against the iOS keyboard presentation and randomly drops 1-3 chars
92
+ // mid-string. 8 cps is the conservative-but-still-usable value that
93
+ // empirically eliminates drops without making typing visibly slow
94
+ // (`petr@volkov.io` types in ~2s instead of ~0.3s).
95
+ WDA_TYPING_FREQUENCY: z.string().default("8").transform((v) => Number.parseInt(v, 10)),
87
96
  // TTL for paused-failed runtimes before auto-abort (D-17). Default 30 min.
88
97
  PAUSED_RUNTIME_TTL_MS: z.string().default("1800000").transform((v) => Number.parseInt(v, 10))
89
98
  });
@@ -131,6 +140,11 @@ ${issues}`
131
140
  );
132
141
  }
133
142
  }
143
+ if (!Number.isFinite(raw.WDA_TYPING_FREQUENCY) || raw.WDA_TYPING_FREQUENCY <= 0) {
144
+ throw new Error(
145
+ `WDA_TYPING_FREQUENCY must be a positive integer (letters/sec). Got "${process.env.WDA_TYPING_FREQUENCY}".`
146
+ );
147
+ }
134
148
  cached = {
135
149
  ...raw,
136
150
  simPool,
@@ -138,6 +152,7 @@ ${issues}`
138
152
  wdaPortBySlot,
139
153
  defaultActionWaitMs: raw.WDA_DEFAULT_ACTION_WAIT_MS,
140
154
  defaultWaitForTimeoutMs: raw.WDA_DEFAULT_WAITFOR_TIMEOUT_MS,
155
+ wdaTypingFrequency: raw.WDA_TYPING_FREQUENCY,
141
156
  pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS,
142
157
  explorationsDir: raw.EXPLORATIONS_DIR ?? `${raw.ARTIFACTS_DIR}/explorations`
143
158
  };
@@ -364,6 +379,17 @@ async function pinEnglishKeyboardSim(udid) {
364
379
  "-string",
365
380
  "en_US"
366
381
  ]);
382
+ await exec("xcrun", [
383
+ "simctl",
384
+ "spawn",
385
+ udid,
386
+ "defaults",
387
+ "write",
388
+ "com.apple.iphonesimulator.SimulatorApp",
389
+ "ConnectHardwareKeyboard",
390
+ "-bool",
391
+ "false"
392
+ ]);
367
393
  }
368
394
  __name(pinEnglishKeyboardSim, "pinEnglishKeyboardSim");
369
395
 
@@ -418,8 +444,11 @@ var SimctlAdapter = class {
418
444
  async privacyGrant(udid, service, bundleId) {
419
445
  return privacyGrantSim(udid, service, bundleId);
420
446
  }
421
- /** S8 — pin the sim's keyboard to en_US@QWERTY so WDA's typeText
422
- * doesn't drop Latin chars when the sim was last on a Cyrillic layout. */
447
+ /** S8 — prepare the sim's keyboard for reliable `typeText`. Pins
448
+ * layout to en_US@QWERTY (otherwise a leftover Cyrillic layout
449
+ * drops Latin chars) and forces "Connect Hardware Keyboard" OFF
450
+ * (otherwise the soft keyboard is suppressed and `typeText` races
451
+ * against keyboard presentation, dropping 1-3 chars mid-string). */
423
452
  async pinEnglishKeyboard(udid) {
424
453
  return pinEnglishKeyboardSim(udid);
425
454
  }
@@ -912,6 +941,11 @@ APP_BUNDLE_ID=com.example.myapp
912
941
  WDA_PORTS=A=8100,B=8101
913
942
  # WDA_DEFAULT_ACTION_WAIT_MS=2000
914
943
  # WDA_DEFAULT_WAITFOR_TIMEOUT_MS=10000
944
+ # Letters/sec for type() \u2014 WDA's fb_typeText:withFrequency:. Conservative
945
+ # default avoids char-drop races against the iOS keyboard layer when the
946
+ # host is under load. Bump higher (e.g. 20) on a quiet machine; lower
947
+ # (e.g. 4) if you still see drops in CI.
948
+ # WDA_TYPING_FREQUENCY=8
915
949
 
916
950
  # --- Artifacts / sessions / paused-runtime TTL -----------------------------
917
951
  # Defaults are sensible \u2014 uncomment only to override.
@@ -930,10 +964,20 @@ WDA_PORTS=A=8100,B=8101
930
964
  "unotest/artifacts/",
931
965
  "unotest/sessions/"
932
966
  ],
933
- mcpServerEntry: {
967
+ /**
968
+ * Build the `.mcp.json` entry for this package, pinned to the supplied
969
+ * version. Caller (`runInit`) reads its own `package.json:version` and
970
+ * passes it in, so the entry written into the consumer's project
971
+ * always matches the `init`-running copy. Pinning side-steps the
972
+ * `npx` stale-global / stale-cache ambush: a bare `@unotest/mobile`
973
+ * arg lets `npx` resolve to whatever globally-installed (often very
974
+ * old) copy a developer happens to have, which then crashes at
975
+ * startup against the current env schema.
976
+ */
977
+ mcpServerEntry: /* @__PURE__ */ __name((version) => ({
934
978
  command: "npx",
935
- args: ["-y", "@unotest/mobile"]
936
- }
979
+ args: ["-y", `@unotest/mobile@${version}`]
980
+ }), "mcpServerEntry")
937
981
  };
938
982
 
939
983
  // src/runner/init/mcp-config-merger.ts
@@ -1031,6 +1075,20 @@ function readPackageFile(relativePath) {
1031
1075
  return readFileSync(abs, "utf8");
1032
1076
  }
1033
1077
  __name(readPackageFile, "readPackageFile");
1078
+ function readOwnVersion() {
1079
+ const pkgRaw = readPackageFile("package.json");
1080
+ if (pkgRaw === null) {
1081
+ throw new Error(
1082
+ `package.json missing at ${packageRoot}. This is a packaging bug \u2014 reinstall \`@unotest/mobile\` or report the issue.`
1083
+ );
1084
+ }
1085
+ const parsed = JSON.parse(pkgRaw);
1086
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
1087
+ throw new Error(`package.json:version is not a non-empty string`);
1088
+ }
1089
+ return parsed.version;
1090
+ }
1091
+ __name(readOwnVersion, "readOwnVersion");
1034
1092
  function runInit(argv = process.argv.slice(2)) {
1035
1093
  const opts = parseInitArgs(argv);
1036
1094
  const target = process.cwd();
@@ -1086,10 +1144,7 @@ function runInit(argv = process.argv.slice(2)) {
1086
1144
  const merge = mergeMcpConfig(
1087
1145
  existing,
1088
1146
  "unotest-mobile",
1089
- {
1090
- command: templates.mcpServerEntry.command,
1091
- args: [...templates.mcpServerEntry.args]
1092
- },
1147
+ templates.mcpServerEntry(readOwnVersion()),
1093
1148
  { force: opts.force }
1094
1149
  );
1095
1150
  if (merge.action !== "already-present") {
@@ -1303,7 +1358,7 @@ async function installApp2(opts, deps) {
1303
1358
  }
1304
1359
  }
1305
1360
  if (opts.pinKeyboard !== false) {
1306
- deps.logger.info(`[${slot}] pinning keyboard to en_US@QWERTY`);
1361
+ deps.logger.info(`[${slot}] pinning keyboard (en_US@QWERTY, soft only)`);
1307
1362
  await deps.simctl.pinEnglishKeyboard(sim.udid);
1308
1363
  }
1309
1364
  let launched = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/mobile",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "AI-native E2E testing for iOS React Native apps. MCP server + CLI runner + JS-DSL scenarios.",
5
5
  "license": "MIT",
6
6
  "type": "module",