@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.
- package/.claude/skills/write-e2e-test.md +294 -0
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/bin/mcp.js +66 -0
- package/dist/mcp/server.js +6026 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/runner/cli.js +5598 -0
- package/dist/runner/cli.js.map +1 -0
- package/dist/runner/doctor.js +158 -0
- package/dist/runner/doctor.js.map +1 -0
- package/dist/runner/init.js +453 -0
- package/dist/runner/init.js.map +1 -0
- package/package.json +75 -0
|
@@ -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));
|