@unotest/mobile 0.1.1 → 0.8.1

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