@unotest/mobile 0.1.0

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.
@@ -0,0 +1,294 @@
1
+ ---
2
+ name: write-e2e-test
3
+ description: Write a new E2E test for the project under test using @unotest/mobile. Use when the user asks to write/cover/add a test for a feature, flow, or screen.
4
+ ---
5
+
6
+ # Skill: write-e2e-test
7
+
8
+ You are writing an end-to-end test using `@unotest/mobile` against an iOS
9
+ React Native app. Tests live in `unotest/e2e/` inside the **project under
10
+ test** (not in the `@unotest/mobile` package itself).
11
+
12
+ This skill is the single source of truth — everything you need to write a
13
+ correct test is below. Do not hunt for separate docs.
14
+
15
+ ## Before you write
16
+
17
+ 1. **Survey existing helpers** in `unotest/e2e/_helpers/`. Don't reinvent
18
+ `signin`, `seed_user_*`, `wipe_*` — reuse what's there. If a helper
19
+ you need doesn't exist, add it to `_helpers/<topic>.js` (not inline).
20
+
21
+ 2. **Survey existing scenarios** in `unotest/e2e/`. Match the style and
22
+ reuse the same setup/seed patterns.
23
+
24
+ 3. **Read the env file** `unotest/.env`. It tells you the app's bundle id,
25
+ simulator slot names, database URL, project CLI paths.
26
+
27
+ 4. **Ask the user only what you can't infer:** which feature/flow to test,
28
+ which user role if the app has roles, happy path, negative cases. Do
29
+ not ask about conventions — they are below.
30
+
31
+ ## Scenario shape
32
+
33
+ Four phases per scenario — Setup, Enter, Act, Assert.
34
+
35
+ ```js
36
+ // id-<scenario-id>
37
+ // <one-line human description>
38
+ // #<6-digit hex color>
39
+ function test_<entry_name>() {
40
+ // 1. SETUP — DB / API / CLI fixtures
41
+ wipe_e2e_users();
42
+ user_id = seed_user("e2e-user@example.com", "e2e-pass-1234");
43
+
44
+ // 2. ENTER — bring the app to a known initial UI state
45
+ setDevice("A");
46
+ appLaunch(true);
47
+ waitFor(getByTestId("screen-welcome"), 15000);
48
+
49
+ // 3. ACT — the actions you're testing
50
+ signin("e2e-user@example.com", "e2e-pass-1234");
51
+ waitFor(getByTestId("screen-home"), 15000);
52
+ tap(getByTestId("btn-create-item"));
53
+ type(getByTestId("input-title"), "Hello");
54
+ tap(getByTestId("btn-save"));
55
+
56
+ // 4. ASSERT — UI + data checks
57
+ assertVisible(getByTestId("item-row-Hello"));
58
+ count = dbQuery("SELECT count(*)::text FROM items WHERE user_id = $1", user_id);
59
+ assertEqual(count, "1");
60
+ }
61
+ ```
62
+
63
+ The **3-line comment header** above `function` is mandatory for `test_*`
64
+ and `flow_*` (linter rule E7). The order is fixed: id, description, hex
65
+ color. The header is metadata used by the future Blockly visual editor;
66
+ keep it stable.
67
+
68
+ ## DSL — strict JS subset
69
+
70
+ The DSL is **not** full JavaScript. The parser rejects what the
71
+ visual-editor target (Blockly) can't render.
72
+
73
+ **Allowed:**
74
+ - Top-level `function name(args) { ... }` definitions
75
+ - Bare-name assignment: `x = expr;` (no `var`/`let`/`const`)
76
+ - `if`/`else`, blocks `{ ... }`
77
+ - Function calls (statement or expression position)
78
+ - Literals: number, string, `true` / `false` / `null`
79
+ - Array literals `[a, b, c]` (used for variadic args)
80
+ - Arithmetic: `+ - * /`
81
+ - Comparisons: `== != < <= > >=`
82
+ - Logical: `&& ||`
83
+ - String concatenation: `"a" + "b"`
84
+
85
+ **Forbidden** (linter will reject):
86
+ - `var` / `let` / `const`
87
+ - `for` / `while` (`maxSteps` budget protects from infinite loops)
88
+ - Object literals `{a: 1}` — pass payloads as JSON-as-string instead
89
+ - Member access `obj.field` — primitives only
90
+ - Index access `arr[i]`
91
+ - Unary `!` / `-` (use `0 - n` for negative numbers)
92
+ - `===` / `!==` (use `==` / `!=`)
93
+ - `+=` / `-=` / `++` / `--`
94
+ - Arrow functions, classes, destructuring
95
+ - Nested function definitions
96
+
97
+ For structured payloads, pass JSON as a string:
98
+ ```js
99
+ apiCall("POST", "/api/users", '{"email":"x@y.z","name":"X"}');
100
+ ```
101
+
102
+ ## DSL functions
103
+
104
+ ### Device
105
+
106
+ | Function | Effect |
107
+ |---|---|
108
+ | `setDevice(slot)` | Switch the "current device" for subsequent UI calls. `slot` must be in `EnvConfig.simBySlot` (defined in `unotest/.env`, e.g. `"A"` or `"B"`). |
109
+ | `appLaunch(clean?)` | Launch the app under test. `clean=true` → terminate + relaunch from clean state. |
110
+ | `openDeeplink(url)` | Open a URL on the current device. |
111
+
112
+ ### Selectors (pure — return Selector objects, no driver call)
113
+
114
+ | Function | Returns |
115
+ |---|---|
116
+ | `getByTestId(s)` | `{ testId: s }` |
117
+ | `getByText(s)` | `{ text: s }` |
118
+ | `getByLabel(s)` | `{ label: s }` |
119
+ | `ordinal(target, n)` | wraps a Selector with `ordinal: n` (0-indexed) |
120
+ | `near(target, anchor, maxPx?)` | wraps a Selector with `near: { anchor, maxDistancePx? }` |
121
+
122
+ `testId`, `text`, `label` are **equal-priority citizens** — there is no
123
+ implicit fallback chain. Use `getByText` on custom buttons that lack a
124
+ `testID`. iOS surfaces a React Native `testID` as the WDA `name` attribute.
125
+
126
+ ### UI actions
127
+
128
+ | Function | Behavior |
129
+ |---|---|
130
+ | `tap(selector)` | Auto-wait up to `defaultActionWaitMs`, then tap the element's center. |
131
+ | `type(selector, text)` | Auto-wait, tap to focus, send keys. |
132
+ | `swipe(direction, fromSel?)` | Direction is `"up"` / `"down"` / `"left"` / `"right"`. If `fromSel` is given, auto-wait + swipe from its center; else from screen center. |
133
+ | `pressKey(key)` | `"home"` / `"enter"` / `"escape"`. iOS has no `back`. No selector → no auto-wait. |
134
+ | `waitFor(selector, timeoutMs?)` | Explicit poll up to `defaultWaitForTimeoutMs`. |
135
+ | `pause(ms)` | Sleep. Use sparingly; prefer `waitFor`. |
136
+
137
+ ### Data
138
+
139
+ | Function | Description |
140
+ |---|---|
141
+ | `dbQuery(sql, ...args)` | Parameterized SELECT through `DbClient` (pg / mysql / sqlite, picked by `DATABASE_URL`). Returns the **first cell of the first row** as a string. For multi-row results use multiple `dbQuery` calls. |
142
+ | `dbExec(sql, ...args)` | Parameterized INSERT/UPDATE/DELETE. If the driver returned rows (e.g. `INSERT ... RETURNING` on pg or sqlite 3.35+), returns the first cell of the first row; otherwise `""`. Does not parse the SQL. |
143
+ | `apiCall(method, path, body?, headers?)` | HTTP request via `ApiClient.call`. `body` is JSON-as-string. Returns raw response body. |
144
+ | `shell(cmd, ...args)` | `execFile`-style spawn — args go straight to `child_process` argv with **no shell interpretation**. Throws on non-zero exit. Returns stdout. |
145
+
146
+ Project-specific seeding (creating users, fixtures, etc.) lives in
147
+ `unotest/e2e/_helpers/`. The core `@unotest/mobile` package does not know
148
+ about your backend stack — you wire it via `shell` / `dbExec` / `apiCall`.
149
+
150
+ ### Time
151
+
152
+ | Function | Description |
153
+ |---|---|
154
+ | `today()` | ISO date `YYYY-MM-DD` (UTC) for the runtime. |
155
+ | `daysFromNow(n)` | ISO date `n` days from today. For negative n use `daysFromNow(0 - n)` — the DSL has no unary minus. |
156
+ | `nowMs()` | Unix timestamp in milliseconds (useful for unique suffixes). |
157
+
158
+ ### Assertions
159
+
160
+ | Function | Effect |
161
+ |---|---|
162
+ | `assertEqual(a, b)` | Throws if `a !== b`. |
163
+ | `assertVisible(selector)` | Resolves the selector against the current screen; throws if no match. |
164
+ | `assertCount(selector, n)` | Probes `ordinal: 0..N-1`; throws if the total count is not `n`. |
165
+ | `assertEnabled(selector)` | Resolves against the **raw** a11y tree (not compact). Throws if `enabled !== true` or the field is missing. |
166
+ | `assertDisabled(selector)` | Same, expects `enabled === false`. |
167
+
168
+ ## Helpers
169
+
170
+ - Live in `unotest/e2e/_helpers/*.js`. Globally visible to all scenarios.
171
+ - `snake_case` function names (`seed_user`, `signin`, `wipe_e2e_users`).
172
+ - `return` is **allowed** in helpers (forbidden in `test_*` / `flow_*`).
173
+ - **Snapshot scoping**: helper mutations don't leak to caller variables.
174
+ Pass information back via return values.
175
+ - Recursion depth capped at `maxCallDepth` (default 32).
176
+ - Cross-file: every file under `_helpers/` is auto-loaded for every
177
+ scenario — convention, not magic.
178
+
179
+ Extract to a helper when:
180
+ - 5+ lines repeat across 2+ scenarios.
181
+ - A piece of setup needs to be available to many tests.
182
+
183
+ Don't extract prematurely — one-off code stays inline.
184
+
185
+ Example:
186
+
187
+ ```js
188
+ // unotest/e2e/_helpers/seed.js
189
+ function seed_user(email, password) {
190
+ shell("npm", "--prefix", "./apps/api", "run", "cli", "--",
191
+ "create-user", "--email", email, "--password", password);
192
+ return dbQuery("SELECT id FROM users WHERE email = $1", email);
193
+ }
194
+ ```
195
+
196
+ ## Setup strategy
197
+
198
+ Pick the highest viable option:
199
+
200
+ 1. **`shell()` to a project CLI** — goes through real business logic
201
+ (password hashing, hooks, side effects). Best fidelity.
202
+ ```js
203
+ shell("npm", "run", "cli", "--", "create-user", "--email", email);
204
+ ```
205
+ 2. **`apiCall()`** — when the CLI doesn't exist but the endpoint does.
206
+ 3. **Direct `dbExec("INSERT INTO ...")`** — only when intentionally
207
+ bypassing business logic. Rare; document why.
208
+
209
+ For dates, use `today()` / `daysFromNow(n)`. **Never** hardcode a date
210
+ like `"2026-05-12"` — tests would silently rot.
211
+
212
+ ## Cleanup
213
+
214
+ Wipe **at the start of the scenario**, not at the end. Failed runs skip
215
+ end-of-test cleanup; start-of-test cleanup always runs.
216
+
217
+ ```js
218
+ function test_X() {
219
+ wipe_e2e_users(); // beginning — always runs
220
+ // ...
221
+ }
222
+ ```
223
+
224
+ ## Authoring workflow
225
+
226
+ 1. **Plan the test.** One-line description. Decide setup, actor, assertion.
227
+ 2. **Pick a path:**
228
+ - Single feature → `unotest/e2e/<feature>/<actor>-<verb>-<context>.js`
229
+ - Cross-feature smoke / integration → `unotest/e2e/<actor>-<verb>-<context>.js`
230
+ 3. **Copy `unotest/e2e/_template.js`**, rename, adjust the 3-line header
231
+ (id / description / color).
232
+ 4. **Write setup** using existing helpers. If you need a new one, add to
233
+ `_helpers/<topic>.js` first, then use it.
234
+ 5. **Run via MCP `run_test` with `pauseOnFailure: true`:**
235
+ - On pause: `inspect_runtime <id>` → see vars, last error.
236
+ - Use `screenshot` + `a11y_tree` to understand UI state.
237
+ - Use `resolve_selector` to find correct testIDs (returns the top-3
238
+ near-misses with reasons when no exact match).
239
+ - Fix the scenario, then `resume <id>` to retry the failed step.
240
+ - Once green: `abort_runtime <id>` to teardown.
241
+ 6. **Lint:** `npx @unotest/mobile lint` (also runs automatically on every
242
+ `e2e <name>`).
243
+
244
+ ## Linter codes
245
+
246
+ | Code | Severity | Meaning |
247
+ |---|---|---|
248
+ | E1 | error | Function name not in `FunctionRegistry` and not declared as a user helper. |
249
+ | E2 | error | AST node outside the MVP subset, or `return` in a `test_*` / `flow_*` body. |
250
+ | E3 | error | `setDevice("X")` slot not in `EnvConfig.simBySlot`. |
251
+ | E4 | error | JSON-string arg in `apiCall` doesn't parse. |
252
+ | W5 | warn | An `assert*` call before any UI action — likely a typo. |
253
+ | E7 | error | `test_*` / `flow_*` function missing the 3-line header (`// id-...`, `// description`, `// #color`) immediately above `function`. |
254
+ | E8 | error | Duplicate user-function name, or a user function shadows a built-in. |
255
+
256
+ ## Common antipatterns
257
+
258
+ - **Hardcoding testIDs without verifying** them in the app. Use
259
+ `resolve_selector` first to confirm the ID exists.
260
+ - **Inlining 30 lines of signin** in every scenario — extract a `signin` helper.
261
+ - **One mega-test** covering everything — split by actor / action so
262
+ failures point at one thing.
263
+ - **Tests depending on each other's state** — every scenario must be
264
+ self-contained.
265
+ - **Using `getByText("...")` when a `testID` exists** — `testID` is stable
266
+ across locale changes; visible text is not.
267
+ - **Hardcoding dates** — use `today()` / `daysFromNow(n)`.
268
+ - **Object literals or member access** — DSL forbids them; use JSON
269
+ strings and primitive returns.
270
+
271
+ ## When the test fails to run
272
+
273
+ | Symptom | Likely cause |
274
+ |---|---|
275
+ | `Invalid environment` | `unotest/.env` missing keys. Compare to `.env.example`. |
276
+ | `ECONNREFUSED` from `dbQuery` | `DATABASE_URL` not reachable. For docker Postgres, ensure the host port is exposed (`ports: ["5432:5432"]`). |
277
+ | `relation "..." does not exist` | DB migrations not run, or a helper references a table that doesn't exist in this project. |
278
+ | `Sim "X" not found` | `SIM_A_NAME` / `SIM_B_NAME` in `.env` don't match `xcrun simctl list devices`. |
279
+ | `Entry function "test_X" not found` | File basename doesn't match the function name. Convention: `unotest/e2e/<path>/foo-bar.js` → `function test_foo_bar()`. |
280
+
281
+ If a selector resolves to the wrong element or times out: use
282
+ `resolve_selector` (MCP) to see the top-3 near-misses with reasons.
283
+
284
+ ## Output to user
285
+
286
+ After writing tests:
287
+
288
+ 1. **Summarize the scenarios you added** — file paths, one line each.
289
+ 2. **List any new helpers added** to `_helpers/`.
290
+ 3. **Call out app-side TODOs** — missing `testID`s, missing CLI
291
+ subcommands, missing DB schema. Don't silently assume; surface them
292
+ so the user can decide.
293
+ 4. **Confirm `npx @unotest/mobile lint` passes.** If you ran `run_test`,
294
+ report pass/fail per scenario.
package/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@unotest/mobile` will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [0.1.0] — 2026-05-14
8
+
9
+ Initial public release.
10
+
11
+ ### Added
12
+
13
+ - MCP server with low-level UI tools (`tap`, `type`, `swipe`, `screenshot`,
14
+ `a11y_tree`, `resolve_selector`, `app_launch`, `wait_for`, ...) and a
15
+ paused-on-failure debugger (`run_test`, `step`, `resume`, `inspect_runtime`).
16
+ - CLI runner: `unotest-mobile init`, `doctor`, `e2e <name>`, `lint`.
17
+ - JS-DSL scenario authoring (strict subset of JS — see the `write-e2e-test`
18
+ Claude Code skill for the reference).
19
+ - WebDriverAgent driver for iOS Simulator, with on-demand build and per-version
20
+ cache (`~/.cache/unotest/mobile/wda/`).
21
+ - Multi-device support (slot A / slot B simulators, parallel WDA sessions).
22
+ - Multi-DB plugin (Postgres, MySQL, SQLite) selected by `DATABASE_URL`.
23
+ - `unotest-mobile init` bootstraps consumer projects with `unotest/e2e/`
24
+ layout, `.mcp.json` registration, and the `write-e2e-test` skill.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ivan Volkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # `@unotest/mobile`
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.
6
+
7
+ Part of the `unotest` family. The web counterpart is a separate product.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install --save-dev @unotest/mobile # or yarn / pnpm — same effect
13
+ npx @unotest/mobile init # bootstrap your project
14
+ ```
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:
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
27
+
28
+ `--force` overwrites existing files (except `unotest/.env`).
29
+ `--allow-non-macos` lets you scaffold on a CI prep stage.
30
+
31
+ ## Quick start
32
+
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
+ ```
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
+ }
74
+ ```
75
+
76
+ ## MCP tools
77
+
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`.
84
+
85
+ **Pause-on-failure debugger:**
86
+ `run_test` (with `pauseOnFailure: true`), `step`, `resume`,
87
+ `inspect_runtime`, `abort_runtime`, `list_runtimes`.
88
+
89
+ ## CLI
90
+
91
+ | Command | What it does |
92
+ |---|---|
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.
114
+
115
+ ## License
116
+
117
+ [MIT](./LICENSE)
package/bin/mcp.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // CLI dispatcher for @unotest/mobile.
3
+ //
4
+ // unotest-mobile → MCP server (default; no subcommand)
5
+ // unotest-mobile init → bootstrap consumer project
6
+ // unotest-mobile doctor → re-run environment checks
7
+ // unotest-mobile e2e <name> → run scenario unotest/e2e/<name>.js
8
+ // unotest-mobile lint → lint scenarios + helpers
9
+ //
10
+ // All entries are compiled by tsup to dist/ before publish; this dispatcher
11
+ // just resolves the right one and execs it via plain node. No tsx at
12
+ // runtime — consumers don't install dev deps.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, resolve } from "node:path";
17
+ import { existsSync } from "node:fs";
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const root = resolve(here, "..");
21
+
22
+ const args = process.argv.slice(2);
23
+ const sub = args[0];
24
+
25
+ function dispatch(sub) {
26
+ switch (sub) {
27
+ case "init":
28
+ return { entry: "dist/runner/init.js", forwardArgs: args.slice(1) };
29
+ case "doctor":
30
+ return { entry: "dist/runner/doctor.js", forwardArgs: args.slice(1) };
31
+ case "lint":
32
+ return { entry: "dist/runner/cli.js", forwardArgs: ["lint"] };
33
+ case "e2e":
34
+ return { entry: "dist/runner/cli.js", forwardArgs: args.slice(1) };
35
+ default:
36
+ // No-arg invocation → MCP server (current claude mcp add behavior).
37
+ return { entry: "dist/mcp/server.js", forwardArgs: [] };
38
+ }
39
+ }
40
+
41
+ const { entry, forwardArgs } = dispatch(sub);
42
+ const entryPath = resolve(root, entry);
43
+
44
+ if (!existsSync(entryPath)) {
45
+ process.stderr.write(
46
+ `[unotest-mobile] missing build artifact: ${entry}\n` +
47
+ `Did you forget to run \`pnpm build\`? (In a published package this should not happen — please open an issue.)\n`,
48
+ );
49
+ process.exit(1);
50
+ }
51
+
52
+ // Hygiene: ensure /usr/bin is on PATH (xcrun etc.) even when launched from
53
+ // minimal-PATH contexts like Claude Desktop.
54
+ const env = { ...process.env };
55
+ const paths = (env.PATH ?? "").split(":");
56
+ if (!paths.includes("/usr/bin") && existsSync("/usr/bin")) {
57
+ env.PATH = `/usr/bin:${env.PATH ?? ""}`;
58
+ }
59
+
60
+ const child = spawn(process.execPath, [entryPath, ...forwardArgs], {
61
+ stdio: "inherit",
62
+ env,
63
+ // No cwd override — inherit caller's cwd (consumer project root, where
64
+ // unotest/e2e/ and unotest/.env live).
65
+ });
66
+ child.on("exit", (code) => process.exit(code ?? 1));