@unotest/mobile 0.1.1 → 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/.claude/skills/write-e2e-test.md +373 -261
- package/CHANGELOG.md +488 -0
- package/README.md +67 -93
- package/bin/mcp.js +66 -8
- package/dist/mcp/server.js +2262 -408
- package/dist/runner/cli.js +1237 -181
- package/dist/runner/doctor.js +0 -1
- package/dist/runner/init.js +131 -55
- package/dist/runner/install.js +1988 -0
- package/package.json +10 -3
- package/dist/mcp/server.js.map +0 -1
- package/dist/runner/cli.js.map +0 -1
- package/dist/runner/doctor.js.map +0 -1
- package/dist/runner/init.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,116 +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
|
-
|
|
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.
|
|
77
69
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
**Low-level UI:**
|
|
81
|
-
`devices_list`, `screenshot`, `a11y_tree`, `resolve_selector`, `tap`,
|
|
82
|
-
`type`, `press_key`, `swipe`, `open_deeplink`, `app_launch`, `wait_for`,
|
|
83
|
-
`session_reset`.
|
|
70
|
+
## Requirements
|
|
84
71
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
88
77
|
|
|
89
78
|
## CLI
|
|
90
79
|
|
|
91
80
|
| Command | What it does |
|
|
92
81
|
|---|---|
|
|
93
|
-
| `npx @unotest/mobile
|
|
94
|
-
| `npx @unotest/mobile
|
|
95
|
-
| `npx @unotest/mobile
|
|
96
|
-
| `npx @unotest/mobile
|
|
97
|
-
| `npx @unotest/mobile`
|
|
98
|
-
|
|
99
|
-
## Requirements
|
|
100
|
-
|
|
101
|
-
- **macOS** with Xcode + iOS Simulator (Apple licensing — iOS simulators
|
|
102
|
-
cannot run on Linux/Windows hosts).
|
|
103
|
-
- **Node 20+**.
|
|
104
|
-
- **Xcode command-line tools** for the on-demand WebDriverAgent build.
|
|
105
|
-
WDA is compiled once and cached in
|
|
106
|
-
`~/.cache/unotest/mobile/wda/<version>/` — first build is ~5–15
|
|
107
|
-
minutes, subsequent runs reuse the cache.
|
|
108
|
-
|
|
109
|
-
## Status
|
|
110
|
-
|
|
111
|
-
`0.1.0` — initial public release. iOS-focused (RN, Expo, native Swift).
|
|
112
|
-
Android driver is on the roadmap. Local execution only; cloud runners
|
|
113
|
-
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). |
|
|
114
88
|
|
|
115
89
|
## License
|
|
116
90
|
|
package/bin/mcp.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// runtime — consumers don't install dev deps.
|
|
13
13
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
16
|
import { dirname, resolve } from "node:path";
|
|
17
|
-
import { existsSync } from "node:fs";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
18
19
|
|
|
19
20
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const root = resolve(here, "..");
|
|
@@ -22,19 +23,65 @@ const root = resolve(here, "..");
|
|
|
22
23
|
const args = process.argv.slice(2);
|
|
23
24
|
const sub = args[0];
|
|
24
25
|
|
|
26
|
+
// Meta flags must work without environment setup — before unotest/.env
|
|
27
|
+
// exists, before any subcommand context. Handle them in the dispatcher
|
|
28
|
+
// before routing.
|
|
29
|
+
if (sub === "--version" || sub === "-v") {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
|
|
31
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
if (sub === "--help" || sub === "-h") {
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
`unotest-mobile — AI-native E2E testing for iOS apps\n\n` +
|
|
37
|
+
`Usage: unotest-mobile [command] [...args]\n\n` +
|
|
38
|
+
`Commands:\n` +
|
|
39
|
+
` init Bootstrap a consumer project (env check + scaffold).\n` +
|
|
40
|
+
` doctor Re-run environment checks.\n` +
|
|
41
|
+
` install <path> Install a .app bundle on the configured simulator slot(s).\n` +
|
|
42
|
+
` e2e <name> Run scenario unotest/e2e/<name>.js.\n` +
|
|
43
|
+
` lint Static check of all scenarios + helpers.\n` +
|
|
44
|
+
` (no command) Run as MCP stdio server.\n\n` +
|
|
45
|
+
`Flags:\n` +
|
|
46
|
+
` --version, -v Print package version.\n` +
|
|
47
|
+
` --help, -h Show this help.\n\n` +
|
|
48
|
+
`Docs: https://www.npmjs.com/package/@unotest/mobile\n`,
|
|
49
|
+
);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// UNOTEST_DEV=1 — used by the evals harness (and useful for local dev)
|
|
54
|
+
// to route subcommands through src/ via tsx instead of the prebuilt dist/.
|
|
55
|
+
// Ensures `npx unotest-mobile lint` in a workdir runs against the CURRENT
|
|
56
|
+
// source, not whatever happens to be in dist/ (or a published npm version).
|
|
57
|
+
const dev = process.env.UNOTEST_DEV === "1";
|
|
58
|
+
const ext = dev ? ".ts" : ".js";
|
|
59
|
+
const baseDir = dev ? "src" : "dist";
|
|
60
|
+
|
|
25
61
|
function dispatch(sub) {
|
|
26
62
|
switch (sub) {
|
|
27
63
|
case "init":
|
|
28
|
-
return { entry:
|
|
64
|
+
return { entry: `${baseDir}/runner/init${ext}`, forwardArgs: args.slice(1) };
|
|
29
65
|
case "doctor":
|
|
30
|
-
return { entry:
|
|
66
|
+
return { entry: `${baseDir}/runner/doctor${ext}`, forwardArgs: args.slice(1) };
|
|
67
|
+
case "install":
|
|
68
|
+
return { entry: `${baseDir}/runner/install${ext}`, forwardArgs: args.slice(1) };
|
|
31
69
|
case "lint":
|
|
32
|
-
return { entry:
|
|
70
|
+
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: ["lint"] };
|
|
33
71
|
case "e2e":
|
|
34
|
-
return { entry:
|
|
72
|
+
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: args.slice(1) };
|
|
35
73
|
default:
|
|
36
74
|
// No-arg invocation → MCP server (current claude mcp add behavior).
|
|
37
|
-
|
|
75
|
+
// Unknown subcommands also land here — print a hint to stderr so
|
|
76
|
+
// typos like `e2e init` (meant `init`) don't silently route into the
|
|
77
|
+
// MCP server with a confusing env error.
|
|
78
|
+
if (sub !== undefined) {
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`[unotest-mobile] unknown command "${sub}" — running MCP server. ` +
|
|
81
|
+
`See \`unotest-mobile --help\` for available commands.\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return { entry: `${baseDir}/mcp/server${ext}`, forwardArgs: [] };
|
|
38
85
|
}
|
|
39
86
|
}
|
|
40
87
|
|
|
@@ -57,7 +104,18 @@ if (!paths.includes("/usr/bin") && existsSync("/usr/bin")) {
|
|
|
57
104
|
env.PATH = `/usr/bin:${env.PATH ?? ""}`;
|
|
58
105
|
}
|
|
59
106
|
|
|
60
|
-
|
|
107
|
+
// In dev mode, resolve tsx as a loader by absolute path (cwd-independent).
|
|
108
|
+
// tsx walks up node_modules from this dispatcher's location, so it's
|
|
109
|
+
// reliably found even when invoked from a workdir with no node_modules.
|
|
110
|
+
const nodeArgs = [];
|
|
111
|
+
if (dev) {
|
|
112
|
+
const req = createRequire(import.meta.url);
|
|
113
|
+
const tsxLoaderPath = req.resolve("tsx", { paths: [root] });
|
|
114
|
+
nodeArgs.push("--import", pathToFileURL(tsxLoaderPath).href);
|
|
115
|
+
}
|
|
116
|
+
nodeArgs.push(entryPath, ...forwardArgs);
|
|
117
|
+
|
|
118
|
+
const child = spawn(process.execPath, nodeArgs, {
|
|
61
119
|
stdio: "inherit",
|
|
62
120
|
env,
|
|
63
121
|
// No cwd override — inherit caller's cwd (consumer project root, where
|