simframe 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -278,6 +278,10 @@ async function main() {
278
278
  if (flags.sensor) options.sensor = String(flags.sensor);
279
279
  if (flags.planner) options.planner = String(flags.planner);
280
280
  if (flags.supervisor) options.supervisor = String(flags.supervisor);
281
+ // Stated rather than defaulted, which is the same rule the settle budgets
282
+ // follow. The default is sized on a developer's machine; a loaded build farm
283
+ // is a different machine and should say so rather than be guessed at.
284
+ if (flags.readyTimeoutMs) options.readyTimeoutMs = num(flags.readyTimeoutMs);
281
285
 
282
286
  switch (command) {
283
287
  case undefined:
@@ -463,6 +467,77 @@ async function main() {
463
467
  }
464
468
 
465
469
  case 'frame': {
470
+ // `--fresh` captures independently of the daemon, and then says whether
471
+ // the two agree.
472
+ //
473
+ // This is the arbiter three field reports had to leave simframe to get.
474
+ // When `sim_look` served a three-hour-stale image labelled `130ms old`,
475
+ // the thing that finally settled it was `xcrun simctl io … screenshot` —
476
+ // run by hand, outside the tool, because nothing inside offered an
477
+ // independent read. Worse, the obvious candidate lies: `--engine` decides
478
+ // how to *start* a daemon, so passing `--engine=screenshot` to a read
479
+ // command returns the running daemon's cached frame. A tester compared
480
+ // the two, got byte-identical files with the same frame number, and
481
+ // reasonably concluded "the fallback engine is not an escape hatch".
482
+ //
483
+ // One command now answers the question the escape hatch was for: capture
484
+ // the screen twice by two different paths and report whether they agree.
485
+ if (flags.fresh) {
486
+ const dev = await resolveDevice(device);
487
+ const out = flags.out || path.join(process.cwd(), 'simframe.png');
488
+ await screenshot(dev.udid, out);
489
+ const png = fs.readFileSync(out);
490
+ // The daemon's own newest frame, for comparison. Absent is fine and
491
+ // interesting in itself: an independent capture that works while the
492
+ // daemon has none is exactly the wedge.
493
+ let cached = null;
494
+ try {
495
+ cached = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
496
+ } catch { /* no daemon, or it has nothing — reported below */ }
497
+ // Compared by *content*, never by bytes. The direct capture is a
498
+ // full-resolution PNG and the daemon's is downscaled, so a byte
499
+ // comparison says "different" every time — which is a confident wrong
500
+ // answer about the one question this command exists to settle. Both
501
+ // are decoded and reduced to the same region signature the change
502
+ // detector already uses, and `signatureDiff` is the same measure that
503
+ // decides whether a screen moved.
504
+ let diff = null;
505
+ if (cached) {
506
+ try {
507
+ const a = analyze.regionSignature(decodePng(png));
508
+ const b = analyze.regionSignature(decodePng(cached.png));
509
+ diff = analyze.signatureDiff(a, b);
510
+ } catch { /* an undecodable frame is reported as "could not compare" */ }
511
+ }
512
+ // The threshold the daemon itself calls a change. Below it the two
513
+ // paths are looking at the same screen.
514
+ const same = diff == null ? null : diff <= analyze.PATHS_AGREE;
515
+ emit(
516
+ flags,
517
+ {
518
+ file: out,
519
+ fresh: true,
520
+ bytes: png.length,
521
+ daemonSeq: cached?.state?.seq ?? null,
522
+ daemonAgeMs: cached?.ageMs ?? null,
523
+ agrees: same,
524
+ difference: diff == null ? null : Number(diff.toFixed(4)),
525
+ },
526
+ [
527
+ `${out} — captured directly from the device, ${png.length} bytes`,
528
+ cached
529
+ ? `the daemon's newest frame is #${cached.state.seq}, ${cached.ageMs}ms old`
530
+ + (same == null
531
+ ? ' — could not be compared (one of the two would not decode)'
532
+ : same
533
+ ? ` — the two paths agree (difference ${diff.toFixed(4)}, under the ${analyze.PATHS_AGREE} two paths may differ by)`
534
+ : ` — they DISAGREE (difference ${diff.toFixed(4)}). Two capture paths see different screens;`
535
+ + ' this file is the one that bypassed the daemon. `simframe revive` re-attaches capture.')
536
+ : 'the daemon has no frame to compare against, while a direct capture worked',
537
+ ],
538
+ );
539
+ return;
540
+ }
466
541
  const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
467
542
  const out = flags.out || path.join(process.cwd(), 'simframe.png');
468
543
  fs.writeFileSync(out, res.png);
@@ -487,7 +562,9 @@ async function main() {
487
562
  } else {
488
563
  const s = res.state;
489
564
  const out = [];
490
- if (!res.live.ok) out.push(`WARNING: ${res.live.note}`);
565
+ // Any note, not only a failing one: a dead surface reports `ok` with
566
+ // something important to say. See `liveness`.
567
+ if (res.live.note) out.push(`WARNING: ${res.live.note}`);
491
568
  // A cause, rather than five silent no-ops. Every tap on a stale
492
569
  // session is dispatched successfully and moves nothing.
493
570
  if (res.input?.stale) out.push(`input: stale — ${res.input.reason}`);
@@ -542,20 +619,36 @@ async function main() {
542
619
  changedBeforeWait: Boolean(res.changedBeforeWait),
543
620
  noVisibleChange: Boolean(res.noVisibleChange),
544
621
  stalled: Boolean(res.stalled),
622
+ // Where it was still moving, when it never stopped — item 123.
623
+ //
624
+ // `waitFor` has computed this since it learned to, and this payload
625
+ // is hand-built, so the field existed and no caller could see it. I
626
+ // read a null here and nearly concluded the tracking was broken; it
627
+ // was the reporting.
628
+ motion: res.motion ?? null,
629
+ animating: res.animating ?? null,
545
630
  hash: res.state?.hash,
546
631
  seq: res.state?.seq,
547
632
  },
548
633
  () => {
549
634
  if (res.satisfied) {
550
635
  return `${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
551
- (res.changedBeforeWait ? ' (change had already happened before the call)' : '');
636
+ (res.changedBeforeWait ? ' (change had already happened before the call)' : '') +
637
+ (res.animating
638
+ ? `\nbut a ${res.animating.width}x${res.animating.height} region is still animating`
639
+ + ' — the stillness signal is a mean and cannot see it'
640
+ : '');
552
641
  }
553
642
  if (res.noVisibleChange) {
554
643
  return `no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`;
555
644
  }
556
645
  if (res.stalled) return `capture stalled after ${res.waitedMs}ms — ${res.live.note}`;
557
646
  return `timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
558
- (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`');
647
+ (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`') +
648
+ (res.motion
649
+ ? `\nthe movement is ${res.motion.where}`
650
+ + `${res.motion.localised ? ` (${res.motion.share}% of it)` : ''}:\n${res.motion.map}`
651
+ : '');
559
652
  },
560
653
  );
561
654
  process.exitCode = res.satisfied ? 0 : 1;
@@ -734,12 +827,25 @@ async function main() {
734
827
  `"${target}" matches more than one screen:`,
735
828
  ...(res.candidates ?? []).map((c) => ` ${c.name} (${c.hash.slice(0, 8)})`),
736
829
  ],
830
+ // Both of these walked, so the steps they took are the useful part and
831
+ // are printed exactly as a flow prints them.
832
+ 'route-halted': () => [
833
+ ...(res.results ?? []).map(stepLine),
834
+ `stopped after ${res.ranSteps} of ${res.steps?.length} step(s) on the way to ${res.screen}`,
835
+ ],
836
+ 'arrived-elsewhere': () => [
837
+ ...(res.results ?? []).map(stepLine),
838
+ `ended at ${res.arrived}, wanted ${res.screen} — every step ran, so an edge the graph`
839
+ + ' remembers no longer leads where it says. Re-walk it and the graph will relearn.',
840
+ ],
737
841
  };
738
842
  if (!res.ok && res.reason) {
739
843
  emit(flags, res, refusal[res.reason] ?? `${res.reason}: cannot reach "${res.to ?? target}" from here`);
740
844
  process.exitCode = 1;
741
845
  return;
742
846
  }
847
+ // Everything that is not ok now carries a reason and was handled above,
848
+ // so this is the arrival path only.
743
849
  emit(
744
850
  flags,
745
851
  res,
@@ -747,9 +853,7 @@ async function main() {
747
853
  ? `already on ${res.screen}`
748
854
  : [
749
855
  ...(res.results ?? []).map(stepLine),
750
- res.ok
751
- ? `arrived at ${res.screen} in ${res.ranSteps} step(s)`
752
- : `ended at ${res.arrived}, wanted ${res.screen}`,
856
+ `arrived at ${res.screen} in ${res.ranSteps} step(s)`,
753
857
  ],
754
858
  );
755
859
  process.exitCode = res.ok ? 0 : 1;
@@ -841,11 +945,15 @@ async function main() {
841
945
  /* capture not running: fall through and report the send alone */
842
946
  }
843
947
  const t0 = Date.now();
948
+ // Item 120, the single-shot half: a coordinate that changed nothing owes
949
+ // an answer about what it landed on.
950
+ let aim = null;
844
951
  switch (command) {
845
952
  case 'tapAt': {
846
953
  if (positional.length < 2 || nums.slice(0, 2).some(Number.isNaN)) {
847
954
  throw new Error('usage: simframe tapAt <x> <y>');
848
955
  }
956
+ aim = { point: { x: nums[0], y: nums[1] }, what: 'the tap point' };
849
957
  await input.tapPoint(dev.udid, nums[0], nums[1], flags.durationMs ? { durationMs: num(flags.durationMs) } : {});
850
958
  break;
851
959
  }
@@ -853,6 +961,7 @@ async function main() {
853
961
  if (positional.length < 4 || nums.slice(0, 4).some(Number.isNaN)) {
854
962
  throw new Error('usage: simframe swipe <x1> <y1> <x2> <y2>');
855
963
  }
964
+ aim = { point: { x: nums[0], y: nums[1] }, what: 'the swipe start point' };
856
965
  await input.swipe(dev.udid, { x: nums[0], y: nums[1] }, { x: nums[2], y: nums[3] }, { durationMs: num(flags.durationMs, 300) });
857
966
  break;
858
967
  }
@@ -886,14 +995,22 @@ async function main() {
886
995
  // Deliberately not an accusation. Pressing home while already on the
887
996
  // springboard legitimately changes nothing, and a warning that cries wolf
888
997
  // is how a real one gets ignored.
998
+ // The map for the screen as it was before the send. Free when the screen
999
+ // has been perceived once — and silent when it has not, because "we did
1000
+ // not look" must not be printed as "there is nothing there".
1001
+ const hit = changed === false && aim
1002
+ ? api.screenmap.describePoint(api.screenmap.recall(dev.udid, before), aim.point, { what: aim.what })
1003
+ : null;
889
1004
  const note = changed === false
890
- ? ' — the screen did not change. That is expected if the press had nothing to do here;'
1005
+ ? ' — the screen did not change'
1006
+ + (hit ? `, and ${hit}` : '')
1007
+ + '. That is expected if the press had nothing to do here;'
891
1008
  + ' if you expected a change, input may not be reaching the device —'
892
1009
  + ' `simframe stop --force && simframe start` rebuilds the session.'
893
1010
  : '';
894
1011
  emit(
895
1012
  flags,
896
- { ok: true, command, ms, driver: driver.name, screenChanged: changed },
1013
+ { ok: true, command, ms, driver: driver.name, screenChanged: changed, hit: hit ?? undefined },
897
1014
  `${command} in ${ms}ms via ${driver.name}${changed === true ? ' — screen changed' : ''}${note}`,
898
1015
  );
899
1016
  return;
@@ -1078,13 +1195,31 @@ async function main() {
1078
1195
 
1079
1196
  case 'supervisions': {
1080
1197
  const dev = await resolveDevice(flags.device);
1081
- const records = metrics.readSupervisions(dev.udid, { limit: flags.last ? num(flags.last) : undefined });
1198
+ // `--session` works here now, and did not before.
1199
+ //
1200
+ // Reported twice from the field: two different session ids returned
1201
+ // byte-identical output while `escalations --session` filtered correctly.
1202
+ // A flag that exists on one command and is silently inert on its sibling
1203
+ // is worse than an absent one — this command's own footer warns that the
1204
+ // counts pool multiple agents and then offered no way to unpool them.
1205
+ const session = flags.session === true
1206
+ ? metrics.sessionId()
1207
+ : (flags.session ? String(flags.session) : null);
1208
+ const all = metrics.readSupervisions(dev.udid, { limit: flags.last ? num(flags.last) : undefined });
1209
+ const records = session ? all.filter((r) => r?.session_id === session) : all;
1082
1210
  const b = metrics.supervisionBreakdown(records);
1083
1211
  if (flags.out) store.writeAtomic(String(flags.out), `${JSON.stringify({ ...b, records }, null, 2)}\n`);
1084
1212
  emit(flags, { ...b, records: flags.verbose ? records : undefined }, [
1085
1213
  `${b.total} supervisor ruling${b.total === 1 ? '' : 's'} on ${dev.name}`,
1086
- b.total ? '' : 'Nothing has been judged on this device yet. The supervisor is off unless'
1087
- + ' SIMFRAME_SUPERVISOR=apple, and a ruling is only recorded when a step actually fails.',
1214
+ // "Nothing here" and "nothing matched your filter" are different
1215
+ // answers, and the first one told a reader the supervisor had never
1216
+ // run on a device holding 111 rulings.
1217
+ b.total
1218
+ ? null
1219
+ : (all.length
1220
+ ? `no ruling in this log belongs to session ${session} — the device has ${all.length}.`
1221
+ : 'Nothing has been judged on this device yet. The supervisor is off unless'
1222
+ + ' SIMFRAME_SUPERVISOR=apple, and a ruling is only recorded when a step actually fails.'),
1088
1223
  ...Object.entries(b.decision_to_outcome)
1089
1224
  .sort((a, c) => c[1] - a[1])
1090
1225
  .map(([k, n]) => ` ${k.padEnd(28)} ${String(n).padStart(4)}`),
@@ -1100,8 +1235,12 @@ async function main() {
1100
1235
  ? ` — ${b.p95_unknown} ruling(s) are on edges with no p95, so they cannot take part in 101's comparison`
1101
1236
  : '')
1102
1237
  : null,
1238
+ session && all.length !== records.length
1239
+ ? `filtered to session ${session}: ${records.length} of ${all.length} ruling(s)`
1240
+ : null,
1103
1241
  b.sessions.length > 1
1104
1242
  ? `WARNING ${b.sessions.length} sessions are pooled here; two agents on one device write one file`
1243
+ + ' — narrow with --session (this process) or --session=<id>'
1105
1244
  : null,
1106
1245
  ].filter((l) => l !== null).join('\n'));
1107
1246
  break;
@@ -1120,10 +1259,29 @@ async function main() {
1120
1259
  ...metrics.REASONS
1121
1260
  .filter((r) => b.by_reason[r])
1122
1261
  .sort((a, c) => b.by_reason[c] - b.by_reason[a])
1123
- .map((r) => ` ${r.padEnd(20)} ${String(b.by_reason[r]).padStart(4)} `
1124
- + (metrics.BUILT_FACULTIES.has(metrics.FACULTY[r])
1262
+ .map((r) => {
1263
+ const n = b.by_reason[r];
1264
+ const read = b.classified_by_reason?.[r] ?? 0;
1265
+ // A faculty is only named for the part of a reason that was read
1266
+ // off the failure. The rest is a count of things nothing could
1267
+ // classify, and naming a phase against it is advice with nothing
1268
+ // behind it — which is how this report came to tell a tester that
1269
+ // their unlabeled-control problem was a timing problem.
1270
+ const assumed = b.assumed_by_reason?.[r] ?? 0;
1271
+ const named = metrics.BUILT_FACULTIES.has(metrics.FACULTY[r])
1125
1272
  ? `not removed by: ${metrics.FACULTY[r]} [built]`
1126
- : `would be removed by: ${metrics.FACULTY[r]}`)),
1273
+ : `would be removed by: ${metrics.FACULTY[r]}`;
1274
+ let verdict;
1275
+ if (read > 0) {
1276
+ verdict = named + (read < n ? ` (on the ${read} of ${n} whose reason was read)` : '');
1277
+ } else if (assumed > 0) {
1278
+ verdict = 'reason assumed, not read — no faculty can be named from these';
1279
+ } else {
1280
+ // Neither read nor assumed: the log predates the distinction.
1281
+ verdict = `${named} — but these records predate the check, so treat it as untested`;
1282
+ }
1283
+ return ` ${r.padEnd(20)} ${String(n).padStart(4)} ${verdict}`;
1284
+ }),
1127
1285
  b.total ? '' : null,
1128
1286
  b.total ? `avoidable ${b.avoidable}/${b.total} (${b.avoidable_escalation_rate})` : null,
1129
1287
  // Said out loud rather than left for someone to discover: the rate is
@@ -1497,9 +1655,38 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1497
1655
  // machine could be doing better and silently is not; a driver someone
1498
1656
  // selected on purpose is neither silent nor a surprise.
1499
1657
  const axState = !ax.available ? 'optional' : ax.name === 'simframed' || ax.chosen ? 'ok' : 'warn';
1500
- add(`accessibility tree (${d.name})`, axState,
1501
- ax.available ? `${ax.name}: ${ax.version}` : `unavailable: ${ax.reason}`,
1658
+ // Prove a round trip, not a presence — the same correction this file
1659
+ // already made for the supervisor, never carried across to here.
1660
+ //
1661
+ // A CI run read the screen eighteen times and every single reading came
1662
+ // back `ocr` with no `ax` at all, while this check said `ok` because a
1663
+ // driver was configured. It is configured; it answers with nothing. Six
1664
+ // minutes later the fingerprint step failed with a distribution mystery,
1665
+ // and the layer that had actually died was named nowhere. Asking the tree
1666
+ // for the current screen costs one read (~50ms) and turns that into a
1667
+ // first-minute failure with the right sentence on it.
1668
+ let axCount = null;
1669
+ if (ax.available) {
1670
+ try {
1671
+ axCount = (await input.describeAll(d.udid)).length;
1672
+ } catch {
1673
+ axCount = 0;
1674
+ }
1675
+ }
1676
+ add(`accessibility tree (${d.name})`,
1677
+ // `warn`, not `fail`: a genuinely empty screen exists — a springboard
1678
+ // mid-boot, a black frame — and a hard error on one would cry wolf.
1679
+ // The count is exported so a caller that knows the screen is not empty
1680
+ // can assert on it, which is what CI does.
1681
+ ax.available && axCount === 0 ? 'warn' : axState,
1682
+ ax.available
1683
+ ? `${ax.name}: ${ax.version}`
1684
+ + (axCount === 0
1685
+ ? ' — but it returned NO elements for the current screen, so every read is OCR alone'
1686
+ : axCount != null ? `; ${axCount} element(s) on the current screen` : '')
1687
+ : `unavailable: ${ax.reason}`,
1502
1688
  { key: 'ax.driver', value: ax.name });
1689
+ if (axCount != null) add(null, null, null, { key: 'ax.elements', value: axCount });
1503
1690
  }
1504
1691
  if (probed.length) {
1505
1692
  const t0 = Date.now();
@@ -1526,6 +1713,13 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1526
1713
  for (const d of probed) {
1527
1714
  const live = api.liveness(d.udid, (await api.getState(d.udid)).state);
1528
1715
  if (live.stalled) add(`capture health (${d.name})`, 'fail', live.note, { key: 'capture.stalled', value: true });
1716
+ // A `warn` rather than a `fail`, because a genuinely inert screen is
1717
+ // possible and this is a contradiction between two numbers rather than
1718
+ // a proven fault. It is still the loudest thing `doctor` can say about
1719
+ // the failure that made a tester report a false application state.
1720
+ else if (live.suspectSurface) {
1721
+ add(`capture surface (${d.name})`, 'warn', live.note, { key: 'capture.suspectSurface', value: true });
1722
+ }
1529
1723
  }
1530
1724
  }
1531
1725
  } catch (err) {
@@ -1566,11 +1760,14 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1566
1760
  warnings: warned.length,
1567
1761
  optional: optional.length,
1568
1762
  ...flat,
1569
- checks: checks.map(({ name, level, detail }) => ({ name, level, detail })),
1763
+ checks: checks.filter((c) => c.name).map(({ name, level, detail }) => ({ name, level, detail })),
1570
1764
  }, null, 2));
1571
1765
  } else {
1572
1766
  const mark = { ok: 'ok ', warn: 'WARN', fail: 'FAIL', optional: '-- ' };
1573
- for (const c of checks) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
1767
+ // A nameless entry is data for `--json` and not a line for a reader — the
1768
+ // element count belongs beside the layer it describes, not on a row of its
1769
+ // own.
1770
+ for (const c of checks) if (c.name) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
1574
1771
  if (warned.length) {
1575
1772
  console.log(`\n${warned.length} layer(s) degraded. simframe still works, but not at full speed or coverage:`);
1576
1773
  for (const c of warned) console.log(` - ${c.name}: ${c.detail}`);
@@ -72,8 +72,17 @@ import * as regions from './regions.js';
72
72
  * graph merged them. Both bounds are absolute now. Screens that were
73
73
  * missed at 7 hash differently at 8, and unlike a stale hash that matches
74
74
  * nothing, these matched the *wrong* thing.
75
+ *
76
+ * 9 — nothing in this file changed. The element list it is given did: item 122
77
+ * stopped dropping accessibility nodes that have no name, so a screen with
78
+ * an icon-only control now carries a token for it that it did not carry
79
+ * before. That is a better identity — a nav bar with an overflow menu and
80
+ * one without are not the same screen — and it is still a different hash
81
+ * for the same screen, which is what this number exists to declare. The
82
+ * lesson worth keeping is that the rules version is not a version of *this
83
+ * file*; it is a version of the token set, and the token set has an input.
75
84
  */
76
- export const TOKEN_RULES_VERSION = 8;
85
+ export const TOKEN_RULES_VERSION = 9;
77
86
 
78
87
  /** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
79
88
  export const GRID = 24;