@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/.claude/skills/write-e2e-test.md +373 -261
- package/CHANGELOG.md +468 -0
- package/README.md +11 -4
- package/bin/mcp.js +66 -8
- package/dist/mcp/server.js +2262 -408
- package/dist/runner/cli.js +1237 -181
- package/dist/runner/doctor.js +11 -12
- package/dist/runner/init.js +114 -59
- package/dist/runner/install.js +1967 -0
- package/package.json +10 -3
- package/dist/mcp/server.js.map +0 -1
- package/dist/runner/cli.js.map +0 -1
- package/dist/runner/doctor.js.map +0 -1
- package/dist/runner/init.js.map +0 -1
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
|
-
**
|
|
81
|
-
`devices_list`, `screenshot`, `a11y_tree`, `resolve_selector`,
|
|
82
|
-
`
|
|
83
|
-
|
|
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:
|
|
64
|
+
return { entry: `${baseDir}/runner/init${ext}`, forwardArgs: args.slice(1) };
|
|
29
65
|
case "doctor":
|
|
30
|
-
return { entry:
|
|
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:
|
|
70
|
+
return { entry: `${baseDir}/runner/cli${ext}`, forwardArgs: ["lint"] };
|
|
33
71
|
case "e2e":
|
|
34
|
-
return { entry:
|
|
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
|
-
|
|
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
|
-
|
|
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
|