@unotest/mobile 0.8.3 → 0.10.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 +23 -14
- package/CHANGELOG.md +56 -15
- package/README.md +4 -3
- package/bin/{mcp.js → unotest-mobile.js} +9 -0
- package/dist/dsl/mobile-dsl-language-service.d.ts +12 -0
- package/dist/dsl/mobile-dsl-language-service.js +1056 -0
- package/dist/mcp/server.js +1095 -2886
- package/dist/runner/cli.js +4247 -4991
- package/dist/runner/doctor.js +4 -3
- package/dist/runner/init.js +67 -73
- package/dist/runner/install.js +103 -89
- package/dist/runner/mobile-runner-adapter.d.ts +6 -0
- package/dist/runner/mobile-runner-adapter.js +227 -0
- package/package.json +39 -33
|
@@ -6,7 +6,7 @@ description: Write a new E2E test for the project under test using @unotest/mobi
|
|
|
6
6
|
# Skill: write-e2e-test
|
|
7
7
|
|
|
8
8
|
You are writing an end-to-end test using `@unotest/mobile` against an
|
|
9
|
-
iOS React Native app. The test lives in `unotest/e2e/<name>.js` in the
|
|
9
|
+
iOS React Native app. The test lives in `unotest/e2e-mobile/<name>.js` in the
|
|
10
10
|
project under test.
|
|
11
11
|
|
|
12
12
|
**Default mode is verify-against-live-app.** A test that hasn't been
|
|
@@ -35,7 +35,7 @@ Five phases, in this order. Don't skip phases.
|
|
|
35
35
|
|
|
36
36
|
### Phase 0 — read context
|
|
37
37
|
|
|
38
|
-
`unotest/e2e/_template/example.js` shows the exact DSL syntax (bare
|
|
38
|
+
`unotest/e2e-mobile/_template/example.js` shows the exact DSL syntax (bare
|
|
39
39
|
top-level functions, no `import`/`export`/`async`/`await`/`const`/`let`/
|
|
40
40
|
`var`/arrow-functions). Read it first. The DSL looks like JavaScript
|
|
41
41
|
but is a frozen subset — when your default JS instincts disagree with
|
|
@@ -196,9 +196,12 @@ save_exploration_as_test { explorationId, scenarioName: "<name>" }
|
|
|
196
196
|
with `// SKIPPED` comments.
|
|
197
197
|
- File exists? Pass `overwrite: true`.
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
`
|
|
199
|
+
Every executable statement in a `test_*` body MUST live inside a
|
|
200
|
+
`step("human-readable intent", () => { ... })` block - the linter's
|
|
201
|
+
`validator:step-coverage` rule enforces it. The generator groups
|
|
202
|
+
recorded sections into `step(...)` blocks automatically; keep the
|
|
203
|
+
labels meaningful when editing (they're what an agent reads when
|
|
204
|
+
repairing a broken step).
|
|
202
205
|
|
|
203
206
|
### Phase 5 — reset state, then run_test
|
|
204
207
|
|
|
@@ -213,6 +216,10 @@ Via MCP — reinstall the app to wipe its sandbox AND auth tokens:
|
|
|
213
216
|
app_install { path: "<APP_PATH from unotest/.env>", clean: true }
|
|
214
217
|
```
|
|
215
218
|
|
|
219
|
+
To just PREPARE devices (boot SIM_POOL + install APP_PATH, no wipe),
|
|
220
|
+
the viewer's App panel runs `npx unotest-mobile app-server` and holds
|
|
221
|
+
it; runs then find booted sims with the build installed.
|
|
222
|
+
|
|
216
223
|
**Do NOT pass `erase: true`.** `erase: true` runs `simctl erase` which
|
|
217
224
|
wipes the entire simulator including WebDriverAgent. `clean: true`
|
|
218
225
|
alone is the right soft-reset — it now resets the keychain too, so
|
|
@@ -354,7 +361,7 @@ Time: `today()`, `daysFromNow(n)`, `nowMs()`.
|
|
|
354
361
|
|
|
355
362
|
## Helpers convention
|
|
356
363
|
|
|
357
|
-
Reusable steps go in `unotest/e2e/_helpers/<group>.js`. Helper naming
|
|
364
|
+
Reusable steps go in `unotest/e2e-mobile/_helpers/<group>.js`. Helper naming
|
|
358
365
|
is **snake_case** (`signin_as`, `seed_workout`). **Before recording an
|
|
359
366
|
inline flow, check `_helpers/` for an existing helper** — `Glob` or
|
|
360
367
|
`Read`. If `signin_as(email, password)` exists, the generated test
|
|
@@ -363,7 +370,7 @@ should call it instead of re-recording four steps. (After
|
|
|
363
370
|
for the helper call.)
|
|
364
371
|
|
|
365
372
|
```
|
|
366
|
-
unotest/e2e/_helpers/auth.js:
|
|
373
|
+
unotest/e2e-mobile/_helpers/auth.js:
|
|
367
374
|
function signin_as(email, password) { ... }
|
|
368
375
|
```
|
|
369
376
|
|
|
@@ -394,13 +401,15 @@ npx unotest-mobile lint
|
|
|
394
401
|
```
|
|
395
402
|
|
|
396
403
|
Lint codes to recognize:
|
|
397
|
-
-
|
|
398
|
-
-
|
|
399
|
-
-
|
|
400
|
-
|
|
401
|
-
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
+
- E2 — statement/expression outside the frozen subset (drift to plain JS).
|
|
405
|
+
- E3 — `setDevice()` with an unknown sim slot.
|
|
406
|
+
- E8 — duplicate helper name / shadowing a built-in.
|
|
407
|
+
- validator:unknown-function — typo'd or undefined function name.
|
|
408
|
+
- validator:arity — wrong arg count for a built-in.
|
|
409
|
+
- validator:arg-kind — wrong arg TYPE / order, e.g.
|
|
410
|
+
`swipe(getByTestId("x"), "up")` — argTypes are `("up"|..., selector)`.
|
|
411
|
+
- validator:step-coverage — a statement in a `test_*` body is not inside
|
|
412
|
+
`step("description", () => { ... })`. Wrap it.
|
|
404
413
|
|
|
405
414
|
If lint flags `import`/`export`/`await`/`const` — re-read the DSL
|
|
406
415
|
section above and rewrite.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ 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.10.0] - 2026-08-06
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [7ed750c]
|
|
12
|
+
- Updated dependencies [574841f]
|
|
13
|
+
- Updated dependencies [337a31c]
|
|
14
|
+
- @unotest/dsl@0.10.0
|
|
15
|
+
- @unotest/protocol@0.10.0
|
|
16
|
+
- @unotest/core@0.10.0
|
|
17
|
+
- @unotest/viewer@0.10.0
|
|
18
|
+
|
|
19
|
+
## [0.9.2] - 2026-06-16
|
|
20
|
+
|
|
21
|
+
Released in lockstep with the rest of the ecosystem. No functional changes.
|
|
22
|
+
|
|
23
|
+
## [0.9.1] - 2026-06-14
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`@unotest/studio` integration** — the mobile runner participates in the
|
|
28
|
+
desktop shell ("mobile in studio").
|
|
29
|
+
- Ecosystem version lockstep to `0.9.1`.
|
|
30
|
+
|
|
31
|
+
## [0.9.0] - 2026-06-11
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Viewer integration.** The mobile runner now drives the localhost
|
|
36
|
+
`@unotest/viewer`: run artifacts (`steps.jsonl` / `runtime.json` / manifest),
|
|
37
|
+
live runtime state, the debug control plane (step / continue / abort / pause +
|
|
38
|
+
breakpoints), and snapshot / console / failure capture — so a mobile run is
|
|
39
|
+
inspectable in the same UI as web.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **Workspace overlay + shared packages.** Moved into the `@unotest` pnpm
|
|
44
|
+
workspace overlay; adopts the shared `@unotest/core` (run / runtime writers,
|
|
45
|
+
debug-commands watcher) and the `@unotest/dsl` executor instead of forked
|
|
46
|
+
copies.
|
|
47
|
+
|
|
7
48
|
## [0.8.3] — 2026-05-18
|
|
8
49
|
|
|
9
50
|
### Added — README onboarding protocol for AI agents
|
|
@@ -37,8 +78,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
37
78
|
all running on the same Mac), the iOS keyboard layer couldn't keep
|
|
38
79
|
up and randomly dropped 1-3 characters from the middle of the string
|
|
39
80
|
— observed live as `petr@volkov.io` → `pr@volkov.io`, `Qwerty34##`
|
|
40
|
-
→ `Qwery34##`. P4 / S8 had only addressed the unrelated
|
|
41
|
-
keyboard-
|
|
81
|
+
→ `Qwery34##`. P4 / S8 had only addressed the unrelated _wrong-
|
|
82
|
+
keyboard-layout_ failure (Cyrillic keyboard eating Latin chars
|
|
42
83
|
wholesale); this race is a separate failure mode that survived.
|
|
43
84
|
- **Fix 1 — `ConnectHardwareKeyboard` pinned OFF on install.**
|
|
44
85
|
The `pinEnglishKeyboard` step (S8) now also writes
|
|
@@ -88,7 +129,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
88
129
|
`npx @unotest/mobile@latest install ...`. The stale global frequently
|
|
89
130
|
had the pre-0.1.4 strict env schema, so the MCP server crashed at
|
|
90
131
|
startup with `Invalid environment. ... INVITE_DEEPLINK_PREFIX:
|
|
91
|
-
|
|
132
|
+
Required, API_BASE_URL: Required, ...`. Claude Code then surfaced
|
|
92
133
|
`-32000 Connection closed` and the agent fell back to draft-mode
|
|
93
134
|
scenario authoring — defeating the entire MCP-driven flow the
|
|
94
135
|
install just bootstrapped.
|
|
@@ -148,7 +189,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
148
189
|
`result.detectedPermissions`.
|
|
149
190
|
- **Pre-grant on install.** When the resolved permissions list is
|
|
150
191
|
non-empty, every service is granted with `simctl privacy <udid> grant
|
|
151
|
-
|
|
192
|
+
<service> <bundleId>` immediately after install. Pre-empts SpringBoard
|
|
152
193
|
permission dialogs (location, motion, photos, …) that would otherwise
|
|
153
194
|
block scenarios on first launch — those dialogs live in SpringBoard,
|
|
154
195
|
not in the app's a11y tree.
|
|
@@ -188,7 +229,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
188
229
|
- **New `alert:` section in `mode: "outline"`.** When a native
|
|
189
230
|
`UIAlertController` is on screen its title, body, and button labels
|
|
190
231
|
appear as the first section of the outline, plus a `_meta.alert_active:
|
|
191
|
-
|
|
232
|
+
true` flag. Previously the alert lived in SpringBoard (outside the
|
|
192
233
|
app's a11y tree) and the agent kept tapping the underlying button
|
|
193
234
|
through the modal — usually the wrong one, sometimes destructive.
|
|
194
235
|
- **Grammar contract.** The `alert:` section and `_meta.alert_active`
|
|
@@ -242,7 +283,7 @@ After the fixes, `run_test` on a freshly-generated exploration returned
|
|
|
242
283
|
is unchanged — keychain reset only fires when the caller explicitly
|
|
243
284
|
opts into a fresh state.
|
|
244
285
|
- Keychain reset is invoked **unconditionally** in the `app_install
|
|
245
|
-
|
|
286
|
+
{ clean: true }` flow even when `simctl uninstall` reports "app not
|
|
246
287
|
installed" — prior-run tokens may still linger, that's the whole
|
|
247
288
|
reason for B5.
|
|
248
289
|
- MCP descriptions for `app_install`'s `clean` parameter and
|
|
@@ -258,7 +299,7 @@ After the fixes, `run_test` on a freshly-generated exploration returned
|
|
|
258
299
|
one tool — `explore_step { action: "tap" | "type" | … }`. The single
|
|
259
300
|
rule: passing `explorationId` records the call into an exploration
|
|
260
301
|
session; omitting it runs the action ad-hoc. There is no `record:
|
|
261
|
-
|
|
302
|
+
false` override.
|
|
262
303
|
- **Alerts (`accept_alert`, `dismiss_alert`) appear here for the first
|
|
263
304
|
time as `explore_step` actions** — not as separate tools. The
|
|
264
305
|
Driver+DSL alert layer landed in P0; this release wires the MCP
|
|
@@ -359,12 +400,12 @@ unchanged.
|
|
|
359
400
|
on UIAlertController, last on action sheet).
|
|
360
401
|
- `readAlert()` — return title + body of the active alert as a string,
|
|
361
402
|
or throw `NoAlertPresentError` if none is on screen.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
403
|
+
Before this, scenarios had no way to dismiss SpringBoard alerts: the
|
|
404
|
+
resolver-driven `tap(getByTestId(...))` couldn't reach buttons that
|
|
405
|
+
live outside the app process — `ordinal` selectors appeared to match
|
|
406
|
+
but the tap landed on the wrong element. Pre-granting permissions via
|
|
407
|
+
`xcrun simctl privacy` was the only workaround. MCP exposure (as
|
|
408
|
+
`explore_step` actions) lands in a follow-up.
|
|
368
409
|
|
|
369
410
|
## [0.3.0] — 2026-05-15
|
|
370
411
|
|
|
@@ -435,7 +476,7 @@ unchanged.
|
|
|
435
476
|
|
|
436
477
|
- **Simulator runtime disambiguation.** When multiple simulators share
|
|
437
478
|
a name across iOS versions, pin to one via `SIM_A_NAME=<name> @
|
|
438
|
-
|
|
479
|
+
<runtime>` (e.g. `SIM_A_NAME=iPhone 16 @ iOS 17.5`). The runtime
|
|
439
480
|
part is matched as a substring of the friendly runtime name, so
|
|
440
481
|
partial values like `iOS 17` work too. The `Sim "X" not found` error
|
|
441
482
|
now lists all available sims with their runtime.
|
|
@@ -468,7 +509,7 @@ unchanged.
|
|
|
468
509
|
persist.
|
|
469
510
|
- **Pre-launch precondition check.** `WdaDriver.getSession()` now
|
|
470
511
|
verifies `APP_BUNDLE_ID` is installed on the target sim via `simctl
|
|
471
|
-
|
|
512
|
+
get_app_container` before starting the WDA session. If missing, throws
|
|
472
513
|
a clear actionable error pointing at `unotest-mobile install`.
|
|
473
514
|
- `SimctlAdapter.isInstalled(udid, bundleId)` and
|
|
474
515
|
`SimctlAdapter.erase(udid)` — exposed on the adapter; ios-utils gains
|
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ npx @unotest/mobile@latest e2e <test-name>
|
|
|
90
90
|
```
|
|
91
91
|
|
|
92
92
|
That's it. You never edit DSL by hand — the agent writes the scenarios,
|
|
93
|
-
you review the resulting `.js` files in `unotest/e2e/`.
|
|
93
|
+
you review the resulting `.js` files in `unotest/e2e-mobile/`.
|
|
94
94
|
|
|
95
95
|
## How it works
|
|
96
96
|
|
|
@@ -116,7 +116,7 @@ you review the resulting `.js` files in `unotest/e2e/`.
|
|
|
116
116
|
returns a structured tree (roles, testID, text, bounds); we render it
|
|
117
117
|
as a token-cheap text outline. Works on RN/Expo and on plain Swift
|
|
118
118
|
apps too, as long as they expose accessibility.
|
|
119
|
-
- **Tests are plain `.js` files in your repo.** `unotest/e2e/*.js`.
|
|
119
|
+
- **Tests are plain `.js` files in your repo.** `unotest/e2e-mobile/*.js`.
|
|
120
120
|
Goes into git, into code review, into CI. No proprietary format, no
|
|
121
121
|
binary blob.
|
|
122
122
|
- **The DSL is a sandboxed JS subset.** Scenarios run in a vendored AST
|
|
@@ -150,8 +150,9 @@ agent already knows; you don't wire anything.
|
|
|
150
150
|
| `npx @unotest/mobile@latest install <path>` | Install a `.app` on the configured sim; auto-detect bundle ID + permissions; `--update-env` persists. |
|
|
151
151
|
| `npx @unotest/mobile@latest init` | Just bootstrap `unotest/` + `.mcp.json` without installing an app. |
|
|
152
152
|
| `npx @unotest/mobile@latest doctor` | Re-check the environment (Xcode, sim, Node, WDA cache). |
|
|
153
|
-
| `npx @unotest/mobile@latest e2e <name>` | Run `unotest/e2e/<name>.js`. |
|
|
153
|
+
| `npx @unotest/mobile@latest e2e <name>` | Run `unotest/e2e-mobile/<name>.js`. |
|
|
154
154
|
| `npx @unotest/mobile@latest lint` | Static check of all scenarios + helpers. |
|
|
155
|
+
| `npx @unotest/mobile@latest viewer` | Start the local results viewer (HTTP+WS) with mobile as the active target; opens in your browser. |
|
|
155
156
|
| `npx @unotest/mobile@latest` (no args) | Run as MCP stdio server (this is what Claude Code launches). |
|
|
156
157
|
|
|
157
158
|
## License
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// unotest-mobile doctor → re-run environment checks
|
|
7
7
|
// unotest-mobile e2e <name> → run scenario unotest/e2e/<name>.js
|
|
8
8
|
// unotest-mobile lint → lint scenarios + helpers
|
|
9
|
+
// unotest-mobile viewer → start the local results viewer (HTTP+WS)
|
|
9
10
|
//
|
|
10
11
|
// All entries are compiled by tsup to dist/ before publish; this dispatcher
|
|
11
12
|
// just resolves the right one and execs it via plain node. No tsx at
|
|
@@ -41,6 +42,8 @@ if (sub === "--help" || sub === "-h") {
|
|
|
41
42
|
` install <path> Install a .app bundle on the configured simulator slot(s).\n` +
|
|
42
43
|
` e2e <name> Run scenario unotest/e2e/<name>.js.\n` +
|
|
43
44
|
` lint Static check of all scenarios + helpers.\n` +
|
|
45
|
+
` viewer Start the local results viewer (HTTP+WS server).\n` +
|
|
46
|
+
` app-server Boot SIM_POOL sims + install APP_PATH; hold until Ctrl-C.\n` +
|
|
44
47
|
` (no command) Run as MCP stdio server.\n\n` +
|
|
45
48
|
`Flags:\n` +
|
|
46
49
|
` --version, -v Print package version.\n` +
|
|
@@ -70,6 +73,12 @@ function dispatch(sub) {
|
|
|
70
73
|
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: ["lint"] };
|
|
71
74
|
case "e2e":
|
|
72
75
|
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: args.slice(1) };
|
|
76
|
+
case "viewer":
|
|
77
|
+
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: ["viewer"] };
|
|
78
|
+
case "app-server":
|
|
79
|
+
// Device holder (M-16): boot SIM_POOL + install APP_PATH, hold
|
|
80
|
+
// until SIGTERM. Spawned by the viewer's App panel.
|
|
81
|
+
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: ["app-server"] };
|
|
73
82
|
default:
|
|
74
83
|
// No-arg invocation → MCP server (current claude mcp add behavior).
|
|
75
84
|
// Unknown subcommands also land here — print a hint to stderr so
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { IDslLanguageService, DslVocabEntry, DslValidateContext, DslDiagnostic } from '@unotest/protocol';
|
|
2
|
+
|
|
3
|
+
declare class MobileDslLanguageService implements IDslLanguageService {
|
|
4
|
+
private readonly vocab;
|
|
5
|
+
private readonly registry;
|
|
6
|
+
constructor();
|
|
7
|
+
getVocab(): DslVocabEntry[];
|
|
8
|
+
validate(source: string, ctx?: DslValidateContext): DslDiagnostic[];
|
|
9
|
+
}
|
|
10
|
+
declare const mobileDslLanguageService: MobileDslLanguageService;
|
|
11
|
+
|
|
12
|
+
export { MobileDslLanguageService, mobileDslLanguageService as default, mobileDslLanguageService };
|