simframe 0.18.0 → 0.19.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/wedge.js CHANGED
@@ -21,6 +21,8 @@
21
21
  // a job. Nothing here needs a device to be tested.
22
22
  import * as api from './index.js';
23
23
  import * as frontmost from './frontmost.js';
24
+ import * as input from './input.js';
25
+ import { launchApp, restartDevice } from './platform/index.js';
24
26
 
25
27
  /**
26
28
  * How little agreement between the sensors counts as "these are different
@@ -52,8 +54,39 @@ import * as frontmost from './frontmost.js';
52
54
  */
53
55
  export const DISAGREEMENT = 0.1;
54
56
 
55
- /** The range observed on healthy screens, for the report to print honestly. */
56
- export const HEALTHY_FUSION = '0.66-0.93';
57
+ /**
58
+ * The range observed on healthy screens, for the report to print honestly.
59
+ *
60
+ * **Widened 2026-09-18, because the first sample was too narrow and the output
61
+ * was contradicting itself.** A field reporter watched `diagnose` print
62
+ * `healthy` twice at **0.567** and **0.615** on the same line as "measured
63
+ * healthy 0.66-0.93", and was right to call that out: a verdict and the band
64
+ * printed beside it should not disagree.
65
+ *
66
+ * Neither number was wrong. The verdict is correct — the threshold that decides
67
+ * it is `DISAGREEMENT`, 0.1, and 0.567 is nowhere near it. The *band* was the
68
+ * wrong thing: it was sampled on five Apple system screens (Settings, General,
69
+ * About, springboard, Reminders), which are uniformly well-labelled, and a real
70
+ * third-party app fuses lower because more of its content is OCR-only. Same
71
+ * afternoon on this device, on system screens: 0.7, 0.846, 0.857 — consistent
72
+ * with the original sample. The 0.567 and 0.615 come from an app.
73
+ *
74
+ * So it is stated as what it is: a range observed across screens, with the floor
75
+ * from an app rather than from Apple's own UI.
76
+ *
77
+ * **And then it was widened once and immediately contradicted again**: the next
78
+ * device read after that change returned **0.471** on a Settings screen with 34
79
+ * elements, 25 by tree and 17 by OCR. Which settles what this constant is for.
80
+ * It is not a health standard — fusion tracks how much of a screen is OCR-only,
81
+ * that is a property of the app, and any fixed band will be below some real
82
+ * screen. `diagnose` no longer prints it on the healthy path for that reason.
83
+ * It survives in the `stale-frame` message, where a reading of 0.03 wants the
84
+ * contrast, and in `docs/BENCHMARKS.md` as evidence rather than a threshold.
85
+ *
86
+ * The number that decides anything is `DISAGREEMENT`, and it is 4.7x below even
87
+ * the lowest healthy reading yet seen.
88
+ */
89
+ export const HEALTHY_FUSION = '0.47-0.93';
57
90
 
58
91
  /**
59
92
  * Both sensors need at least this many elements before their disagreement means
@@ -193,6 +226,33 @@ export function classify(snap) {
193
226
  revive: true,
194
227
  };
195
228
  }
229
+ // One sensor is reading and the other is silent.
230
+ //
231
+ // **Found within an hour of shipping this classifier, by it calling a device
232
+ // `healthy` while the accessibility tree returned nothing at all** — 0 by
233
+ // tree, 20 by OCR. `agreement` is null unless both sensors report something,
234
+ // so the disagreement check above cannot fire on this, and nothing else was
235
+ // looking. A classifier blind to a whole sensor being dead is worse than no
236
+ // classifier, because it answers.
237
+ //
238
+ // It is not cosmetic. CLAUDE.md's perception order makes the tree
239
+ // authoritative when present, so a silent tree means every intent resolves
240
+ // against OCR alone — the reading quality a field report called "materially
241
+ // less reliable" on web content, applied to the whole device, with nothing
242
+ // saying so.
243
+ if (total >= ENOUGH_TO_COMPARE && (ax === 0 || ocr === 0)) {
244
+ const treeDead = ax === 0;
245
+ return {
246
+ state: treeDead ? 'tree-silent' : 'ocr-silent',
247
+ detail: `${treeDead ? 'OCR' : 'the accessibility tree'} found ${Math.max(ax, ocr)} element(s)`
248
+ + ` and ${treeDead ? 'the accessibility tree' : 'OCR'} found none.`
249
+ + (treeDead
250
+ ? ' The tree is authoritative when present, so every intent is now resolving against'
251
+ + ' OCR alone — materially less reliable, and nothing else says so.'
252
+ : ' Frames are not being read, so anything that needs pixels is unavailable.'),
253
+ revive: true,
254
+ };
255
+ }
196
256
  // An app holds the front, and the display is showing almost nothing. This is
197
257
  // the CI signature that `device-state.mjs` recognises as "a clock and nothing
198
258
  // else", now stated as an observation. It deliberately does NOT claim to know
@@ -212,7 +272,99 @@ export function classify(snap) {
212
272
  return { state: 'healthy', detail: `${total} element(s), sensors agree on ${snap.agreement ?? 'n/a'}`, revive: false };
213
273
  }
214
274
 
275
+ /**
276
+ * States that mean the device cannot be driven at all, as opposed to states
277
+ * that mean it is degraded and should be said out loud.
278
+ *
279
+ * The distinction earns its place in `revive`. Demanding `healthy` there turned
280
+ * a **transient** `tree-silent` into a hard failure and a non-zero exit — and it
281
+ * is transient: observed surviving a full revive, then clearing after any app
282
+ * launch, with the springboard reading 13 tree elements again afterwards. A
283
+ * device whose tree is briefly silent still taps, still reads by OCR, and still
284
+ * recovers. Failing it is the false-refusal shape of item 175.
285
+ */
286
+ export const UNUSABLE = new Set(['capture-down', 'nothing-readable', 'read-failed']);
287
+
215
288
  export async function diagnose(deviceQuery, { options } = {}) {
216
289
  const snap = await snapshot(deviceQuery, { options });
217
290
  return { ...snap, verdict: classify(snap) };
218
291
  }
292
+
293
+
294
+ /**
295
+ * How hard a revive looks before calling a device unhealthy.
296
+ *
297
+ * A device seconds out of a boot is still settling, and one reading would make
298
+ * that a false alarm — the failure shape of item 175.
299
+ */
300
+ export const REVIVE_HEALTH_POLLS = 5;
301
+ export const REVIVE_HEALTH_WAIT_MS = 2000;
302
+
303
+ /**
304
+ * What a revive launches to bring a silent accessibility tree back, and the one
305
+ * platform it makes sense on.
306
+ *
307
+ * Settings, because it is on every iOS simulator and opening it changes nothing
308
+ * a caller could be relying on. The app is incidental — what clears the tree is
309
+ * that *something* launched. Android is excluded by name rather than by
310
+ * accident: it has no accessibility tree at all and says so, so `tree-silent`
311
+ * there is the backend's normal state and not a fault.
312
+ */
313
+ export const REVIVE_LAUNCH = { ios: 'com.apple.Preferences' };
314
+
315
+ /**
316
+ * Power-cycle a device and say what state it came back in.
317
+ *
318
+ * The order is load bearing and was learned by hand: stop the daemon, shut the
319
+ * device down, boot it and *wait for the boot to finish*, start capture, rebuild
320
+ * the HID session. Any other order leaves a daemon holding a dead device.
321
+ *
322
+ * Lifted out of `cli.js` on 2026-09-18 so the bench can use it between passes.
323
+ * That is not tidying: `bench-hpi` runs `passes x runs` consecutively with no
324
+ * recovery in between, which on this device is 30 runs against a tolerance of
325
+ * roughly 8-10, so a three-pass suite has never been physically completable and
326
+ * the gate has never produced a hosted-runner reading. DEFERRED 173 listed a
327
+ * revive between passes as worth trying and nothing had tried it.
328
+ *
329
+ * `onStep` is called with each step's outcome so a CLI can print it live and a
330
+ * script can stay quiet.
331
+ */
332
+ export async function revive(deviceQuery, { options, onStep = () => {}, device } = {}) {
333
+ const dev = device ?? { udid: String(deviceQuery), name: String(deviceQuery), platform: 'ios' };
334
+ const steps = [];
335
+ const did = async (what, fn) => {
336
+ try {
337
+ await fn();
338
+ steps.push({ step: what, ok: true });
339
+ onStep({ step: what, ok: true });
340
+ } catch (err) {
341
+ steps.push({ step: what, ok: false, error: err.message });
342
+ onStep({ step: what, ok: false, error: err.message });
343
+ }
344
+ };
345
+ // Forced: the point of this is that the device is wedged, so something is
346
+ // certainly still holding it.
347
+ await did('stopped the daemon', async () => { api.stopDaemon(dev.udid, { force: true }); });
348
+ await did('restarted the device, and waited for the boot to finish', () => restartDevice(dev.udid));
349
+ await did('started capture', () => api.ensureDaemon(dev.udid));
350
+ await did('rebuilt the HID session', () => input.resetSession(dev.udid));
351
+
352
+ let verdict = null;
353
+ for (let attempt = 0; attempt < REVIVE_HEALTH_POLLS; attempt += 1) {
354
+ verdict = classify(await snapshot(dev.udid, { options }).catch(() => null));
355
+ if (verdict?.state === 'healthy') break;
356
+ if (attempt < REVIVE_HEALTH_POLLS - 1) await new Promise((r) => setTimeout(r, REVIVE_HEALTH_WAIT_MS));
357
+ }
358
+ // A silent tree does not heal with time and does heal with a launch — six
359
+ // reads over 60s at 0 elements, then 14 immediately after one launch.
360
+ const bundle = REVIVE_LAUNCH[dev.platform];
361
+ if (verdict?.state === 'tree-silent' && bundle) {
362
+ await did('launched an app, which is what brings a silent tree back', async () => {
363
+ await launchApp(dev.udid, bundle, { args: [], env: {} });
364
+ await new Promise((r) => setTimeout(r, REVIVE_HEALTH_WAIT_MS));
365
+ });
366
+ verdict = classify(await snapshot(dev.udid, { options }).catch(() => null)) ?? verdict;
367
+ }
368
+ const state = verdict?.state ?? 'read-failed';
369
+ return { device: dev, steps, verdict, state, usable: !UNUSABLE.has(state), healthy: state === 'healthy' };
370
+ }