simframe 0.18.0 → 0.19.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 +126 -1106
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +12 -6
- package/package.json +1 -1
- package/scripts/article-md.mjs +111 -45
- package/scripts/bench-hpi.mjs +45 -1
- package/scripts/ci-memory.mjs +33 -6
- package/scripts/demo-gif/README.md +36 -0
- package/scripts/demo-gif/compose.swift +106 -0
- package/scripts/demo-gif/events.example.json +74 -0
- package/scripts/demo-gif/flow.json +6 -0
- package/scripts/smithery/icon.png +0 -0
- package/scripts/smithery-bundle.mjs +77 -0
- package/src/actions.js +291 -31
- package/src/cli.js +45 -26
- package/src/device-state.js +37 -0
- package/src/index.js +139 -11
- package/src/platform/cdp.js +242 -0
- package/src/platform/index.js +28 -2
- package/src/store.js +39 -0
- package/src/wedge.js +154 -2
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
|
-
/**
|
|
56
|
-
|
|
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
|
+
}
|