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.
@@ -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
- const detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
97
- let settled = null;
98
- if (autoSettle && ACTION_STEPS.has(step.action)) {
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
- settled = {
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
- // Only an unexpected *screen* stops a flow. `unexpected-transition` is no
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
- if (wrongTurn && stopOnUnexpected && !continueOnError) {
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 = `${verification.verdict}: ${verification.detail}`;
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
- const { node } = await input.tapLabel(udid, step.into, { index: step.index });
207
- await sleep(150);
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 "${node.label ?? step.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
- await launchApp(udid, step.value ?? step.bundleId);
250
- return `launched ${step.value ?? step.bundleId}`;
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 });