@unotest/mobile 0.1.1 → 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.
@@ -1,294 +1,406 @@
1
1
  ---
2
2
  name: write-e2e-test
3
- description: Write a new E2E test for the project under test using @unotest/mobile. Use when the user asks to write/cover/add a test for a feature, flow, or screen.
3
+ description: Write a new E2E test for the project under test using @unotest/mobile. Records actions through explore_step (execute + record in one call), then generates a DSL test and verifies it via run_test. Use when the user asks to write/cover/add a test for a feature, flow, or screen.
4
4
  ---
5
5
 
6
6
  # Skill: write-e2e-test
7
7
 
8
- You are writing an end-to-end test using `@unotest/mobile` against an iOS
9
- React Native app. Tests live in `unotest/e2e/` inside the **project under
10
- test** (not in the `@unotest/mobile` package itself).
8
+ You are writing an end-to-end test using `@unotest/mobile` against an
9
+ iOS React Native app. The test lives in `unotest/e2e/<name>.js` in the
10
+ project under test.
11
11
 
12
- This skill is the single source of truth — everything you need to write a
13
- correct test is below. Do not hunt for separate docs.
12
+ **Default mode is verify-against-live-app.** A test that hasn't been
13
+ run is unverified selectors may be invented, transitions may race,
14
+ the scenario may not actually pass. Producing a test you can't show
15
+ green is the failure case.
14
16
 
15
- ## Before you write
17
+ Two modes, picked at phase 1 based on environment:
16
18
 
17
- 1. **Survey existing helpers** in `unotest/e2e/_helpers/`. Don't reinvent
18
- `signin`, `seed_user_*`, `wipe_*` reuse what's there. If a helper
19
- you need doesn't exist, add it to `_helpers/<topic>.js` (not inline).
19
+ - **Interactive (preferred)** phases 1-5 below. The app is launchable
20
+ on a configured simulator, MCP tools (`explore_start`, `explore_step`,
21
+ `a11y_tree`, `resolve_selector`, `run_test`, …) are available. You
22
+ record the flow live, generate the DSL, save it, run it, iterate
23
+ until green.
20
24
 
21
- 2. **Survey existing scenarios** in `unotest/e2e/`. Match the style and
22
- reuse the same setup/seed patterns.
25
+ - **Draft-only (fallback)** if the first `explore_step { action:
26
+ "app_launch" }` fails or no sim is configured, you cannot verify.
27
+ Write the file from the description the user gave you, mark it as
28
+ unverified in your final reply ("⚠ unverified — no live app —
29
+ run `npx unotest-mobile e2e <name>` after fixing the env"), and STOP.
30
+ Don't pretend a draft is a working test.
23
31
 
24
- 3. **Read the env file** `unotest/.env`. It tells you the app's bundle id,
25
- simulator slot names, database URL, project CLI paths.
32
+ ## Workflow record then generate then verify
26
33
 
27
- 4. **Ask the user only what you can't infer:** which feature/flow to test,
28
- which user role if the app has roles, happy path, negative cases. Do
29
- not ask about conventions — they are below.
34
+ Five phases, in this order. Don't skip phases.
30
35
 
31
- ## Scenario shape
36
+ ### Phase 0 — read context
32
37
 
33
- Four phases per scenario Setup, Enter, Act, Assert.
38
+ `unotest/e2e/_template/example.js` shows the exact DSL syntax (bare
39
+ top-level functions, no `import`/`export`/`async`/`await`/`const`/`let`/
40
+ `var`/arrow-functions). Read it first. The DSL looks like JavaScript
41
+ but is a frozen subset — when your default JS instincts disagree with
42
+ the template, the template wins.
34
43
 
35
- ```js
36
- // id-<scenario-id>
37
- // <one-line human description>
38
- // #<6-digit hex color>
39
- function test_<entry_name>() {
40
- // 1. SETUP — DB / API / CLI fixtures
41
- wipe_e2e_users();
42
- user_id = seed_user("e2e-user@example.com", "e2e-pass-1234");
44
+ `unotest/.env` has `APP_BUNDLE_ID` and `APP_PATH` — you'll need both.
45
+ Read it.
43
46
 
44
- // 2. ENTERbring the app to a known initial UI state
45
- setDevice("A");
46
- appLaunch(true);
47
- waitFor(getByTestId("screen-welcome"), 15000);
47
+ ### Phase 1start the exploration
48
48
 
49
- // 3. ACT — the actions you're testing
50
- signin("e2e-user@example.com", "e2e-pass-1234");
51
- waitFor(getByTestId("screen-home"), 15000);
52
- tap(getByTestId("btn-create-item"));
53
- type(getByTestId("input-title"), "Hello");
54
- tap(getByTestId("btn-save"));
49
+ ```
50
+ explore_start { scenario_name: "<flow>", device: "A" }
51
+ ```
52
+
53
+ Returns `{ explorationId }`. Keep this id — every recorded step needs
54
+ it. Device slot ("A"/"B") is fixed for the session.
55
55
 
56
- // 4. ASSERTUI + data checks
57
- assertVisible(getByTestId("item-row-Hello"));
58
- count = dbQuery("SELECT count(*)::text FROM items WHERE user_id = $1", user_id);
59
- assertEqual(count, "1");
56
+ ### Phase 2first recorded step: launch the app
57
+
58
+ ```
59
+ explore_step {
60
+ explorationId,
61
+ action: "app_launch",
62
+ clean: true,
63
+ description: "Cold start",
64
+ section: "Setup"
60
65
  }
61
66
  ```
62
67
 
63
- The **3-line comment header** above `function` is mandatory for `test_*`
64
- and `flow_*` (linter rule E7). The order is fixed: id, description, hex
65
- color. The header is metadata used by the future Blockly visual editor;
66
- keep it stable.
67
-
68
- ## DSL — strict JS subset
69
-
70
- The DSL is **not** full JavaScript. The parser rejects what the
71
- visual-editor target (Blockly) can't render.
72
-
73
- **Allowed:**
74
- - Top-level `function name(args) { ... }` definitions
75
- - Bare-name assignment: `x = expr;` (no `var`/`let`/`const`)
76
- - `if`/`else`, blocks `{ ... }`
77
- - Function calls (statement or expression position)
78
- - Literals: number, string, `true` / `false` / `null`
79
- - Array literals `[a, b, c]` (used for variadic args)
80
- - Arithmetic: `+ - * /`
81
- - Comparisons: `== != < <= > >=`
82
- - Logical: `&& ||`
83
- - String concatenation: `"a" + "b"`
84
-
85
- **Forbidden** (linter will reject):
86
- - `var` / `let` / `const`
87
- - `for` / `while` (`maxSteps` budget protects from infinite loops)
88
- - Object literals `{a: 1}` — pass payloads as JSON-as-string instead
89
- - Member access `obj.field` — primitives only
90
- - Index access `arr[i]`
91
- - Unary `!` / `-` (use `0 - n` for negative numbers)
92
- - `===` / `!==` (use `==` / `!=`)
93
- - `+=` / `-=` / `++` / `--`
94
- - Arrow functions, classes, destructuring
95
- - Nested function definitions
96
-
97
- For structured payloads, pass JSON as a string:
98
- ```js
99
- apiCall("POST", "/api/users", '{"email":"x@y.z","name":"X"}');
68
+ `clean: true` does terminate + launch (NOT a full wipe — login state
69
+ may persist; phase 4 handles that). The first step **must** be the
70
+ bootstrap without `app_launch` (or `open_deeplink`) the generated
71
+ test has no entry point. Wait for the launch to settle before
72
+ discovering.
73
+
74
+ ### Phase 3 — discover, then act (loop)
75
+
76
+ For each screen you traverse:
77
+
78
+ 1. **Discover** — `a11y_tree { device: "A" }`. Default `mode: "outline"`
79
+ returns a compact text format partitioned into `on_screen` /
80
+ `off_screen`. Reading it:
81
+
82
+ ```
83
+ on_screen:
84
+ - screen #screen-welcome
85
+ - button "Sign In" #btn-signin
86
+ - "Welcome back!" {clipped: top}
87
+ off_screen:
88
+ bottom:
89
+ - "Settings"
90
+ - button "Sign Out"
91
+ _meta:
92
+ totalNodes: 142 onScreen: 18 offScreen: 23
93
+ viewport: 393x852
94
+ mode: outline
95
+ ```
96
+
97
+ Line grammar: `- [role] ["name"] [#testId] [{clipped: side}]`.
98
+ `#testId` is the stable selector prefer it over text matching.
99
+ `{clipped: top}` means the node straddles the top viewport edge.
100
+
101
+ **`off_screen` is a HINT, not a selector source.** Items there
102
+ exist in the tree but are past the viewport — `explore_step
103
+ { action: "swipe", direction: "up", ... }` (ad-hoc — omit
104
+ `explorationId` if you're just exploring) then re-call `a11y_tree`.
105
+
106
+ Use `mode: "full"` only when you need raw bounds for debugging.
107
+
108
+ 2. **Verify** the selector before using it:
109
+ ```
110
+ resolve_selector { device: "A", selector: { testId: "btn-signin" } }
111
+ ```
112
+ `{ status: "found" }` → use it. `{ status: "miss", candidates: [...] }`
113
+ → read the top-3 candidates, pick the closest, retry. Don't guess.
114
+
115
+ 3. **Act + record** — every UI action goes through `explore_step`.
116
+ Passing `explorationId` records the step into the session log.
117
+ `section` and `description` are required when recording (they
118
+ shape the generated DSL into readable `//@collapse` blocks):
119
+
120
+ ```
121
+ explore_step {
122
+ explorationId,
123
+ action: "tap",
124
+ selector: { testId: "btn-signin" },
125
+ description: "Open signin",
126
+ section: "Sign in flow"
127
+ }
128
+ explore_step {
129
+ explorationId,
130
+ action: "type",
131
+ selector: { testId: "input-email" },
132
+ value: "petr@volkov.io",
133
+ description: "Email",
134
+ section: "Sign in flow"
135
+ }
136
+ ```
137
+
138
+ Group related steps under one `section`. Adjacent same-section
139
+ entries collapse into one block. Sections like `"Setup"`,
140
+ `"Sign in flow"`, `"Verify dashboard"` work well.
141
+
142
+ **Ad-hoc probes** — to try a tap or swipe without polluting the
143
+ recording, **omit `explorationId`** (you must then pass `device`
144
+ explicitly). Discovery tools (`a11y_tree`, `resolve_selector`,
145
+ `screenshot`) never record either way.
146
+
147
+ **Recording-time reject:** `explore_step { action: "wait_for",
148
+ optional: true, explorationId, ... }` is refused — DSL `waitFor`
149
+ has no optional semantics; the generated test would diverge.
150
+ Run it ad-hoc instead.
151
+
152
+ 4. **Re-discover** after every action that changes the screen.
153
+
154
+ 5. If you record a wrong step, **remove it** before continuing:
155
+ ```
156
+ explore_remove_step { explorationId, entryId: "<from explore_step result>" }
157
+ ```
158
+ To add an action you forgot (or an assertion), use
159
+ `explore_record { explorationId, action, ..., description, section }` —
160
+ same shape as `explore_step` but without executing.
161
+
162
+ Anti-pattern: building selectors by analogy from web testing instead
163
+ of from `a11y_tree`. Selectors invented this way are the #1 source of
164
+ `run_test` failures.
165
+
166
+ ### Phase 4 — stop, generate, save
167
+
168
+ ```
169
+ explore_stop { explorationId } → { stepCount, readyForConversion: true }
170
+ generate_dsl_from_exploration { explorationId } → { draftDsl, warnings }
100
171
  ```
101
172
 
102
- ## DSL functions
103
-
104
- ### Device
105
-
106
- | Function | Effect |
107
- |---|---|
108
- | `setDevice(slot)` | Switch the "current device" for subsequent UI calls. `slot` must be in `EnvConfig.simBySlot` (defined in `unotest/.env`, e.g. `"A"` or `"B"`). |
109
- | `appLaunch(clean?)` | Launch the app under test. `clean=true` terminate + relaunch from clean state. |
110
- | `openDeeplink(url)` | Open a URL on the current device. |
111
-
112
- ### Selectors (pure return Selector objects, no driver call)
113
-
114
- | Function | Returns |
115
- |---|---|
116
- | `getByTestId(s)` | `{ testId: s }` |
117
- | `getByText(s)` | `{ text: s }` |
118
- | `getByLabel(s)` | `{ label: s }` |
119
- | `ordinal(target, n)` | wraps a Selector with `ordinal: n` (0-indexed) |
120
- | `near(target, anchor, maxPx?)` | wraps a Selector with `near: { anchor, maxDistancePx? }` |
121
-
122
- `testId`, `text`, `label` are **equal-priority citizens** — there is no
123
- implicit fallback chain. Use `getByText` on custom buttons that lack a
124
- `testID`. iOS surfaces a React Native `testID` as the WDA `name` attribute.
125
-
126
- ### UI actions
127
-
128
- | Function | Behavior |
129
- |---|---|
130
- | `tap(selector)` | Auto-wait up to `defaultActionWaitMs`, then tap the element's center. |
131
- | `type(selector, text)` | Auto-wait, tap to focus, send keys. |
132
- | `swipe(direction, fromSel?)` | Direction is `"up"` / `"down"` / `"left"` / `"right"`. If `fromSel` is given, auto-wait + swipe from its center; else from screen center. |
133
- | `pressKey(key)` | `"home"` / `"enter"` / `"escape"`. iOS has no `back`. No selector → no auto-wait. |
134
- | `waitFor(selector, timeoutMs?)` | Explicit poll up to `defaultWaitForTimeoutMs`. |
135
- | `pause(ms)` | Sleep. Use sparingly; prefer `waitFor`. |
136
-
137
- ### Data
138
-
139
- | Function | Description |
140
- |---|---|
141
- | `dbQuery(sql, ...args)` | Parameterized SELECT through `DbClient` (pg / mysql / sqlite, picked by `DATABASE_URL`). Returns the **first cell of the first row** as a string. For multi-row results use multiple `dbQuery` calls. |
142
- | `dbExec(sql, ...args)` | Parameterized INSERT/UPDATE/DELETE. If the driver returned rows (e.g. `INSERT ... RETURNING` on pg or sqlite 3.35+), returns the first cell of the first row; otherwise `""`. Does not parse the SQL. |
143
- | `apiCall(method, path, body?, headers?)` | HTTP request via `ApiClient.call`. `body` is JSON-as-string. Returns raw response body. |
144
- | `shell(cmd, ...args)` | `execFile`-style spawn — args go straight to `child_process` argv with **no shell interpretation**. Throws on non-zero exit. Returns stdout. |
145
-
146
- Project-specific seeding (creating users, fixtures, etc.) lives in
147
- `unotest/e2e/_helpers/`. The core `@unotest/mobile` package does not know
148
- about your backend stack — you wire it via `shell` / `dbExec` / `apiCall`.
149
-
150
- ### Time
151
-
152
- | Function | Description |
153
- |---|---|
154
- | `today()` | ISO date `YYYY-MM-DD` (UTC) for the runtime. |
155
- | `daysFromNow(n)` | ISO date `n` days from today. For negative n use `daysFromNow(0 - n)` — the DSL has no unary minus. |
156
- | `nowMs()` | Unix timestamp in milliseconds (useful for unique suffixes). |
157
-
158
- ### Assertions
159
-
160
- | Function | Effect |
161
- |---|---|
162
- | `assertEqual(a, b)` | Throws if `a !== b`. |
163
- | `assertVisible(selector)` | Resolves the selector against the current screen; throws if no match. |
164
- | `assertCount(selector, n)` | Probes `ordinal: 0..N-1`; throws if the total count is not `n`. |
165
- | `assertEnabled(selector)` | Resolves against the **raw** a11y tree (not compact). Throws if `enabled !== true` or the field is missing. |
166
- | `assertDisabled(selector)` | Same, expects `enabled === false`. |
167
-
168
- ## Helpers
169
-
170
- - Live in `unotest/e2e/_helpers/*.js`. Globally visible to all scenarios.
171
- - `snake_case` function names (`seed_user`, `signin`, `wipe_e2e_users`).
172
- - `return` is **allowed** in helpers (forbidden in `test_*` / `flow_*`).
173
- - **Snapshot scoping**: helper mutations don't leak to caller variables.
174
- Pass information back via return values.
175
- - Recursion depth capped at `maxCallDepth` (default 32).
176
- - Cross-file: every file under `_helpers/` is auto-loaded for every
177
- scenario — convention, not magic.
178
-
179
- Extract to a helper when:
180
- - 5+ lines repeat across 2+ scenarios.
181
- - A piece of setup needs to be available to many tests.
182
-
183
- Don't extract prematurely — one-off code stays inline.
184
-
185
- Example:
186
-
187
- ```js
188
- // unotest/e2e/_helpers/seed.js
189
- function seed_user(email, password) {
190
- shell("npm", "--prefix", "./apps/api", "run", "cli", "--",
191
- "create-user", "--email", email, "--password", password);
192
- return dbQuery("SELECT id FROM users WHERE email = $1", email);
193
- }
173
+ Read the warnings before saving:
174
+
175
+ - **`FRAGILE_LOCATOR`** — selector lacks a stable identifier (only
176
+ `pointPercent`, or `ordinal` without a pinned `testId`/`text`/`label`).
177
+ Ideally add a `testId` in the app and re-record. If not possible,
178
+ accept and document.
179
+ - **`NO_DSL_PRIMITIVE`** selector shape has no DSL function (e.g.
180
+ `near`). The line is rendered as `// SKIPPED …`. Fix the entry:
181
+ `explore_remove_step` + `explore_record` with a stable selector.
182
+ (Or `save_exploration_as_test { force: true }` to keep the comment
183
+ inline only if a partial test is genuinely better than none.)
184
+ - **`BUNDLE_ID_IGNORED`** — `app_launch` had an explicit `bundleId`,
185
+ but DSL `appLaunch()` reads bundle from `APP_BUNDLE_ID` env.
186
+ Harmless; the generated `appLaunch(clean)` works the same.
187
+
188
+ Then persist:
189
+
190
+ ```
191
+ save_exploration_as_test { explorationId, scenarioName: "<name>" }
194
192
  ```
195
193
 
196
- ## Setup strategy
194
+ - Default behavior: any `NO_DSL_PRIMITIVE` blocks the save with an
195
+ actionable error. Fix the entries, or pass `force: true` to write
196
+ with `// SKIPPED` comments.
197
+ - File exists? Pass `overwrite: true`.
197
198
 
198
- Pick the highest viable option:
199
+ The 3-line scenario header (`// id-...`, `// <title>`, `// #<color>`)
200
+ is emitted automatically by the generator above the
201
+ `function test_<name>()` line.
199
202
 
200
- 1. **`shell()` to a project CLI** goes through real business logic
201
- (password hashing, hooks, side effects). Best fidelity.
202
- ```js
203
- shell("npm", "run", "cli", "--", "create-user", "--email", email);
204
- ```
205
- 2. **`apiCall()`** — when the CLI doesn't exist but the endpoint does.
206
- 3. **Direct `dbExec("INSERT INTO ...")`** — only when intentionally
207
- bypassing business logic. Rare; document why.
203
+ ### Phase 5reset state, then run_test
208
204
 
209
- For dates, use `today()` / `daysFromNow(n)`. **Never** hardcode a date
210
- like `"2026-05-12"` tests would silently rot.
205
+ You probably logged in / created data interactively. The test must
206
+ start from the same clean state your scenario assumes.
207
+ `appLaunch(clean: true)` alone is NOT enough — it terminates and
208
+ launches the app, but if you also need a fresh sandbox + uninstall,
209
+ use `app_install { clean: true }` (it wipes the keychain too — B5).
211
210
 
212
- ## Cleanup
211
+ Via MCP — reinstall the app to wipe its sandbox AND auth tokens:
212
+ ```
213
+ app_install { path: "<APP_PATH from unotest/.env>", clean: true }
214
+ ```
213
215
 
214
- Wipe **at the start of the scenario**, not at the end. Failed runs skip
215
- end-of-test cleanup; start-of-test cleanup always runs.
216
+ **Do NOT pass `erase: true`.** `erase: true` runs `simctl erase` which
217
+ wipes the entire simulator including WebDriverAgent. `clean: true`
218
+ alone is the right soft-reset — it now resets the keychain too, so
219
+ the legacy `xcrun simctl keychain booted reset` workaround is no
220
+ longer needed.
216
221
 
217
- ```js
218
- function test_X() {
219
- wipe_e2e_users(); // beginning always runs
220
- // ...
221
- }
222
+ Then verify:
223
+ ```
224
+ run_test { name: "<scenario-name>", pauseOnFailure: true }
225
+ ```
226
+
227
+ The response is JSON wrapped in `{ content: [{ type: "text", text: "<json>" }] }`:
228
+ ```
229
+ JSON.parse(response.content[0].text).next.outcome
230
+ ```
231
+
232
+ - `"completed"` → green, you're done.
233
+ - `"paused-failure"` → fix and `resume { runtimeId }` (or
234
+ `abort_runtime` + `run_test` again).
235
+ - Common causes: wrong initial state (re-run `app_install { clean: true }`),
236
+ wrong testId (re-discover, fix the .js file), timing
237
+ (add `waitFor(getByTestId(...), 15000)`).
238
+
239
+ Iterate until `outcome === "completed"`.
240
+
241
+ ## `explore_step` cheat-sheet
242
+
243
+ | `action` | required | optional |
244
+ |-----------------|---------------------------|------------------------------|
245
+ | `tap` | `selector` | |
246
+ | `type` | `selector`, `value` | |
247
+ | `press_key` | `key` | |
248
+ | `swipe` | `direction` | `from` (anchor selector) |
249
+ | `wait_for` | `selector` | `timeoutMs`, `optional`* |
250
+ | `app_launch` | | `bundleId`, `clean` |
251
+ | `open_deeplink` | `url` | |
252
+ | `accept_alert` | | `button` (label) |
253
+ | `dismiss_alert` | | |
254
+
255
+ \* When recording: `optional: true` is rejected — run ad-hoc instead.
256
+
257
+ When recording, also required: `description`, `section`. Ad-hoc
258
+ (no `explorationId`) requires `device` instead.
259
+
260
+ ## When a11y_tree shows an `alert:` section (P2a)
261
+
262
+ Outline starts with `alert:` → a native iOS alert is active
263
+ (UIAlertController, system permission prompt, ATT, Sign Out confirm, etc.).
264
+ **Do not tap by coordinates — native alerts live in SpringBoard, not in
265
+ the app a11y tree. A coord-tap goes through to the underlying app view
266
+ behind the alert and either does nothing or re-triggers the alert.**
267
+
268
+ Route through the explore_step alert actions:
269
+
270
+ ```
271
+ explore_step { explorationId, action: "accept_alert", button: "<label>", ... }
272
+ explore_step { explorationId, action: "dismiss_alert", ... }
273
+ ```
274
+
275
+ (Or DSL `acceptAlert("<label>")` / `dismissAlert()` when running the
276
+ generated test.)
277
+
278
+ Example outline:
279
+ ```
280
+ alert:
281
+ text: "Are you sure you want to sign out?"
282
+ buttons: [Sign Out, Cancel]
283
+ hint: use acceptAlert("<label>") or dismissAlert — direct tap won't reach SpringBoard
284
+ ```
285
+
286
+ Action by intent:
287
+ - sign-out confirm → `accept_alert { button: "Sign Out" }`
288
+ - location permission → `accept_alert { button: "Allow Once" }` or `"Don't Allow"`
289
+ - ATT prompt → `accept_alert { button: "Allow" }` or `"Ask App Not to Track"`
290
+ - cancel → `dismiss_alert` (or `accept_alert { button: "Cancel" }`)
291
+
292
+ The `buttons:` array is the authoritative source of labels. If `buttons:`
293
+ is missing (WDA endpoint failed) — infer from `text:` or fall back to
294
+ `dismiss_alert` (WDA finds Cancel/Dismiss on its own).
295
+
296
+ After accept/dismiss — always re-call `a11y_tree`. If the `alert:`
297
+ section is still present, handle the next one (permission prompts can
298
+ chain: TCC → confirm).
299
+
300
+ ## DSL cheat-sheet (in saved scenarios)
301
+
302
+ The generated test file uses these DSL functions — they have not
303
+ changed from the previous surface.
304
+
305
+ Device & lifecycle:
306
+ - `setDevice("A")` — required before any UI call; "A"/"B" slots.
307
+ - `appLaunch(clean?)` — boot the app. `clean=true` is terminate+launch.
308
+ - `openDeeplink(url)` — open `myapp://...`.
309
+
310
+ UI actions (selector arg always first):
311
+ - `tap(selector)`, `type(selector, "text")`
312
+ - `swipe("up"|"down"|"left"|"right", selector?)` — direction first; selector optional (defaults to whole screen).
313
+ - `pressKey("enter"|"back"|"space")`
314
+ - `waitFor(selector, timeoutMs)` — waits until present.
315
+ - `pause(ms)` — last resort; prefer `waitFor`.
316
+
317
+ Native iOS alerts (UIAlertController — permission prompts, ATT,
318
+ Sign Out confirmation, iOS update banners):
319
+ - `acceptAlert("Allow Once")` — tap a specific button by label.
320
+ **Always prefer this form** — the no-arg fallback is position-based
321
+ and kind-dependent.
322
+ - `dismissAlert()` — tap the cancel button (first button on a standard
323
+ modal alert, last button on an action sheet).
324
+ - `acceptAlert()` — no label. Position-based fallback: for a standard
325
+ UIAlertController modal it taps the LAST button (the affirmative one
326
+ like `OK` / `Allow` / `Sign Out`); for an action sheet it taps FIRST.
327
+ - `readAlert()` → string (title + body, newline-separated). Throws if
328
+ no alert is on screen.
329
+
330
+ **Use the alert API for any `UIAlertController`** — that includes
331
+ system SpringBoard prompts AND React Native `Alert.alert(...)` (the
332
+ iOS 26 redesigned alerts in particular break naive selector taps).
333
+ App-level `<Modal>` / BottomSheet / custom overlays with arbitrary
334
+ children are regular `tap()` territory.
335
+
336
+ Selectors:
337
+ - `getByTestId("id")`, `getByText("text")`, `getByLabel("a11y label")`
338
+ - `ordinal(selector, n)` — nth match (0-indexed). Don't rely on
339
+ `ordinal` for native alert buttons.
340
+ - `near(a, b)` — A closest to B.
341
+
342
+ Assertions:
343
+ - `assertVisible(selector)`, `assertCount(selector, n)`,
344
+ `assertEnabled(selector)`, `assertDisabled(selector)`,
345
+ `assertEqual(a, b)`.
346
+
347
+ Data primitives (when scenario needs them):
348
+ - `dbQuery("SELECT ... WHERE x = ?", arg)` → rows.
349
+ - `dbExec("INSERT INTO t ... VALUES (?, ?)", a, b)` → affected count.
350
+ - `apiCall("POST", "/path", "<json-string>")` → response.
351
+ - `shell("git", "status")` → `{stdout, stderr, exitCode}`.
352
+
353
+ Time: `today()`, `daysFromNow(n)`, `nowMs()`.
354
+
355
+ ## Helpers convention
356
+
357
+ Reusable steps go in `unotest/e2e/_helpers/<group>.js`. Helper naming
358
+ is **snake_case** (`signin_as`, `seed_workout`). **Before recording an
359
+ inline flow, check `_helpers/` for an existing helper** — `Glob` or
360
+ `Read`. If `signin_as(email, password)` exists, the generated test
361
+ should call it instead of re-recording four steps. (After
362
+ `generate_dsl_from_exploration`, edit the file to swap inline steps
363
+ for the helper call.)
364
+
365
+ ```
366
+ unotest/e2e/_helpers/auth.js:
367
+ function signin_as(email, password) { ... }
368
+ ```
369
+
370
+ Call without import — helpers are auto-discovered.
371
+
372
+ ## Anti-patterns
373
+
374
+ - **Don't record selectors you haven't verified via `resolve_selector`.**
375
+ `miss` → fix before recording the step.
376
+ - **Don't tap UIAlertController buttons with
377
+ `explore_step { action: "tap", selector: { text: "..." } }`.** Use
378
+ `explore_step { action: "accept_alert" | "dismiss_alert", button? }`.
379
+ - **Don't `app_install { erase: true }` mid-session.** Kills WDA.
380
+ - **Don't consider the task done without a green `run_test`.** "I
381
+ recorded the flow, generated the file, looks right" is not done.
382
+ `outcome === "completed"` is done.
383
+ - **Don't `await` anything in the saved scenario. No `import`/`export`/
384
+ `async`/arrow-functions/`const`/`let`/`var`/object-literals.** DSL,
385
+ not Node.
386
+ - **Don't omit `section` / `description` when recording.** They're how
387
+ the agent (and humans) will read the generated test later.
388
+
389
+ ## Fast feedback
390
+
391
+ After editing any scenario:
392
+ ```
393
+ npx unotest-mobile lint
222
394
  ```
223
395
 
224
- ## Authoring workflow
225
-
226
- 1. **Plan the test.** One-line description. Decide setup, actor, assertion.
227
- 2. **Pick a path:**
228
- - Single feature → `unotest/e2e/<feature>/<actor>-<verb>-<context>.js`
229
- - Cross-feature smoke / integration `unotest/e2e/<actor>-<verb>-<context>.js`
230
- 3. **Copy `unotest/e2e/_template.js`**, rename, adjust the 3-line header
231
- (id / description / color).
232
- 4. **Write setup** using existing helpers. If you need a new one, add to
233
- `_helpers/<topic>.js` first, then use it.
234
- 5. **Run via MCP `run_test` with `pauseOnFailure: true`:**
235
- - On pause: `inspect_runtime <id>` → see vars, last error.
236
- - Use `screenshot` + `a11y_tree` to understand UI state.
237
- - Use `resolve_selector` to find correct testIDs (returns the top-3
238
- near-misses with reasons when no exact match).
239
- - Fix the scenario, then `resume <id>` to retry the failed step.
240
- - Once green: `abort_runtime <id>` to teardown.
241
- 6. **Lint:** `npx @unotest/mobile lint` (also runs automatically on every
242
- `e2e <name>`).
243
-
244
- ## Linter codes
245
-
246
- | Code | Severity | Meaning |
247
- |---|---|---|
248
- | E1 | error | Function name not in `FunctionRegistry` and not declared as a user helper. |
249
- | E2 | error | AST node outside the MVP subset, or `return` in a `test_*` / `flow_*` body. |
250
- | E3 | error | `setDevice("X")` slot not in `EnvConfig.simBySlot`. |
251
- | E4 | error | JSON-string arg in `apiCall` doesn't parse. |
252
- | W5 | warn | An `assert*` call before any UI action — likely a typo. |
253
- | E7 | error | `test_*` / `flow_*` function missing the 3-line header (`// id-...`, `// description`, `// #color`) immediately above `function`. |
254
- | E8 | error | Duplicate user-function name, or a user function shadows a built-in. |
255
-
256
- ## Common antipatterns
257
-
258
- - **Hardcoding testIDs without verifying** them in the app. Use
259
- `resolve_selector` first to confirm the ID exists.
260
- - **Inlining 30 lines of signin** in every scenario — extract a `signin` helper.
261
- - **One mega-test** covering everything — split by actor / action so
262
- failures point at one thing.
263
- - **Tests depending on each other's state** — every scenario must be
264
- self-contained.
265
- - **Using `getByText("...")` when a `testID` exists** — `testID` is stable
266
- across locale changes; visible text is not.
267
- - **Hardcoding dates** — use `today()` / `daysFromNow(n)`.
268
- - **Object literals or member access** — DSL forbids them; use JSON
269
- strings and primitive returns.
270
-
271
- ## When the test fails to run
272
-
273
- | Symptom | Likely cause |
274
- |---|---|
275
- | `Invalid environment` | `unotest/.env` missing keys. Compare to `.env.example`. |
276
- | `ECONNREFUSED` from `dbQuery` | `DATABASE_URL` not reachable. For docker Postgres, ensure the host port is exposed (`ports: ["5432:5432"]`). |
277
- | `relation "..." does not exist` | DB migrations not run, or a helper references a table that doesn't exist in this project. |
278
- | `Sim "X" not found` | `SIM_A_NAME` / `SIM_B_NAME` in `.env` don't match `xcrun simctl list devices`. |
279
- | `Entry function "test_X" not found` | File basename doesn't match the function name. Convention: `unotest/e2e/<path>/foo-bar.js` → `function test_foo_bar()`. |
280
-
281
- If a selector resolves to the wrong element or times out: use
282
- `resolve_selector` (MCP) to see the top-3 near-misses with reasons.
283
-
284
- ## Output to user
285
-
286
- After writing tests:
287
-
288
- 1. **Summarize the scenarios you added** — file paths, one line each.
289
- 2. **List any new helpers added** to `_helpers/`.
290
- 3. **Call out app-side TODOs** — missing `testID`s, missing CLI
291
- subcommands, missing DB schema. Don't silently assume; surface them
292
- so the user can decide.
293
- 4. **Confirm `npx @unotest/mobile lint` passes.** If you ran `run_test`,
294
- report pass/fail per scenario.
396
+ Lint codes to recognize:
397
+ - E1/E2 — unknown function or unsupported statement (drift to plain JS).
398
+ - E5 wrong arg count for a built-in.
399
+ - E6 — wrong arg TYPE / order, e.g. `swipe(getByTestId("x"), "up")`
400
+ argTypes are `("up"|..., selector)`.
401
+ - E7 — missing 3-line header above `test_*`/`flow_*` (generator emits
402
+ it; only fires if you hand-edited).
403
+ - E8 helper used but not defined in `_helpers/`.
404
+
405
+ If lint flags `import`/`export`/`await`/`const` re-read the DSL
406
+ section above and rewrite.