@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/README.md CHANGED
@@ -1,116 +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
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
- After `init`, Claude Code (or any MCP-aware client) gets:
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
- **Pause-on-failure debugger:**
86
- `run_test` (with `pauseOnFailure: true`), `step`, `resume`,
87
- `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.
88
77
 
89
78
  ## CLI
90
79
 
91
80
  | Command | What it does |
92
81
  |---|---|
93
- | `npx @unotest/mobile init` | Bootstrap a consumer project. Idempotent. |
94
- | `npx @unotest/mobile doctor` | Re-run environment checks. |
95
- | `npx @unotest/mobile e2e <name>` | Run `unotest/e2e/<name>.js`. Supports nested paths. |
96
- | `npx @unotest/mobile lint` | Static check of all scenarios + helpers. |
97
- | `npx @unotest/mobile` (no args) | Run as MCP stdio server. |
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: "dist/runner/init.js", forwardArgs: args.slice(1) };
64
+ return { entry: `${baseDir}/runner/init${ext}`, forwardArgs: args.slice(1) };
29
65
  case "doctor":
30
- return { entry: "dist/runner/doctor.js", forwardArgs: args.slice(1) };
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: "dist/runner/cli.js", forwardArgs: ["lint"] };
70
+ return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: ["lint"] };
33
71
  case "e2e":
34
- return { entry: "dist/runner/cli.js", forwardArgs: args.slice(1) };
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
- return { entry: "dist/mcp/server.js", forwardArgs: [] };
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
- const child = spawn(process.execPath, [entryPath, ...forwardArgs], {
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