simframe 0.17.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 -1058
- 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 +53 -1
- package/scripts/ci-device-guard.mjs +27 -0
- 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/device-state.mjs +5 -62
- package/scripts/smithery/icon.png +0 -0
- package/scripts/smithery-bundle.mjs +77 -0
- package/src/actions.js +309 -32
- package/src/cli.js +111 -29
- package/src/device-state.js +101 -0
- package/src/index.js +139 -11
- package/src/metrics.js +144 -3
- package/src/navigate.js +10 -0
- package/src/platform/cdp.js +242 -0
- package/src/platform/index.js +28 -2
- package/src/store.js +39 -0
- package/src/wedge.js +370 -0
package/src/wedge.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// What is this device actually doing right now?
|
|
2
|
+
//
|
|
3
|
+
// `doctor` answers "can this machine capture". This answers a different
|
|
4
|
+
// question, and it is the one item 173 has never been able to answer: when a
|
|
5
|
+
// device stops presenting the app, *which* of several failures is it?
|
|
6
|
+
//
|
|
7
|
+
// The reason this exists is that the evidence has been arriving as a
|
|
8
|
+
// consequence rather than an observation. `scripts/device-state.mjs` recognises
|
|
9
|
+
// a wedge from the shape of a tour's failure text — "two labels, one of them a
|
|
10
|
+
// clock, on a still screen" — and names it "a launched app never came to the
|
|
11
|
+
// front". That classification is useful for deciding whether to revive, and it
|
|
12
|
+
// is not a diagnosis: it cannot tell a dead framebuffer from a device that is
|
|
13
|
+
// genuinely showing a lock screen from an app that never fronted. Three
|
|
14
|
+
// hypotheses, one symptom, and a regex standing where a measurement should be.
|
|
15
|
+
//
|
|
16
|
+
// So the facts get gathered at the moment of the wedge instead of inferred
|
|
17
|
+
// afterwards, and the classifier over them is a **pure function** with fixtures.
|
|
18
|
+
// That part is deliberate: `device-state.mjs` records that "two runtime bugs in
|
|
19
|
+
// this project came from logic that was correct and had never executed",
|
|
20
|
+
// because the only thing exercising it was a hosted runner at minute fifteen of
|
|
21
|
+
// a job. Nothing here needs a device to be tested.
|
|
22
|
+
import * as api from './index.js';
|
|
23
|
+
import * as frontmost from './frontmost.js';
|
|
24
|
+
import * as input from './input.js';
|
|
25
|
+
import { launchApp, restartDevice } from './platform/index.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How little agreement between the sensors counts as "these are different
|
|
29
|
+
* screens".
|
|
30
|
+
*
|
|
31
|
+
* **Measured, and the first version of this constant was justified with the
|
|
32
|
+
* wrong number.** It cited CLAUDE.md's "the tree and OCR agree on 0.33–0.47",
|
|
33
|
+
* which is a figure about *structural tokens* — a different quantity from the
|
|
34
|
+
* element-level fusion counted here. Printing a healthy band of 0.33–0.47 next
|
|
35
|
+
* to a live reading of 0.846 is how that got noticed.
|
|
36
|
+
*
|
|
37
|
+
* Sampled on `326464A4` (iPhone 17 Pro, iOS 26.5) across five real screens —
|
|
38
|
+
* Settings root, General, About, springboard, Reminders:
|
|
39
|
+
*
|
|
40
|
+
* 0.857 0.833 0.929 0.846 0.667 median 0.846
|
|
41
|
+
*
|
|
42
|
+
* So healthy element fusion is **0.67-0.93** (stated as 0.66 so a reading at the floor does not print as below it), and the floor belongs to the
|
|
43
|
+
* sparsest screen, which is also the case `ENOUGH_TO_COMPARE` withholds
|
|
44
|
+
* judgement on. A threshold of 0.1 sits 6.7x below the observed floor: this is
|
|
45
|
+
* not a band inside the metric's own noise, which is the mistake the HPI_time
|
|
46
|
+
* gate made.
|
|
47
|
+
*
|
|
48
|
+
* What the collapse means: the tree is read live and in-process, OCR reads a
|
|
49
|
+
* framebuffer that can go stale without saying so. If both sensors report
|
|
50
|
+
* plenty and almost nothing fuses, they are looking at different screens, and
|
|
51
|
+
* the frame is the one that is behind. A peer hit exactly this — `sim_look`
|
|
52
|
+
* returned a web form from an earlier session on the device while the element
|
|
53
|
+
* map, taken at the same moment, correctly described the app in front of them.
|
|
54
|
+
*/
|
|
55
|
+
export const DISAGREEMENT = 0.1;
|
|
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';
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Both sensors need at least this many elements before their disagreement means
|
|
93
|
+
* anything. A screen with one label from each cannot be said to disagree, and a
|
|
94
|
+
* springboard or a lock screen is legitimately sparse.
|
|
95
|
+
*/
|
|
96
|
+
export const ENOUGH_TO_COMPARE = 3;
|
|
97
|
+
|
|
98
|
+
/** At or below this from both sensors, there is effectively nothing on screen. */
|
|
99
|
+
export const SPARSE = 2;
|
|
100
|
+
|
|
101
|
+
const seenBy = (targets, sensor) =>
|
|
102
|
+
targets.filter((t) => String(t.source ?? '').split('|').includes(sensor)).length;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read everything that discriminates between the failure modes, in one pass.
|
|
106
|
+
*
|
|
107
|
+
* Every field here exists because it separates two hypotheses. Nothing is
|
|
108
|
+
* collected because it is interesting.
|
|
109
|
+
*/
|
|
110
|
+
export async function snapshot(deviceQuery, { options } = {}) {
|
|
111
|
+
// **`ensureDaemon` throws on the loudest condition this module exists to
|
|
112
|
+
// name.** "the daemon is running and the display produced no frame in 60s" is
|
|
113
|
+
// reported as an exception, so the first version of this function propagated
|
|
114
|
+
// it and `simframe diagnose` died with a stack trace on a genuinely wedged
|
|
115
|
+
// device — the one moment it is worth running. Caught within an hour of
|
|
116
|
+
// shipping, by the device wedging.
|
|
117
|
+
//
|
|
118
|
+
// So a snapshot of a dead device is a snapshot, not an error. `classify`
|
|
119
|
+
// already has a verdict for it.
|
|
120
|
+
let device;
|
|
121
|
+
let state = null;
|
|
122
|
+
let daemonError = null;
|
|
123
|
+
try {
|
|
124
|
+
({ device, state } = await api.ensureDaemon(deviceQuery, options));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
daemonError = err.message;
|
|
127
|
+
device = { udid: String(deviceQuery ?? '?'), name: String(deviceQuery ?? 'unknown device') };
|
|
128
|
+
}
|
|
129
|
+
const udid = device.udid;
|
|
130
|
+
if (daemonError) {
|
|
131
|
+
return {
|
|
132
|
+
device: { udid, name: device.name },
|
|
133
|
+
readError: daemonError,
|
|
134
|
+
frame: null,
|
|
135
|
+
elements: { total: 0, ax: 0, ocr: 0, fused: 0 },
|
|
136
|
+
agreement: null,
|
|
137
|
+
frontmost: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let identity = null;
|
|
142
|
+
let readError = null;
|
|
143
|
+
try {
|
|
144
|
+
identity = await api.screenIdentity(udid, { options, confirmNovel: false });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
readError = err.message;
|
|
147
|
+
}
|
|
148
|
+
const targets = identity?.entry?.targets ?? [];
|
|
149
|
+
|
|
150
|
+
// Who holds the front, by pid, which is item 169's contribution and the only
|
|
151
|
+
// sensor here that does not go through the display at all.
|
|
152
|
+
let front = null;
|
|
153
|
+
try {
|
|
154
|
+
front = await frontmost.read(udid);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
front = { pid: null, title: null, error: err.message };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ax = seenBy(targets, 'ax');
|
|
160
|
+
const ocr = seenBy(targets, 'ocr');
|
|
161
|
+
const fused = targets.filter((t) => {
|
|
162
|
+
const parts = String(t.source ?? '').split('|');
|
|
163
|
+
return parts.includes('ax') && parts.includes('ocr');
|
|
164
|
+
}).length;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
device: { udid, name: device.name },
|
|
168
|
+
readError,
|
|
169
|
+
frame: state
|
|
170
|
+
? {
|
|
171
|
+
seq: state.seq ?? null,
|
|
172
|
+
ageMs: state.capturedAt ? Date.now() - state.capturedAt : null,
|
|
173
|
+
stableForMs: state.stableForMs ?? null,
|
|
174
|
+
size: state.width && state.height ? `${state.width}x${state.height}` : null,
|
|
175
|
+
hash: state.hash ? String(state.hash).slice(0, 8) : null,
|
|
176
|
+
}
|
|
177
|
+
: null,
|
|
178
|
+
elements: { total: targets.length, ax, ocr, fused },
|
|
179
|
+
// Null rather than a number when it cannot be computed, so a caller cannot
|
|
180
|
+
// read "0 agreement" off a screen nobody could compare.
|
|
181
|
+
agreement: ax > 0 && ocr > 0 ? Number((fused / Math.min(ax, ocr)).toFixed(3)) : null,
|
|
182
|
+
frontmost: front,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Name the state, or say it looks fine.
|
|
188
|
+
*
|
|
189
|
+
* Ordered most specific first, and each verdict says what it is evidence *of*
|
|
190
|
+
* rather than what to do about it — the same discipline as the escalation
|
|
191
|
+
* reasons, where a vocabulary that admits "other" collects a pile of "other".
|
|
192
|
+
*/
|
|
193
|
+
export function classify(snap) {
|
|
194
|
+
if (!snap?.frame) {
|
|
195
|
+
return {
|
|
196
|
+
state: 'capture-down',
|
|
197
|
+
// The daemon's own words when it has them. They are more specific than
|
|
198
|
+
// anything derivable here — "produced no frame in 60s" distinguishes a
|
|
199
|
+
// live daemon over a dead display from a daemon that is not running.
|
|
200
|
+
detail: snap?.readError
|
|
201
|
+
? `capture is not producing frames: ${snap.readError}`
|
|
202
|
+
: 'no frame state at all — the daemon is not producing frames',
|
|
203
|
+
revive: true,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const { total, ax, ocr } = snap.elements ?? { total: 0, ax: 0, ocr: 0 };
|
|
207
|
+
if (snap.readError) {
|
|
208
|
+
return { state: 'read-failed', detail: `the screen could not be read: ${snap.readError}`, revive: true };
|
|
209
|
+
}
|
|
210
|
+
if (total === 0) {
|
|
211
|
+
return {
|
|
212
|
+
state: 'nothing-readable',
|
|
213
|
+
detail: 'neither the accessibility tree nor OCR found a single element',
|
|
214
|
+
revive: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// The two sensors are describing different screens. The tree is read live and
|
|
218
|
+
// in-process; OCR reads the framebuffer. So the frame is the stale one.
|
|
219
|
+
if (ax >= ENOUGH_TO_COMPARE && ocr >= ENOUGH_TO_COMPARE
|
|
220
|
+
&& snap.agreement != null && snap.agreement <= DISAGREEMENT) {
|
|
221
|
+
return {
|
|
222
|
+
state: 'stale-frame',
|
|
223
|
+
detail: `the tree found ${ax} element(s) and OCR found ${ocr}, and they fuse on `
|
|
224
|
+
+ `${snap.agreement} of them (measured healthy: ${HEALTHY_FUSION}). The tree is read live, `
|
|
225
|
+
+ 'so the framebuffer is the one that is behind — an image from this device is not safe to trust',
|
|
226
|
+
revive: true,
|
|
227
|
+
};
|
|
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
|
+
}
|
|
256
|
+
// An app holds the front, and the display is showing almost nothing. This is
|
|
257
|
+
// the CI signature that `device-state.mjs` recognises as "a clock and nothing
|
|
258
|
+
// else", now stated as an observation. It deliberately does NOT claim to know
|
|
259
|
+
// whether this is a lock screen, a dead surface or a crashed SpringBoard —
|
|
260
|
+
// that is the next question, and pretending to answer it is what put a regex
|
|
261
|
+
// where a measurement belongs.
|
|
262
|
+
if (snap.frontmost?.pid && ax <= SPARSE && ocr <= SPARSE) {
|
|
263
|
+
return {
|
|
264
|
+
state: 'not-presenting',
|
|
265
|
+
detail: `pid ${snap.frontmost.pid}`
|
|
266
|
+
+ `${snap.frontmost.title ? ` (${snap.frontmost.title})` : ''} holds the front, but the `
|
|
267
|
+
+ `display shows ${total} element(s). Something is in front of the app, or the display is `
|
|
268
|
+
+ 'not painting it. Which of those it is, is not knowable from here',
|
|
269
|
+
revive: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return { state: 'healthy', detail: `${total} element(s), sensors agree on ${snap.agreement ?? 'n/a'}`, revive: false };
|
|
273
|
+
}
|
|
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
|
+
|
|
288
|
+
export async function diagnose(deviceQuery, { options } = {}) {
|
|
289
|
+
const snap = await snapshot(deviceQuery, { options });
|
|
290
|
+
return { ...snap, verdict: classify(snap) };
|
|
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
|
+
}
|