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/README.md +163 -9
- package/native/supervise.swift +63 -4
- package/package.json +1 -1
- package/scripts/article-md.mjs +185 -0
- package/scripts/ci-integration-local.sh +22 -1
- package/scripts/ci-memory.mjs +98 -15
- package/scripts/collect-rulings.mjs +14 -0
- package/scripts/eval-fingerprint.mjs +48 -3
- package/scripts/replay-rulings.mjs +206 -0
- package/scripts/score-rulings.mjs +19 -1
- package/src/actions.js +158 -18
- package/src/analyze.js +56 -0
- package/src/cli.js +215 -18
- package/src/fingerprint.js +10 -1
- package/src/index.js +337 -12
- package/src/input.js +4 -0
- package/src/mcp.js +7 -1
- package/src/metrics.js +68 -7
- package/src/navigate.js +28 -2
- package/src/ollama.js +232 -0
- package/src/platform/ios.js +30 -5
- package/src/refs.js +12 -1
- package/src/regions.js +54 -0
- package/src/screenmap.js +161 -6
- package/src/store.js +53 -0
- package/src/supervisor.js +108 -5
- package/src/view.js +84 -9
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
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) =>
|
|
1124
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
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
|
|
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}`);
|
package/src/fingerprint.js
CHANGED
|
@@ -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 =
|
|
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;
|