simframe 0.4.2 → 0.6.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- package/src/view.js +342 -0
package/src/actions.js
CHANGED
|
@@ -3,16 +3,28 @@
|
|
|
3
3
|
// Waiting uses a baseline captured BEFORE each action, which is the whole
|
|
4
4
|
// reason these scripts are reliable rather than racy.
|
|
5
5
|
import * as api from './index.js';
|
|
6
|
+
import * as graph from './graph.js';
|
|
6
7
|
import * as input from './input.js';
|
|
7
8
|
import * as intent from './intent.js';
|
|
8
|
-
import { launchApp, openUrl, setPasteboard, terminateApp } from './simctl.js';
|
|
9
|
+
import { launchApp, openUrl, setPasteboard, setPermission, terminateApp } from './simctl.js';
|
|
9
10
|
|
|
10
11
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
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;
|
|
12
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
|
+
*/
|
|
13
25
|
const ACTION_STEPS = new Set([
|
|
14
|
-
'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'button', 'key',
|
|
15
|
-
'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny',
|
|
26
|
+
'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'scrollTo', 'button', 'key',
|
|
27
|
+
'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny', 'permission',
|
|
16
28
|
]);
|
|
17
29
|
|
|
18
30
|
/** Accept both `{tap: "Save"}` shorthand and `{action: "tap", target: "Save"}`. */
|
|
@@ -24,12 +36,63 @@ export function normalizeStep(raw) {
|
|
|
24
36
|
// Siblings like timeoutMs sit alongside the shorthand key and must survive.
|
|
25
37
|
const { [key]: value, ...rest } = raw;
|
|
26
38
|
const inline = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
|
|
27
|
-
|
|
39
|
+
const step = { ...rest, ...inline, action: key };
|
|
40
|
+
// Drop keys that are present but undefined. `simframe tap X` used to pass
|
|
41
|
+
// `index: undefined`, which survived here and then crashed the signature
|
|
42
|
+
// builder before the tap was ever sent.
|
|
43
|
+
for (const k of Object.keys(step)) if (step[k] === undefined) delete step[k];
|
|
44
|
+
return step;
|
|
45
|
+
}
|
|
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}` };
|
|
28
73
|
}
|
|
29
74
|
|
|
30
75
|
export async function runScript(
|
|
31
76
|
deviceQuery,
|
|
32
|
-
{
|
|
77
|
+
{
|
|
78
|
+
steps,
|
|
79
|
+
autoSettle = true,
|
|
80
|
+
stableMs = 500,
|
|
81
|
+
timeoutMs = 8000,
|
|
82
|
+
confirmNovel = true,
|
|
83
|
+
continueOnError = false,
|
|
84
|
+
// Check each action against what it did last time, and remember what it
|
|
85
|
+
// does this time. On by default: a flow that cannot tell a wrong turn from
|
|
86
|
+
// a right one is worse than no flow.
|
|
87
|
+
verify = true,
|
|
88
|
+
// Stop when a verified step lands somewhere it should not have. A flow
|
|
89
|
+
// continuing past a wrong turn taps controls on a screen nobody intended.
|
|
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,
|
|
94
|
+
options,
|
|
95
|
+
} = {},
|
|
33
96
|
) {
|
|
34
97
|
if (!Array.isArray(steps) || !steps.length) throw new Error('a script needs at least one step');
|
|
35
98
|
const { device } = await api.ensureDaemon(deviceQuery, options);
|
|
@@ -38,7 +101,11 @@ export async function runScript(
|
|
|
38
101
|
|
|
39
102
|
const needsInput = steps.some((s) => ACTION_STEPS.has(normalizeStep(s).action));
|
|
40
103
|
if (needsInput) {
|
|
41
|
-
|
|
104
|
+
// driverFor, not detectDriver: detectDriver asks specifically whether idb
|
|
105
|
+
// is installed, so every batch flow demanded idb even on a machine where
|
|
106
|
+
// the daemon was doing the input perfectly well. Single-step `simframe tap`
|
|
107
|
+
// already went through driverFor, so `tap` worked and `do` did not.
|
|
108
|
+
const driver = await input.driverFor(udid);
|
|
42
109
|
if (!driver.available) throw new Error(driver.reason);
|
|
43
110
|
}
|
|
44
111
|
|
|
@@ -48,16 +115,37 @@ export async function runScript(
|
|
|
48
115
|
const results = [];
|
|
49
116
|
const frames = [];
|
|
50
117
|
let failed = false;
|
|
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;
|
|
51
127
|
|
|
52
128
|
for (const [i, raw] of steps.entries()) {
|
|
53
129
|
const step = normalizeStep(raw);
|
|
54
130
|
const stepStart = Date.now();
|
|
55
131
|
// The baseline for "did the screen react" must predate the action itself.
|
|
56
|
-
const
|
|
132
|
+
const beforeState = (await api.getState(deviceQuery, { options })).state;
|
|
133
|
+
const before = beforeState.hash;
|
|
134
|
+
// Identity is structural: a list with new rows is the same screen, and the
|
|
135
|
+
// pixel hash cannot say so.
|
|
136
|
+
// The screen this step starts on is the screen the last one ended on —
|
|
137
|
+
// nothing happens in between. Recomputing it cost a full perception pass
|
|
138
|
+
// per step for an answer already in hand.
|
|
139
|
+
const beforeScreen = verify
|
|
140
|
+
? (carriedScreen ?? await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs, confirmNovel }))
|
|
141
|
+
: null;
|
|
142
|
+
carriedScreen = null;
|
|
143
|
+
// What this action did last time it was taken here, if ever.
|
|
144
|
+
const prediction = verify && beforeScreen?.hash ? graph.predict(udid, beforeScreen, step) : null;
|
|
57
145
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
|
61
149
|
const w = await api.waitFor(deviceQuery, {
|
|
62
150
|
mode: 'settle',
|
|
63
151
|
since: before,
|
|
@@ -65,23 +153,82 @@ export async function runScript(
|
|
|
65
153
|
timeoutMs: step.timeoutMs ?? timeoutMs,
|
|
66
154
|
options,
|
|
67
155
|
});
|
|
68
|
-
|
|
156
|
+
return {
|
|
69
157
|
ok: w.satisfied,
|
|
70
158
|
waitedMs: w.waitedMs,
|
|
71
159
|
sawChange: w.sawChange,
|
|
72
160
|
stalled: Boolean(w.stalled),
|
|
73
161
|
noVisibleChange: Boolean(w.noVisibleChange),
|
|
74
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
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Verify against what was predicted, and remember what actually
|
|
189
|
+
// happened. Without this a step that moved the screen the wrong way
|
|
190
|
+
// reports success, and the flow carries on believing it worked.
|
|
191
|
+
let verification = null;
|
|
192
|
+
if (verify && ACTION_STEPS.has(step.action) && beforeScreen?.hash) {
|
|
193
|
+
const afterState = (await api.getState(deviceQuery, { options })).state;
|
|
194
|
+
const kind = afterState.transition?.kind;
|
|
195
|
+
const afterScreen = await api.screenIdentity(deviceQuery, { options, settleMs: stableMs, timeoutMs, confirmNovel });
|
|
196
|
+
verification = {
|
|
197
|
+
...graph.verdict({ udid, prediction, before: beforeScreen.hash, after: afterScreen.hash, kind }),
|
|
198
|
+
predicted: prediction ? { to: prediction.to.slice(0, 10), kind: prediction.kind, seen: prediction.count } : null,
|
|
199
|
+
observed: { to: afterScreen.hash?.slice(0, 10), kind },
|
|
200
|
+
};
|
|
201
|
+
// Only remember what was seen on a settled screen: an edge recorded
|
|
202
|
+
// mid-transition points at a screen that never really existed.
|
|
203
|
+
// `confirmed` already means the fingerprint held still across two
|
|
204
|
+
// independent readings, which is the thing `settled` was standing in
|
|
205
|
+
// for. Requiring both meant a screen that settled slowly recorded
|
|
206
|
+
// nothing at all.
|
|
207
|
+
endScreen = afterScreen;
|
|
208
|
+
if (afterScreen.confirmed && afterScreen.hash) {
|
|
209
|
+
graph.record(udid, { from: beforeScreen, action: step, to: afterScreen, kind });
|
|
210
|
+
carriedScreen = afterScreen;
|
|
211
|
+
}
|
|
75
212
|
}
|
|
213
|
+
|
|
214
|
+
const wrongTurn = wrongTurnFrom(verification);
|
|
76
215
|
const note = settled?.noVisibleChange ? ' [no visible change]' : '';
|
|
77
216
|
results.push({
|
|
78
217
|
index: i,
|
|
79
218
|
action: step.action,
|
|
219
|
+
verification,
|
|
80
220
|
ok: true,
|
|
81
221
|
ms: Date.now() - stepStart,
|
|
82
|
-
detail: `${detail}${note}`,
|
|
222
|
+
detail: `${detail}${note}${wrongTurn ? ` [${verification.verdict}: ${verification.detail}]` : ''}`,
|
|
83
223
|
settled,
|
|
84
224
|
});
|
|
225
|
+
const halt = haltDecision({ verification, stopOnUnexpected, continueOnError });
|
|
226
|
+
if (halt.halt) {
|
|
227
|
+
results[results.length - 1].ok = false;
|
|
228
|
+
results[results.length - 1].error = halt.error;
|
|
229
|
+
failed = halt.failRun;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
85
232
|
} catch (err) {
|
|
86
233
|
results.push({ index: i, action: step.action, ok: false, ms: Date.now() - stepStart, error: err.message });
|
|
87
234
|
failed = true;
|
|
@@ -91,6 +238,10 @@ export async function runScript(
|
|
|
91
238
|
|
|
92
239
|
return {
|
|
93
240
|
device,
|
|
241
|
+
// Returned so a run that verified end to end can be handed straight to
|
|
242
|
+
// navigate.saveFlow without the caller reassembling what it just ran.
|
|
243
|
+
steps,
|
|
244
|
+
endScreen,
|
|
94
245
|
results,
|
|
95
246
|
ok: !failed,
|
|
96
247
|
totalMs: Date.now() - startedAt,
|
|
@@ -126,10 +277,14 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
126
277
|
}
|
|
127
278
|
case 'type': {
|
|
128
279
|
if (step.into) {
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
286
|
await input.typeText(udid, step.text ?? step.value);
|
|
132
|
-
return `typed into "${
|
|
287
|
+
return `typed into "${found.target.label}" at ${found.target.x},${found.target.y}`;
|
|
133
288
|
}
|
|
134
289
|
await input.typeText(udid, step.text ?? step.value);
|
|
135
290
|
return 'typed text';
|
|
@@ -168,9 +323,15 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
168
323
|
case 'key':
|
|
169
324
|
await input.pressKey(udid, step.value ?? step.code);
|
|
170
325
|
return `pressed key ${step.value ?? step.code}`;
|
|
171
|
-
case 'launch':
|
|
172
|
-
|
|
173
|
-
|
|
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
|
+
}
|
|
174
335
|
case 'terminate':
|
|
175
336
|
await terminateApp(udid, step.value ?? step.bundleId);
|
|
176
337
|
return `terminated ${step.value ?? step.bundleId}`;
|
|
@@ -228,6 +389,91 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
228
389
|
}
|
|
229
390
|
throw new Error(`"${target}" is still on screen`);
|
|
230
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
|
+
|
|
231
477
|
case 'look': {
|
|
232
478
|
const frame = await api.getFrame(deviceQuery, { detail: step.detail ?? 'normal', options: ctx.options });
|
|
233
479
|
ctx.frames.push({ label: step.label ?? `step frame`, png: frame.png });
|