simframe 0.4.2 → 0.6.0-rc.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/README.md +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- package/src/view.js +342 -0
package/src/cli.js
CHANGED
|
@@ -6,7 +6,9 @@ import { bootedDevices, listDevices, resolveDevice } from './simctl.js';
|
|
|
6
6
|
import * as actions from './actions.js';
|
|
7
7
|
import * as api from './index.js';
|
|
8
8
|
import * as input from './input.js';
|
|
9
|
+
import * as navigate from './navigate.js';
|
|
9
10
|
import * as store from './store.js';
|
|
11
|
+
import * as view from './view.js';
|
|
10
12
|
|
|
11
13
|
const USAGE = `simframe — always-warm iOS Simulator frames
|
|
12
14
|
|
|
@@ -20,33 +22,61 @@ const USAGE = `simframe — always-warm iOS Simulator frames
|
|
|
20
22
|
simframe wait [device] wait for the screen to react (see --mode)
|
|
21
23
|
simframe strip [device] write a contact sheet of recent frames
|
|
22
24
|
simframe recall [device] what happened in the last minute (--ago=<ms> for a frame)
|
|
23
|
-
simframe ui [device]
|
|
24
|
-
simframe
|
|
25
|
-
simframe
|
|
25
|
+
simframe ui [device] the screen as a numbered element map
|
|
26
|
+
simframe find "<intent>" resolve an intent to one control
|
|
27
|
+
simframe tap <selector> tap #3, "Save", or @120,400
|
|
28
|
+
simframe do <script.json> run a scripted flow (see below)
|
|
29
|
+
simframe screens [device] list screens this device has learned
|
|
30
|
+
simframe goto <screen> walk to a known screen through known steps
|
|
31
|
+
simframe flow save <name> <script.json> run a flow and save it if every step verifies
|
|
32
|
+
simframe flow run <name> replay a saved flow
|
|
33
|
+
simframe flow list list saved flows
|
|
34
|
+
simframe tapAt <x> <y> tap at a point, in points
|
|
35
|
+
simframe swipe <x1> <y1> <x2> <y2> swipe between two points
|
|
36
|
+
simframe type <text> enter text (exact; uses the pasteboard)
|
|
37
|
+
simframe keys <text> send key events instead (layout-dependent)
|
|
38
|
+
simframe press <button> a hardware button, e.g. home
|
|
26
39
|
simframe devices list simulators
|
|
27
40
|
simframe doctor check that this machine can capture
|
|
41
|
+
(--strict, or SIMFRAME_STRICT=1, makes any
|
|
42
|
+
degraded layer a non-zero exit)
|
|
43
|
+
|
|
44
|
+
Selectors — anywhere a control is named
|
|
45
|
+
#3 the number \`simframe ui\` gave it. Cheapest, unambiguous.
|
|
46
|
+
"Save" a label or a phrase, resolved by intent (verbs, typos, synonyms)
|
|
47
|
+
@120,400 raw point coordinates
|
|
28
48
|
|
|
29
49
|
Options
|
|
30
50
|
--device=<udid|name> simulator to target (default: the booted one)
|
|
31
|
-
--
|
|
51
|
+
--json machine-readable output — on every command
|
|
52
|
+
--out=<file> output path for frame/strip/recall
|
|
32
53
|
--detail=low|normal|high|full or --detail=<max pixels>
|
|
33
|
-
--
|
|
54
|
+
--engine=simframed|simctl capture engine (default simframed)
|
|
55
|
+
--fps=<n> capture rate while the screen is moving (simctl engine only)
|
|
34
56
|
--count=<n> frames in a strip (default 5)
|
|
35
57
|
--since=<hash|seq> compare against this frame (see: simframe mark)
|
|
36
58
|
--mode=settle|change|stable what wait waits for (default settle)
|
|
37
59
|
--stable-ms=<n> settle window for wait (default 600)
|
|
38
60
|
--timeout-ms=<n> give up after this long (default 8000)
|
|
39
|
-
--
|
|
40
|
-
--
|
|
61
|
+
--filter=<text> ui: only elements whose text contains this
|
|
62
|
+
--interactive ui: only elements that look tappable
|
|
63
|
+
--all ui: include the status bar and collapsed regions
|
|
64
|
+
--refresh ui: re-read this screen instead of using memory
|
|
65
|
+
--save=<name> do: save the flow if every step verifies
|
|
66
|
+
--force let stop kill a loop another client is using;
|
|
67
|
+
let flow save keep an unverified flow
|
|
41
68
|
|
|
42
69
|
A script is a JSON array of steps, run in one go with a settle between each:
|
|
43
70
|
|
|
44
71
|
[{"tap":"Assets"},{"tap":"Add Asset"},
|
|
45
72
|
{"type":{"into":"Name","text":"Fryer 3"}},
|
|
46
|
-
{"
|
|
73
|
+
{"scrollTo":"Save"},{"tap":"Save"},
|
|
74
|
+
{"waitFor":{"value":"Saved","timeoutMs":5000}},
|
|
75
|
+
{"assert":{"value":"Saved","is":"visible"}}]
|
|
47
76
|
|
|
48
|
-
Input
|
|
49
|
-
|
|
77
|
+
Input, text recognition and the accessibility tree all come from the daemon.
|
|
78
|
+
Nothing else needs installing; idb remains a fallback for the tree and for
|
|
79
|
+
input on a machine where the daemon cannot run.
|
|
50
80
|
|
|
51
81
|
The reliable pattern around an action is:
|
|
52
82
|
|
|
@@ -73,6 +103,39 @@ function parseArgs(argv) {
|
|
|
73
103
|
|
|
74
104
|
const num = (v, fallback) => (v == null ? fallback : Number(v));
|
|
75
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Print one thing, two ways.
|
|
108
|
+
*
|
|
109
|
+
* `--json` is on every command rather than most of them, because a skill or a
|
|
110
|
+
* script that has to parse one command's prose and another's JSON will parse
|
|
111
|
+
* the prose wrong exactly once and then be trusted anyway.
|
|
112
|
+
*/
|
|
113
|
+
function emit(flags, json, lines) {
|
|
114
|
+
if (flags.json) {
|
|
115
|
+
console.log(JSON.stringify(json, null, 2));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const body = typeof lines === 'function' ? lines() : lines;
|
|
119
|
+
if (body != null) console.log(Array.isArray(body) ? body.filter((l) => l != null).join('\n') : body);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The end-state screen map, rendered from a reading the flow already took. */
|
|
123
|
+
async function mapText(device, options, identity) {
|
|
124
|
+
try {
|
|
125
|
+
const m = await view.screenMap(device, { options, identity: identity?.entry ? identity : undefined });
|
|
126
|
+
return m.text;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return `(could not read the screen: ${err.message})`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A step result, the same shape in every command that runs steps. */
|
|
133
|
+
const stepLine = (r) => {
|
|
134
|
+
const settle = r.settled ? (r.settled.ok ? ` (settled ${r.settled.waitedMs}ms)` : ' (never settled)') : '';
|
|
135
|
+
const verdict = r.verification && r.verification.verdict !== 'ok' ? ` [${r.verification.verdict}]` : '';
|
|
136
|
+
return `${r.ok ? 'ok ' : 'FAIL'} [${r.index}] ${r.action}: ${r.ok ? r.detail : r.error}${settle}${verdict}`;
|
|
137
|
+
};
|
|
138
|
+
|
|
76
139
|
async function main() {
|
|
77
140
|
const [command, ...rest] = process.argv.slice(2);
|
|
78
141
|
const { flags, positional } = parseArgs(rest);
|
|
@@ -81,6 +144,7 @@ async function main() {
|
|
|
81
144
|
if (flags.fps) options.fps = num(flags.fps);
|
|
82
145
|
if (flags.maxDim) options.maxDim = num(flags.maxDim);
|
|
83
146
|
if (flags.ringSize) options.ringSize = num(flags.ringSize);
|
|
147
|
+
if (flags.engine) options.engine = String(flags.engine);
|
|
84
148
|
|
|
85
149
|
switch (command) {
|
|
86
150
|
case undefined:
|
|
@@ -112,16 +176,32 @@ async function main() {
|
|
|
112
176
|
|
|
113
177
|
case 'start': {
|
|
114
178
|
const { device: dev, state, started } = await api.ensureDaemon(device, options);
|
|
179
|
+
const engineModule = await import('./engine.js');
|
|
180
|
+
const running = engineModule.runningEngine(dev.udid) ?? 'simctl';
|
|
115
181
|
console.log(
|
|
116
|
-
`${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime})
|
|
182
|
+
`${started ? 'started' : 'already running'} — ${dev.name} (${dev.runtime}) ` +
|
|
183
|
+
`engine=${running} frame #${state.seq} ${state.width}x${state.height}`,
|
|
117
184
|
);
|
|
185
|
+
// Say which engine, and if it is the slow one, say why. A downgrade that
|
|
186
|
+
// prints nothing is how this shipped broken twice.
|
|
187
|
+
if (running !== 'simframed') {
|
|
188
|
+
const why = api.fallbackReason(dev.udid);
|
|
189
|
+
console.log(
|
|
190
|
+
`WARN engine=simctl — roughly 30x slower per frame. ` +
|
|
191
|
+
(why ? `simframed unavailable: ${why}` : 'reason unrecorded; run simframe doctor'),
|
|
192
|
+
);
|
|
193
|
+
if (Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1') {
|
|
194
|
+
console.error('--strict: refusing to run on a degraded engine');
|
|
195
|
+
process.exitCode = 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
118
198
|
return;
|
|
119
199
|
}
|
|
120
200
|
|
|
121
201
|
case 'stop': {
|
|
122
202
|
const targets = flags.all
|
|
123
203
|
? fs.existsSync(store.ROOT)
|
|
124
|
-
? fs.readdirSync(store.ROOT)
|
|
204
|
+
? fs.readdirSync(store.ROOT).filter(store.isUdid)
|
|
125
205
|
: []
|
|
126
206
|
: [(await resolveDevice(device)).udid];
|
|
127
207
|
let stopped = 0;
|
|
@@ -142,7 +222,7 @@ async function main() {
|
|
|
142
222
|
const udids = device
|
|
143
223
|
? [(await resolveDevice(device)).udid]
|
|
144
224
|
: fs.existsSync(store.ROOT)
|
|
145
|
-
? fs.readdirSync(store.ROOT)
|
|
225
|
+
? fs.readdirSync(store.ROOT).filter(store.isUdid)
|
|
146
226
|
: [];
|
|
147
227
|
const rows = udids.map((udid) => {
|
|
148
228
|
const { meta, pid, alive, stale } = api.daemonStatus(udid);
|
|
@@ -177,13 +257,17 @@ async function main() {
|
|
|
177
257
|
const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
|
|
178
258
|
const out = flags.out || path.join(process.cwd(), 'simframe.png');
|
|
179
259
|
fs.writeFileSync(out, res.png);
|
|
180
|
-
|
|
260
|
+
emit(
|
|
261
|
+
flags,
|
|
262
|
+
{ file: out, width: res.width, height: res.height, ageMs: res.ageMs, seq: res.state.seq, hash: res.state.hash },
|
|
263
|
+
`${out} — ${res.width}x${res.height}, ${res.ageMs}ms old, frame #${res.state.seq}`,
|
|
264
|
+
);
|
|
181
265
|
return;
|
|
182
266
|
}
|
|
183
267
|
|
|
184
268
|
case 'mark': {
|
|
185
269
|
const res = await api.getState(device, { options });
|
|
186
|
-
|
|
270
|
+
emit(flags, { hash: res.state.hash, seq: res.state.seq }, res.state.hash);
|
|
187
271
|
return;
|
|
188
272
|
}
|
|
189
273
|
|
|
@@ -229,23 +313,32 @@ async function main() {
|
|
|
229
313
|
timeoutMs: num(flags.timeoutMs, 8000),
|
|
230
314
|
options,
|
|
231
315
|
});
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
316
|
+
emit(
|
|
317
|
+
flags,
|
|
318
|
+
{
|
|
319
|
+
satisfied: res.satisfied,
|
|
320
|
+
mode: res.mode,
|
|
321
|
+
waitedMs: res.waitedMs,
|
|
322
|
+
sawChange: res.sawChange,
|
|
323
|
+
changedBeforeWait: Boolean(res.changedBeforeWait),
|
|
324
|
+
noVisibleChange: Boolean(res.noVisibleChange),
|
|
325
|
+
stalled: Boolean(res.stalled),
|
|
326
|
+
hash: res.state?.hash,
|
|
327
|
+
seq: res.state?.seq,
|
|
328
|
+
},
|
|
329
|
+
() => {
|
|
330
|
+
if (res.satisfied) {
|
|
331
|
+
return `${res.mode === 'change' ? 'changed' : 'settled'} after ${res.waitedMs}ms — frame #${res.state.seq}` +
|
|
332
|
+
(res.changedBeforeWait ? ' (change had already happened before the call)' : '');
|
|
333
|
+
}
|
|
334
|
+
if (res.noVisibleChange) {
|
|
335
|
+
return `no visible change after ${res.waitedMs}ms — screen stable, nothing moved (the action may have had no visible effect)`;
|
|
336
|
+
}
|
|
337
|
+
if (res.stalled) return `capture stalled after ${res.waitedMs}ms — ${res.live.note}`;
|
|
338
|
+
return `timed out after ${res.waitedMs}ms — no ${res.mode === 'change' ? 'change' : 'settle'}` +
|
|
339
|
+
(res.sawChange ? '' : '; if the change happened before this call, pass `--since` from `simframe mark`');
|
|
340
|
+
},
|
|
341
|
+
);
|
|
249
342
|
process.exitCode = res.satisfied ? 0 : 1;
|
|
250
343
|
return;
|
|
251
344
|
}
|
|
@@ -258,7 +351,9 @@ async function main() {
|
|
|
258
351
|
});
|
|
259
352
|
const out = flags.out || path.join(process.cwd(), 'simframe-strip.png');
|
|
260
353
|
fs.writeFileSync(out, res.png);
|
|
261
|
-
|
|
354
|
+
emit(
|
|
355
|
+
flags,
|
|
356
|
+
{ file: out, frames: res.frames.length, spanMs: res.spanMs, width: res.width, height: res.height },
|
|
262
357
|
`${out} — ${res.frames.length} frames over ${res.spanMs}ms (${res.width}x${res.height})`,
|
|
263
358
|
);
|
|
264
359
|
return;
|
|
@@ -269,7 +364,9 @@ async function main() {
|
|
|
269
364
|
const res = await api.getFrameAt(device, { msAgo: num(flags.ago), options });
|
|
270
365
|
const out = flags.out || path.join(process.cwd(), 'simframe-recall.png');
|
|
271
366
|
fs.writeFileSync(out, res.png);
|
|
272
|
-
|
|
367
|
+
emit(
|
|
368
|
+
flags,
|
|
369
|
+
{ file: out, seq: res.seq, actualMsAgo: res.actualMsAgo, requestedMsAgo: res.requestedMsAgo, oldestMsAgo: res.oldestMsAgo },
|
|
273
370
|
`${out} — frame #${res.seq} from ${Math.round(res.actualMsAgo)}ms ago ` +
|
|
274
371
|
`(memory reaches back ${Math.round(res.oldestMsAgo / 1000)}s)`,
|
|
275
372
|
);
|
|
@@ -298,29 +395,32 @@ async function main() {
|
|
|
298
395
|
}
|
|
299
396
|
|
|
300
397
|
case 'ui': {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
398
|
+
// The compact map, not a raw tree dump: region, a ref number, type, tap
|
|
399
|
+
// point, label. And no idb gate — OCR reads most screens on its own, and
|
|
400
|
+
// refusing to describe a screen because idb is missing was the surest way
|
|
401
|
+
// to make the fallback look broken.
|
|
402
|
+
const m = await view.screenMap(device, {
|
|
403
|
+
options,
|
|
404
|
+
filter: flags.filter,
|
|
405
|
+
interactive: Boolean(flags.interactive),
|
|
406
|
+
all: Boolean(flags.all),
|
|
407
|
+
refresh: Boolean(flags.refresh),
|
|
408
|
+
});
|
|
409
|
+
emit(
|
|
410
|
+
flags,
|
|
411
|
+
{
|
|
412
|
+
device: m.device.name,
|
|
413
|
+
screen: { hash: m.identity.hash, name: m.name, exits: m.exits, keyboard: m.identity.keyboard },
|
|
414
|
+
sources: m.identity.entry?.sources ?? [],
|
|
415
|
+
// Which layer is missing and why. A map built from one perception
|
|
416
|
+
// layer looks exactly like a map built from two until this says so.
|
|
417
|
+
degraded: m.identity.entry?.degraded ?? [],
|
|
418
|
+
points: m.screen,
|
|
419
|
+
elements: m.rows,
|
|
420
|
+
truncated: m.truncated,
|
|
421
|
+
},
|
|
422
|
+
m.text,
|
|
423
|
+
);
|
|
324
424
|
return;
|
|
325
425
|
}
|
|
326
426
|
|
|
@@ -328,12 +428,23 @@ async function main() {
|
|
|
328
428
|
const label = positional[0];
|
|
329
429
|
if (!label) throw new Error('usage: simframe tap <label>');
|
|
330
430
|
const res = await actions.runScript(flags.device, {
|
|
331
|
-
steps: [{ tap: label, index:
|
|
431
|
+
steps: [flags.index != null ? { tap: label, index: num(flags.index) } : { tap: label }],
|
|
332
432
|
options,
|
|
333
433
|
});
|
|
334
434
|
const step = res.results[0];
|
|
335
|
-
if (!step.ok)
|
|
336
|
-
|
|
435
|
+
if (!step.ok) {
|
|
436
|
+
if (flags.json) {
|
|
437
|
+
console.log(JSON.stringify({ ok: false, error: step.error }, null, 2));
|
|
438
|
+
process.exitCode = 1;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
throw new Error(step.error);
|
|
442
|
+
}
|
|
443
|
+
emit(
|
|
444
|
+
flags,
|
|
445
|
+
{ ok: true, ...step },
|
|
446
|
+
`${step.detail}${step.settled?.ok ? `, settled in ${step.settled.waitedMs}ms` : ''}`,
|
|
447
|
+
);
|
|
337
448
|
return;
|
|
338
449
|
}
|
|
339
450
|
|
|
@@ -349,15 +460,237 @@ async function main() {
|
|
|
349
460
|
continueOnError: Boolean(flags.continueOnError),
|
|
350
461
|
options,
|
|
351
462
|
});
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
463
|
+
const saved = flags.save
|
|
464
|
+
? navigate.saveFlow(res.device.udid, String(flags.save), res, { force: Boolean(flags.force) })
|
|
465
|
+
: null;
|
|
466
|
+
// `--map=false` arrives as the string "false"; `--no-map` as true.
|
|
467
|
+
const wantMap = !flags.json && flags.noMap !== true && String(flags.map ?? 'true') !== 'false';
|
|
468
|
+
const map = wantMap ? await mapText(flags.device, options, res.endScreen) : null;
|
|
469
|
+
emit(
|
|
470
|
+
flags,
|
|
471
|
+
{
|
|
472
|
+
ok: res.ok,
|
|
473
|
+
ranSteps: res.ranSteps,
|
|
474
|
+
totalSteps: res.totalSteps,
|
|
475
|
+
totalMs: res.totalMs,
|
|
476
|
+
results: res.results,
|
|
477
|
+
saved,
|
|
478
|
+
},
|
|
479
|
+
[
|
|
480
|
+
...res.results.map(stepLine),
|
|
481
|
+
`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps in ${res.totalMs}ms`,
|
|
482
|
+
saved && (saved.ok ? `saved flow "${saved.name}" — ${saved.steps} steps` : `not saved: ${saved.reason}`),
|
|
483
|
+
map && `\n${map}`,
|
|
484
|
+
],
|
|
485
|
+
);
|
|
486
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
case 'goto': {
|
|
491
|
+
const target = positional.join(' ').trim();
|
|
492
|
+
if (!target) throw new Error('usage: simframe goto "<screen>"');
|
|
493
|
+
const res = await navigate.goto(flags.device, target, {
|
|
494
|
+
stableMs: num(flags.stableMs, 500),
|
|
495
|
+
timeoutMs: num(flags.timeoutMs, 8000),
|
|
496
|
+
options,
|
|
497
|
+
});
|
|
498
|
+
const refusal = {
|
|
499
|
+
'unknown-screen': () => [
|
|
500
|
+
`no screen matching "${target}". known screens:`,
|
|
501
|
+
...(res.known ?? []).map((k) => ` ${k.name} (${k.hash}, ${k.edges} edges)`),
|
|
502
|
+
],
|
|
503
|
+
ambiguous: () => [
|
|
504
|
+
`"${target}" matches more than one screen:`,
|
|
505
|
+
...(res.candidates ?? []).map((c) => ` ${c.name} (${c.hash.slice(0, 8)})`),
|
|
506
|
+
],
|
|
507
|
+
};
|
|
508
|
+
if (!res.ok && res.reason) {
|
|
509
|
+
emit(flags, res, refusal[res.reason] ?? `${res.reason}: cannot reach "${res.to ?? target}" from here`);
|
|
510
|
+
process.exitCode = 1;
|
|
511
|
+
return;
|
|
355
512
|
}
|
|
356
|
-
|
|
513
|
+
emit(
|
|
514
|
+
flags,
|
|
515
|
+
res,
|
|
516
|
+
res.already
|
|
517
|
+
? `already on ${res.screen}`
|
|
518
|
+
: [
|
|
519
|
+
...(res.results ?? []).map(stepLine),
|
|
520
|
+
res.ok
|
|
521
|
+
? `arrived at ${res.screen} in ${res.ranSteps} step(s)`
|
|
522
|
+
: `ended at ${res.arrived}, wanted ${res.screen}`,
|
|
523
|
+
],
|
|
524
|
+
);
|
|
357
525
|
process.exitCode = res.ok ? 0 : 1;
|
|
358
526
|
return;
|
|
359
527
|
}
|
|
360
528
|
|
|
529
|
+
case 'screens': {
|
|
530
|
+
// Reading what this device has learned is a file read. It used to go
|
|
531
|
+
// through ensureDaemon, so a device whose capture had stopped could not
|
|
532
|
+
// even list the screens already on disk — the tool went blind about
|
|
533
|
+
// things it already knew.
|
|
534
|
+
const device = await resolveDevice(flags.device);
|
|
535
|
+
const known = navigate.knownScreens(device.udid);
|
|
536
|
+
emit(
|
|
537
|
+
flags,
|
|
538
|
+
known,
|
|
539
|
+
known.length
|
|
540
|
+
? known.map((k) => `${k.hash} ${k.edges} edges ${k.name}`)
|
|
541
|
+
: 'no screens known yet — run a flow first',
|
|
542
|
+
);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
case 'flow': {
|
|
547
|
+
const [sub, name] = positional;
|
|
548
|
+
// `list` is a directory read; `save` and `run` genuinely need the device
|
|
549
|
+
// awake, and each starts the daemon on its own path.
|
|
550
|
+
const device = await resolveDevice(flags.device);
|
|
551
|
+
if (sub === 'list') {
|
|
552
|
+
const flows = navigate.listFlows(device.udid);
|
|
553
|
+
emit(flags, flows, flows.length ? flows.map((f) => `${f.name} ${f.steps} steps`) : 'no saved flows');
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (sub === 'save') {
|
|
557
|
+
const file = positional[2];
|
|
558
|
+
if (!name || !file) throw new Error('usage: simframe flow save <name> <script.json>');
|
|
559
|
+
const steps = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
560
|
+
const res = await actions.runScript(flags.device, {
|
|
561
|
+
steps,
|
|
562
|
+
stableMs: num(flags.stableMs, 500),
|
|
563
|
+
timeoutMs: num(flags.timeoutMs, 8000),
|
|
564
|
+
options,
|
|
565
|
+
});
|
|
566
|
+
const saved = navigate.saveFlow(device.udid, name, res, { force: Boolean(flags.force) });
|
|
567
|
+
if (!saved.ok) {
|
|
568
|
+
emit(flags, saved, `not saved: ${saved.reason} (${(saved.verdicts ?? []).join(', ')}) — re-run, or pass --force`);
|
|
569
|
+
process.exitCode = 1;
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
emit(flags, saved, `saved ${saved.name} — ${saved.steps} steps`);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (sub === 'run') {
|
|
576
|
+
if (!name) throw new Error('usage: simframe flow run <name>');
|
|
577
|
+
const res = await navigate.runFlow(flags.device, name, {
|
|
578
|
+
stableMs: num(flags.stableMs, 500),
|
|
579
|
+
timeoutMs: num(flags.timeoutMs, 8000),
|
|
580
|
+
options,
|
|
581
|
+
});
|
|
582
|
+
if (res.reason === 'unknown-flow') {
|
|
583
|
+
emit(flags, res, `no flow "${name}". known: ${res.known.join(', ') || '(none)'}`);
|
|
584
|
+
process.exitCode = 1;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
emit(flags, res, [
|
|
588
|
+
...(res.results ?? []).map(stepLine),
|
|
589
|
+
`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`,
|
|
590
|
+
]);
|
|
591
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
throw new Error('usage: simframe flow <list|save|run>');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
case 'tapAt':
|
|
598
|
+
case 'swipe':
|
|
599
|
+
case 'type':
|
|
600
|
+
case 'keys':
|
|
601
|
+
case 'press': {
|
|
602
|
+
const input = await import('./input.js');
|
|
603
|
+
const dev = await resolveDevice(flags.device);
|
|
604
|
+
const nums = positional.map(Number);
|
|
605
|
+
// Only meaningful when frames are being captured; without them there is
|
|
606
|
+
// nothing to compare against and the command says only what it sent.
|
|
607
|
+
let before = null;
|
|
608
|
+
try {
|
|
609
|
+
before = (await api.getState(flags.device, { options })).state.hash;
|
|
610
|
+
} catch {
|
|
611
|
+
/* capture not running: fall through and report the send alone */
|
|
612
|
+
}
|
|
613
|
+
const t0 = Date.now();
|
|
614
|
+
switch (command) {
|
|
615
|
+
case 'tapAt': {
|
|
616
|
+
if (positional.length < 2 || nums.slice(0, 2).some(Number.isNaN)) {
|
|
617
|
+
throw new Error('usage: simframe tapAt <x> <y>');
|
|
618
|
+
}
|
|
619
|
+
await input.tapPoint(dev.udid, nums[0], nums[1], flags.durationMs ? { durationMs: num(flags.durationMs) } : {});
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
case 'swipe': {
|
|
623
|
+
if (positional.length < 4 || nums.slice(0, 4).some(Number.isNaN)) {
|
|
624
|
+
throw new Error('usage: simframe swipe <x1> <y1> <x2> <y2>');
|
|
625
|
+
}
|
|
626
|
+
await input.swipe(dev.udid, { x: nums[0], y: nums[1] }, { x: nums[2], y: nums[3] }, { durationMs: num(flags.durationMs, 300) });
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
case 'type':
|
|
630
|
+
if (!positional.length) throw new Error('usage: simframe type <text>');
|
|
631
|
+
await input.typeText(dev.udid, positional.join(' '));
|
|
632
|
+
break;
|
|
633
|
+
case 'keys':
|
|
634
|
+
if (!positional.length) throw new Error('usage: simframe keys <text>');
|
|
635
|
+
await input.typeKeys(dev.udid, positional.join(' '));
|
|
636
|
+
break;
|
|
637
|
+
default:
|
|
638
|
+
if (!positional.length) throw new Error('usage: simframe press <button>');
|
|
639
|
+
await input.pressButton(dev.udid, positional[0]);
|
|
640
|
+
}
|
|
641
|
+
const driver = await input.driverFor(dev.udid);
|
|
642
|
+
const ms = Date.now() - t0;
|
|
643
|
+
// Did the device act on it? Input has no feedback channel — a dispatched
|
|
644
|
+
// Indigo message reports success whether or not the device did anything,
|
|
645
|
+
// and this command once reported `press in 66ms` while the screen sat
|
|
646
|
+
// frozen. The frames are the only witness there is, so ask them.
|
|
647
|
+
let changed = null;
|
|
648
|
+
if (before) {
|
|
649
|
+
try {
|
|
650
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
651
|
+
changed = (await api.getState(flags.device, { options })).state.hash !== before;
|
|
652
|
+
} catch {
|
|
653
|
+
/* no daemon, or capture is down: report the send and say nothing more */
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Deliberately not an accusation. Pressing home while already on the
|
|
657
|
+
// springboard legitimately changes nothing, and a warning that cries wolf
|
|
658
|
+
// is how a real one gets ignored.
|
|
659
|
+
const note = changed === false
|
|
660
|
+
? ' — the screen did not change. That is expected if the press had nothing to do here;'
|
|
661
|
+
+ ' if you expected a change, input may not be reaching the device —'
|
|
662
|
+
+ ' `simframe stop --force && simframe start` rebuilds the session.'
|
|
663
|
+
: '';
|
|
664
|
+
emit(
|
|
665
|
+
flags,
|
|
666
|
+
{ ok: true, command, ms, driver: driver.name, screenChanged: changed },
|
|
667
|
+
`${command} in ${ms}ms via ${driver.name}${changed === true ? ' — screen changed' : ''}${note}`,
|
|
668
|
+
);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
case 'find': {
|
|
673
|
+
const intent = positional.join(' ');
|
|
674
|
+
if (!intent) throw new Error('usage: simframe find "<intent>"');
|
|
675
|
+
try {
|
|
676
|
+
const r = await api.locate(flags.device, intent, { options });
|
|
677
|
+
emit(
|
|
678
|
+
flags,
|
|
679
|
+
{ ok: true, target: r.target, score: r.score, from: r.from, reasons: r.reasons, alternatives: r.alternatives },
|
|
680
|
+
[
|
|
681
|
+
`${r.target.label ?? '(icon-only)'} @(${r.target.x},${r.target.y}) ` +
|
|
682
|
+
`${r.target.region ?? 'content'} ${r.target.type ?? '?'}/${r.target.source} score ${r.score ?? '-'}`,
|
|
683
|
+
r.reasons?.length ? ` because: ${r.reasons.join(', ')}` : null,
|
|
684
|
+
...(r.alternatives ?? []).map((a) => ` also considered: "${a.label}" ${a.score}`),
|
|
685
|
+
],
|
|
686
|
+
);
|
|
687
|
+
} catch (err) {
|
|
688
|
+
emit(flags, { ok: false, error: err.message }, err.message);
|
|
689
|
+
process.exitCode = 1;
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
361
694
|
case 'devices': {
|
|
362
695
|
const all = await listDevices();
|
|
363
696
|
const shown = flags.all ? all : all.filter((d) => d.state === 'Booted');
|
|
@@ -372,7 +705,11 @@ async function main() {
|
|
|
372
705
|
}
|
|
373
706
|
|
|
374
707
|
case 'doctor': {
|
|
375
|
-
await doctor(
|
|
708
|
+
await doctor({
|
|
709
|
+
json: Boolean(flags.json),
|
|
710
|
+
strict: Boolean(flags.strict) || process.env.SIMFRAME_STRICT === '1',
|
|
711
|
+
device: flags.device,
|
|
712
|
+
});
|
|
376
713
|
return;
|
|
377
714
|
}
|
|
378
715
|
|
|
@@ -382,55 +719,190 @@ async function main() {
|
|
|
382
719
|
}
|
|
383
720
|
}
|
|
384
721
|
|
|
385
|
-
|
|
722
|
+
/**
|
|
723
|
+
* Report every layer, and treat a silent downgrade as a problem.
|
|
724
|
+
*
|
|
725
|
+
* The tool's policy is to degrade rather than fail, which is right — a machine
|
|
726
|
+
* without a Swift toolchain should still capture frames. What was wrong was
|
|
727
|
+
* that degrading looked identical to working: a published package missing one
|
|
728
|
+
* file made every install fall back to the simctl engine, and another shipped
|
|
729
|
+
* OCR disabled. Both passed CI, and nothing printed a word.
|
|
730
|
+
*
|
|
731
|
+
* So a fallback is a `warn`, not an `ok`, and `--strict` (or SIMFRAME_STRICT=1)
|
|
732
|
+
* makes any warn a non-zero exit. CI runs strict; users see the warning.
|
|
733
|
+
*
|
|
734
|
+
* `optional` is a fourth level and a deliberate distinction, not a softer warn.
|
|
735
|
+
* A `warn` means this machine could be doing better and silently is not — the
|
|
736
|
+
* failure this whole mechanism exists to catch. `optional` means a dependency
|
|
737
|
+
* documented as optional is simply not installed, which doctor says plainly
|
|
738
|
+
* with install instructions. idb is the only one: it is optional, it is being
|
|
739
|
+
* removed, and a fresh machine without it has not degraded from anything.
|
|
740
|
+
* Strict fails on warn and fail, never on optional.
|
|
741
|
+
*/
|
|
742
|
+
async function doctor({ json = false, strict = false, device } = {}) {
|
|
386
743
|
const checks = [];
|
|
387
|
-
|
|
744
|
+
// `level` is 'ok' | 'warn' | 'fail'. A warn means it works but not the way it
|
|
745
|
+
// should — the exact state that used to be invisible.
|
|
746
|
+
const add = (name, level, detail, extra = {}) => checks.push({ name, level, detail, ...extra });
|
|
747
|
+
const startedHere = [];
|
|
388
748
|
|
|
389
|
-
add('node',
|
|
749
|
+
add('node', 'ok', process.version);
|
|
750
|
+
const { execFileSync } = await import('node:child_process');
|
|
390
751
|
try {
|
|
391
|
-
|
|
392
|
-
add('xcrun', true, execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
|
|
752
|
+
add('xcrun', 'ok', execFileSync('xcrun', ['--version'], { encoding: 'utf8' }).trim().split('\n')[0]);
|
|
393
753
|
} catch (err) {
|
|
394
|
-
add('xcrun',
|
|
754
|
+
add('xcrun', 'fail', err.message);
|
|
395
755
|
}
|
|
396
756
|
try {
|
|
397
|
-
const { execFileSync } = await import('node:child_process');
|
|
398
757
|
execFileSync('sips', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
|
|
399
|
-
add('sips',
|
|
758
|
+
add('sips', 'ok', 'available');
|
|
400
759
|
} catch (err) {
|
|
401
|
-
add('sips',
|
|
760
|
+
add('sips', 'fail', err.message);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const engineModule = await import('./engine.js');
|
|
764
|
+
// Build first, then report. doctor compiles the daemon on demand, so
|
|
765
|
+
// reporting the state beforehand printed "present, not yet built" in output
|
|
766
|
+
// that was already false by the time it reached the terminal.
|
|
767
|
+
await engineModule.ensureBuilt().catch(() => {});
|
|
768
|
+
const build = engineModule.status();
|
|
769
|
+
if (!build.haveSource) {
|
|
770
|
+
add('simframed sources', 'fail', 'not present in this install — the daemon cannot be built', {
|
|
771
|
+
key: 'daemon.sources',
|
|
772
|
+
});
|
|
773
|
+
} else {
|
|
774
|
+
add('simframed sources', 'ok', build.haveBinary ? (build.stale ? 'present, binary stale' : 'present, built') : 'present, not yet built', {
|
|
775
|
+
key: 'daemon.sources',
|
|
776
|
+
});
|
|
402
777
|
}
|
|
403
|
-
|
|
404
|
-
|
|
778
|
+
|
|
779
|
+
let ocrAvailable = false;
|
|
405
780
|
try {
|
|
406
781
|
const ocr = await import('./ocr.js');
|
|
407
782
|
const built = await ocr.ensureBinary();
|
|
408
|
-
|
|
783
|
+
ocrAvailable = Boolean(built.available);
|
|
784
|
+
add('on-device OCR', ocrAvailable ? 'ok' : 'warn', ocrAvailable ? 'available' : built.reason, {
|
|
785
|
+
key: 'ocr.available',
|
|
786
|
+
value: ocrAvailable,
|
|
787
|
+
});
|
|
409
788
|
} catch (err) {
|
|
410
|
-
add('on-device OCR',
|
|
789
|
+
add('on-device OCR', 'warn', err.message, { key: 'ocr.available', value: false });
|
|
411
790
|
}
|
|
791
|
+
|
|
412
792
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
793
|
+
let booted = await bootedDevices();
|
|
794
|
+
// Respect --device. Without this, doctor reports on every booted simulator,
|
|
795
|
+
// which on a CI runner meant checking an Apple Vision Pro nobody asked
|
|
796
|
+
// about and failing strict on its layers.
|
|
797
|
+
if (device) {
|
|
798
|
+
const wanted = await resolveDevice(device);
|
|
799
|
+
booted = booted.filter((d) => d.udid === wanted.udid);
|
|
800
|
+
}
|
|
801
|
+
add('booted simulator', booted.length ? 'ok' : 'warn',
|
|
802
|
+
booted.map((d) => `${d.name} (${d.runtime})`).join(', ') || 'none');
|
|
803
|
+
for (const d of booted) {
|
|
804
|
+
const input = await import('./input.js');
|
|
805
|
+
const control = await import('./control.js');
|
|
806
|
+
// Start the engine before asking which engine is in use. Reading it first
|
|
807
|
+
// reports `simctl` on any machine where nothing happens to be running
|
|
808
|
+
// yet — a warning about a downgrade that has not occurred, and one that
|
|
809
|
+
// would have made the CI assertion fail for the wrong reason.
|
|
810
|
+
const wasRunning = Boolean(engineModule.runningEngine(d.udid));
|
|
811
|
+
await api.ensureDaemon(d.udid).catch(() => {});
|
|
812
|
+
if (!wasRunning) startedHere.push(d.udid);
|
|
813
|
+
// ensureDaemon waits for a frame; the control socket comes up a moment
|
|
814
|
+
// later. Asking immediately reports `idb` for a device whose own input
|
|
815
|
+
// path is seconds from ready — a race that would read as CI flake.
|
|
816
|
+
for (let i = 0; i < 40 && !control.available(d.udid); i += 1) {
|
|
817
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
818
|
+
}
|
|
819
|
+
const driver = await input.driverFor(d.udid, { refresh: true });
|
|
820
|
+
// Which engine is actually capturing, from the daemon's own record.
|
|
821
|
+
// `control.available` answers a different question — whether the input
|
|
822
|
+
// socket is up — and using it here reported simctl on a machine that was
|
|
823
|
+
// capturing with simframed perfectly well.
|
|
824
|
+
const captureEngine = engineModule.runningEngine(d.udid) ?? 'simctl';
|
|
825
|
+
const daemon = captureEngine === 'simframed';
|
|
826
|
+
const why = captureEngine === 'simctl' ? api.fallbackReason(d.udid) : null;
|
|
827
|
+
add(`capture engine (${d.name})`, captureEngine === 'simframed' ? 'ok' : 'warn',
|
|
828
|
+
captureEngine === 'simframed'
|
|
829
|
+
? 'simframed'
|
|
830
|
+
: `simctl — roughly 30x slower per frame${why ? `; simframed unavailable: ${why}` : '. Run simframe start to see why'}`,
|
|
831
|
+
{ key: 'capture.engine', value: captureEngine });
|
|
832
|
+
add(`input driver (${d.name})`, driver.available ? (driver.name === 'simframed' ? 'ok' : 'warn') : 'warn',
|
|
833
|
+
driver.available ? `${driver.name}: ${driver.version}` : driver.reason,
|
|
834
|
+
{ key: 'input.driver', value: driver.available ? driver.name : null });
|
|
835
|
+
add(`text recognition (${d.name})`, 'ok',
|
|
836
|
+
daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
|
|
837
|
+
const ax = await input.axDriverFor(d.udid);
|
|
838
|
+
// idb here is a downgrade unless it was asked for. `warn` means this
|
|
839
|
+
// machine could be doing better and silently is not; a driver someone
|
|
840
|
+
// selected on purpose is neither silent nor a surprise.
|
|
841
|
+
const axState = !ax.available ? 'optional' : ax.name === 'simframed' || ax.chosen ? 'ok' : 'warn';
|
|
842
|
+
add(`accessibility tree (${d.name})`, axState,
|
|
843
|
+
ax.available ? `${ax.name}: ${ax.version}` : `unavailable: ${ax.reason}`,
|
|
844
|
+
{ key: 'ax.driver', value: ax.name });
|
|
845
|
+
}
|
|
415
846
|
if (booted.length) {
|
|
416
847
|
const t0 = Date.now();
|
|
417
848
|
const res = await api.getFrame(booted[0].udid);
|
|
418
|
-
add('capture',
|
|
849
|
+
add('capture', 'ok',
|
|
850
|
+
`frame #${res.state.seq} ${res.width}x${res.height} in ${Date.now() - t0}ms (age ${res.ageMs}ms)`,
|
|
851
|
+
{ key: 'capture.frames', value: res.state.seq });
|
|
419
852
|
}
|
|
420
853
|
} catch (err) {
|
|
421
|
-
add('capture',
|
|
854
|
+
add('capture', 'fail', err.message);
|
|
422
855
|
}
|
|
423
856
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
857
|
+
// doctor is a diagnostic, not a way to start things. If it had to start a
|
|
858
|
+
// daemon to answer "which engine is in use", it stops it again rather than
|
|
859
|
+
// leaving a detached process behind.
|
|
860
|
+
for (const udid of startedHere) {
|
|
861
|
+
try { await api.stopDaemon(udid); } catch { /* best effort */ }
|
|
427
862
|
}
|
|
428
|
-
|
|
429
|
-
const
|
|
430
|
-
|
|
863
|
+
|
|
864
|
+
const failed = checks.filter((c) => c.level === 'fail');
|
|
865
|
+
const warned = checks.filter((c) => c.level === 'warn');
|
|
866
|
+
const optional = checks.filter((c) => c.level === 'optional');
|
|
867
|
+
|
|
868
|
+
if (json) {
|
|
869
|
+
const flat = {};
|
|
870
|
+
for (const c of checks) if (c.key) flat[c.key] = c.value;
|
|
871
|
+
console.log(JSON.stringify({
|
|
872
|
+
ok: failed.length === 0 && (!strict || warned.length === 0),
|
|
873
|
+
strict,
|
|
874
|
+
failures: failed.length,
|
|
875
|
+
warnings: warned.length,
|
|
876
|
+
optional: optional.length,
|
|
877
|
+
...flat,
|
|
878
|
+
checks: checks.map(({ name, level, detail }) => ({ name, level, detail })),
|
|
879
|
+
}, null, 2));
|
|
880
|
+
} else {
|
|
881
|
+
const mark = { ok: 'ok ', warn: 'WARN', fail: 'FAIL', optional: '-- ' };
|
|
882
|
+
for (const c of checks) console.log(`${mark[c.level]} ${c.name.padEnd(24)} ${c.detail}`);
|
|
883
|
+
if (warned.length) {
|
|
884
|
+
console.log(`\n${warned.length} layer(s) degraded. simframe still works, but not at full speed or coverage:`);
|
|
885
|
+
for (const c of warned) console.log(` - ${c.name}: ${c.detail}`);
|
|
886
|
+
if (!strict) console.log('Use --strict to make this an error (CI does).');
|
|
887
|
+
}
|
|
888
|
+
if (optional.length) {
|
|
889
|
+
console.log(`\n${optional.length} optional layer(s) not installed (not a downgrade):`);
|
|
890
|
+
for (const c of optional) console.log(` - ${c.name}: ${c.detail}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
process.exitCode = failed.length || (strict && warned.length) ? 1 : 0;
|
|
431
895
|
}
|
|
432
896
|
|
|
433
897
|
main().catch((err) => {
|
|
434
|
-
|
|
898
|
+
// A caller that asked for JSON gets JSON, failures included. Printing prose
|
|
899
|
+
// here handed `JSON.parse` a SyntaxError instead of a reason, so a script
|
|
900
|
+
// could not tell "the daemon lost the display" from "simframe is broken" —
|
|
901
|
+
// which is the whole point of a machine-readable interface.
|
|
902
|
+
if (process.argv.includes('--json')) {
|
|
903
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: err.message }, null, 2)}\n`);
|
|
904
|
+
} else {
|
|
905
|
+
process.stderr.write(`simframe: ${err.message}\n`);
|
|
906
|
+
}
|
|
435
907
|
process.exitCode = 1;
|
|
436
908
|
});
|