simframe 0.1.0 → 0.4.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/src/actions.js ADDED
@@ -0,0 +1,244 @@
1
+ // The batch engine. A flow that would cost one model round trip per tap runs
2
+ // here as a single call: act, wait for the screen to settle, assert, repeat.
3
+ // Waiting uses a baseline captured BEFORE each action, which is the whole
4
+ // reason these scripts are reliable rather than racy.
5
+ import * as api from './index.js';
6
+ import * as input from './input.js';
7
+ import * as intent from './intent.js';
8
+ import { launchApp, openUrl, setPasteboard, terminateApp } from './simctl.js';
9
+
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+ const MAX_PAUSE_MS = 5000;
12
+
13
+ const ACTION_STEPS = new Set([
14
+ 'tap', 'tapAt', 'type', 'paste', 'swipe', 'scroll', 'button', 'key',
15
+ 'launch', 'terminate', 'openUrl', 'confirm', 'chooseAny',
16
+ ]);
17
+
18
+ /** Accept both `{tap: "Save"}` shorthand and `{action: "tap", target: "Save"}`. */
19
+ export function normalizeStep(raw) {
20
+ if (typeof raw === 'string') return { action: raw };
21
+ if (raw.action) return { ...raw };
22
+ const [key] = Object.keys(raw);
23
+ if (!key) throw new Error('empty step');
24
+ // Siblings like timeoutMs sit alongside the shorthand key and must survive.
25
+ const { [key]: value, ...rest } = raw;
26
+ const inline = value && typeof value === 'object' && !Array.isArray(value) ? value : { value };
27
+ return { ...rest, ...inline, action: key };
28
+ }
29
+
30
+ export async function runScript(
31
+ deviceQuery,
32
+ { steps, autoSettle = true, stableMs = 500, timeoutMs = 8000, continueOnError = false, options } = {},
33
+ ) {
34
+ if (!Array.isArray(steps) || !steps.length) throw new Error('a script needs at least one step');
35
+ const { device } = await api.ensureDaemon(deviceQuery, options);
36
+ const udid = device.udid;
37
+ const startedAt = Date.now();
38
+
39
+ const needsInput = steps.some((s) => ACTION_STEPS.has(normalizeStep(s).action));
40
+ if (needsInput) {
41
+ const driver = await input.detectDriver();
42
+ if (!driver.available) throw new Error(driver.reason);
43
+ }
44
+
45
+ let geometry = null;
46
+ const screen = async () => (geometry ??= await input.screenInfo(udid));
47
+
48
+ const results = [];
49
+ const frames = [];
50
+ let failed = false;
51
+
52
+ for (const [i, raw] of steps.entries()) {
53
+ const step = normalizeStep(raw);
54
+ const stepStart = Date.now();
55
+ // The baseline for "did the screen react" must predate the action itself.
56
+ const before = (await api.getState(deviceQuery, { options })).state.hash;
57
+ try {
58
+ const detail = await runStep(deviceQuery, udid, step, { screen, options, frames });
59
+ let settled = null;
60
+ if (autoSettle && ACTION_STEPS.has(step.action)) {
61
+ const w = await api.waitFor(deviceQuery, {
62
+ mode: 'settle',
63
+ since: before,
64
+ stableMs: step.stableMs ?? stableMs,
65
+ timeoutMs: step.timeoutMs ?? timeoutMs,
66
+ options,
67
+ });
68
+ settled = {
69
+ ok: w.satisfied,
70
+ waitedMs: w.waitedMs,
71
+ sawChange: w.sawChange,
72
+ stalled: Boolean(w.stalled),
73
+ noVisibleChange: Boolean(w.noVisibleChange),
74
+ };
75
+ }
76
+ const note = settled?.noVisibleChange ? ' [no visible change]' : '';
77
+ results.push({
78
+ index: i,
79
+ action: step.action,
80
+ ok: true,
81
+ ms: Date.now() - stepStart,
82
+ detail: `${detail}${note}`,
83
+ settled,
84
+ });
85
+ } catch (err) {
86
+ results.push({ index: i, action: step.action, ok: false, ms: Date.now() - stepStart, error: err.message });
87
+ failed = true;
88
+ if (!continueOnError) break;
89
+ }
90
+ }
91
+
92
+ return {
93
+ device,
94
+ results,
95
+ ok: !failed,
96
+ totalMs: Date.now() - startedAt,
97
+ ranSteps: results.length,
98
+ totalSteps: steps.length,
99
+ frames,
100
+ };
101
+ }
102
+
103
+ async function runStep(deviceQuery, udid, step, ctx) {
104
+ switch (step.action) {
105
+ case 'tap': {
106
+ const query = step.value ?? step.target ?? step.label;
107
+ // Screen memory first: a familiar screen needs no tree read and no OCR.
108
+ const found = await api.locate(deviceQuery, query, { index: step.index, refresh: step.refresh });
109
+ await input.tapPoint(udid, found.target.x, found.target.y, { durationMs: step.durationMs });
110
+ return `tapped "${found.target.label}" at ${found.target.x},${found.target.y} (${found.from}${found.from === 'memory' ? ` d=${found.distance}` : ''}, via ${found.target.source})`;
111
+ }
112
+ case 'tapAt': {
113
+ const geo = await ctx.screen();
114
+ let { x, y } = step;
115
+ if (step.space === 'image') {
116
+ const frame = await api.getFrame(deviceQuery, { options: ctx.options });
117
+ ({ x, y } = input.imageToPoints({ x, y }, {
118
+ imageWidth: frame.width,
119
+ imageHeight: frame.height,
120
+ pointWidth: geo.pointWidth,
121
+ pointHeight: geo.pointHeight,
122
+ }));
123
+ }
124
+ await input.tapPoint(udid, x, y, { durationMs: step.durationMs });
125
+ return `tapped ${x},${y}`;
126
+ }
127
+ case 'type': {
128
+ if (step.into) {
129
+ const { node } = await input.tapLabel(udid, step.into, { index: step.index });
130
+ await sleep(150);
131
+ await input.typeText(udid, step.text ?? step.value);
132
+ return `typed into "${node.label ?? step.into}"`;
133
+ }
134
+ await input.typeText(udid, step.text ?? step.value);
135
+ return 'typed text';
136
+ }
137
+ case 'paste': {
138
+ // Long strings are much faster on the pasteboard than through the keyboard.
139
+ await setPasteboard(udid, step.text ?? step.value);
140
+ if (step.into) await input.tapLabel(udid, step.into, { index: step.index, durationMs: 900 });
141
+ return 'placed text on the pasteboard';
142
+ }
143
+ case 'swipe': {
144
+ const from = { x: step.from?.[0] ?? step.from?.x, y: step.from?.[1] ?? step.from?.y };
145
+ const to = { x: step.to?.[0] ?? step.to?.x, y: step.to?.[1] ?? step.to?.y };
146
+ await input.swipe(udid, from, to, { durationMs: step.durationMs });
147
+ return `swiped ${from.x},${from.y} -> ${to.x},${to.y}`;
148
+ }
149
+ case 'scroll': {
150
+ const geo = await ctx.screen();
151
+ const dir = String(step.value ?? step.direction ?? 'down').toLowerCase();
152
+ const midX = Math.round(geo.pointWidth / 2);
153
+ const midY = Math.round(geo.pointHeight / 2);
154
+ const span = Math.round(geo.pointHeight * 0.3);
155
+ const moves = {
156
+ down: [{ x: midX, y: midY + span }, { x: midX, y: midY - span }],
157
+ up: [{ x: midX, y: midY - span }, { x: midX, y: midY + span }],
158
+ left: [{ x: midX + span, y: midY }, { x: midX - span, y: midY }],
159
+ right: [{ x: midX - span, y: midY }, { x: midX + span, y: midY }],
160
+ };
161
+ if (!moves[dir]) throw new Error(`unknown scroll direction "${dir}"`);
162
+ await input.swipe(udid, moves[dir][0], moves[dir][1], { durationMs: step.durationMs ?? 250 });
163
+ return `scrolled ${dir}`;
164
+ }
165
+ case 'button':
166
+ await input.pressButton(udid, step.value ?? step.name);
167
+ return `pressed ${step.value ?? step.name}`;
168
+ case 'key':
169
+ await input.pressKey(udid, step.value ?? step.code);
170
+ return `pressed key ${step.value ?? step.code}`;
171
+ case 'launch':
172
+ await launchApp(udid, step.value ?? step.bundleId);
173
+ return `launched ${step.value ?? step.bundleId}`;
174
+ case 'terminate':
175
+ await terminateApp(udid, step.value ?? step.bundleId);
176
+ return `terminated ${step.value ?? step.bundleId}`;
177
+ case 'openUrl':
178
+ await openUrl(udid, step.value ?? step.url);
179
+ return `opened ${step.value ?? step.url}`;
180
+
181
+ // Human-level steps: act on what is on screen without reasoning about it.
182
+ case 'confirm': {
183
+ const geo = await ctx.screen();
184
+ const r = await intent.confirm(udid, { geo });
185
+ return `confirmed via "${r.label}" at ${r.point.x},${r.point.y}`;
186
+ }
187
+ case 'chooseAny': {
188
+ const geo = await ctx.screen();
189
+ const r = await intent.chooseAny(udid, { prefer: step.value ?? step.prefer, geo });
190
+ return `chose "${r.label}" of ${r.optionCount} options`;
191
+ }
192
+ case 'settle': {
193
+ const w = await api.waitFor(deviceQuery, {
194
+ mode: step.mode ?? 'stable',
195
+ stableMs: step.stableMs ?? 500,
196
+ timeoutMs: step.timeoutMs ?? 8000,
197
+ options: ctx.options,
198
+ });
199
+ if (!w.satisfied) throw new Error(w.stalled ? w.live.note : `screen did not settle within ${w.waitedMs}ms`);
200
+ return `settled after ${w.waitedMs}ms`;
201
+ }
202
+ case 'waitText': {
203
+ const target = step.value ?? step.text;
204
+ const limit = Date.now() + (step.timeoutMs ?? 8000);
205
+ let lastError = 'never appeared';
206
+ while (Date.now() < limit) {
207
+ try {
208
+ const node = input.matchElement(await input.describeAll(udid), target, { index: step.index });
209
+ return `"${node.label ?? target}" appeared`;
210
+ } catch (err) {
211
+ lastError = err.message;
212
+ }
213
+ await sleep(250);
214
+ }
215
+ throw new Error(`waited ${step.timeoutMs ?? 8000}ms for "${target}": ${lastError}`);
216
+ }
217
+ case 'assertText': {
218
+ const target = step.value ?? step.text;
219
+ const node = input.matchElement(await input.describeAll(udid), target, { index: step.index });
220
+ return `"${node.label ?? target}" is on screen`;
221
+ }
222
+ case 'assertGone': {
223
+ const target = step.value ?? step.text;
224
+ try {
225
+ input.matchElement(await input.describeAll(udid), target, { index: step.index });
226
+ } catch {
227
+ return `"${target}" is gone`;
228
+ }
229
+ throw new Error(`"${target}" is still on screen`);
230
+ }
231
+ case 'look': {
232
+ const frame = await api.getFrame(deviceQuery, { detail: step.detail ?? 'normal', options: ctx.options });
233
+ ctx.frames.push({ label: step.label ?? `step frame`, png: frame.png });
234
+ return `captured a frame (${frame.width}x${frame.height})`;
235
+ }
236
+ case 'pause': {
237
+ const ms = Math.min(MAX_PAUSE_MS, Number(step.value ?? step.ms ?? 0));
238
+ await sleep(ms);
239
+ return `paused ${ms}ms`;
240
+ }
241
+ default:
242
+ throw new Error(`unknown step "${step.action}"`);
243
+ }
244
+ }
package/src/analyze.js CHANGED
@@ -63,3 +63,62 @@ export function regionMap(deltas, cols = REGION_COLS) {
63
63
  }
64
64
  return lines.join('\n');
65
65
  }
66
+
67
+ /** Signatures live in state.json, so they are stored as compact hex. */
68
+ export function signatureToHex(sig) {
69
+ return sig.map((v) => v.toString(16).padStart(2, '0')).join('');
70
+ }
71
+
72
+ export function hexToSignature(hex) {
73
+ const out = [];
74
+ for (let i = 0; i < hex.length; i += 2) out.push(parseInt(hex.slice(i, i + 2), 16));
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * A hash of the screen's LAYOUT rather than its content.
80
+ *
81
+ * The frame hash changes whenever any pixel group changes — a clock digit, a new
82
+ * row of data — which makes it useless as a key for "have I seen this screen
83
+ * before?". This one skips the status bar and thresholds a coarse grid, so two
84
+ * visits to the same list with different rows still land close together, while a
85
+ * genuinely different screen lands far away.
86
+ */
87
+ export const LAYOUT_COLS = 12;
88
+ export const LAYOUT_ROWS = 24;
89
+ export const LAYOUT_TOP_SKIP = 0.07; // the status bar, as a fraction of height
90
+
91
+ export function layoutHash(bmp) {
92
+ const top = Math.floor(bmp.height * LAYOUT_TOP_SKIP);
93
+ const cropped = {
94
+ width: bmp.width,
95
+ height: bmp.height - top,
96
+ data: bmp.data.subarray(top * bmp.width * 4),
97
+ };
98
+ // A difference hash: each bit compares a cell with its right-hand neighbour.
99
+ // Thresholding against a global mean collapses low-contrast app screens onto
100
+ // the same value; local gradients stay discriminative.
101
+ const { gray } = grayGrid(cropped, LAYOUT_COLS + 1, LAYOUT_ROWS);
102
+ const bits = [];
103
+ for (let r = 0; r < LAYOUT_ROWS; r++) {
104
+ for (let c = 0; c < LAYOUT_COLS; c++) {
105
+ const i = r * (LAYOUT_COLS + 1) + c;
106
+ bits.push(gray[i] > gray[i + 1] ? 1 : 0);
107
+ }
108
+ }
109
+ let hex = '';
110
+ for (let i = 0; i < bits.length; i += 4) {
111
+ hex += (bits[i] | (bits[i + 1] << 1) | (bits[i + 2] << 2) | (bits[i + 3] << 3)).toString(16);
112
+ }
113
+ return hex;
114
+ }
115
+
116
+ const BITS = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
117
+
118
+ /** Hamming distance between two hex hashes of equal length. */
119
+ export function hashDistance(a, b) {
120
+ if (!a || !b || a.length !== b.length) return Infinity;
121
+ let d = 0;
122
+ for (let i = 0; i < a.length; i++) d += BITS[(parseInt(a[i], 16) ^ parseInt(b[i], 16)) & 0xf];
123
+ return d;
124
+ }
package/src/cli.js CHANGED
@@ -3,7 +3,9 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { runDaemon, DEFAULTS } from './daemon.js';
5
5
  import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
6
+ import * as actions from './actions.js';
6
7
  import * as api from './index.js';
8
+ import * as input from './input.js';
7
9
  import * as store from './store.js';
8
10
 
9
11
  const USAGE = `simframe — always-warm iOS Simulator frames
@@ -14,8 +16,13 @@ const USAGE = `simframe — always-warm iOS Simulator frames
14
16
  simframe status [device] show daemon and newest-frame status
15
17
  simframe frame [device] write the newest frame to a file
16
18
  simframe state [device] print frame metadata and the change map
17
- simframe wait [device] wait for the screen to settle
19
+ simframe mark [device] print the current frame hash, to use as --since
20
+ simframe wait [device] wait for the screen to react (see --mode)
18
21
  simframe strip [device] write a contact sheet of recent frames
22
+ simframe recall [device] what happened in the last minute (--ago=<ms> for a frame)
23
+ simframe ui [device] read the screen as an accessibility tree
24
+ simframe tap <label> tap an element by its accessibility label
25
+ simframe do <script.json> run a scripted flow (see below)
19
26
  simframe devices list simulators
20
27
  simframe doctor check that this machine can capture
21
28
 
@@ -25,9 +32,28 @@ Options
25
32
  --detail=low|normal|high|full or --detail=<max pixels>
26
33
  --fps=<n> capture rate while the screen is moving (default ${DEFAULTS.fps})
27
34
  --count=<n> frames in a strip (default 5)
35
+ --since=<hash|seq> compare against this frame (see: simframe mark)
36
+ --mode=settle|change|stable what wait waits for (default settle)
28
37
  --stable-ms=<n> settle window for wait (default 600)
29
38
  --timeout-ms=<n> give up after this long (default 8000)
39
+ --force let stop kill a loop another client is using
30
40
  --json machine-readable output
41
+
42
+ A script is a JSON array of steps, run in one go with a settle between each:
43
+
44
+ [{"tap":"Assets"},{"tap":"Add Asset"},
45
+ {"type":{"into":"Name","text":"Fryer 3"}},
46
+ {"tap":"Save"},{"waitText":"Saved","timeoutMs":5000}]
47
+
48
+ Input needs idb (brew tap facebook/fb && brew install idb-companion,
49
+ then pipx install fb-idb). Observation works without it.
50
+
51
+ The reliable pattern around an action is:
52
+
53
+ H=$(simframe mark)
54
+ ...tap, launch or navigate...
55
+ simframe wait --since=$H --mode=settle
56
+ simframe state --since=$H
31
57
  `;
32
58
 
33
59
  function parseArgs(argv) {
@@ -99,8 +125,16 @@ async function main() {
99
125
  : []
100
126
  : [(await resolveDevice(device)).udid];
101
127
  let stopped = 0;
102
- for (const udid of targets) if (api.stopDaemon(udid)) stopped++;
103
- console.log(`stopped ${stopped} daemon${stopped === 1 ? '' : 's'}`);
128
+ let inUse = 0;
129
+ for (const udid of targets) {
130
+ const result = api.stopDaemon(udid, { force: Boolean(flags.force) });
131
+ if (result === 'in-use') inUse++;
132
+ else if (result) stopped++;
133
+ }
134
+ console.log(
135
+ `stopped ${stopped} daemon${stopped === 1 ? '' : 's'}` +
136
+ (inUse ? `; left ${inUse} in use by another client (pass --force to stop anyway)` : ''),
137
+ );
104
138
  return;
105
139
  }
106
140
 
@@ -147,30 +181,71 @@ async function main() {
147
181
  return;
148
182
  }
149
183
 
150
- case 'state': {
184
+ case 'mark': {
151
185
  const res = await api.getState(device, { options });
186
+ console.log(res.state.hash);
187
+ return;
188
+ }
189
+
190
+ case 'state': {
191
+ const res = await api.getState(device, { since: flags.since, options });
152
192
  if (flags.json) {
153
- console.log(JSON.stringify({ ...res.state, ageMs: res.ageMs }, null, 2));
193
+ console.log(JSON.stringify({ ...res.state, history: undefined, ageMs: res.ageMs, since: res.since, live: res.live }, null, 2));
154
194
  } else {
155
195
  const s = res.state;
156
- console.log(
157
- `${res.device.name} frame #${s.seq} age ${res.ageMs}ms ${s.width}x${s.height}\n` +
158
- `hash ${s.hash} diff ${s.diff} stable ${s.stableForMs}ms\n${res.map}`,
159
- );
196
+ const out = [];
197
+ if (!res.live.ok) out.push(`WARNING: ${res.live.note}`);
198
+ out.push(`${res.device.name} frame #${s.seq} age ${res.ageMs}ms ${s.width}x${s.height}`);
199
+ out.push(`hash ${s.hash} stable ${s.stableForMs}ms`);
200
+ if (res.since?.kind === 'history') {
201
+ out.push(
202
+ res.since.changed
203
+ ? `CHANGED since ${res.since.ageMs}ms ago: ${(res.since.diff * 100).toFixed(1)}% of the screen`
204
+ : `unchanged since ${res.since.ageMs}ms ago`,
205
+ res.since.map,
206
+ );
207
+ } else if (res.since?.kind === 'coarse') {
208
+ out.push(`${res.since.changed ? 'CHANGED' : 'unchanged'} since your baseline (too old for a detailed diff)`);
209
+ } else if (res.since?.kind === 'unmatched') {
210
+ out.push(`baseline ${res.since.requested} is not in the buffered history`);
211
+ } else {
212
+ out.push(
213
+ s.firstFrame
214
+ ? 'first frame of this capture loop — nothing to compare against yet'
215
+ : `change vs the previous frame only: ${(s.diff * 100).toFixed(1)}%`,
216
+ );
217
+ if (!s.firstFrame) out.push(res.map);
218
+ }
219
+ console.log(out.join('\n'));
160
220
  }
161
221
  return;
162
222
  }
163
223
 
164
224
  case 'wait': {
165
225
  const res = await api.waitFor(device, {
166
- mode: flags.mode || (flags.change ? 'change' : 'stable'),
226
+ mode: flags.mode || (flags.change ? 'change' : 'settle'),
227
+ since: flags.since,
167
228
  stableMs: num(flags.stableMs, 600),
168
229
  timeoutMs: num(flags.timeoutMs, 8000),
169
230
  options,
170
231
  });
171
- console.log(
172
- `${res.satisfied ? 'settled' : 'timed out'} after ${res.waitedMs}ms — frame #${res.state.seq}, stable ${res.state.stableForMs}ms`,
173
- );
232
+ if (res.satisfied) {
233
+ console.log(
234
+ `${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
235
+ (res.changedBeforeWait ? ' (change had already happened before the call)' : ''),
236
+ );
237
+ } else if (res.noVisibleChange) {
238
+ console.log(
239
+ `no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`,
240
+ );
241
+ } else if (res.stalled) {
242
+ console.log(`capture stalled after ${res.waitedMs}ms — ${res.live.note}`);
243
+ } else {
244
+ console.log(
245
+ `timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
246
+ (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`'),
247
+ );
248
+ }
174
249
  process.exitCode = res.satisfied ? 0 : 1;
175
250
  return;
176
251
  }
@@ -189,6 +264,100 @@ async function main() {
189
264
  return;
190
265
  }
191
266
 
267
+ case 'recall': {
268
+ if (flags.ago != null) {
269
+ const res = await api.getFrameAt(device, { msAgo: num(flags.ago), options });
270
+ const out = flags.out || path.join(process.cwd(), 'simframe-recall.png');
271
+ fs.writeFileSync(out, res.png);
272
+ console.log(
273
+ `${out} — frame #${res.seq} from ${Math.round(res.actualMsAgo)}ms ago ` +
274
+ `(memory reaches back ${Math.round(res.oldestMsAgo / 1000)}s)`,
275
+ );
276
+ return;
277
+ }
278
+ const res = await api.getTimeline(device, { spanMs: num(flags.spanMs, 60_000), options });
279
+ if (flags.json) {
280
+ console.log(JSON.stringify({ ...res, state: undefined }, null, 2));
281
+ return;
282
+ }
283
+ console.log(
284
+ `${res.device.name} — remembering ${Math.round(res.coveredMs / 1000)}s, ${res.buffered} frames buffered`,
285
+ );
286
+ if (!res.events.length) {
287
+ console.log(`nothing changed; still for ${Math.round(res.idleForMs / 1000)}s`);
288
+ } else {
289
+ for (const e of res.events) {
290
+ console.log(
291
+ ` ${(e.startedMsAgo / 1000).toFixed(1)}s ago ${e.level === 'major' ? 'screen changed' : 'small change '} ` +
292
+ `${(e.magnitude * 100).toFixed(0)}% over ${(e.durationMs / 1000).toFixed(1)}s`,
293
+ );
294
+ }
295
+ console.log(`still for ${(res.idleForMs / 1000).toFixed(1)}s`);
296
+ }
297
+ return;
298
+ }
299
+
300
+ case 'ui': {
301
+ const { device: dev } = await api.ensureDaemon(device, options);
302
+ const driver = await input.detectDriver();
303
+ if (!driver.available) {
304
+ process.stderr.write(`${driver.reason}\n`);
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ let nodes = await input.describeAll(dev.udid);
309
+ if (flags.filter) {
310
+ const q = String(flags.filter).toLowerCase();
311
+ nodes = nodes.filter((n) => [n.label, n.value, n.identifier].filter(Boolean).join(' ').toLowerCase().includes(q));
312
+ }
313
+ if (flags.json) {
314
+ console.log(JSON.stringify(nodes.map(({ raw, ...n }) => n), null, 2));
315
+ return;
316
+ }
317
+ for (const n of nodes) {
318
+ const c = input.centerOf(n);
319
+ console.log(
320
+ `${(n.type || '?').padEnd(14)} ${String(`${c.x},${c.y}`).padEnd(10)} ` +
321
+ `${[n.label, n.value && `= ${n.value}`, n.identifier && `#${n.identifier}`].filter(Boolean).join(' ') || '(unlabelled)'}`,
322
+ );
323
+ }
324
+ return;
325
+ }
326
+
327
+ case 'tap': {
328
+ const label = positional[0];
329
+ if (!label) throw new Error('usage: simframe tap <label>');
330
+ const res = await actions.runScript(flags.device, {
331
+ steps: [{ tap: label, index: flags.index != null ? num(flags.index) : undefined }],
332
+ options,
333
+ });
334
+ const step = res.results[0];
335
+ if (!step.ok) throw new Error(step.error);
336
+ console.log(`${step.detail}${step.settled?.ok ? `, settled in ${step.settled.waitedMs}ms` : ''}`);
337
+ return;
338
+ }
339
+
340
+ case 'do': {
341
+ const file = positional[0];
342
+ if (!file) throw new Error('usage: simframe do <script.json>');
343
+ const steps = JSON.parse(fs.readFileSync(file, 'utf8'));
344
+ const res = await actions.runScript(flags.device, {
345
+ steps,
346
+ autoSettle: flags.autoSettle !== 'false',
347
+ stableMs: num(flags.stableMs, 500),
348
+ timeoutMs: num(flags.timeoutMs, 8000),
349
+ continueOnError: Boolean(flags.continueOnError),
350
+ options,
351
+ });
352
+ for (const r of res.results) {
353
+ const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
354
+ console.log(`${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}`);
355
+ }
356
+ console.log(`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`);
357
+ process.exitCode = res.ok ? 0 : 1;
358
+ return;
359
+ }
360
+
192
361
  case 'devices': {
193
362
  const all = await listDevices();
194
363
  const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
@@ -231,6 +400,15 @@ async function doctor() {
231
400
  } catch (err) {
232
401
  add('sips', false, err.message);
233
402
  }
403
+ const driver = await input.detectDriver();
404
+ add('input driver (idb)', driver.available, driver.available ? driver.version : driver.reason);
405
+ try {
406
+ const ocr = await import('./ocr.js');
407
+ const built = await ocr.ensureBinary();
408
+ add('on-device OCR', built.available, built.available ? 'available' : built.reason);
409
+ } catch (err) {
410
+ add('on-device OCR', false, err.message);
411
+ }
234
412
  try {
235
413
  const booted = await bootedDevices();
236
414
  add('booted simulator', booted.length > 0, booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
@@ -243,8 +421,13 @@ async function doctor() {
243
421
  add('capture', false, err.message);
244
422
  }
245
423
 
246
- for (const c of checks) console.log(`${c.ok ? 'ok ' : 'FAIL'} ${c.name.padEnd(18)} ${c.detail}`);
247
- process.exitCode = checks.every((c) => c.ok) ? 0 : 1;
424
+ for (const c of checks) {
425
+ const mark = c.ok ? 'ok ' : c.name.startsWith('input driver') ? 'none' : 'FAIL';
426
+ console.log(`${mark} ${c.name.padEnd(18)} ${c.detail}`);
427
+ }
428
+ // Input is optional: simframe is still useful as a pure observer.
429
+ const required = checks.filter((c) => !c.name.startsWith('input driver'));
430
+ process.exitCode = required.every((c) => c.ok) ? 0 : 1;
248
431
  }
249
432
 
250
433
  main().catch((err) => {