@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.
- package/.claude/skills/write-e2e-test.md +373 -261
- package/CHANGELOG.md +457 -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 +0 -1
- package/dist/runner/init.js +103 -48
- 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
|
@@ -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
|
|
9
|
-
React Native app.
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
+
Two modes, picked at phase 1 based on environment:
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
simulator slot names, database URL, project CLI paths.
|
|
32
|
+
## Workflow — record then generate then verify
|
|
26
33
|
|
|
27
|
-
|
|
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
|
-
|
|
36
|
+
### Phase 0 — read context
|
|
32
37
|
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
45
|
-
setDevice("A");
|
|
46
|
-
appLaunch(true);
|
|
47
|
-
waitFor(getByTestId("screen-welcome"), 15000);
|
|
47
|
+
### Phase 1 — start the exploration
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
### Phase 2 — first 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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 5 — reset state, then run_test
|
|
208
204
|
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|