@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 +31 -0
- package/README.md +67 -100
- package/dist/runner/init.js +28 -7
- package/dist/runner/install.js +28 -7
- package/package.json +1 -1
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
## Install
|
|
8
|
+
## Quick start
|
|
10
9
|
|
|
11
10
|
```bash
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
101
|
-
| `npx @unotest/mobile
|
|
102
|
-
| `npx @unotest/mobile
|
|
103
|
-
| `npx @unotest/mobile
|
|
104
|
-
| `npx @unotest/mobile`
|
|
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
|
|
package/dist/runner/init.js
CHANGED
|
@@ -290,10 +290,20 @@ WDA_PORTS=A=8100,B=8101
|
|
|
290
290
|
"unotest/artifacts/",
|
|
291
291
|
"unotest/sessions/"
|
|
292
292
|
],
|
|
293
|
-
|
|
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",
|
|
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") {
|
package/dist/runner/install.js
CHANGED
|
@@ -930,10 +930,20 @@ WDA_PORTS=A=8100,B=8101
|
|
|
930
930
|
"unotest/artifacts/",
|
|
931
931
|
"unotest/sessions/"
|
|
932
932
|
],
|
|
933
|
-
|
|
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",
|
|
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") {
|