@unotest/mobile 0.8.0 → 0.8.1

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,37 @@ 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.1] — 2026-05-18
8
+
9
+ ### Fixed — `init` pins the exact `@unotest/mobile` version in `.mcp.json`
10
+
11
+ - **What broke.** The generated `.mcp.json` entry used a bare
12
+ `"args": ["-y", "@unotest/mobile"]` with no version pin. `npx` then
13
+ resolved that name against any **globally installed** copy first
14
+ (e.g. an old `npm i -g @unotest/mobile@0.1.x` from earlier
15
+ experimentation), bypassing the version the user just installed via
16
+ `npx @unotest/mobile@latest install ...`. The stale global frequently
17
+ had the pre-0.1.4 strict env schema, so the MCP server crashed at
18
+ startup with `Invalid environment. ... INVITE_DEEPLINK_PREFIX:
19
+ Required, API_BASE_URL: Required, ...`. Claude Code then surfaced
20
+ `-32000 Connection closed` and the agent fell back to draft-mode
21
+ scenario authoring — defeating the entire MCP-driven flow the
22
+ install just bootstrapped.
23
+ - **Fix.** `init` now reads its own `package.json:version` and writes
24
+ the pinned form `"args": ["-y", "@unotest/mobile@<version>"]` into
25
+ `.mcp.json`. The MCP server Claude Code launches always matches the
26
+ `init`-running copy, so a stale global can no longer ambush startup.
27
+ Pre-release versions (e.g. `1.0.0-rc.3`) pass through verbatim.
28
+ - **Migration.** Existing projects generated by `init` ≤ 0.8.0 keep
29
+ the bare entry. Run `npx @unotest/mobile@latest init --force` to
30
+ rewrite `.mcp.json` with the pinned form (preserves `.env`, only
31
+ rewrites templates).
32
+ - Tests: +3 (`mcpServerEntry: pins to passed version`,
33
+ `pre-release versions pass through verbatim`,
34
+ `never emits a bare unversioned reference`). The third is the
35
+ belt-and-braces regression guard that fails loudly if anyone
36
+ reintroduces a bare `@unotest/mobile` arg in the template.
37
+
7
38
  ## [0.8.0] — 2026-05-18
8
39
 
9
40
  ### 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
 
@@ -290,10 +290,20 @@ WDA_PORTS=A=8100,B=8101
290
290
  "unotest/artifacts/",
291
291
  "unotest/sessions/"
292
292
  ],
293
- mcpServerEntry: {
293
+ /**
294
+ * Build the `.mcp.json` entry for this package, pinned to the supplied
295
+ * version. Caller (`runInit`) reads its own `package.json:version` and
296
+ * passes it in, so the entry written into the consumer's project
297
+ * always matches the `init`-running copy. Pinning side-steps the
298
+ * `npx` stale-global / stale-cache ambush: a bare `@unotest/mobile`
299
+ * arg lets `npx` resolve to whatever globally-installed (often very
300
+ * old) copy a developer happens to have, which then crashes at
301
+ * startup against the current env schema.
302
+ */
303
+ mcpServerEntry: /* @__PURE__ */ __name((version) => ({
294
304
  command: "npx",
295
- args: ["-y", "@unotest/mobile"]
296
- }
305
+ args: ["-y", `@unotest/mobile@${version}`]
306
+ }), "mcpServerEntry")
297
307
  };
298
308
 
299
309
  // src/runner/init/mcp-config-merger.ts
@@ -391,6 +401,20 @@ function readPackageFile(relativePath) {
391
401
  return readFileSync(abs, "utf8");
392
402
  }
393
403
  __name(readPackageFile, "readPackageFile");
404
+ function readOwnVersion() {
405
+ const pkgRaw = readPackageFile("package.json");
406
+ if (pkgRaw === null) {
407
+ throw new Error(
408
+ `package.json missing at ${packageRoot}. This is a packaging bug \u2014 reinstall \`@unotest/mobile\` or report the issue.`
409
+ );
410
+ }
411
+ const parsed = JSON.parse(pkgRaw);
412
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
413
+ throw new Error(`package.json:version is not a non-empty string`);
414
+ }
415
+ return parsed.version;
416
+ }
417
+ __name(readOwnVersion, "readOwnVersion");
394
418
  function runInit(argv = process.argv.slice(2)) {
395
419
  const opts = parseInitArgs(argv);
396
420
  const target = process.cwd();
@@ -446,10 +470,7 @@ function runInit(argv = process.argv.slice(2)) {
446
470
  const merge = mergeMcpConfig(
447
471
  existing,
448
472
  "unotest-mobile",
449
- {
450
- command: templates.mcpServerEntry.command,
451
- args: [...templates.mcpServerEntry.args]
452
- },
473
+ templates.mcpServerEntry(readOwnVersion()),
453
474
  { force: opts.force }
454
475
  );
455
476
  if (merge.action !== "already-present") {
@@ -930,10 +930,20 @@ WDA_PORTS=A=8100,B=8101
930
930
  "unotest/artifacts/",
931
931
  "unotest/sessions/"
932
932
  ],
933
- mcpServerEntry: {
933
+ /**
934
+ * Build the `.mcp.json` entry for this package, pinned to the supplied
935
+ * version. Caller (`runInit`) reads its own `package.json:version` and
936
+ * passes it in, so the entry written into the consumer's project
937
+ * always matches the `init`-running copy. Pinning side-steps the
938
+ * `npx` stale-global / stale-cache ambush: a bare `@unotest/mobile`
939
+ * arg lets `npx` resolve to whatever globally-installed (often very
940
+ * old) copy a developer happens to have, which then crashes at
941
+ * startup against the current env schema.
942
+ */
943
+ mcpServerEntry: /* @__PURE__ */ __name((version) => ({
934
944
  command: "npx",
935
- args: ["-y", "@unotest/mobile"]
936
- }
945
+ args: ["-y", `@unotest/mobile@${version}`]
946
+ }), "mcpServerEntry")
937
947
  };
938
948
 
939
949
  // src/runner/init/mcp-config-merger.ts
@@ -1031,6 +1041,20 @@ function readPackageFile(relativePath) {
1031
1041
  return readFileSync(abs, "utf8");
1032
1042
  }
1033
1043
  __name(readPackageFile, "readPackageFile");
1044
+ function readOwnVersion() {
1045
+ const pkgRaw = readPackageFile("package.json");
1046
+ if (pkgRaw === null) {
1047
+ throw new Error(
1048
+ `package.json missing at ${packageRoot}. This is a packaging bug \u2014 reinstall \`@unotest/mobile\` or report the issue.`
1049
+ );
1050
+ }
1051
+ const parsed = JSON.parse(pkgRaw);
1052
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
1053
+ throw new Error(`package.json:version is not a non-empty string`);
1054
+ }
1055
+ return parsed.version;
1056
+ }
1057
+ __name(readOwnVersion, "readOwnVersion");
1034
1058
  function runInit(argv = process.argv.slice(2)) {
1035
1059
  const opts = parseInitArgs(argv);
1036
1060
  const target = process.cwd();
@@ -1086,10 +1110,7 @@ function runInit(argv = process.argv.slice(2)) {
1086
1110
  const merge = mergeMcpConfig(
1087
1111
  existing,
1088
1112
  "unotest-mobile",
1089
- {
1090
- command: templates.mcpServerEntry.command,
1091
- args: [...templates.mcpServerEntry.args]
1092
- },
1113
+ templates.mcpServerEntry(readOwnVersion()),
1093
1114
  { force: opts.force }
1094
1115
  );
1095
1116
  if (merge.action !== "already-present") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unotest/mobile",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",