simframe 0.5.0 → 0.6.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/README.md +128 -53
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +78 -4
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +32 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +24 -1
- package/native/simframed/Sources/SimframeCore/Element.swift +29 -1
- package/native/simframed/Sources/simframed/main.swift +137 -9
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +30 -0
- package/package.json +4 -2
- package/scripts/check-package.mjs +8 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +189 -20
- package/src/cli.js +272 -116
- package/src/control.js +1 -0
- package/src/fingerprint.js +33 -0
- package/src/graph.js +3 -1
- package/src/index.js +62 -4
- package/src/input.js +99 -0
- package/src/matching.js +72 -1
- package/src/mcp.js +384 -115
- package/src/refs.js +141 -0
- package/src/regions.js +203 -26
- package/src/screenmap.js +57 -16
- package/src/simctl.js +55 -2
- package/src/view.js +342 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simframe
|
|
3
|
+
description: Drive and inspect the iOS Simulator with eyes, hands and memory. Use for any task that involves running, testing, navigating or verifying an iOS app on a simulator — "does this screen look right", "tap through the signup flow", "why is this button not working", "is the list loading". Reads screens as text rather than screenshots, batches whole flows into one command, and verifies each step against what it did last time.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# simframe
|
|
7
|
+
|
|
8
|
+
A background daemon keeps the simulator's framebuffer warm, reads the screen
|
|
9
|
+
through the accessibility tree and on-device OCR, and remembers which action
|
|
10
|
+
leads from which screen to which. So the three things that make simulator work
|
|
11
|
+
expensive — waiting for screenshots, spending tokens on images, and re-deriving
|
|
12
|
+
the same screen every time — are already paid for.
|
|
13
|
+
|
|
14
|
+
## Read the screen as text, not as an image
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
simframe ui
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
iPhone 17 Pro · 402x874pt · screen a1b2c3d4 "Inbox" (known, 3 known exits)
|
|
22
|
+
nav-bar:
|
|
23
|
+
#1 button 24,64 Back
|
|
24
|
+
#2 text 201,64 Inbox
|
|
25
|
+
content:
|
|
26
|
+
#3 cell 201,140 Weekly digest
|
|
27
|
+
#4 cell 201,196 Payment received
|
|
28
|
+
tab-bar:
|
|
29
|
+
#5 text 62,835 Inbox
|
|
30
|
+
#6 text 201,835 Settings
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That is the whole screen: region, a number, type, tap point in points, label.
|
|
34
|
+
Measured against the same screen as an image: **~460 tokens of text versus
|
|
35
|
+
~1,600 for a correctly-handled image**, and 10–40× worse than that if the MCP
|
|
36
|
+
image path degrades to base64-as-text. The text also says what is *tappable*
|
|
37
|
+
and where, which an image does not.
|
|
38
|
+
|
|
39
|
+
The numbers are selectors. Whatever `ui` calls `#3`, you can tap as `#3`.
|
|
40
|
+
|
|
41
|
+
**Reach for an image only when the text genuinely cannot answer the question:**
|
|
42
|
+
visual layout, colour, spacing, an animation, or something neither the
|
|
43
|
+
accessibility tree nor OCR can see. Then `simframe frame --out=/tmp/s.png`, or
|
|
44
|
+
`sim_look` over MCP.
|
|
45
|
+
|
|
46
|
+
## Run the whole flow in one command
|
|
47
|
+
|
|
48
|
+
One command, not one per tap. Each step waits for the screen to settle against
|
|
49
|
+
a baseline captured *before* it, so steps cannot race the UI.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cat > /tmp/flow.json <<'JSON'
|
|
53
|
+
[{"tap": "Inbox tab"},
|
|
54
|
+
{"assert": {"value": "Weekly digest", "is": "visible"}},
|
|
55
|
+
{"tap": "#3"},
|
|
56
|
+
{"type": {"into": "Reply", "text": "on it"}},
|
|
57
|
+
{"scrollTo": "Send"},
|
|
58
|
+
{"tap": "Send"},
|
|
59
|
+
{"waitFor": {"value": "Sent", "timeoutMs": 5000}}]
|
|
60
|
+
JSON
|
|
61
|
+
simframe do /tmp/flow.json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Steps stop at the first failure and say which step and why. Measured: **a
|
|
65
|
+
10-step flow is one command, ~5 seconds, ~460 tokens, zero images.**
|
|
66
|
+
|
|
67
|
+
Steps — every place a control is named accepts a selector:
|
|
68
|
+
|
|
69
|
+
| Act | Check |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| `{"tap": "Save"}` · add `"index"` if a label is ambiguous | `{"assert": {"value": "Saved", "is": "visible"}}` |
|
|
72
|
+
| `{"type": {"into": "Name", "text": "..."}}` | `is`: `visible` · `gone` · `enabled` · `disabled` · `value` (with `equals`) |
|
|
73
|
+
| `{"paste": {"into": "Notes", "text": "long text"}}` | `{"waitFor": {"value": "Saved", "timeoutMs": 5000}}` |
|
|
74
|
+
| `{"scroll": "down"}` · `{"scrollTo": "Delete account"}` | `{"settle": {"stableMs": 600}}` |
|
|
75
|
+
| `{"swipe": {"from": [x,y], "to": [x,y]}}` | `{"pause": 300}` |
|
|
76
|
+
| `{"button": "HOME"}` | |
|
|
77
|
+
| `{"launch": {"value": "com.example.app", "relaunch": true, "args": ["-uiTest","1"]}}` | |
|
|
78
|
+
| `{"openUrl": "myapp://path"}` | |
|
|
79
|
+
| `{"permission": {"value": "photos", "grant": "grant", "bundleId": "com.example.app"}}` | |
|
|
80
|
+
|
|
81
|
+
## Selectors
|
|
82
|
+
|
|
83
|
+
| | |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `#3` | the number `simframe ui` gave it. Cheapest, and unambiguous. |
|
|
86
|
+
| `"Save"` · `the Assets tab` · `back` | resolved by intent — verbs, typos, synonyms, icon-only controls by their common name |
|
|
87
|
+
| `@120,400` | raw point coordinates. Last resort; it cannot tell you it missed. |
|
|
88
|
+
|
|
89
|
+
A ref is valid only while that screen is showing. Use one on a different screen
|
|
90
|
+
and it refuses rather than tapping whatever now sits at those coordinates.
|
|
91
|
+
|
|
92
|
+
## Every step is verified, and the verdict means something
|
|
93
|
+
|
|
94
|
+
simframe records which action led from which screen to which, so it can check
|
|
95
|
+
each step against what that action did here last time.
|
|
96
|
+
|
|
97
|
+
| Verdict | What it means | What to do |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| `ok` | landed where this action has landed before | nothing |
|
|
100
|
+
| `unverified` | this action has not been taken on this screen before | nothing — it is learning. Run the flow again and it becomes `ok`. |
|
|
101
|
+
| `no-visible-change` | the screen is stable and nothing moved | the tap may have missed, or its effect may be invisible (a checkbox, a button state). Check with `simframe ui`, not by waiting longer. |
|
|
102
|
+
| `unexpected-screen` | it went somewhere it has not gone before from here | the flow **stops here**. Read the map it returns: either the app changed, or the tap hit the wrong thing. |
|
|
103
|
+
|
|
104
|
+
A first run through a new part of an app is mostly `unverified`, and a
|
|
105
|
+
transition-kind mismatch is reported inside `ok` rather than failing — that
|
|
106
|
+
classifier is noisy and a verdict that cries wolf teaches you to ignore
|
|
107
|
+
verdicts.
|
|
108
|
+
|
|
109
|
+
## Navigate by memory
|
|
110
|
+
|
|
111
|
+
Once simframe has been somewhere, getting back is a search over remembered
|
|
112
|
+
transitions — no reasoning, no images.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
simframe screens # what it knows, and how many exits each has
|
|
116
|
+
simframe goto "Settings" # plan a route and walk it, verifying each step
|
|
117
|
+
simframe do /tmp/flow.json --save=checkout # save it if every step verified
|
|
118
|
+
simframe flow run checkout # replay it
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`goto` refuses rather than guesses. Unknown screen, a name that fits two
|
|
122
|
+
screens equally, no remembered path — each is reported, with what it does know.
|
|
123
|
+
A wrong route is worse than no route, because it taps things.
|
|
124
|
+
|
|
125
|
+
## It refuses rather than guesses
|
|
126
|
+
|
|
127
|
+
When two controls answer a query equally well, simframe lists them and asks
|
|
128
|
+
instead of picking. That is deliberate: a wrong tap can *do something* and
|
|
129
|
+
leave you believing it did the right thing. Pass `index`, or use a `#ref`.
|
|
130
|
+
|
|
131
|
+
## Everything speaks JSON
|
|
132
|
+
|
|
133
|
+
`--json` is on every command, so nothing has to be parsed out of prose:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
simframe ui --json | jq '.elements[] | select(.type=="button") | .label'
|
|
137
|
+
simframe do /tmp/flow.json --json | jq '.results[] | select(.ok==false)'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## The cheap-to-expensive order
|
|
141
|
+
|
|
142
|
+
1. `simframe state` — has anything changed at all? Cheapest thing there is.
|
|
143
|
+
2. `simframe ui` — what is on screen and what can I tap? Text.
|
|
144
|
+
3. `simframe do` — act, in a batch, with asserts inside the batch.
|
|
145
|
+
4. `simframe frame` / `sim_look` — pixels. Only for a question about pixels.
|
|
146
|
+
|
|
147
|
+
## When something is wrong with simframe itself
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
simframe doctor # capture engine, input driver, a11y, OCR — each honestly
|
|
151
|
+
simframe doctor --strict # any degraded layer is a non-zero exit
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
simframe falls back when it must — the simctl capture loop instead of the
|
|
155
|
+
daemon, idb instead of the in-process input and accessibility paths — but it
|
|
156
|
+
never falls back quietly. If
|
|
157
|
+
`doctor` says a layer is degraded, believe it: the numbers above assume the
|
|
158
|
+
daemon.
|
|
159
|
+
|
|
160
|
+
## Other commands
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
simframe start [device] # capture starts on first use anyway
|
|
164
|
+
simframe devices # booted simulators
|
|
165
|
+
simframe recall # what happened in the last ~60s, as text
|
|
166
|
+
simframe strip # recent frames tiled into one image, for an animation
|
|
167
|
+
simframe find "the save button" # resolve an intent without acting on it
|
|
168
|
+
simframe wait --mode=settle # block until the screen stops reacting
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`recall` matters more than it looks: if you look up and the screen is already
|
|
172
|
+
different, it tells you what happened and when, instead of you re-running the
|
|
173
|
+
action to find out.
|
package/src/actions.js
CHANGED
|
@@ -6,14 +6,25 @@ import * as api from './index.js';
|
|
|
6
6
|
import * as graph from './graph.js';
|
|
7
7
|
import * as input from './input.js';
|
|
8
8
|
import * as intent from './intent.js';
|
|
9
|
-
import { launchApp, openUrl, setPasteboard, terminateApp } from './simctl.js';
|
|
9
|
+
import { launchApp, openUrl, setPasteboard, setPermission, terminateApp } from './simctl.js';
|
|
10
10
|
|
|
11
11
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
12
|
const MAX_PAUSE_MS = 5000;
|
|
13
|
+
/** How long a text field needs after being tapped before it holds the keyboard focus. */
|
|
14
|
+
const FOCUS_SETTLE_MS = 150;
|
|
15
|
+
const POLL_MS = 250;
|
|
16
|
+
/** A list that has not produced the target in this many screens does not contain it. */
|
|
17
|
+
const MAX_SCROLLS = 20;
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Steps that change the device. Only these get a settle wait and a verified
|
|
21
|
+
* edge in the graph — asserting something is on screen does not move it.
|
|
22
|
+
* `scrollTo` is here because it scrolls; `permission` because a granted
|
|
23
|
+
* permission can change what the app shows.
|
|
24
|
+
*/
|
|
14
25
|
const ACTION_STEPS = new Set([
|
|
15
|
-
'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'button', 'key',
|
|
16
|
-
'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny',
|
|
26
|
+
'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'scrollTo', 'button', 'key',
|
|
27
|
+
'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny', 'permission',
|
|
17
28
|
]);
|
|
18
29
|
|
|
19
30
|
/** Accept both `{tap: "Save"}` shorthand and `{action: "tap", target: "Save"}`. */
|
|
@@ -33,6 +44,34 @@ export function normalizeStep(raw) {
|
|
|
33
44
|
return step;
|
|
34
45
|
}
|
|
35
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Does this verdict mean the flow went somewhere nobody intended?
|
|
49
|
+
*
|
|
50
|
+
* Only an unexpected *screen* does. `unexpected-transition` is not a verdict at
|
|
51
|
+
* all any more — a noisy classifier disagreeing about whether a tab switch was
|
|
52
|
+
* a push or a pop is not a reason to call a correct navigation wrong, and a
|
|
53
|
+
* verdict that cries wolf trains you to ignore verdicts.
|
|
54
|
+
*/
|
|
55
|
+
export function wrongTurnFrom(verification) {
|
|
56
|
+
return verification?.verdict === 'unexpected-screen';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a halted step does to the run as a whole.
|
|
61
|
+
*
|
|
62
|
+
* Both halves matter, and only one of them used to happen: the step is marked
|
|
63
|
+
* failed, AND so is the run. Without the second, `ok` meant no more than
|
|
64
|
+
* "nothing threw", so a flow stopped dead at step 0 by a wrong turn reported
|
|
65
|
+
* "flow completed" with no error — the exact shape of failure the verdict
|
|
66
|
+
* exists to make loud.
|
|
67
|
+
*/
|
|
68
|
+
export function haltDecision({ verification, stopOnUnexpected = true, continueOnError = false } = {}) {
|
|
69
|
+
if (!wrongTurnFrom(verification) || !stopOnUnexpected || continueOnError) {
|
|
70
|
+
return { halt: false, failRun: false, error: null };
|
|
71
|
+
}
|
|
72
|
+
return { halt: true, failRun: true, error: `${verification.verdict}: ${verification.detail}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
36
75
|
export async function runScript(
|
|
37
76
|
deviceQuery,
|
|
38
77
|
{
|
|
@@ -49,6 +88,9 @@ export async function runScript(
|
|
|
49
88
|
// Stop when a verified step lands somewhere it should not have. A flow
|
|
50
89
|
// continuing past a wrong turn taps controls on a screen nobody intended.
|
|
51
90
|
stopOnUnexpected = true,
|
|
91
|
+
// Rebuild the HID session and retry once when a hardware button provably
|
|
92
|
+
// did nothing. Off only for a caller deliberately testing that path.
|
|
93
|
+
recoverInput = true,
|
|
52
94
|
options,
|
|
53
95
|
} = {},
|
|
54
96
|
) {
|
|
@@ -74,6 +116,14 @@ export async function runScript(
|
|
|
74
116
|
const frames = [];
|
|
75
117
|
let failed = false;
|
|
76
118
|
let carriedScreen = null;
|
|
119
|
+
// The last reading of where we ended up, confirmed or not. The compact map
|
|
120
|
+
// the caller returns to Claude is rendered from this, so describing the end
|
|
121
|
+
// state costs nothing beyond the verification pass the flow already ran.
|
|
122
|
+
let endScreen = null;
|
|
123
|
+
// At most one recovery per run. Pressing home while already on the springboard
|
|
124
|
+
// moves nothing and is not a failure, so an unbounded retry would rebuild the
|
|
125
|
+
// session and press again on every such step for no reason.
|
|
126
|
+
let inputRecovered = false;
|
|
77
127
|
|
|
78
128
|
for (const [i, raw] of steps.entries()) {
|
|
79
129
|
const step = normalizeStep(raw);
|
|
@@ -93,9 +143,9 @@ export async function runScript(
|
|
|
93
143
|
// What this action did last time it was taken here, if ever.
|
|
94
144
|
const prediction = verify && beforeScreen?.hash ? graph.predict(udid, beforeScreen, step) : null;
|
|
95
145
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
146
|
+
let detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
|
|
147
|
+
const settleFor = async () => {
|
|
148
|
+
if (!autoSettle || !ACTION_STEPS.has(step.action)) return null;
|
|
99
149
|
const w = await api.waitFor(deviceQuery, {
|
|
100
150
|
mode: 'settle',
|
|
101
151
|
since: before,
|
|
@@ -103,13 +153,37 @@ export async function runScript(
|
|
|
103
153
|
timeoutMs: step.timeoutMs ?? timeoutMs,
|
|
104
154
|
options,
|
|
105
155
|
});
|
|
106
|
-
|
|
156
|
+
return {
|
|
107
157
|
ok: w.satisfied,
|
|
108
158
|
waitedMs: w.waitedMs,
|
|
109
159
|
sawChange: w.sawChange,
|
|
110
160
|
stalled: Boolean(w.stalled),
|
|
111
161
|
noVisibleChange: Boolean(w.noVisibleChange),
|
|
112
162
|
};
|
|
163
|
+
};
|
|
164
|
+
let settled = await settleFor();
|
|
165
|
+
|
|
166
|
+
// A hardware button that moved nothing did not arrive.
|
|
167
|
+
//
|
|
168
|
+
// Input is the one path with no feedback, so a dispatched Indigo message
|
|
169
|
+
// reports success whether or not the device acted on it — measured, a
|
|
170
|
+
// long-running daemon returned `press in 66ms` with the screen frozen,
|
|
171
|
+
// and the same press worked on a fresh daemon. The frames are the only
|
|
172
|
+
// witness, and by here we have them.
|
|
173
|
+
//
|
|
174
|
+
// Only buttons, and only on no visible change. Home and lock always move
|
|
175
|
+
// the screen, so nothing moving is unambiguous; a tap that changes
|
|
176
|
+
// nothing is ordinary, and retrying one could act twice. Retrying an
|
|
177
|
+
// action that provably did nothing is not a repeat — it is the first
|
|
178
|
+
// attempt that counts.
|
|
179
|
+
if (recoverInput && !inputRecovered && step.action === 'button' && settled?.noVisibleChange) {
|
|
180
|
+
inputRecovered = true;
|
|
181
|
+
const reset = await input.resetSession(udid);
|
|
182
|
+
if (reset) {
|
|
183
|
+
detail += ' [input was not being delivered; HID session reset and retried]';
|
|
184
|
+
await runStep(deviceQuery, udid, step, { screen, options, frames });
|
|
185
|
+
settled = await settleFor();
|
|
186
|
+
}
|
|
113
187
|
}
|
|
114
188
|
// Verify against what was predicted, and remember what actually
|
|
115
189
|
// happened. Without this a step that moved the screen the wrong way
|
|
@@ -130,17 +204,14 @@ export async function runScript(
|
|
|
130
204
|
// independent readings, which is the thing `settled` was standing in
|
|
131
205
|
// for. Requiring both meant a screen that settled slowly recorded
|
|
132
206
|
// nothing at all.
|
|
207
|
+
endScreen = afterScreen;
|
|
133
208
|
if (afterScreen.confirmed && afterScreen.hash) {
|
|
134
209
|
graph.record(udid, { from: beforeScreen, action: step, to: afterScreen, kind });
|
|
135
210
|
carriedScreen = afterScreen;
|
|
136
211
|
}
|
|
137
212
|
}
|
|
138
213
|
|
|
139
|
-
|
|
140
|
-
// longer a verdict at all — a noisy classifier disagreeing about whether
|
|
141
|
-
// a tab switch was a push or a pop is not a reason to call a correct
|
|
142
|
-
// navigation wrong.
|
|
143
|
-
const wrongTurn = verification?.verdict === 'unexpected-screen';
|
|
214
|
+
const wrongTurn = wrongTurnFrom(verification);
|
|
144
215
|
const note = settled?.noVisibleChange ? ' [no visible change]' : '';
|
|
145
216
|
results.push({
|
|
146
217
|
index: i,
|
|
@@ -151,9 +222,11 @@ export async function runScript(
|
|
|
151
222
|
detail: `${detail}${note}${wrongTurn ? ` [${verification.verdict}: ${verification.detail}]` : ''}`,
|
|
152
223
|
settled,
|
|
153
224
|
});
|
|
154
|
-
|
|
225
|
+
const halt = haltDecision({ verification, stopOnUnexpected, continueOnError });
|
|
226
|
+
if (halt.halt) {
|
|
155
227
|
results[results.length - 1].ok = false;
|
|
156
|
-
results[results.length - 1].error =
|
|
228
|
+
results[results.length - 1].error = halt.error;
|
|
229
|
+
failed = halt.failRun;
|
|
157
230
|
break;
|
|
158
231
|
}
|
|
159
232
|
} catch (err) {
|
|
@@ -168,6 +241,7 @@ export async function runScript(
|
|
|
168
241
|
// Returned so a run that verified end to end can be handed straight to
|
|
169
242
|
// navigate.saveFlow without the caller reassembling what it just ran.
|
|
170
243
|
steps,
|
|
244
|
+
endScreen,
|
|
171
245
|
results,
|
|
172
246
|
ok: !failed,
|
|
173
247
|
totalMs: Date.now() - startedAt,
|
|
@@ -203,10 +277,14 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
203
277
|
}
|
|
204
278
|
case 'type': {
|
|
205
279
|
if (step.into) {
|
|
206
|
-
|
|
207
|
-
|
|
280
|
+
// locate, not tapLabel: tapLabel asks the accessibility tree directly,
|
|
281
|
+
// so a field that only OCR can see was untypeable, and a selector
|
|
282
|
+
// (`#4`, `@x,y`) meant nothing here.
|
|
283
|
+
const found = await api.locate(deviceQuery, step.into, { index: step.index, refresh: step.refresh });
|
|
284
|
+
await input.tapPoint(udid, found.target.x, found.target.y);
|
|
285
|
+
await sleep(FOCUS_SETTLE_MS);
|
|
208
286
|
await input.typeText(udid, step.text ?? step.value);
|
|
209
|
-
return `typed into "${
|
|
287
|
+
return `typed into "${found.target.label}" at ${found.target.x},${found.target.y}`;
|
|
210
288
|
}
|
|
211
289
|
await input.typeText(udid, step.text ?? step.value);
|
|
212
290
|
return 'typed text';
|
|
@@ -245,9 +323,15 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
245
323
|
case 'key':
|
|
246
324
|
await input.pressKey(udid, step.value ?? step.code);
|
|
247
325
|
return `pressed key ${step.value ?? step.code}`;
|
|
248
|
-
case 'launch':
|
|
249
|
-
|
|
250
|
-
|
|
326
|
+
case 'launch': {
|
|
327
|
+
const bundleId = step.value ?? step.bundleId;
|
|
328
|
+
await launchApp(udid, bundleId, {
|
|
329
|
+
args: step.args ?? [],
|
|
330
|
+
env: step.env ?? {},
|
|
331
|
+
terminateFirst: step.relaunch === true,
|
|
332
|
+
});
|
|
333
|
+
return `launched ${bundleId}${step.relaunch ? ' (relaunched)' : ''}`;
|
|
334
|
+
}
|
|
251
335
|
case 'terminate':
|
|
252
336
|
await terminateApp(udid, step.value ?? step.bundleId);
|
|
253
337
|
return `terminated ${step.value ?? step.bundleId}`;
|
|
@@ -305,6 +389,91 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
305
389
|
}
|
|
306
390
|
throw new Error(`"${target}" is still on screen`);
|
|
307
391
|
}
|
|
392
|
+
// Bring something into view. A control that scrolled off the bottom of a
|
|
393
|
+
// list is not missing, and "not on this screen" is the wrong answer to give
|
|
394
|
+
// about it.
|
|
395
|
+
case 'scrollTo': {
|
|
396
|
+
const query = step.value ?? step.target ?? step.label;
|
|
397
|
+
const dir = String(step.direction ?? 'down').toLowerCase();
|
|
398
|
+
const max = Math.min(MAX_SCROLLS, step.maxScrolls ?? 6);
|
|
399
|
+
for (let i = 0; i <= max; i += 1) {
|
|
400
|
+
try {
|
|
401
|
+
const found = await api.locate(deviceQuery, query, { index: step.index, refresh: i > 0 });
|
|
402
|
+
return `"${found.target.label}" is in view at ${found.target.x},${found.target.y}` +
|
|
403
|
+
(i ? ` after ${i} scroll${i === 1 ? '' : 's'}` : ' already');
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (i === max) throw new Error(`scrolled ${dir} ${max}x without finding ${query}: ${err.message}`);
|
|
406
|
+
}
|
|
407
|
+
await runStep(deviceQuery, udid, { action: 'scroll', value: dir }, ctx);
|
|
408
|
+
await api.waitFor(deviceQuery, { mode: 'stable', stableMs: 250, timeoutMs: 2500, options: ctx.options });
|
|
409
|
+
}
|
|
410
|
+
throw new Error(`could not bring ${query} into view`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Wait for a selector rather than a label, so it works on screens the
|
|
414
|
+
// accessibility tree never described.
|
|
415
|
+
case 'waitFor': {
|
|
416
|
+
const query = step.value ?? step.target ?? step.text;
|
|
417
|
+
const limit = Date.now() + (step.timeoutMs ?? 8000);
|
|
418
|
+
let lastError = 'never appeared';
|
|
419
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
420
|
+
try {
|
|
421
|
+
const found = await api.locate(deviceQuery, query, { index: step.index, refresh: attempt > 0 });
|
|
422
|
+
return `"${found.target.label}" appeared at ${found.target.x},${found.target.y}`;
|
|
423
|
+
} catch (err) {
|
|
424
|
+
lastError = err.message;
|
|
425
|
+
}
|
|
426
|
+
if (Date.now() >= limit) break;
|
|
427
|
+
await sleep(POLL_MS);
|
|
428
|
+
}
|
|
429
|
+
throw new Error(`waited ${step.timeoutMs ?? 8000}ms for ${query}: ${lastError}`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// One assert step for every condition, because `assertText` could only ask
|
|
433
|
+
// one question and the interesting ones are about state: is Save enabled
|
|
434
|
+
// yet, does the field hold what was typed into it.
|
|
435
|
+
case 'assert': {
|
|
436
|
+
const query = step.value ?? step.target ?? step.text;
|
|
437
|
+
const want = String(step.is ?? (step.gone ? 'gone' : 'visible')).toLowerCase();
|
|
438
|
+
let found = null;
|
|
439
|
+
try {
|
|
440
|
+
found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh });
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (want === 'gone') return `${query} is gone`;
|
|
443
|
+
throw new Error(`${query}: ${err.message}`);
|
|
444
|
+
}
|
|
445
|
+
const t = found.target;
|
|
446
|
+
switch (want) {
|
|
447
|
+
case 'visible':
|
|
448
|
+
return `${query} is on screen at ${t.x},${t.y}`;
|
|
449
|
+
case 'gone':
|
|
450
|
+
throw new Error(`${query} is still on screen at ${t.x},${t.y}`);
|
|
451
|
+
case 'enabled':
|
|
452
|
+
if (t.enabled === false) throw new Error(`"${t.label}" is disabled`);
|
|
453
|
+
return `"${t.label}" is enabled`;
|
|
454
|
+
case 'disabled':
|
|
455
|
+
if (t.enabled !== false) throw new Error(`"${t.label}" is not disabled`);
|
|
456
|
+
return `"${t.label}" is disabled`;
|
|
457
|
+
case 'value': {
|
|
458
|
+
const expected = String(step.equals ?? step.text ?? '');
|
|
459
|
+
const actual = [t.label, t.value, ...(t.aliases ?? [])].filter(Boolean).join(' ');
|
|
460
|
+
if (!actual.toLowerCase().includes(expected.toLowerCase())) {
|
|
461
|
+
throw new Error(`expected "${expected}" but read "${actual}"`);
|
|
462
|
+
}
|
|
463
|
+
return `"${expected}" is what ${query} reads`;
|
|
464
|
+
}
|
|
465
|
+
default:
|
|
466
|
+
throw new Error(`unknown assert condition "${want}" — visible, gone, enabled, disabled or value`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Answering a system permission alert is not a test of the app. Setting the
|
|
471
|
+
// permission is.
|
|
472
|
+
case 'permission': {
|
|
473
|
+
const service = step.value ?? step.service;
|
|
474
|
+
return await setPermission(udid, step.grant ?? step.action ?? 'grant', service, step.bundleId);
|
|
475
|
+
}
|
|
476
|
+
|
|
308
477
|
case 'look': {
|
|
309
478
|
const frame = await api.getFrame(deviceQuery, { detail: step.detail ?? 'normal', options: ctx.options });
|
|
310
479
|
ctx.frames.push({ label: step.label ?? `step frame`, png: frame.png });
|