simframe 0.12.2 → 0.14.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
@@ -12,6 +12,7 @@ import * as baseline from './baseline.js';
12
12
  import * as metrics from './metrics.js';
13
13
  import * as navigate from './navigate.js';
14
14
  import { decodePng } from './png.js';
15
+ import * as storage from './storage.js';
15
16
  import * as store from './store.js';
16
17
  import * as view from './view.js';
17
18
 
@@ -33,6 +34,7 @@ const USAGE = `simframe — always-warm iOS Simulator frames
33
34
  simframe tap <selector> tap #3, "Save", or @120,400
34
35
  simframe do <script.json> run a scripted flow (see below)
35
36
  simframe screens [device] list screens this device has learned
37
+ simframe storage [bundle-id] what the app saved (works on a shut-down device)
36
38
  simframe goto <screen> walk to a known screen through known steps
37
39
  simframe flow save <name> <script.json> run a flow and save it if every step verifies
38
40
  simframe flow run <name> replay a saved flow
@@ -467,6 +469,77 @@ async function main() {
467
469
  }
468
470
 
469
471
  case 'frame': {
472
+ // `--fresh` captures independently of the daemon, and then says whether
473
+ // the two agree.
474
+ //
475
+ // This is the arbiter three field reports had to leave simframe to get.
476
+ // When `sim_look` served a three-hour-stale image labelled `130ms old`,
477
+ // the thing that finally settled it was `xcrun simctl io … screenshot` —
478
+ // run by hand, outside the tool, because nothing inside offered an
479
+ // independent read. Worse, the obvious candidate lies: `--engine` decides
480
+ // how to *start* a daemon, so passing `--engine=screenshot` to a read
481
+ // command returns the running daemon's cached frame. A tester compared
482
+ // the two, got byte-identical files with the same frame number, and
483
+ // reasonably concluded "the fallback engine is not an escape hatch".
484
+ //
485
+ // One command now answers the question the escape hatch was for: capture
486
+ // the screen twice by two different paths and report whether they agree.
487
+ if (flags.fresh) {
488
+ const dev = await resolveDevice(device);
489
+ const out = flags.out || path.join(process.cwd(), 'simframe.png');
490
+ await screenshot(dev.udid, out);
491
+ const png = fs.readFileSync(out);
492
+ // The daemon's own newest frame, for comparison. Absent is fine and
493
+ // interesting in itself: an independent capture that works while the
494
+ // daemon has none is exactly the wedge.
495
+ let cached = null;
496
+ try {
497
+ cached = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
498
+ } catch { /* no daemon, or it has nothing — reported below */ }
499
+ // Compared by *content*, never by bytes. The direct capture is a
500
+ // full-resolution PNG and the daemon's is downscaled, so a byte
501
+ // comparison says "different" every time — which is a confident wrong
502
+ // answer about the one question this command exists to settle. Both
503
+ // are decoded and reduced to the same region signature the change
504
+ // detector already uses, and `signatureDiff` is the same measure that
505
+ // decides whether a screen moved.
506
+ let diff = null;
507
+ if (cached) {
508
+ try {
509
+ const a = analyze.regionSignature(decodePng(png));
510
+ const b = analyze.regionSignature(decodePng(cached.png));
511
+ diff = analyze.signatureDiff(a, b);
512
+ } catch { /* an undecodable frame is reported as "could not compare" */ }
513
+ }
514
+ // The threshold the daemon itself calls a change. Below it the two
515
+ // paths are looking at the same screen.
516
+ const same = diff == null ? null : diff <= analyze.PATHS_AGREE;
517
+ emit(
518
+ flags,
519
+ {
520
+ file: out,
521
+ fresh: true,
522
+ bytes: png.length,
523
+ daemonSeq: cached?.state?.seq ?? null,
524
+ daemonAgeMs: cached?.ageMs ?? null,
525
+ agrees: same,
526
+ difference: diff == null ? null : Number(diff.toFixed(4)),
527
+ },
528
+ [
529
+ `${out} — captured directly from the device, ${png.length} bytes`,
530
+ cached
531
+ ? `the daemon's newest frame is #${cached.state.seq}, ${cached.ageMs}ms old`
532
+ + (same == null
533
+ ? ' — could not be compared (one of the two would not decode)'
534
+ : same
535
+ ? ` — the two paths agree (difference ${diff.toFixed(4)}, under the ${analyze.PATHS_AGREE} two paths may differ by)`
536
+ : ` — they DISAGREE (difference ${diff.toFixed(4)}). Two capture paths see different screens;`
537
+ + ' this file is the one that bypassed the daemon. `simframe revive` re-attaches capture.')
538
+ : 'the daemon has no frame to compare against, while a direct capture worked',
539
+ ],
540
+ );
541
+ return;
542
+ }
470
543
  const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
471
544
  const out = flags.out || path.join(process.cwd(), 'simframe.png');
472
545
  fs.writeFileSync(out, res.png);
@@ -491,7 +564,9 @@ async function main() {
491
564
  } else {
492
565
  const s = res.state;
493
566
  const out = [];
494
- if (!res.live.ok) out.push(`WARNING: ${res.live.note}`);
567
+ // Any note, not only a failing one: a dead surface reports `ok` with
568
+ // something important to say. See `liveness`.
569
+ if (res.live.note) out.push(`WARNING: ${res.live.note}`);
495
570
  // A cause, rather than five silent no-ops. Every tap on a stale
496
571
  // session is dispatched successfully and moves nothing.
497
572
  if (res.input?.stale) out.push(`input: stale — ${res.input.reason}`);
@@ -546,20 +621,36 @@ async function main() {
546
621
  changedBeforeWait: Boolean(res.changedBeforeWait),
547
622
  noVisibleChange: Boolean(res.noVisibleChange),
548
623
  stalled: Boolean(res.stalled),
624
+ // Where it was still moving, when it never stopped — item 123.
625
+ //
626
+ // `waitFor` has computed this since it learned to, and this payload
627
+ // is hand-built, so the field existed and no caller could see it. I
628
+ // read a null here and nearly concluded the tracking was broken; it
629
+ // was the reporting.
630
+ motion: res.motion ?? null,
631
+ animating: res.animating ?? null,
549
632
  hash: res.state?.hash,
550
633
  seq: res.state?.seq,
551
634
  },
552
635
  () => {
553
636
  if (res.satisfied) {
554
637
  return `${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
555
- (res.changedBeforeWait ? ' (change had already happened before the call)' : '');
638
+ (res.changedBeforeWait ? ' (change had already happened before the call)' : '') +
639
+ (res.animating
640
+ ? `\nbut a ${res.animating.width}x${res.animating.height} region is still animating`
641
+ + ' — the stillness signal is a mean and cannot see it'
642
+ : '');
556
643
  }
557
644
  if (res.noVisibleChange) {
558
645
  return `no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`;
559
646
  }
560
647
  if (res.stalled) return `capture stalled after ${res.waitedMs}ms — ${res.live.note}`;
561
648
  return `timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
562
- (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`');
649
+ (res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`') +
650
+ (res.motion
651
+ ? `\nthe movement is ${res.motion.where}`
652
+ + `${res.motion.localised ? ` (${res.motion.share}% of it)` : ''}:\n${res.motion.map}`
653
+ : '');
563
654
  },
564
655
  );
565
656
  process.exitCode = res.satisfied ? 0 : 1;
@@ -712,7 +803,7 @@ async function main() {
712
803
  },
713
804
  [
714
805
  ...res.results.map(stepLine),
715
- `${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`,
806
+ actions.flowSummary(res),
716
807
  saved && (saved.ok ? `saved flow "${saved.name}" — ${saved.steps} steps` : `not saved: ${saved.reason}`),
717
808
  map && `\n${map}`,
718
809
  ],
@@ -771,6 +862,25 @@ async function main() {
771
862
  return;
772
863
  }
773
864
 
865
+ case 'storage': {
866
+ // A file read, like `screens`, and deliberately not a daemon call. The
867
+ // whole value of this command is that it answers on a device that is not
868
+ // running — measured: simctl itself cannot, on this Xcode. Routing it
869
+ // through the daemon would throw that away for no gain.
870
+ const device = await resolveDevice(flags.device);
871
+ const [bundleId] = positional;
872
+ if (!bundleId) {
873
+ const list = await storage.apps(device.udid);
874
+ const match = flags.match ? String(flags.match).toLowerCase() : null;
875
+ const shown = match ? list.filter((a) => a.bundleId.toLowerCase().includes(match)) : list;
876
+ emit(flags, shown, storage.formatApps(shown));
877
+ return;
878
+ }
879
+ const result = await storage.read(device.udid, bundleId);
880
+ emit(flags, result, storage.format(result));
881
+ return;
882
+ }
883
+
774
884
  case 'screens': {
775
885
  // Reading what this device has learned is a file read. It used to go
776
886
  // through ensureDaemon, so a device whose capture had stopped could not
@@ -831,7 +941,7 @@ async function main() {
831
941
  }
832
942
  emit(flags, res, [
833
943
  ...(res.results ?? []).map(stepLine),
834
- `${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`,
944
+ actions.flowSummary(res, { withTime: false }),
835
945
  ]);
836
946
  process.exitCode = res.ok ? 0 : 1;
837
947
  return;
@@ -1106,13 +1216,31 @@ async function main() {
1106
1216
 
1107
1217
  case 'supervisions': {
1108
1218
  const dev = await resolveDevice(flags.device);
1109
- const records = metrics.readSupervisions(dev.udid, { limit: flags.last ? num(flags.last) : undefined });
1219
+ // `--session` works here now, and did not before.
1220
+ //
1221
+ // Reported twice from the field: two different session ids returned
1222
+ // byte-identical output while `escalations --session` filtered correctly.
1223
+ // A flag that exists on one command and is silently inert on its sibling
1224
+ // is worse than an absent one — this command's own footer warns that the
1225
+ // counts pool multiple agents and then offered no way to unpool them.
1226
+ const session = flags.session === true
1227
+ ? metrics.sessionId()
1228
+ : (flags.session ? String(flags.session) : null);
1229
+ const all = metrics.readSupervisions(dev.udid, { limit: flags.last ? num(flags.last) : undefined });
1230
+ const records = session ? all.filter((r) => r?.session_id === session) : all;
1110
1231
  const b = metrics.supervisionBreakdown(records);
1111
1232
  if (flags.out) store.writeAtomic(String(flags.out), `${JSON.stringify({ ...b, records }, null, 2)}\n`);
1112
1233
  emit(flags, { ...b, records: flags.verbose ? records : undefined }, [
1113
1234
  `${b.total} supervisor ruling${b.total === 1 ? '' : 's'} on ${dev.name}`,
1114
- b.total ? '' : 'Nothing has been judged on this device yet. The supervisor is off unless'
1115
- + ' SIMFRAME_SUPERVISOR=apple, and a ruling is only recorded when a step actually fails.',
1235
+ // "Nothing here" and "nothing matched your filter" are different
1236
+ // answers, and the first one told a reader the supervisor had never
1237
+ // run on a device holding 111 rulings.
1238
+ b.total
1239
+ ? null
1240
+ : (all.length
1241
+ ? `no ruling in this log belongs to session ${session} — the device has ${all.length}.`
1242
+ : 'Nothing has been judged on this device yet. The supervisor is off unless'
1243
+ + ' SIMFRAME_SUPERVISOR=apple, and a ruling is only recorded when a step actually fails.'),
1116
1244
  ...Object.entries(b.decision_to_outcome)
1117
1245
  .sort((a, c) => c[1] - a[1])
1118
1246
  .map(([k, n]) => ` ${k.padEnd(28)} ${String(n).padStart(4)}`),
@@ -1128,8 +1256,12 @@ async function main() {
1128
1256
  ? ` — ${b.p95_unknown} ruling(s) are on edges with no p95, so they cannot take part in 101's comparison`
1129
1257
  : '')
1130
1258
  : null,
1259
+ session && all.length !== records.length
1260
+ ? `filtered to session ${session}: ${records.length} of ${all.length} ruling(s)`
1261
+ : null,
1131
1262
  b.sessions.length > 1
1132
1263
  ? `WARNING ${b.sessions.length} sessions are pooled here; two agents on one device write one file`
1264
+ + ' — narrow with --session (this process) or --session=<id>'
1133
1265
  : null,
1134
1266
  ].filter((l) => l !== null).join('\n'));
1135
1267
  break;
@@ -1148,10 +1280,29 @@ async function main() {
1148
1280
  ...metrics.REASONS
1149
1281
  .filter((r) => b.by_reason[r])
1150
1282
  .sort((a, c) => b.by_reason[c] - b.by_reason[a])
1151
- .map((r) => ` ${r.padEnd(20)} ${String(b.by_reason[r]).padStart(4)} `
1152
- + (metrics.BUILT_FACULTIES.has(metrics.FACULTY[r])
1283
+ .map((r) => {
1284
+ const n = b.by_reason[r];
1285
+ const read = b.classified_by_reason?.[r] ?? 0;
1286
+ // A faculty is only named for the part of a reason that was read
1287
+ // off the failure. The rest is a count of things nothing could
1288
+ // classify, and naming a phase against it is advice with nothing
1289
+ // behind it — which is how this report came to tell a tester that
1290
+ // their unlabeled-control problem was a timing problem.
1291
+ const assumed = b.assumed_by_reason?.[r] ?? 0;
1292
+ const named = metrics.BUILT_FACULTIES.has(metrics.FACULTY[r])
1153
1293
  ? `not removed by: ${metrics.FACULTY[r]} [built]`
1154
- : `would be removed by: ${metrics.FACULTY[r]}`)),
1294
+ : `would be removed by: ${metrics.FACULTY[r]}`;
1295
+ let verdict;
1296
+ if (read > 0) {
1297
+ verdict = named + (read < n ? ` (on the ${read} of ${n} whose reason was read)` : '');
1298
+ } else if (assumed > 0) {
1299
+ verdict = 'reason assumed, not read — no faculty can be named from these';
1300
+ } else {
1301
+ // Neither read nor assumed: the log predates the distinction.
1302
+ verdict = `${named} — but these records predate the check, so treat it as untested`;
1303
+ }
1304
+ return ` ${r.padEnd(20)} ${String(n).padStart(4)} ${verdict}`;
1305
+ }),
1155
1306
  b.total ? '' : null,
1156
1307
  b.total ? `avoidable ${b.avoidable}/${b.total} (${b.avoidable_escalation_rate})` : null,
1157
1308
  // Said out loud rather than left for someone to discover: the rate is
@@ -1525,9 +1676,38 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1525
1676
  // machine could be doing better and silently is not; a driver someone
1526
1677
  // selected on purpose is neither silent nor a surprise.
1527
1678
  const axState = !ax.available ? 'optional' : ax.name === 'simframed' || ax.chosen ? 'ok' : 'warn';
1528
- add(`accessibility tree (${d.name})`, axState,
1529
- ax.available ? `${ax.name}: ${ax.version}` : `unavailable: ${ax.reason}`,
1679
+ // Prove a round trip, not a presence — the same correction this file
1680
+ // already made for the supervisor, never carried across to here.
1681
+ //
1682
+ // A CI run read the screen eighteen times and every single reading came
1683
+ // back `ocr` with no `ax` at all, while this check said `ok` because a
1684
+ // driver was configured. It is configured; it answers with nothing. Six
1685
+ // minutes later the fingerprint step failed with a distribution mystery,
1686
+ // and the layer that had actually died was named nowhere. Asking the tree
1687
+ // for the current screen costs one read (~50ms) and turns that into a
1688
+ // first-minute failure with the right sentence on it.
1689
+ let axCount = null;
1690
+ if (ax.available) {
1691
+ try {
1692
+ axCount = (await input.describeAll(d.udid)).length;
1693
+ } catch {
1694
+ axCount = 0;
1695
+ }
1696
+ }
1697
+ add(`accessibility tree (${d.name})`,
1698
+ // `warn`, not `fail`: a genuinely empty screen exists — a springboard
1699
+ // mid-boot, a black frame — and a hard error on one would cry wolf.
1700
+ // The count is exported so a caller that knows the screen is not empty
1701
+ // can assert on it, which is what CI does.
1702
+ ax.available && axCount === 0 ? 'warn' : axState,
1703
+ ax.available
1704
+ ? `${ax.name}: ${ax.version}`
1705
+ + (axCount === 0
1706
+ ? ' — but it returned NO elements for the current screen, so every read is OCR alone'
1707
+ : axCount != null ? `; ${axCount} element(s) on the current screen` : '')
1708
+ : `unavailable: ${ax.reason}`,
1530
1709
  { key: 'ax.driver', value: ax.name });
1710
+ if (axCount != null) add(null, null, null, { key: 'ax.elements', value: axCount });
1531
1711
  }
1532
1712
  if (probed.length) {
1533
1713
  const t0 = Date.now();
@@ -1554,6 +1734,13 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1554
1734
  for (const d of probed) {
1555
1735
  const live = api.liveness(d.udid, (await api.getState(d.udid)).state);
1556
1736
  if (live.stalled) add(`capture health (${d.name})`, 'fail', live.note, { key: 'capture.stalled', value: true });
1737
+ // A `warn` rather than a `fail`, because a genuinely inert screen is
1738
+ // possible and this is a contradiction between two numbers rather than
1739
+ // a proven fault. It is still the loudest thing `doctor` can say about
1740
+ // the failure that made a tester report a false application state.
1741
+ else if (live.suspectSurface) {
1742
+ add(`capture surface (${d.name})`, 'warn', live.note, { key: 'capture.suspectSurface', value: true });
1743
+ }
1557
1744
  }
1558
1745
  }
1559
1746
  } catch (err) {
@@ -1594,11 +1781,14 @@ async function doctor({ json = false, strict = false, device, options = {} } = {
1594
1781
  warnings: warned.length,
1595
1782
  optional: optional.length,
1596
1783
  ...flat,
1597
- checks: checks.map(({ name, level, detail }) => ({ name, level, detail })),
1784
+ checks: checks.filter((c) => c.name).map(({ name, level, detail }) => ({ name, level, detail })),
1598
1785
  }, null, 2));
1599
1786
  } else {
1600
1787
  const mark = { ok: 'ok ', warn: 'WARN', fail: 'FAIL', optional: '-- ' };
1601
- for (const c of checks) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
1788
+ // A nameless entry is data for `--json` and not a line for a reader — the
1789
+ // element count belongs beside the layer it describes, not on a row of its
1790
+ // own.
1791
+ for (const c of checks) if (c.name) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
1602
1792
  if (warned.length) {
1603
1793
  console.log(`\n${warned.length} layer(s) degraded. simframe still works, but not at full speed or coverage:`);
1604
1794
  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;
package/src/graph.js CHANGED
@@ -396,7 +396,18 @@ export function describe(node) {
396
396
  if (tabs.length) return tabs.slice(0, 3).join(' / ');
397
397
  const anyChrome = labels(/:(nav-bar|tab-bar):/);
398
398
  if (anyChrome.length) return anyChrome.slice(0, 3).join(' ');
399
- return node.hash.slice(0, 8);
399
+ // No name. Say so, rather than handing back a hash dressed as one.
400
+ //
401
+ // This fell back to `node.hash.slice(0, 8)`, and the map prints the name in
402
+ // quotes after the identity hash — so an unnamed screen read
403
+ // `screen 299dd147 "a9505378"`: two hashes, one of them looking like a title,
404
+ // beside the `screen 089bec77 "time sheets"` a named screen produces.
405
+ // Reported from the field, with the right fix attached: omit the quoted part
406
+ // rather than echo a second hash.
407
+ //
408
+ // Callers that need *something* to print in a list supply their own fallback,
409
+ // which is a decision about presentation and belongs at the point of display.
410
+ return null;
400
411
  }
401
412
 
402
413
  /** Find a known screen by what a human would call it. */
@@ -404,7 +415,9 @@ export function findScreen(udid, query) {
404
415
  const wanted = String(query ?? '').trim();
405
416
  if (!wanted) return null;
406
417
  const scored = allNodes(udid)
407
- .map((node) => ({ node, name: describe(node) }))
418
+ // The short hash stays searchable: `goto 089bec77` worked before `describe`
419
+ // stopped inventing names and must keep working.
420
+ .map((node) => ({ node, name: describe(node) ?? node.hash.slice(0, 8) }))
408
421
  .map((c) => ({ ...c, score: matching.nameScore(c.name, wanted) }))
409
422
  .filter((c) => c.score > 0)
410
423
  .sort((a, b) => b.score - a.score);