@unotest/mobile 0.1.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -4,6 +4,474 @@ 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.8.0] — 2026-05-18
8
+
9
+ ### Fixed — WdaDriver auto-recovers from stale WDA sessions (B6)
10
+
11
+ - **What broke.** The MCP server cached a `sessionId` per slot. Anything
12
+ killing the underlying XCTest runner — `simctl erase`, app crash,
13
+ WDA process killed — invalidated that id, but the server kept using
14
+ it. Result: every WDA call returned `HTTP 404 "no such session"` and
15
+ the agent had no recovery path; the only fix was a manual MCP
16
+ reconnect. Observed live during a `signout-with-alert` eval re-run
17
+ immediately after a full suite (10-min timeout, ~$1 burned).
18
+ - **Fix.** `WdaHttpClient.request()` now detects the three flavours of
19
+ WDA's session-gone signal (`no such session`, `Could not find session`,
20
+ `invalid session id`) and throws a typed `WdaSessionGoneError`.
21
+ `WdaDriver` wraps every session-using public method in a
22
+ `withFreshSession(slot, fn)` helper that catches the typed error,
23
+ drops the cached session, recreates it via the normal factory, and
24
+ retries the call **once**. Persistent failures bubble up unchanged
25
+ (no infinite loop). Invisible to user code — only a `wda:<slot>` log
26
+ warning marks the recovery.
27
+ - Methods wrapped: `tap`, `type`, `swipe`, `pressKey`, `screenshot`,
28
+ `a11yTree`, `windowSize`, `acceptAlert`, `dismissAlert`, `readAlert`,
29
+ `readAlertButtons`. Inner helpers (`resolveBounds`,
30
+ `resolveElementId`) ride their outer caller's retry — no nested
31
+ wrapping needed.
32
+ - Tests: +6 (4× HTTP layer session-gone classification covering all
33
+ three message flavours plus a negative case proving alert-404 isn't
34
+ mis-classified as session-gone; 2× driver-level retry path covering
35
+ the recover-and-succeed case and the persistent-failure-bounded-retry
36
+ case — the second guards against the obvious "what if retry also
37
+ fails" infinite-loop regression). 561 → 567 total.
38
+
39
+ ### Added — `install` auto-detects + pre-grants iOS permissions, pins keyboard (P4 / S4 + S8)
40
+
41
+ - **Permission detection from Info.plist.** `unotest-mobile install`
42
+ (CLI + MCP `app_install`) parses the .app's `NS*UsageDescription`
43
+ keys via `plutil -convert json`, maps them to
44
+ `simctl privacy <service>` names, and surfaces the list as
45
+ `result.detectedPermissions`.
46
+ - **Pre-grant on install.** When the resolved permissions list is
47
+ non-empty, every service is granted with `simctl privacy <udid> grant
48
+ <service> <bundleId>` immediately after install. Pre-empts SpringBoard
49
+ permission dialogs (location, motion, photos, …) that would otherwise
50
+ block scenarios on first launch — those dialogs live in SpringBoard,
51
+ not in the app's a11y tree.
52
+ - **Keyboard pin.** `install` always pins the sim's software keyboard
53
+ to `en_US@QWERTY` via three `defaults write -g` calls. WDA's
54
+ `typeText` routes through whatever layout the sim was last using;
55
+ a leftover Cyrillic layout silently drops Latin characters
56
+ (`Qwerty34##` → `34`). Pin makes typing deterministic.
57
+ - **Single grant owner.** `installApp(opts.permissions, opts.pinKeyboard)`
58
+ is the only call site for both `simctl.privacyGrant` and
59
+ `simctl.pinEnglishKeyboard`. CLI and MCP layers just compute the
60
+ permissions list (CLI flag / `APP_PERMISSIONS` env / interactive
61
+ prompt / MCP `updateEnv` auto-confirm) and pass it through. No more
62
+ double-grants from CLI + MCP racing each other on `app_install`.
63
+ - **New CLI flags.**
64
+ - `--permissions=<list>` — explicit comma-separated services for this
65
+ run (e.g. `--permissions=location,motion`). Empty value
66
+ (`--permissions=`) = grant nothing. Wins over `APP_PERMISSIONS`.
67
+ - `--no-permissions` — explicit opt-out even when `APP_PERMISSIONS`
68
+ is set. Conflicts with `--permissions=<list>` (parser throws).
69
+ - **`--update-env` extended.** Now persists `APP_PERMISSIONS` (when
70
+ newly resolved) alongside `APP_PATH`, `APP_BUNDLE_ID`,
71
+ `APP_URL_SCHEME`. Pre-passes the plist before install so detected
72
+ permissions land in **this** install (not just the next one) — the
73
+ prior gap where `install --update-env` only wrote `.env` but didn't
74
+ grant is closed.
75
+ - **New env var:** `APP_PERMISSIONS` (comma-separated list, optional).
76
+ Default empty. Set automatically by `install --update-env`.
77
+ - **Internal eval-harness cleanup.** The internal sim-setup helper
78
+ drops its standalone `grantPermissions` + `forceEnglishKeyboard`
79
+ routines (~70 lines) — both are now done by
80
+ `install --permissions=<kitchen-sink>`. Pregrant remains opt-in via
81
+ the existing `pregrantPermissions` option (default `true`).
82
+
83
+ ### Added — `a11y_tree` outline surfaces active iOS alerts (P2a)
84
+
85
+ - **New `alert:` section in `mode: "outline"`.** When a native
86
+ `UIAlertController` is on screen its title, body, and button labels
87
+ appear as the first section of the outline, plus a `_meta.alert_active:
88
+ true` flag. Previously the alert lived in SpringBoard (outside the
89
+ app's a11y tree) and the agent kept tapping the underlying button
90
+ through the modal — usually the wrong one, sometimes destructive.
91
+ - **Grammar contract.** The `alert:` section and `_meta.alert_active`
92
+ flag are cross-checked on parse: presence of one without the other is
93
+ a hard error, not a warning. Drift between them would silently put the
94
+ agent back in the "tap under the modal" failure mode.
95
+ - **Wiring.** New `WdaHttpClient.alertButtons()` wraps
96
+ `GET /wda/alert/buttons`; `AlertController.readAlertButtons(slot)`
97
+ returns `string[] | null` (null when no alert is present). The
98
+ `a11y_tree` tool probes `readAlert` + `readAlertButtons` before
99
+ rendering — when both come back empty it skips the section entirely,
100
+ so the outline stays unchanged for non-alert screens.
101
+ - Live-verified end-to-end: tap Sign Out → outline shows the
102
+ `Sign Out / Are you sure? / Cancel / Sign Out` alert → `dismiss_alert`
103
+ closes it, `accept_alert { button: "Sign Out" }` performs the real
104
+ logout. S13 (modal z-order) in the agent-blockers catalog is now
105
+ marked **partially fixed** — the native `UIAlertController` case is
106
+ closed; RN `<Modal>` remains open.
107
+
108
+ ### Fixed — three exploration → DSL bugs found in live acceptance (P2 follow-up)
109
+
110
+ Live acceptance on a real app exposed three bugs the unit tests didn't
111
+ cover. Each had its own root cause and its own fix:
112
+
113
+ - **`//@collapse "Setup"` broke the vendor parser.** The generator
114
+ emitted bare-string-arg form, but vendor's `metaBlock()` expects
115
+ `//@collapse("Setup")` (paren'd call form). DSL view now emits the
116
+ paren'd form so the generated test parses.
117
+ - **Generated tests didn't emit `setDevice("<slot>")`.** Without it the
118
+ runtime had no device selected and the first action threw "No device
119
+ selected". `DslViewService` now emits `setDevice(...)` as the first
120
+ line of the test body, derived from the exploration's slot.
121
+ - **`AstExecutor` threw `UnsupportedAstNodeError "MetaBlockStatement"`.**
122
+ Meta blocks are a vendor AST node we need to round-trip transparently
123
+ (for future Blockly export). Added an `instanceof MetaBlockStatement`
124
+ branch that passes the body through without altering execution
125
+ semantics.
126
+
127
+ After the fixes, `run_test` on a freshly-generated exploration returned
128
+ `outcome: "completed"` for the first time end-to-end.
129
+
130
+ ### Fixed — `clean` now wipes the simulator keychain (B5)
131
+
132
+ - **`appLaunch(clean: true)` and `app_install { clean: true }` now run
133
+ `simctl keychain <udid> reset`** between terminate/uninstall and the
134
+ next launch. Without this, auth tokens stored in the iOS Keychain
135
+ survived `simctl uninstall` (kSecAttrAccessibleWhenUnlocked without
136
+ bundle scoping), so the next launch silently landed on a logged-in
137
+ screen — defeating the "fresh state" intent of `clean: true`.
138
+ - Behavior of the **default** `appLaunch()` / `app_install` (no `clean`)
139
+ is unchanged — keychain reset only fires when the caller explicitly
140
+ opts into a fresh state.
141
+ - Keychain reset is invoked **unconditionally** in the `app_install
142
+ { clean: true }` flow even when `simctl uninstall` reports "app not
143
+ installed" — prior-run tokens may still linger, that's the whole
144
+ reason for B5.
145
+ - MCP descriptions for `app_install`'s `clean` parameter and
146
+ `explore_step { action: "app_launch", clean: true }` updated.
147
+ - The previously-required `xcrun simctl keychain booted reset` bash
148
+ workaround in `.claude/skills/write-e2e-test.md` (phase 5) is
149
+ removed; `app_install { clean: true }` now suffices.
150
+
151
+ ### Changed — MCP action surface unified into `explore_step` (BREAKING)
152
+
153
+ - **`tap`, `type`, `swipe`, `press_key`, `wait_for`, `app_launch`,
154
+ `open_deeplink` MCP tools are removed.** Every UI action goes through
155
+ one tool — `explore_step { action: "tap" | "type" | … }`. The single
156
+ rule: passing `explorationId` records the call into an exploration
157
+ session; omitting it runs the action ad-hoc. There is no `record:
158
+ false` override.
159
+ - **Alerts (`accept_alert`, `dismiss_alert`) appear here for the first
160
+ time as `explore_step` actions** — not as separate tools. The
161
+ Driver+DSL alert layer landed in P0; this release wires the MCP
162
+ entry point.
163
+ - **New tools** — `explore_start`, `explore_stop`, `explore_state`,
164
+ `explore_step`, `explore_record`, `explore_remove_step`,
165
+ `generate_dsl_from_exploration`, `save_exploration_as_test`. The
166
+ `write-e2e-test` Claude Code skill covers the full surface.
167
+ - **Recording-time reject:** `wait_for { optional: true }` cannot be
168
+ recorded — DSL `waitFor` has no optional semantics, so the
169
+ generated test would diverge. Use it ad-hoc.
170
+ - **Recording-time warning:** `app_launch { bundleId }` is accepted
171
+ but `generate_dsl_from_exploration` emits `BUNDLE_ID_IGNORED` —
172
+ DSL `appLaunch()` reads the bundle from `APP_BUNDLE_ID` env.
173
+ - **New env var:** `EXPLORATIONS_DIR` (default
174
+ `${ARTIFACTS_DIR}/explorations`). Append-only JSONL per session,
175
+ rescanned on MCP server restart to recover from crashes. Default
176
+ path is covered by the init template's `unotest/.gitignore`.
177
+
178
+ Migration: any consumer that previously called `tap` / `type` / `swipe`
179
+ / `press_key` / `wait_for` / `app_launch` / `open_deeplink` directly
180
+ through MCP must switch to `explore_step { action: <verb>, … }`. The
181
+ JS-DSL functions of the same names (`tap()`, `type()`, …) are
182
+ unchanged.
183
+
184
+ ### Changed — `a11y_tree` MCP tool returns a compact outline by default (BREAKING)
185
+
186
+ - **New default `mode: "outline"`** — line-per-node text format with
187
+ hierarchy via indent + `on_screen` / `off_screen` partition against
188
+ the viewport. ~90% token reduction on typical mobile screens vs the
189
+ old compact JSON (measured on two captured UnoPeak screens: 4467 →
190
+ 393 tokens, 4421 → 428 tokens; plan target was 60-75%, actual ~90%).
191
+ Grammar reference and full examples live in the
192
+ `write-e2e-test` Claude Code skill (phase 2).
193
+ - **`mode: "compact"` REMOVED.** The pre-P1 compacted-JSON shape no
194
+ longer ships through MCP. Callers must use `mode: "outline"` (default)
195
+ or `mode: "full"` (raw JSON tree with bounds — escape hatch for
196
+ debugging / programmatic consumption).
197
+ - **Off-screen partition** — nodes beyond viewport land in
198
+ `off_screen.{top,bottom,left,right}` as flat lists with stripped
199
+ identifiers; they're a HINT for the agent (which direction to scroll),
200
+ NOT a selector source. Duplicate-testId `@N` indexing is applied to
201
+ `on_screen` only (it'd be unstable across scrolls in `off_screen`).
202
+ - **`clipped: side` flag** on `on_screen` entries that straddle the
203
+ viewport edge (e.g. a header sliding under the status bar).
204
+
205
+ ### Changed — `A11yNode.role` is normalized to short lowercase form (BREAKING, internal)
206
+
207
+ - The WDA tree parser now strips the `XCUIElementType` prefix and
208
+ lowercases — `"XCUIElementTypeButton"` becomes `"button"`. Affects
209
+ any code that reads `A11yNode.role`:
210
+ selectors that matched on the full XCUI type string would silently
211
+ miss after this change. Internal-only — DSL surface unchanged.
212
+ Inspection layer (`isInteractiveRole`, `dedupeFields`, outline
213
+ renderer) consumes the normalized form via an exact-match Set.
214
+
215
+ ### Added — `InspectionDriver.windowSize(slot)`
216
+
217
+ - Required by the new `treeInspector.semanticTree(raw, viewport)` flow
218
+ to partition nodes against viewport bounds. `WdaDriver` exposes it
219
+ via `/session/{id}/window/size` (same endpoint `swipe()` already used
220
+ internally; promoted to a public method).
221
+
222
+ ### Added — DSL linter: E5 (arity) + E6 (arg-type mismatch)
223
+
224
+ - Linter now reads `argTypes` from FunctionRegistry and validates call
225
+ sites statically. E5 catches `tap()` (missing required arg) and
226
+ `tap(a, b)` (too many). E6 catches `swipe(getByTestId(...), "up")`
227
+ (selector where string expected) and similar reversed-args / wrong-
228
+ shape bugs before runtime. E6 only flags UNAMBIGUOUS shapes (literals,
229
+ built-in calls with known returnType) — variables, user-helper calls,
230
+ BinaryExpression are skipped to avoid false positives.
231
+ - `DslFunction.variadic?: boolean` added; `dbQuery`/`dbExec`/`shell`
232
+ marked variadic so trailing SQL params / shell argv don't trigger E5.
233
+
234
+ ### Fixed — `bin/mcp.js` MCP-server entry hardcoded to `dist/`
235
+
236
+ - Default-case (no subcommand) routed to `dist/mcp/server.js` unconditionally,
237
+ ignoring `UNOTEST_DEV`. Devs running with `UNOTEST_DEV=1` were silently
238
+ served the stale dist build, breaking iterative development. Now uses
239
+ the same `${baseDir}/mcp/server${ext}` pattern as other subcommands.
240
+
241
+ ### Added — native iOS alert handling
242
+
243
+ - **Driver + DSL surface for native `UIAlertController` dialogs** —
244
+ permission prompts, ATT, Sign Out confirmations, iOS update banners.
245
+ Wraps WDA `/session/{id}/alert/{accept,dismiss,text}` endpoints, which
246
+ talk to SpringBoard's alert hierarchy instead of the app's a11y tree.
247
+ New DSL functions usable inside scenarios:
248
+ - `acceptAlert("label")` — tap a button by label (`"Allow Once"`,
249
+ `"Sign Out"`). **Recommended form** — label is the only path stable
250
+ across alert kinds and locales.
251
+ - `acceptAlert()` — no label. Falls back to WDA's position-based rule,
252
+ which is kind-dependent (UIAlertController: last button; action
253
+ sheet: first button — see FBAlert.m). Use only for single-button
254
+ modals or where the affirmative button has no stable label.
255
+ - `dismissAlert()` — position-based fallback for cancel (first button
256
+ on UIAlertController, last on action sheet).
257
+ - `readAlert()` — return title + body of the active alert as a string,
258
+ or throw `NoAlertPresentError` if none is on screen.
259
+ Before this, scenarios had no way to dismiss SpringBoard alerts: the
260
+ resolver-driven `tap(getByTestId(...))` couldn't reach buttons that
261
+ live outside the app process — `ordinal` selectors appeared to match
262
+ but the tap landed on the wrong element. Pre-granting permissions via
263
+ `xcrun simctl privacy` was the only workaround. MCP exposure (as
264
+ `explore_step` actions) lands in a follow-up.
265
+
266
+ ## [0.3.0] — 2026-05-15
267
+
268
+ ### Fixed
269
+
270
+ - **MCP server no longer exits ~200ms after handshake.** `startMcpServer`
271
+ used to resolve once `server.connect(transport)` returned, which made
272
+ the outer wrapper call `process.exit(0)` and the client saw
273
+ `MCP error -32000: Connection closed`. The server now blocks on a
274
+ never-resolving promise; the only exits are via `transport.onclose`
275
+ or signal handlers, as intended. Affected anyone launching the server
276
+ via the no-arg `unotest-mobile` entry (`claude mcp add` flow).
277
+ - **`BaseTool.tracked()` classifies structured failures as errors.**
278
+ Tools that return `this.fail(...)` / `this.failJson(...)` without
279
+ throwing (e.g. `app_install` on missing path) used to be recorded as
280
+ `result: "ok"` in the session log. They are now recorded as
281
+ `result: "error"` with `error_class: "ReturnedError"`. Thrown
282
+ exceptions get `error_class` set to the exception class name.
283
+
284
+ ### Added — skill
285
+
286
+ - **`write-e2e-test` skill rewritten to discover-first / verify-by-running.**
287
+ Default mode walks the agent through interactive exploration
288
+ (`app_launch` → `a11y_tree` → `resolve_selector` → `tap`/`type`
289
+ per screen), then file write, then `run_test pauseOnFailure: true`,
290
+ then iterate until `next.outcome === "completed"`. A draft-only
291
+ fallback path triggers if MCP cannot reach a live app — the skill
292
+ marks the output as unverified and instructs the user to run
293
+ `npx unotest-mobile e2e <name>` later. Compared to the prior
294
+ write-only version: agent verifies the test against the live app
295
+ by default, no more "looks right, may not run" outputs.
296
+
297
+ ### Added — init / template
298
+
299
+ - **`init` seeds `unotest/e2e/_template/example.js`** — canonical
300
+ syntax reference for AI agents and humans. First line is a sentinel
301
+ banner ("unotest-mobile JS-DSL — NOT Node.js. No import/export/
302
+ async/await…") so an agent reading the file picks up the DSL
303
+ constraints immediately, without having to consult the skill for
304
+ every quirk. The previous `_template.js` (TODO-scaffolding) stays
305
+ for human "copy to start" usage.
306
+
307
+ ### Added — session log
308
+
309
+ - `SessionRecorder` now records `duration_ms`, `result_preview`
310
+ (truncated to 4 KB), `error_class`, and (under `SESSION_LOG_FULL=1`)
311
+ the full tool result. Schema additions are backward-compatible —
312
+ old entries still parse.
313
+ - New env switches:
314
+ - `SESSION_LOG_DISABLE=1` (or empty `SESSION_LOG_PATH`) wires a
315
+ `NoopSessionRecorder` — no on-disk log, useful for CI / evals.
316
+ - `SESSION_LOG_FULL=1` — keep the full tool result alongside the
317
+ truncated preview.
318
+
319
+ ### Changed — docs
320
+
321
+ - The consumer-facing manuals (quickstart, database-setup) no longer
322
+ hard-code `pnpm` in their examples. All consumer-side commands are
323
+ `npx unotest-mobile <cmd>` (works with npm / yarn / pnpm / bun
324
+ installs). The dev-only local-development manual keeps `pnpm` because
325
+ that's what contributors of this package use.
326
+ - CLAUDE.md gains a "package-manager-agnostic commands in public
327
+ artifacts" section codifying the above rule.
328
+
329
+ ## [0.2.1] — 2026-05-14
330
+
331
+ ### Added
332
+
333
+ - **Simulator runtime disambiguation.** When multiple simulators share
334
+ a name across iOS versions, pin to one via `SIM_A_NAME=<name> @
335
+ <runtime>` (e.g. `SIM_A_NAME=iPhone 16 @ iOS 17.5`). The runtime
336
+ part is matched as a substring of the friendly runtime name, so
337
+ partial values like `iOS 17` work too. The `Sim "X" not found` error
338
+ now lists all available sims with their runtime.
339
+
340
+ ### Fixed
341
+
342
+ - CLI fatal-error output is now a clean one-liner (`✗ <message>`) instead
343
+ of a Node stack trace. Applies to `unotest-mobile install`, `e2e`,
344
+ `lint`, and the MCP server entry. Set `UNOTEST_DEBUG=1` to opt back
345
+ into stack traces when diagnosing harness-internal bugs.
346
+
347
+ ## [0.2.0] — 2026-05-14
348
+
349
+ ### Added
350
+
351
+ - **`unotest-mobile install <path-to-.app>`** — new CLI subcommand to
352
+ install an iOS Simulator `.app` bundle on the configured slot(s). Reads
353
+ CFBundleIdentifier from Info.plist, warns on mismatch with
354
+ `APP_BUNDLE_ID` in `.env`, supports `--slot A|B|all` (default `A`),
355
+ `--clean` (uninstall existing), `--erase` (wipe sim — destructive),
356
+ `--launch` (sanity-check launch), `--update-env` (persist `APP_PATH`
357
+ and sync `APP_BUNDLE_ID`).
358
+ - **`APP_PATH`** env var. When set in `unotest/.env`, `install` (CLI and
359
+ MCP) can be invoked without an explicit path. Useful for repeated
360
+ installs after each rebuild.
361
+ - **`app_install` MCP tool** — same install logic as the CLI, exposed to
362
+ Claude Code / Desktop. With no args reads `APP_PATH`; otherwise returns
363
+ a structured `missing-app-path` error instructing the agent to ask the
364
+ user for the path, then call again with `path` + `updateEnv: true` to
365
+ persist.
366
+ - **Pre-launch precondition check.** `WdaDriver.getSession()` now
367
+ verifies `APP_BUNDLE_ID` is installed on the target sim via `simctl
368
+ get_app_container` before starting the WDA session. If missing, throws
369
+ a clear actionable error pointing at `unotest-mobile install`.
370
+ - `SimctlAdapter.isInstalled(udid, bundleId)` and
371
+ `SimctlAdapter.erase(udid)` — exposed on the adapter; ios-utils gains
372
+ matching `isAppInstalled` and `eraseSim`.
373
+
374
+ ### Changed
375
+
376
+ - The `write-e2e-test` Claude Code skill gained a `Setup` section
377
+ covering install workflow, `--erase` / `--launch` / `--update-env`
378
+ flags, and the `app_install` MCP tool. The `When tests fail` table
379
+ also covers the new error modes (`App ... is not installed`,
380
+ `appLaunch needs a bundle id`, missing `DATABASE_URL` / `API_BASE_URL`,
381
+ driver-not-installed peer-dep error).
382
+ - `unotest/.env.example` template adds a commented `APP_PATH=...` hint
383
+ next to `APP_BUNDLE_ID`.
384
+ - `CLAUDE.md` adds a "Skill discipline" rule: any change to public
385
+ surface (CLI subcommands, MCP tools, DSL functions, linter codes, env
386
+ vars) must update the skill in the same commit.
387
+ - The README inside the friend-test zip generated by
388
+ `unopeak/scripts/build-for-simulator.sh` now recommends
389
+ `npx @unotest/mobile install ./unopeak.app --launch --update-env` over
390
+ raw `simctl` commands.
391
+
392
+ ## [0.1.4] — 2026-05-14
393
+
394
+ ### Changed
395
+
396
+ - **Most env vars are now optional.** Previously every variable in
397
+ `unotest/.env` was required at startup, even for scenarios that never
398
+ used the corresponding feature. Now the schema requires nothing
399
+ upfront — each var is validated at its actual use site:
400
+ - `APP_BUNDLE_ID` → required when a scenario calls `appLaunch()` or
401
+ starts a WDA session. Clear error if missing.
402
+ - `API_BASE_URL` → required when a scenario calls `apiCall(...)`.
403
+ The ApiClient is constructed lazily; missing-env stub throws a clear
404
+ error on first use.
405
+ - `DATABASE_URL` → required when a scenario calls `dbQuery(...)` /
406
+ `dbExec(...)`. The DbClient is constructed lazily; missing-env stub
407
+ throws a clear error on first use.
408
+ - `SIM_A_NAME` / `SIM_B_NAME` → schema-optional. Pool-aware validation
409
+ in `loadEnv()` requires the names only for slots actually present in
410
+ `SIM_POOL`. Improved error message points at the fix.
411
+ - `APP_URL_SCHEME`, `METRO_URL` → optional, reserved for future Expo
412
+ dev-client recovery flow (not yet wired).
413
+ - The generated `unotest/.env.example` template reflects the new
414
+ optionality — only `SIM_A_NAME` / `SIM_POOL` and `WDA_PORTS` are
415
+ uncommented; everything else is shown as commented hints.
416
+
417
+ ### Removed
418
+
419
+ - `INVITE_DEEPLINK_PREFIX` env var. It was required by the schema but
420
+ consumed nowhere in production code — a UnoPeak-specific leftover from
421
+ early MVP. Setting it now is a no-op (extra env vars are ignored).
422
+
423
+ ## [0.1.3] — 2026-05-14
424
+
425
+ ### Added
426
+
427
+ - `unotest-mobile --version` / `-v` prints the package version.
428
+ - `unotest-mobile --help` / `-h` prints a brief command summary.
429
+
430
+ ### Fixed
431
+
432
+ - Meta flags (`--version`, `--help`) and unknown subcommands no longer
433
+ silently start the MCP server (which then crashed on missing
434
+ `unotest/.env`). Meta flags handled in the CLI dispatcher before any
435
+ environment load; unknown subcommands print a hint to stderr.
436
+
437
+ ## [0.1.2] — 2026-05-14
438
+
439
+ ### Fixed
440
+
441
+ - Default `SESSION_LOG_PATH` and `ARTIFACTS_DIR` are now under `unotest/`
442
+ (`unotest/sessions/current.jsonl`, `unotest/artifacts`). Previously they
443
+ defaulted to bare `sessions/` and `artifacts/` at the consumer's project
444
+ root, which contradicted the `.gitignore` rules `init` writes
445
+ (`unotest/sessions/`, `unotest/artifacts/`) and polluted the project root.
446
+ - DB driver "package not installed" error messages no longer hardcode
447
+ `pnpm add -D ...` — they now say `npm i -D ...` (with a note about your
448
+ package manager's equivalent).
449
+
450
+ ### Migration
451
+
452
+ If you have an existing `unotest/.env` from `0.1.0` / `0.1.1` and want to
453
+ adopt the new defaults, either delete the lines for `SESSION_LOG_PATH` and
454
+ `ARTIFACTS_DIR` (defaults will kick in) or update them explicitly:
455
+
456
+ ```
457
+ SESSION_LOG_PATH=unotest/sessions/current.jsonl
458
+ ARTIFACTS_DIR=unotest/artifacts
459
+ ```
460
+
461
+ Existing `sessions/` and `artifacts/` folders at the project root can be
462
+ moved into `unotest/` or deleted.
463
+
464
+ ## [0.1.1] — 2026-05-14
465
+
466
+ ### Changed
467
+
468
+ - `doctor` no longer warns when `package.json` lacks `react-native` / `expo`.
469
+ The harness drives iOS via WebDriverAgent, which works against any iOS app
470
+ (RN/Expo, native Swift/SwiftUI/Obj-C, Flutter exposing accessibility
471
+ semantics). The check is now an informational hint about the kind of
472
+ project detected, with guidance on `accessibilityIdentifier` for native
473
+ apps.
474
+
7
475
  ## [0.1.0] — 2026-05-14
8
476
 
9
477
  Initial public release.
package/README.md CHANGED
@@ -77,10 +77,17 @@ function test_smoke_welcome() {
77
77
 
78
78
  After `init`, Claude Code (or any MCP-aware client) gets:
79
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`.
80
+ **Discovery + lifecycle:**
81
+ `devices_list`, `screenshot`, `a11y_tree`, `resolve_selector`,
82
+ `app_install`, `session_reset`.
83
+
84
+ **Exploration recording (P2):**
85
+ `explore_start`, `explore_step`, `explore_record`, `explore_remove_step`,
86
+ `explore_state`, `explore_stop`, `generate_dsl_from_exploration`,
87
+ `save_exploration_as_test`. `explore_step` is the single execute-and-
88
+ optionally-record entry point for all UI actions (tap, type, press_key,
89
+ swipe, wait_for, app_launch, open_deeplink, accept_alert, dismiss_alert).
90
+ Pass `explorationId` to record into a session, omit it for ad-hoc.
84
91
 
85
92
  **Pause-on-failure debugger:**
86
93
  `run_test` (with `pauseOnFailure: true`), `step`, `resume`,
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