simframe 0.5.0 → 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/src/cli.js CHANGED
@@ -8,6 +8,7 @@ import * as api from './index.js';
8
8
  import * as input from './input.js';
9
9
  import * as navigate from './navigate.js';
10
10
  import * as store from './store.js';
11
+ import * as view from './view.js';
11
12
 
12
13
  const USAGE = `simframe — always-warm iOS Simulator frames
13
14
 
@@ -21,27 +22,34 @@ const USAGE = `simframe — always-warm iOS Simulator frames
21
22
  simframe wait [device] wait for the screen to react (see --mode)
22
23
  simframe strip [device] write a contact sheet of recent frames
23
24
  simframe recall [device] what happened in the last minute (--ago=<ms> for a frame)
24
- simframe tapAt <x> <y> tap at a point, in points
25
- simframe swipe <x1> <y1> <x2> <y2> swipe between two points
26
- simframe type <text> enter text (exact; uses the pasteboard)
27
- simframe keys <text> send key events instead (layout-dependent)
28
- simframe press <button> a hardware button, e.g. home
29
- simframe ui [device] read the screen as an accessibility tree
30
- simframe tap <label> tap an element by its accessibility label
31
- 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)
32
29
  simframe screens [device] list screens this device has learned
33
30
  simframe goto <screen> walk to a known screen through known steps
34
31
  simframe flow save <name> <script.json> run a flow and save it if every step verifies
35
32
  simframe flow run <name> replay a saved flow
36
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
37
39
  simframe devices list simulators
38
- simframe doctor [--json] check that this machine can capture
40
+ simframe doctor check that this machine can capture
39
41
  (--strict, or SIMFRAME_STRICT=1, makes any
40
42
  degraded layer a non-zero exit)
41
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
48
+
42
49
  Options
43
50
  --device=<udid|name> simulator to target (default: the booted one)
44
- --out=<file> output path for frame/strip
51
+ --json machine-readable output — on every command
52
+ --out=<file> output path for frame/strip/recall
45
53
  --detail=low|normal|high|full or --detail=<max pixels>
46
54
  --engine=simframed|simctl capture engine (default simframed)
47
55
  --fps=<n> capture rate while the screen is moving (simctl engine only)
@@ -50,18 +58,25 @@ Options
50
58
  --mode=settle|change|stable what wait waits for (default settle)
51
59
  --stable-ms=<n> settle window for wait (default 600)
52
60
  --timeout-ms=<n> give up after this long (default 8000)
53
- --force let stop kill a loop another client is using
54
- --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
55
68
 
56
69
  A script is a JSON array of steps, run in one go with a settle between each:
57
70
 
58
71
  [{"tap":"Assets"},{"tap":"Add Asset"},
59
72
  {"type":{"into":"Name","text":"Fryer 3"}},
60
- {"tap":"Save"},{"waitText":"Saved","timeoutMs":5000}]
73
+ {"scrollTo":"Save"},{"tap":"Save"},
74
+ {"waitFor":{"value":"Saved","timeoutMs":5000}},
75
+ {"assert":{"value":"Saved","is":"visible"}}]
61
76
 
62
- Input comes from the daemon. idb is needed only for the accessibility tree
63
- (brew tap facebook/fb && brew install idb-companion,
64
- 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.
65
80
 
66
81
  The reliable pattern around an action is:
67
82
 
@@ -88,6 +103,39 @@ function parseArgs(argv) {
88
103
 
89
104
  const num = (v, fallback) => (v == null ? fallback : Number(v));
90
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
+
91
139
  async function main() {
92
140
  const [command, ...rest] = process.argv.slice(2);
93
141
  const { flags, positional } = parseArgs(rest);
@@ -209,13 +257,17 @@ async function main() {
209
257
  const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
210
258
  const out = flags.out || path.join(process.cwd(), 'simframe.png');
211
259
  fs.writeFileSync(out, res.png);
212
- 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
+ );
213
265
  return;
214
266
  }
215
267
 
216
268
  case 'mark': {
217
269
  const res = await api.getState(device, { options });
218
- console.log(res.state.hash);
270
+ emit(flags, { hash: res.state.hash, seq: res.state.seq }, res.state.hash);
219
271
  return;
220
272
  }
221
273
 
@@ -261,23 +313,32 @@ async function main() {
261
313
  timeoutMs: num(flags.timeoutMs, 8000),
262
314
  options,
263
315
  });
264
- if (res.satisfied) {
265
- console.log(
266
- `${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
267
- (res.changedBeforeWait ? ' (change had already happened before the call)' : ''),
268
- );
269
- } else if (res.noVisibleChange) {
270
- console.log(
271
- `no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`,
272
- );
273
- } else if (res.stalled) {
274
- console.log(`capture stalled after ${res.waitedMs}ms — ${res.live.note}`);
275
- } else {
276
- console.log(
277
- `timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
278
- (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`'),
279
- );
280
- }
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
+ );
281
342
  process.exitCode = res.satisfied ? 0 : 1;
282
343
  return;
283
344
  }
@@ -290,7 +351,9 @@ async function main() {
290
351
  });
291
352
  const out = flags.out || path.join(process.cwd(), 'simframe-strip.png');
292
353
  fs.writeFileSync(out, res.png);
293
- console.log(
354
+ emit(
355
+ flags,
356
+ { file: out, frames: res.frames.length, spanMs: res.spanMs, width: res.width, height: res.height },
294
357
  `${out} — ${res.frames.length} frames over ${res.spanMs}ms (${res.width}x${res.height})`,
295
358
  );
296
359
  return;
@@ -301,7 +364,9 @@ async function main() {
301
364
  const res = await api.getFrameAt(device, { msAgo: num(flags.ago), options });
302
365
  const out = flags.out || path.join(process.cwd(), 'simframe-recall.png');
303
366
  fs.writeFileSync(out, res.png);
304
- console.log(
367
+ emit(
368
+ flags,
369
+ { file: out, seq: res.seq, actualMsAgo: res.actualMsAgo, requestedMsAgo: res.requestedMsAgo, oldestMsAgo: res.oldestMsAgo },
305
370
  `${out} — frame #${res.seq} from ${Math.round(res.actualMsAgo)}ms ago ` +
306
371
  `(memory reaches back ${Math.round(res.oldestMsAgo / 1000)}s)`,
307
372
  );
@@ -330,29 +395,32 @@ async function main() {
330
395
  }
331
396
 
332
397
  case 'ui': {
333
- const { device: dev } = await api.ensureDaemon(device, options);
334
- const driver = await input.detectDriver();
335
- if (!driver.available) {
336
- process.stderr.write(`${driver.reason}\n`);
337
- process.exitCode = 1;
338
- return;
339
- }
340
- let nodes = await input.describeAll(dev.udid);
341
- if (flags.filter) {
342
- const q = String(flags.filter).toLowerCase();
343
- nodes = nodes.filter((n) => [n.label, n.value, n.identifier].filter(Boolean).join(' ').toLowerCase().includes(q));
344
- }
345
- if (flags.json) {
346
- console.log(JSON.stringify(nodes.map(({ raw, ...n }) => n), null, 2));
347
- return;
348
- }
349
- for (const n of nodes) {
350
- const c = input.centerOf(n);
351
- console.log(
352
- `${(n.type || '?').padEnd(14)} ${String(`${c.x},${c.y}`).padEnd(10)} ` +
353
- `${[n.label, n.value && `= ${n.value}`, n.identifier && `#${n.identifier}`].filter(Boolean).join(' ') || '(unlabelled)'}`,
354
- );
355
- }
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
+ );
356
424
  return;
357
425
  }
358
426
 
@@ -364,8 +432,19 @@ async function main() {
364
432
  options,
365
433
  });
366
434
  const step = res.results[0];
367
- if (!step.ok) throw new Error(step.error);
368
- 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
+ );
369
448
  return;
370
449
  }
371
450
 
@@ -381,11 +460,29 @@ async function main() {
381
460
  continueOnError: Boolean(flags.continueOnError),
382
461
  options,
383
462
  });
384
- for (const r of res.results) {
385
- const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
386
- console.log(`${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}`);
387
- }
388
- console.log(`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`);
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
+ );
389
486
  process.exitCode = res.ok ? 0 : 1;
390
487
  return;
391
488
  }
@@ -398,53 +495,62 @@ async function main() {
398
495
  timeoutMs: num(flags.timeoutMs, 8000),
399
496
  options,
400
497
  });
401
- if (!res.ok && res.reason === 'unknown-screen') {
402
- console.log(`no screen matching "${target}". known screens:`);
403
- for (const s of res.known) console.log(` ${s.name} (${s.hash}, ${s.edges} edges)`);
404
- process.exitCode = 1;
405
- return;
406
- }
407
- if (!res.ok && res.reason === 'ambiguous') {
408
- console.log(`"${target}" matches more than one screen:`);
409
- for (const c of res.candidates) console.log(` ${c.name} (${c.hash.slice(0, 8)})`);
410
- process.exitCode = 1;
411
- return;
412
- }
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
+ };
413
508
  if (!res.ok && res.reason) {
414
- console.log(`${res.reason}: cannot reach "${res.to ?? target}" from here`);
509
+ emit(flags, res, refusal[res.reason] ?? `${res.reason}: cannot reach "${res.to ?? target}" from here`);
415
510
  process.exitCode = 1;
416
511
  return;
417
512
  }
418
- if (res.already) {
419
- console.log(`already on ${res.screen}`);
420
- return;
421
- }
422
- for (const r of res.results ?? []) {
423
- console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.action}: ${r.verification?.verdict ?? (r.ok ? r.detail : r.error)}`);
424
- }
425
- console.log(res.ok ? `arrived at ${res.screen} in ${res.ranSteps} step(s)` : `ended at ${res.arrived}, wanted ${res.screen}`);
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
+ );
426
525
  process.exitCode = res.ok ? 0 : 1;
427
526
  return;
428
527
  }
429
528
 
430
529
  case 'screens': {
431
- const { device } = await api.ensureDaemon(flags.device, options);
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);
432
535
  const known = navigate.knownScreens(device.udid);
433
- if (!known.length) {
434
- console.log('no screens known yet — run a flow first');
435
- return;
436
- }
437
- for (const s of known) console.log(`${s.hash} ${s.edges} edges ${s.name}`);
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
+ );
438
543
  return;
439
544
  }
440
545
 
441
546
  case 'flow': {
442
547
  const [sub, name] = positional;
443
- const { device } = await api.ensureDaemon(flags.device, options);
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);
444
551
  if (sub === 'list') {
445
552
  const flows = navigate.listFlows(device.udid);
446
- if (!flows.length) console.log('no saved flows');
447
- for (const f of flows) console.log(`${f.name} ${f.steps} steps`);
553
+ emit(flags, flows, flows.length ? flows.map((f) => `${f.name} ${f.steps} steps`) : 'no saved flows');
448
554
  return;
449
555
  }
450
556
  if (sub === 'save') {
@@ -459,11 +565,11 @@ async function main() {
459
565
  });
460
566
  const saved = navigate.saveFlow(device.udid, name, res, { force: Boolean(flags.force) });
461
567
  if (!saved.ok) {
462
- console.log(`not saved: ${saved.reason} (${saved.verdicts.join(', ')}) — re-run, or pass --force`);
568
+ emit(flags, saved, `not saved: ${saved.reason} (${(saved.verdicts ?? []).join(', ')}) — re-run, or pass --force`);
463
569
  process.exitCode = 1;
464
570
  return;
465
571
  }
466
- console.log(`saved ${saved.name} — ${saved.steps} steps`);
572
+ emit(flags, saved, `saved ${saved.name} — ${saved.steps} steps`);
467
573
  return;
468
574
  }
469
575
  if (sub === 'run') {
@@ -474,14 +580,14 @@ async function main() {
474
580
  options,
475
581
  });
476
582
  if (res.reason === 'unknown-flow') {
477
- console.log(`no flow "${name}". known: ${res.known.join(', ') || '(none)'}`);
583
+ emit(flags, res, `no flow "${name}". known: ${res.known.join(', ') || '(none)'}`);
478
584
  process.exitCode = 1;
479
585
  return;
480
586
  }
481
- for (const r of res.results ?? []) {
482
- console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.action}: ${r.verification?.verdict ?? (r.ok ? r.detail : r.error)}`);
483
- }
484
- console.log(`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`);
587
+ emit(flags, res, [
588
+ ...(res.results ?? []).map(stepLine),
589
+ `${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`,
590
+ ]);
485
591
  process.exitCode = res.ok ? 0 : 1;
486
592
  return;
487
593
  }
@@ -496,6 +602,14 @@ async function main() {
496
602
  const input = await import('./input.js');
497
603
  const dev = await resolveDevice(flags.device);
498
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
+ }
499
613
  const t0 = Date.now();
500
614
  switch (command) {
501
615
  case 'tapAt': {
@@ -525,7 +639,33 @@ async function main() {
525
639
  await input.pressButton(dev.udid, positional[0]);
526
640
  }
527
641
  const driver = await input.driverFor(dev.udid);
528
- console.log(`${command} in ${Date.now() - t0}ms via ${driver.name}`);
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
+ );
529
669
  return;
530
670
  }
531
671
 
@@ -534,14 +674,18 @@ async function main() {
534
674
  if (!intent) throw new Error('usage: simframe find "<intent>"');
535
675
  try {
536
676
  const r = await api.locate(flags.device, intent, { options });
537
- console.log(
538
- `${r.target.label ?? '(icon-only)'} @(${r.target.x},${r.target.y}) ` +
539
- `${r.target.region ?? 'content'} ${r.target.type ?? '?'}/${r.target.source} score ${r.score ?? '-'}`,
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
+ ],
540
686
  );
541
- if (r.reasons?.length) console.log(` because: ${r.reasons.join(', ')}`);
542
- for (const a of r.alternatives ?? []) console.log(` also considered: "${a.label}" ${a.score}`);
543
687
  } catch (err) {
544
- console.log(err.message);
688
+ emit(flags, { ok: false, error: err.message }, err.message);
545
689
  process.exitCode = 1;
546
690
  }
547
691
  return;
@@ -690,10 +834,14 @@ async function doctor({ json = false, strict = false, device } = {}) {
690
834
  { key: 'input.driver', value: driver.available ? driver.name : null });
691
835
  add(`text recognition (${d.name})`, 'ok',
692
836
  daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
693
- const ax = await input.detectDriver();
694
- add(`accessibility tree (${d.name})`, ax.available ? 'ok' : 'optional',
695
- ax.available ? 'idb — the only thing idb is still required for' : `not installed: ${ax.reason}`,
696
- { key: 'ax.driver', value: ax.available ? 'idb' : null });
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 });
697
845
  }
698
846
  if (booted.length) {
699
847
  const t0 = Date.now();
@@ -747,6 +895,14 @@ async function doctor({ json = false, strict = false, device } = {}) {
747
895
  }
748
896
 
749
897
  main().catch((err) => {
750
- 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
+ }
751
907
  process.exitCode = 1;
752
908
  });
package/src/control.js CHANGED
@@ -66,6 +66,7 @@ export const type = (udid, text) => request(udid, { action: 'type', text });
66
66
  export const paste = (udid, text) => request(udid, { action: 'paste', text });
67
67
  export const press = (udid, button) => request(udid, { action: 'press', button });
68
68
  export const status = (udid) => request(udid, { action: 'status' });
69
+ export const resetInput = (udid) => request(udid, { action: 'resetInput' });
69
70
  export const longPress = (udid, x, y, opts = {}) => request(udid, { action: 'longPress', x, y, ...opts });
70
71
  export const drag = (udid, from, to, opts = {}) =>
71
72
  request(udid, { action: 'drag', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
@@ -64,6 +64,38 @@ function bucket(n) {
64
64
 
65
65
  const normLabel = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 40);
66
66
 
67
+ /**
68
+ * A value, not a name.
69
+ *
70
+ * Phase 6d removed a date banner from one screen's identity after that
71
+ * fingerprint would have expired at midnight. It came back through a different
72
+ * door: measured across twenty screens of a real app, three carried content in
73
+ * their identity because the positional region bands had called it chrome — a
74
+ * store address, a phone number, and a nav title reading "Tuesday, September 8".
75
+ * That last one is a screen whose identity has until midnight to live.
76
+ *
77
+ * The band misclassification is the root cause and is fixed by clustering, not
78
+ * by another threshold (docs/DEFERRED.md). What can be fixed here without
79
+ * guessing at geometry is the narrower question: is this text a name for the
80
+ * screen, or is it today's value? A name is words. A date, a phone number, a
81
+ * price and a bare count are not, and every one of them changes while the
82
+ * screen stays the same screen.
83
+ */
84
+ const MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
85
+ const WEEKDAYS = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*day?\b/i;
86
+ const DATE_LIKE = /\d{1,4}[/.-]\d{1,2}([/.-]\d{1,4})?|\b\d{1,2}:\d{2}\b/;
87
+
88
+ export function isVolatileLabel(label) {
89
+ const text = String(label ?? '').trim();
90
+ if (!text) return true;
91
+ if (MONTHS.test(text) || WEEKDAYS.test(text) || DATE_LIKE.test(text)) return true;
92
+ const letters = (text.match(/\p{L}/gu) ?? []).length;
93
+ const digits = (text.match(/\p{N}/gu) ?? []).length;
94
+ // Mostly digits: a count, a price, a phone number, an ID. "1020" and
95
+ // "+1 (111) 111-1111" are both this; "Assets" is not.
96
+ return digits > 0 && digits >= letters;
97
+ }
98
+
67
99
  /**
68
100
  * The canonical tokens this screen is made of.
69
101
  *
@@ -100,6 +132,7 @@ export function tokens(targets, screen) {
100
132
  // tab label is narrow; content that merely fell into the band is not a name.
101
133
  const labelWorthKeeping = CHROME.has(region)
102
134
  && t.label
135
+ && !isVolatileLabel(t.label)
103
136
  && (region !== 'tab-bar' || (frame.width ?? 0) <= screen.width * TAB_LABEL_MAX_WIDTH_FRACTION);
104
137
  if (labelWorthKeeping) parts.push(`"${normLabel(t.label)}"`);
105
138
  const key = parts.join(':');
package/src/graph.js CHANGED
@@ -205,8 +205,10 @@ export function describe(node) {
205
205
  // that happens to sit in the nav bar is not a name for anything.
206
206
  const title = labels(/:nav-bar:@title:/);
207
207
  if (title.length) return title.join(' ');
208
+ // Three at most. A screen named after seven tab-bar fragments — several of
209
+ // them OCR reading a divider — is not a name anybody can type into `goto`.
208
210
  const tabs = labels(/:tab-bar:/);
209
- if (tabs.length) return tabs.join(' / ');
211
+ if (tabs.length) return tabs.slice(0, 3).join(' / ');
210
212
  const anyChrome = labels(/:(nav-bar|tab-bar):/);
211
213
  if (anyChrome.length) return anyChrome.slice(0, 3).join(' ');
212
214
  return node.hash.slice(0, 8);