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.
Files changed (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
package/src/cli.js CHANGED
@@ -6,7 +6,9 @@ import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
6
6
  import * as actions from './actions.js';
7
7
  import * as api from './index.js';
8
8
  import * as input from './input.js';
9
+ import * as navigate from './navigate.js';
9
10
  import * as store from './store.js';
11
+ import * as view from './view.js';
10
12
 
11
13
  const USAGE = `simframe — always-warm iOS Simulator frames
12
14
 
@@ -20,33 +22,61 @@ const USAGE = `simframe — always-warm iOS Simulator frames
20
22
  simframe wait [device] wait for the screen to react (see --mode)
21
23
  simframe strip [device] write a contact sheet of recent frames
22
24
  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)
25
+ simframe ui [device] the screen as a numbered element map
26
+ simframe find "<intent>" resolve an intent to one control
27
+ simframe tap <selector> tap #3, "Save", or @120,400
28
+ simframe do <script.json> run a scripted flow (see below)
29
+ simframe screens [device] list screens this device has learned
30
+ simframe goto <screen> walk to a known screen through known steps
31
+ simframe flow save <name> <script.json> run a flow and save it if every step verifies
32
+ simframe flow run <name> replay a saved flow
33
+ simframe flow list list saved flows
34
+ simframe tapAt <x> <y> tap at a point, in points
35
+ simframe swipe <x1> <y1> <x2> <y2> swipe between two points
36
+ simframe type <text> enter text (exact; uses the pasteboard)
37
+ simframe keys <text> send key events instead (layout-dependent)
38
+ simframe press <button> a hardware button, e.g. home
26
39
  simframe devices list simulators
27
40
  simframe doctor check that this machine can capture
41
+ (--strict, or SIMFRAME_STRICT=1, makes any
42
+ degraded layer a non-zero exit)
43
+
44
+ Selectors — anywhere a control is named
45
+ #3 the number \`simframe ui\` gave it. Cheapest, unambiguous.
46
+ "Save" a label or a phrase, resolved by intent (verbs, typos, synonyms)
47
+ @120,400 raw point coordinates
28
48
 
29
49
  Options
30
50
  --device=<udid|name> simulator to target (default: the booted one)
31
- --out=<file> output path for frame/strip
51
+ --json machine-readable output — on every command
52
+ --out=<file> output path for frame/strip/recall
32
53
  --detail=low|normal|high|full or --detail=<max pixels>
33
- --fps=<n> capture rate while the screen is moving (default ${DEFAULTS.fps})
54
+ --engine=simframed|simctl capture engine (default simframed)
55
+ --fps=<n> capture rate while the screen is moving (simctl engine only)
34
56
  --count=<n> frames in a strip (default 5)
35
57
  --since=<hash|seq> compare against this frame (see: simframe mark)
36
58
  --mode=settle|change|stable what wait waits for (default settle)
37
59
  --stable-ms=<n> settle window for wait (default 600)
38
60
  --timeout-ms=<n> give up after this long (default 8000)
39
- --force let stop kill a loop another client is using
40
- --json machine-readable output
61
+ --filter=<text> ui: only elements whose text contains this
62
+ --interactive ui: only elements that look tappable
63
+ --all ui: include the status bar and collapsed regions
64
+ --refresh ui: re-read this screen instead of using memory
65
+ --save=<name> do: save the flow if every step verifies
66
+ --force let stop kill a loop another client is using;
67
+ let flow save keep an unverified flow
41
68
 
42
69
  A script is a JSON array of steps, run in one go with a settle between each:
43
70
 
44
71
  [{"tap":"Assets"},{"tap":"Add Asset"},
45
72
  {"type":{"into":"Name","text":"Fryer 3"}},
46
- {"tap":"Save"},{"waitText":"Saved","timeoutMs":5000}]
73
+ {"scrollTo":"Save"},{"tap":"Save"},
74
+ {"waitFor":{"value":"Saved","timeoutMs":5000}},
75
+ {"assert":{"value":"Saved","is":"visible"}}]
47
76
 
48
- Input needs idb (brew tap facebook/fb && brew install idb-companion,
49
- then pipx install fb-idb). Observation works without it.
77
+ Input, text recognition and the accessibility tree all come from the daemon.
78
+ Nothing else needs installing; idb remains a fallback for the tree and for
79
+ input on a machine where the daemon cannot run.
50
80
 
51
81
  The reliable pattern around an action is:
52
82
 
@@ -73,6 +103,39 @@ function parseArgs(argv) {
73
103
 
74
104
  const num = (v, fallback) => (v == null ? fallback : Number(v));
75
105
 
106
+ /**
107
+ * Print one thing, two ways.
108
+ *
109
+ * `--json` is on every command rather than most of them, because a skill or a
110
+ * script that has to parse one command's prose and another's JSON will parse
111
+ * the prose wrong exactly once and then be trusted anyway.
112
+ */
113
+ function emit(flags, json, lines) {
114
+ if (flags.json) {
115
+ console.log(JSON.stringify(json, null, 2));
116
+ return;
117
+ }
118
+ const body = typeof lines === 'function' ? lines() : lines;
119
+ if (body != null) console.log(Array.isArray(body) ? body.filter((l) => l != null).join('\n') : body);
120
+ }
121
+
122
+ /** The end-state screen map, rendered from a reading the flow already took. */
123
+ async function mapText(device, options, identity) {
124
+ try {
125
+ const m = await view.screenMap(device, { options, identity: identity?.entry ? identity : undefined });
126
+ return m.text;
127
+ } catch (err) {
128
+ return `(could not read the screen: ${err.message})`;
129
+ }
130
+ }
131
+
132
+ /** A step result, the same shape in every command that runs steps. */
133
+ const stepLine = (r) => {
134
+ const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
135
+ const verdict = r.verification && r.verification.verdict !== 'ok' ? ` [${r.verification.verdict}]` : '';
136
+ return `${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}${verdict}`;
137
+ };
138
+
76
139
  async function main() {
77
140
  const [command, ...rest] = process.argv.slice(2);
78
141
  const { flags, positional } = parseArgs(rest);
@@ -81,6 +144,7 @@ async function main() {
81
144
  if (flags.fps) options.fps = num(flags.fps);
82
145
  if (flags.maxDim) options.maxDim = num(flags.maxDim);
83
146
  if (flags.ringSize) options.ringSize = num(flags.ringSize);
147
+ if (flags.engine) options.engine = String(flags.engine);
84
148
 
85
149
  switch (command) {
86
150
  case undefined:
@@ -112,16 +176,32 @@ async function main() {
112
176
 
113
177
  case 'start': {
114
178
  const { device: dev, state, started } = await api.ensureDaemon(device, options);
179
+ const engineModule = await import('./engine.js');
180
+ const running = engineModule.runningEngine(dev.udid) ?? 'simctl';
115
181
  console.log(
116
- `${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) frame #${state.seq} ${state.width}x${state.height}`,
182
+ `${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) ` +
183
+ `engine=${running} frame #${state.seq} ${state.width}x${state.height}`,
117
184
  );
185
+ // Say which engine, and if it is the slow one, say why. A downgrade that
186
+ // prints nothing is how this shipped broken twice.
187
+ if (running !== 'simframed') {
188
+ const why = api.fallbackReason(dev.udid);
189
+ console.log(
190
+ `WARN engine=simctl — roughly 30x slower per frame. ` +
191
+ (why ? `simframed unavailable: ${why}` : 'reason unrecorded; run simframe doctor'),
192
+ );
193
+ if (Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1') {
194
+ console.error('--strict: refusing to run on a degraded engine');
195
+ process.exitCode = 1;
196
+ }
197
+ }
118
198
  return;
119
199
  }
120
200
 
121
201
  case 'stop': {
122
202
  const targets = flags.all
123
203
  ? fs.existsSync(store.ROOT)
124
- ? fs.readdirSync(store.ROOT)
204
+ ? fs.readdirSync(store.ROOT).filter(store.isUdid)
125
205
  : []
126
206
  : [(await resolveDevice(device)).udid];
127
207
  let stopped = 0;
@@ -142,7 +222,7 @@ async function main() {
142
222
  const udids = device
143
223
  ? [(await resolveDevice(device)).udid]
144
224
  : fs.existsSync(store.ROOT)
145
- ? fs.readdirSync(store.ROOT)
225
+ ? fs.readdirSync(store.ROOT).filter(store.isUdid)
146
226
  : [];
147
227
  const rows = udids.map((udid) => {
148
228
  const { meta, pid, alive, stale } = api.daemonStatus(udid);
@@ -177,13 +257,17 @@ async function main() {
177
257
  const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
178
258
  const out = flags.out || path.join(process.cwd(), 'simframe.png');
179
259
  fs.writeFileSync(out, res.png);
180
- console.log(`${out} — ${res.width}x${res.height}, ${res.ageMs}ms old, frame #${res.state.seq}`);
260
+ emit(
261
+ flags,
262
+ { file: out, width: res.width, height: res.height, ageMs: res.ageMs, seq: res.state.seq, hash: res.state.hash },
263
+ `${out} — ${res.width}x${res.height}, ${res.ageMs}ms old, frame #${res.state.seq}`,
264
+ );
181
265
  return;
182
266
  }
183
267
 
184
268
  case 'mark': {
185
269
  const res = await api.getState(device, { options });
186
- console.log(res.state.hash);
270
+ emit(flags, { hash: res.state.hash, seq: res.state.seq }, res.state.hash);
187
271
  return;
188
272
  }
189
273
 
@@ -229,23 +313,32 @@ async function main() {
229
313
  timeoutMs: num(flags.timeoutMs, 8000),
230
314
  options,
231
315
  });
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
- }
316
+ emit(
317
+ flags,
318
+ {
319
+ satisfied: res.satisfied,
320
+ mode: res.mode,
321
+ waitedMs: res.waitedMs,
322
+ sawChange: res.sawChange,
323
+ changedBeforeWait: Boolean(res.changedBeforeWait),
324
+ noVisibleChange: Boolean(res.noVisibleChange),
325
+ stalled: Boolean(res.stalled),
326
+ hash: res.state?.hash,
327
+ seq: res.state?.seq,
328
+ },
329
+ () => {
330
+ if (res.satisfied) {
331
+ return `${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
332
+ (res.changedBeforeWait ? ' (change had already happened before the call)' : '');
333
+ }
334
+ if (res.noVisibleChange) {
335
+ return `no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`;
336
+ }
337
+ if (res.stalled) return `capture stalled after ${res.waitedMs}ms — ${res.live.note}`;
338
+ return `timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
339
+ (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`');
340
+ },
341
+ );
249
342
  process.exitCode = res.satisfied ? 0 : 1;
250
343
  return;
251
344
  }
@@ -258,7 +351,9 @@ async function main() {
258
351
  });
259
352
  const out = flags.out || path.join(process.cwd(), 'simframe-strip.png');
260
353
  fs.writeFileSync(out, res.png);
261
- console.log(
354
+ emit(
355
+ flags,
356
+ { file: out, frames: res.frames.length, spanMs: res.spanMs, width: res.width, height: res.height },
262
357
  `${out} — ${res.frames.length} frames over ${res.spanMs}ms (${res.width}x${res.height})`,
263
358
  );
264
359
  return;
@@ -269,7 +364,9 @@ async function main() {
269
364
  const res = await api.getFrameAt(device, { msAgo: num(flags.ago), options });
270
365
  const out = flags.out || path.join(process.cwd(), 'simframe-recall.png');
271
366
  fs.writeFileSync(out, res.png);
272
- console.log(
367
+ emit(
368
+ flags,
369
+ { file: out, seq: res.seq, actualMsAgo: res.actualMsAgo, requestedMsAgo: res.requestedMsAgo, oldestMsAgo: res.oldestMsAgo },
273
370
  `${out} — frame #${res.seq} from ${Math.round(res.actualMsAgo)}ms ago ` +
274
371
  `(memory reaches back ${Math.round(res.oldestMsAgo / 1000)}s)`,
275
372
  );
@@ -298,29 +395,32 @@ async function main() {
298
395
  }
299
396
 
300
397
  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
- }
398
+ // The compact map, not a raw tree dump: region, a ref number, type, tap
399
+ // point, label. And no idb gate — OCR reads most screens on its own, and
400
+ // refusing to describe a screen because idb is missing was the surest way
401
+ // to make the fallback look broken.
402
+ const m = await view.screenMap(device, {
403
+ options,
404
+ filter: flags.filter,
405
+ interactive: Boolean(flags.interactive),
406
+ all: Boolean(flags.all),
407
+ refresh: Boolean(flags.refresh),
408
+ });
409
+ emit(
410
+ flags,
411
+ {
412
+ device: m.device.name,
413
+ screen: { hash: m.identity.hash, name: m.name, exits: m.exits, keyboard: m.identity.keyboard },
414
+ sources: m.identity.entry?.sources ?? [],
415
+ // Which layer is missing and why. A map built from one perception
416
+ // layer looks exactly like a map built from two until this says so.
417
+ degraded: m.identity.entry?.degraded ?? [],
418
+ points: m.screen,
419
+ elements: m.rows,
420
+ truncated: m.truncated,
421
+ },
422
+ m.text,
423
+ );
324
424
  return;
325
425
  }
326
426
 
@@ -328,12 +428,23 @@ async function main() {
328
428
  const label = positional[0];
329
429
  if (!label) throw new Error('usage: simframe tap <label>');
330
430
  const res = await actions.runScript(flags.device, {
331
- steps: [{ tap: label, index: flags.index != null ? num(flags.index) : undefined }],
431
+ steps: [flags.index != null ? { tap: label, index: num(flags.index) } : { tap: label }],
332
432
  options,
333
433
  });
334
434
  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` : ''}`);
435
+ if (!step.ok) {
436
+ if (flags.json) {
437
+ console.log(JSON.stringify({ ok: false, error: step.error }, null, 2));
438
+ process.exitCode = 1;
439
+ return;
440
+ }
441
+ throw new Error(step.error);
442
+ }
443
+ emit(
444
+ flags,
445
+ { ok: true, ...step },
446
+ `${step.detail}${step.settled?.ok ? `, settled in ${step.settled.waitedMs}ms` : ''}`,
447
+ );
337
448
  return;
338
449
  }
339
450
 
@@ -349,15 +460,237 @@ async function main() {
349
460
  continueOnError: Boolean(flags.continueOnError),
350
461
  options,
351
462
  });
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}`);
463
+ const saved = flags.save
464
+ ? navigate.saveFlow(res.device.udid, String(flags.save), res, { force: Boolean(flags.force) })
465
+ : null;
466
+ // `--map=false` arrives as the string "false"; `--no-map` as true.
467
+ const wantMap = !flags.json && flags.noMap !== true && String(flags.map ?? 'true') !== 'false';
468
+ const map = wantMap ? await mapText(flags.device, options, res.endScreen) : null;
469
+ emit(
470
+ flags,
471
+ {
472
+ ok: res.ok,
473
+ ranSteps: res.ranSteps,
474
+ totalSteps: res.totalSteps,
475
+ totalMs: res.totalMs,
476
+ results: res.results,
477
+ saved,
478
+ },
479
+ [
480
+ ...res.results.map(stepLine),
481
+ `${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`,
482
+ saved && (saved.ok ? `saved flow "${saved.name}" — ${saved.steps} steps` : `not saved: ${saved.reason}`),
483
+ map && `\n${map}`,
484
+ ],
485
+ );
486
+ process.exitCode = res.ok ? 0 : 1;
487
+ return;
488
+ }
489
+
490
+ case 'goto': {
491
+ const target = positional.join(' ').trim();
492
+ if (!target) throw new Error('usage: simframe goto "<screen>"');
493
+ const res = await navigate.goto(flags.device, target, {
494
+ stableMs: num(flags.stableMs, 500),
495
+ timeoutMs: num(flags.timeoutMs, 8000),
496
+ options,
497
+ });
498
+ const refusal = {
499
+ 'unknown-screen': () => [
500
+ `no screen matching "${target}". known screens:`,
501
+ ...(res.known ?? []).map((k) => ` ${k.name} (${k.hash}, ${k.edges} edges)`),
502
+ ],
503
+ ambiguous: () => [
504
+ `"${target}" matches more than one screen:`,
505
+ ...(res.candidates ?? []).map((c) => ` ${c.name} (${c.hash.slice(0, 8)})`),
506
+ ],
507
+ };
508
+ if (!res.ok && res.reason) {
509
+ emit(flags, res, refusal[res.reason] ?? `${res.reason}: cannot reach "${res.to ?? target}" from here`);
510
+ process.exitCode = 1;
511
+ return;
355
512
  }
356
- console.log(`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`);
513
+ emit(
514
+ flags,
515
+ res,
516
+ res.already
517
+ ? `already on ${res.screen}`
518
+ : [
519
+ ...(res.results ?? []).map(stepLine),
520
+ res.ok
521
+ ? `arrived at ${res.screen} in ${res.ranSteps} step(s)`
522
+ : `ended at ${res.arrived}, wanted ${res.screen}`,
523
+ ],
524
+ );
357
525
  process.exitCode = res.ok ? 0 : 1;
358
526
  return;
359
527
  }
360
528
 
529
+ case 'screens': {
530
+ // Reading what this device has learned is a file read. It used to go
531
+ // through ensureDaemon, so a device whose capture had stopped could not
532
+ // even list the screens already on disk — the tool went blind about
533
+ // things it already knew.
534
+ const device = await resolveDevice(flags.device);
535
+ const known = navigate.knownScreens(device.udid);
536
+ emit(
537
+ flags,
538
+ known,
539
+ known.length
540
+ ? known.map((k) => `${k.hash} ${k.edges} edges ${k.name}`)
541
+ : 'no screens known yet — run a flow first',
542
+ );
543
+ return;
544
+ }
545
+
546
+ case 'flow': {
547
+ const [sub, name] = positional;
548
+ // `list` is a directory read; `save` and `run` genuinely need the device
549
+ // awake, and each starts the daemon on its own path.
550
+ const device = await resolveDevice(flags.device);
551
+ if (sub === 'list') {
552
+ const flows = navigate.listFlows(device.udid);
553
+ emit(flags, flows, flows.length ? flows.map((f) => `${f.name} ${f.steps} steps`) : 'no saved flows');
554
+ return;
555
+ }
556
+ if (sub === 'save') {
557
+ const file = positional[2];
558
+ if (!name || !file) throw new Error('usage: simframe flow save <name> <script.json>');
559
+ const steps = JSON.parse(fs.readFileSync(file, 'utf8'));
560
+ const res = await actions.runScript(flags.device, {
561
+ steps,
562
+ stableMs: num(flags.stableMs, 500),
563
+ timeoutMs: num(flags.timeoutMs, 8000),
564
+ options,
565
+ });
566
+ const saved = navigate.saveFlow(device.udid, name, res, { force: Boolean(flags.force) });
567
+ if (!saved.ok) {
568
+ emit(flags, saved, `not saved: ${saved.reason} (${(saved.verdicts ?? []).join(', ')}) — re-run, or pass --force`);
569
+ process.exitCode = 1;
570
+ return;
571
+ }
572
+ emit(flags, saved, `saved ${saved.name} — ${saved.steps} steps`);
573
+ return;
574
+ }
575
+ if (sub === 'run') {
576
+ if (!name) throw new Error('usage: simframe flow run <name>');
577
+ const res = await navigate.runFlow(flags.device, name, {
578
+ stableMs: num(flags.stableMs, 500),
579
+ timeoutMs: num(flags.timeoutMs, 8000),
580
+ options,
581
+ });
582
+ if (res.reason === 'unknown-flow') {
583
+ emit(flags, res, `no flow "${name}". known: ${res.known.join(', ') || '(none)'}`);
584
+ process.exitCode = 1;
585
+ return;
586
+ }
587
+ emit(flags, res, [
588
+ ...(res.results ?? []).map(stepLine),
589
+ `${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`,
590
+ ]);
591
+ process.exitCode = res.ok ? 0 : 1;
592
+ return;
593
+ }
594
+ throw new Error('usage: simframe flow <list|save|run>');
595
+ }
596
+
597
+ case 'tapAt':
598
+ case 'swipe':
599
+ case 'type':
600
+ case 'keys':
601
+ case 'press': {
602
+ const input = await import('./input.js');
603
+ const dev = await resolveDevice(flags.device);
604
+ const nums = positional.map(Number);
605
+ // Only meaningful when frames are being captured; without them there is
606
+ // nothing to compare against and the command says only what it sent.
607
+ let before = null;
608
+ try {
609
+ before = (await api.getState(flags.device, { options })).state.hash;
610
+ } catch {
611
+ /* capture not running: fall through and report the send alone */
612
+ }
613
+ const t0 = Date.now();
614
+ switch (command) {
615
+ case 'tapAt': {
616
+ if (positional.length < 2 || nums.slice(0, 2).some(Number.isNaN)) {
617
+ throw new Error('usage: simframe tapAt <x> <y>');
618
+ }
619
+ await input.tapPoint(dev.udid, nums[0], nums[1], flags.durationMs ? { durationMs: num(flags.durationMs) } : {});
620
+ break;
621
+ }
622
+ case 'swipe': {
623
+ if (positional.length < 4 || nums.slice(0, 4).some(Number.isNaN)) {
624
+ throw new Error('usage: simframe swipe <x1> <y1> <x2> <y2>');
625
+ }
626
+ await input.swipe(dev.udid, { x: nums[0], y: nums[1] }, { x: nums[2], y: nums[3] }, { durationMs: num(flags.durationMs, 300) });
627
+ break;
628
+ }
629
+ case 'type':
630
+ if (!positional.length) throw new Error('usage: simframe type <text>');
631
+ await input.typeText(dev.udid, positional.join(' '));
632
+ break;
633
+ case 'keys':
634
+ if (!positional.length) throw new Error('usage: simframe keys <text>');
635
+ await input.typeKeys(dev.udid, positional.join(' '));
636
+ break;
637
+ default:
638
+ if (!positional.length) throw new Error('usage: simframe press <button>');
639
+ await input.pressButton(dev.udid, positional[0]);
640
+ }
641
+ const driver = await input.driverFor(dev.udid);
642
+ const ms = Date.now() - t0;
643
+ // Did the device act on it? Input has no feedback channel — a dispatched
644
+ // Indigo message reports success whether or not the device did anything,
645
+ // and this command once reported `press in 66ms` while the screen sat
646
+ // frozen. The frames are the only witness there is, so ask them.
647
+ let changed = null;
648
+ if (before) {
649
+ try {
650
+ await new Promise((r) => setTimeout(r, 400));
651
+ changed = (await api.getState(flags.device, { options })).state.hash !== before;
652
+ } catch {
653
+ /* no daemon, or capture is down: report the send and say nothing more */
654
+ }
655
+ }
656
+ // Deliberately not an accusation. Pressing home while already on the
657
+ // springboard legitimately changes nothing, and a warning that cries wolf
658
+ // is how a real one gets ignored.
659
+ const note = changed === false
660
+ ? ' — the screen did not change. That is expected if the press had nothing to do here;'
661
+ + ' if you expected a change, input may not be reaching the device —'
662
+ + ' `simframe stop --force && simframe start` rebuilds the session.'
663
+ : '';
664
+ emit(
665
+ flags,
666
+ { ok: true, command, ms, driver: driver.name, screenChanged: changed },
667
+ `${command} in ${ms}ms via ${driver.name}${changed === true ? ' — screen changed' : ''}${note}`,
668
+ );
669
+ return;
670
+ }
671
+
672
+ case 'find': {
673
+ const intent = positional.join(' ');
674
+ if (!intent) throw new Error('usage: simframe find "<intent>"');
675
+ try {
676
+ const r = await api.locate(flags.device, intent, { options });
677
+ emit(
678
+ flags,
679
+ { ok: true, target: r.target, score: r.score, from: r.from, reasons: r.reasons, alternatives: r.alternatives },
680
+ [
681
+ `${r.target.label ?? '(icon-only)'} @(${r.target.x},${r.target.y}) ` +
682
+ `${r.target.region ?? 'content'} ${r.target.type ?? '?'}/${r.target.source} score ${r.score ?? '-'}`,
683
+ r.reasons?.length ? ` because: ${r.reasons.join(', ')}` : null,
684
+ ...(r.alternatives ?? []).map((a) => ` also considered: "${a.label}" ${a.score}`),
685
+ ],
686
+ );
687
+ } catch (err) {
688
+ emit(flags, { ok: false, error: err.message }, err.message);
689
+ process.exitCode = 1;
690
+ }
691
+ return;
692
+ }
693
+
361
694
  case 'devices': {
362
695
  const all = await listDevices();
363
696
  const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
@@ -372,7 +705,11 @@ async function main() {
372
705
  }
373
706
 
374
707
  case 'doctor': {
375
- await doctor();
708
+ await doctor({
709
+ json: Boolean(flags.json),
710
+ strict: Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1',
711
+ device: flags.device,
712
+ });
376
713
  return;
377
714
  }
378
715
 
@@ -382,55 +719,190 @@ async function main() {
382
719
  }
383
720
  }
384
721
 
385
- async function doctor() {
722
+ /**
723
+ * Report every layer, and treat a silent downgrade as a problem.
724
+ *
725
+ * The tool's policy is to degrade rather than fail, which is right — a machine
726
+ * without a Swift toolchain should still capture frames. What was wrong was
727
+ * that degrading looked identical to working: a published package missing one
728
+ * file made every install fall back to the simctl engine, and another shipped
729
+ * OCR disabled. Both passed CI, and nothing printed a word.
730
+ *
731
+ * So a fallback is a `warn`, not an `ok`, and `--strict` (or SIMFRAME_STRICT=1)
732
+ * makes any warn a non-zero exit. CI runs strict; users see the warning.
733
+ *
734
+ * `optional` is a fourth level and a deliberate distinction, not a softer warn.
735
+ * A `warn` means this machine could be doing better and silently is not — the
736
+ * failure this whole mechanism exists to catch. `optional` means a dependency
737
+ * documented as optional is simply not installed, which doctor says plainly
738
+ * with install instructions. idb is the only one: it is optional, it is being
739
+ * removed, and a fresh machine without it has not degraded from anything.
740
+ * Strict fails on warn and fail, never on optional.
741
+ */
742
+ async function doctor({ json = false, strict = false, device } = {}) {
386
743
  const checks = [];
387
- const add = (name, ok, detail) => checks.push({ name, ok, detail });
744
+ // `level` is 'ok' | 'warn' | 'fail'. A warn means it works but not the way it
745
+ // should — the exact state that used to be invisible.
746
+ const add = (name, level, detail, extra = {}) => checks.push({ name, level, detail, ...extra });
747
+ const startedHere = [];
388
748
 
389
- add('node', true, process.version);
749
+ add('node', 'ok', process.version);
750
+ const { execFileSync } = await import('node:child_process');
390
751
  try {
391
- const { execFileSync } = await import('node:child_process');
392
- add('xcrun', true, execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
752
+ add('xcrun', 'ok', execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
393
753
  } catch (err) {
394
- add('xcrun', false, err.message);
754
+ add('xcrun', 'fail', err.message);
395
755
  }
396
756
  try {
397
- const { execFileSync } = await import('node:child_process');
398
757
  execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
399
- add('sips', true, 'available');
758
+ add('sips', 'ok', 'available');
400
759
  } catch (err) {
401
- add('sips', false, err.message);
760
+ add('sips', 'fail', err.message);
761
+ }
762
+
763
+ const engineModule = await import('./engine.js');
764
+ // Build first, then report. doctor compiles the daemon on demand, so
765
+ // reporting the state beforehand printed "present, not yet built" in output
766
+ // that was already false by the time it reached the terminal.
767
+ await engineModule.ensureBuilt().catch(() => {});
768
+ const build = engineModule.status();
769
+ if (!build.haveSource) {
770
+ add('simframed sources', 'fail', 'not present in this install — the daemon cannot be built', {
771
+ key: 'daemon.sources',
772
+ });
773
+ } else {
774
+ add('simframed sources', 'ok', build.haveBinary ? (build.stale ? 'present, binary stale' : 'present, built') : 'present, not yet built', {
775
+ key: 'daemon.sources',
776
+ });
402
777
  }
403
- const driver = await input.detectDriver();
404
- add('input driver (idb)', driver.available, driver.available ? driver.version : driver.reason);
778
+
779
+ let ocrAvailable = false;
405
780
  try {
406
781
  const ocr = await import('./ocr.js');
407
782
  const built = await ocr.ensureBinary();
408
- add('on-device OCR', built.available, built.available ? 'available' : built.reason);
783
+ ocrAvailable = Boolean(built.available);
784
+ add('on-device OCR', ocrAvailable ? 'ok' : 'warn', ocrAvailable ? 'available' : built.reason, {
785
+ key: 'ocr.available',
786
+ value: ocrAvailable,
787
+ });
409
788
  } catch (err) {
410
- add('on-device OCR', false, err.message);
789
+ add('on-device OCR', 'warn', err.message, { key: 'ocr.available', value: false });
411
790
  }
791
+
412
792
  try {
413
- const booted = await bootedDevices();
414
- add('booted simulator', booted.length > 0, booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
793
+ let booted = await bootedDevices();
794
+ // Respect --device. Without this, doctor reports on every booted simulator,
795
+ // which on a CI runner meant checking an Apple Vision Pro nobody asked
796
+ // about and failing strict on its layers.
797
+ if (device) {
798
+ const wanted = await resolveDevice(device);
799
+ booted = booted.filter((d) => d.udid === wanted.udid);
800
+ }
801
+ add('booted simulator', booted.length ? 'ok' : 'warn',
802
+ booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
803
+ for (const d of booted) {
804
+ const input = await import('./input.js');
805
+ const control = await import('./control.js');
806
+ // Start the engine before asking which engine is in use. Reading it first
807
+ // reports `simctl` on any machine where nothing happens to be running
808
+ // yet — a warning about a downgrade that has not occurred, and one that
809
+ // would have made the CI assertion fail for the wrong reason.
810
+ const wasRunning = Boolean(engineModule.runningEngine(d.udid));
811
+ await api.ensureDaemon(d.udid).catch(() => {});
812
+ if (!wasRunning) startedHere.push(d.udid);
813
+ // ensureDaemon waits for a frame; the control socket comes up a moment
814
+ // later. Asking immediately reports `idb` for a device whose own input
815
+ // path is seconds from ready — a race that would read as CI flake.
816
+ for (let i = 0; i < 40 && !control.available(d.udid); i += 1) {
817
+ await new Promise((r) => setTimeout(r, 50));
818
+ }
819
+ const driver = await input.driverFor(d.udid, { refresh: true });
820
+ // Which engine is actually capturing, from the daemon's own record.
821
+ // `control.available` answers a different question — whether the input
822
+ // socket is up — and using it here reported simctl on a machine that was
823
+ // capturing with simframed perfectly well.
824
+ const captureEngine = engineModule.runningEngine(d.udid) ?? 'simctl';
825
+ const daemon = captureEngine === 'simframed';
826
+ const why = captureEngine === 'simctl' ? api.fallbackReason(d.udid) : null;
827
+ add(`capture engine (${d.name})`, captureEngine === 'simframed' ? 'ok' : 'warn',
828
+ captureEngine === 'simframed'
829
+ ? 'simframed'
830
+ : `simctl — roughly 30x slower per frame${why ? `; simframed unavailable: ${why}` : '. Run simframe start to see why'}`,
831
+ { key: 'capture.engine', value: captureEngine });
832
+ add(`input driver (${d.name})`, driver.available ? (driver.name === 'simframed' ? 'ok' : 'warn') : 'warn',
833
+ driver.available ? `${driver.name}: ${driver.version}` : driver.reason,
834
+ { key: 'input.driver', value: driver.available ? driver.name : null });
835
+ add(`text recognition (${d.name})`, 'ok',
836
+ daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
837
+ const ax = await input.axDriverFor(d.udid);
838
+ // idb here is a downgrade unless it was asked for. `warn` means this
839
+ // machine could be doing better and silently is not; a driver someone
840
+ // selected on purpose is neither silent nor a surprise.
841
+ const axState = !ax.available ? 'optional' : ax.name === 'simframed' || ax.chosen ? 'ok' : 'warn';
842
+ add(`accessibility tree (${d.name})`, axState,
843
+ ax.available ? `${ax.name}: ${ax.version}` : `unavailable: ${ax.reason}`,
844
+ { key: 'ax.driver', value: ax.name });
845
+ }
415
846
  if (booted.length) {
416
847
  const t0 = Date.now();
417
848
  const res = await api.getFrame(booted[0].udid);
418
- add('capture', true, `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`);
849
+ add('capture', 'ok',
850
+ `frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`,
851
+ { key: 'capture.frames', value: res.state.seq });
419
852
  }
420
853
  } catch (err) {
421
- add('capture', false, err.message);
854
+ add('capture', 'fail', err.message);
422
855
  }
423
856
 
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}`);
857
+ // doctor is a diagnostic, not a way to start things. If it had to start a
858
+ // daemon to answer "which engine is in use", it stops it again rather than
859
+ // leaving a detached process behind.
860
+ for (const udid of startedHere) {
861
+ try { await api.stopDaemon(udid); } catch { /* best effort */ }
427
862
  }
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;
863
+
864
+ const failed = checks.filter((c) => c.level === 'fail');
865
+ const warned = checks.filter((c) => c.level === 'warn');
866
+ const optional = checks.filter((c) => c.level === 'optional');
867
+
868
+ if (json) {
869
+ const flat = {};
870
+ for (const c of checks) if (c.key) flat[c.key] = c.value;
871
+ console.log(JSON.stringify({
872
+ ok: failed.length === 0 && (!strict || warned.length === 0),
873
+ strict,
874
+ failures: failed.length,
875
+ warnings: warned.length,
876
+ optional: optional.length,
877
+ ...flat,
878
+ checks: checks.map(({ name, level, detail }) => ({ name, level, detail })),
879
+ }, null, 2));
880
+ } else {
881
+ const mark = { ok: 'ok ', warn: 'WARN', fail: 'FAIL', optional: '-- ' };
882
+ for (const c of checks) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
883
+ if (warned.length) {
884
+ console.log(`\n${warned.length} layer(s) degraded. simframe still works, but not at full speed or coverage:`);
885
+ for (const c of warned) console.log(` - ${c.name}: ${c.detail}`);
886
+ if (!strict) console.log('Use --strict to make this an error (CI does).');
887
+ }
888
+ if (optional.length) {
889
+ console.log(`\n${optional.length} optional layer(s) not installed (not a downgrade):`);
890
+ for (const c of optional) console.log(` - ${c.name}: ${c.detail}`);
891
+ }
892
+ }
893
+
894
+ process.exitCode = failed.length || (strict && warned.length) ? 1 : 0;
431
895
  }
432
896
 
433
897
  main().catch((err) => {
434
- process.stderr.write(`simframe: ${err.message}\n`);
898
+ // A caller that asked for JSON gets JSON, failures included. Printing prose
899
+ // here handed `JSON.parse` a SyntaxError instead of a reason, so a script
900
+ // could not tell "the daemon lost the display" from "simframe is broken" —
901
+ // which is the whole point of a machine-readable interface.
902
+ if (process.argv.includes('--json')) {
903
+ process.stdout.write(`${JSON.stringify({ ok: false, error: err.message }, null, 2)}\n`);
904
+ } else {
905
+ process.stderr.write(`simframe: ${err.message}\n`);
906
+ }
435
907
  process.exitCode = 1;
436
908
  });