simframe 0.5.0 → 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 +128 -53
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +78 -4
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +32 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +24 -1
- package/native/simframed/Sources/SimframeCore/Element.swift +29 -1
- package/native/simframed/Sources/simframed/main.swift +137 -9
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +30 -0
- package/package.json +4 -2
- package/scripts/check-package.mjs +8 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +189 -20
- package/src/cli.js +272 -116
- package/src/control.js +1 -0
- package/src/fingerprint.js +33 -0
- package/src/graph.js +3 -1
- package/src/index.js +62 -4
- package/src/input.js +99 -0
- package/src/matching.js +72 -1
- package/src/mcp.js +384 -115
- package/src/refs.js +141 -0
- package/src/regions.js +203 -26
- package/src/screenmap.js +57 -16
- package/src/simctl.js +55 -2
- package/src/view.js +342 -0
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import * as api from './index.js';
|
|
|
8
8
|
import * as input from './input.js';
|
|
9
9
|
import * as navigate from './navigate.js';
|
|
10
10
|
import * as store from './store.js';
|
|
11
|
+
import * as view from './view.js';
|
|
11
12
|
|
|
12
13
|
const USAGE = `simframe — always-warm iOS Simulator frames
|
|
13
14
|
|
|
@@ -21,27 +22,34 @@ const USAGE = `simframe — always-warm iOS Simulator frames
|
|
|
21
22
|
simframe wait [device] wait for the screen to react (see --mode)
|
|
22
23
|
simframe strip [device] write a contact sheet of recent frames
|
|
23
24
|
simframe recall [device] what happened in the last minute (--ago=<ms> for a frame)
|
|
24
|
-
simframe
|
|
25
|
-
simframe
|
|
26
|
-
simframe
|
|
27
|
-
simframe
|
|
28
|
-
simframe press <button> a hardware button, e.g. home
|
|
29
|
-
simframe ui [device] read the screen as an accessibility tree
|
|
30
|
-
simframe tap <label> tap an element by its accessibility label
|
|
31
|
-
simframe do <script.json> run a scripted flow (see below)
|
|
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)
|
|
32
29
|
simframe screens [device] list screens this device has learned
|
|
33
30
|
simframe goto <screen> walk to a known screen through known steps
|
|
34
31
|
simframe flow save <name> <script.json> run a flow and save it if every step verifies
|
|
35
32
|
simframe flow run <name> replay a saved flow
|
|
36
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
|
|
37
39
|
simframe devices list simulators
|
|
38
|
-
simframe doctor
|
|
40
|
+
simframe doctor check that this machine can capture
|
|
39
41
|
(--strict, or SIMFRAME_STRICT=1, makes any
|
|
40
42
|
degraded layer a non-zero exit)
|
|
41
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
|
|
48
|
+
|
|
42
49
|
Options
|
|
43
50
|
--device=<udid|name> simulator to target (default: the booted one)
|
|
44
|
-
--
|
|
51
|
+
--json machine-readable output — on every command
|
|
52
|
+
--out=<file> output path for frame/strip/recall
|
|
45
53
|
--detail=low|normal|high|full or --detail=<max pixels>
|
|
46
54
|
--engine=simframed|simctl capture engine (default simframed)
|
|
47
55
|
--fps=<n> capture rate while the screen is moving (simctl engine only)
|
|
@@ -50,18 +58,25 @@ Options
|
|
|
50
58
|
--mode=settle|change|stable what wait waits for (default settle)
|
|
51
59
|
--stable-ms=<n> settle window for wait (default 600)
|
|
52
60
|
--timeout-ms=<n> give up after this long (default 8000)
|
|
53
|
-
--
|
|
54
|
-
--
|
|
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
|
|
55
68
|
|
|
56
69
|
A script is a JSON array of steps, run in one go with a settle between each:
|
|
57
70
|
|
|
58
71
|
[{"tap":"Assets"},{"tap":"Add Asset"},
|
|
59
72
|
{"type":{"into":"Name","text":"Fryer 3"}},
|
|
60
|
-
{"
|
|
73
|
+
{"scrollTo":"Save"},{"tap":"Save"},
|
|
74
|
+
{"waitFor":{"value":"Saved","timeoutMs":5000}},
|
|
75
|
+
{"assert":{"value":"Saved","is":"visible"}}]
|
|
61
76
|
|
|
62
|
-
Input
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
65
80
|
|
|
66
81
|
The reliable pattern around an action is:
|
|
67
82
|
|
|
@@ -88,6 +103,39 @@ function parseArgs(argv) {
|
|
|
88
103
|
|
|
89
104
|
const num = (v, fallback) => (v == null ? fallback : Number(v));
|
|
90
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
|
+
|
|
91
139
|
async function main() {
|
|
92
140
|
const [command, ...rest] = process.argv.slice(2);
|
|
93
141
|
const { flags, positional } = parseArgs(rest);
|
|
@@ -209,13 +257,17 @@ async function main() {
|
|
|
209
257
|
const res = await api.getFrame(device, { detail: flags.detail ?? 'normal', options });
|
|
210
258
|
const out = flags.out || path.join(process.cwd(), 'simframe.png');
|
|
211
259
|
fs.writeFileSync(out, res.png);
|
|
212
|
-
|
|
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
|
+
);
|
|
213
265
|
return;
|
|
214
266
|
}
|
|
215
267
|
|
|
216
268
|
case 'mark': {
|
|
217
269
|
const res = await api.getState(device, { options });
|
|
218
|
-
|
|
270
|
+
emit(flags, { hash: res.state.hash, seq: res.state.seq }, res.state.hash);
|
|
219
271
|
return;
|
|
220
272
|
}
|
|
221
273
|
|
|
@@ -261,23 +313,32 @@ async function main() {
|
|
|
261
313
|
timeoutMs: num(flags.timeoutMs, 8000),
|
|
262
314
|
options,
|
|
263
315
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
);
|
|
281
342
|
process.exitCode = res.satisfied ? 0 : 1;
|
|
282
343
|
return;
|
|
283
344
|
}
|
|
@@ -290,7 +351,9 @@ async function main() {
|
|
|
290
351
|
});
|
|
291
352
|
const out = flags.out || path.join(process.cwd(), 'simframe-strip.png');
|
|
292
353
|
fs.writeFileSync(out, res.png);
|
|
293
|
-
|
|
354
|
+
emit(
|
|
355
|
+
flags,
|
|
356
|
+
{ file: out, frames: res.frames.length, spanMs: res.spanMs, width: res.width, height: res.height },
|
|
294
357
|
`${out} — ${res.frames.length} frames over ${res.spanMs}ms (${res.width}x${res.height})`,
|
|
295
358
|
);
|
|
296
359
|
return;
|
|
@@ -301,7 +364,9 @@ async function main() {
|
|
|
301
364
|
const res = await api.getFrameAt(device, { msAgo: num(flags.ago), options });
|
|
302
365
|
const out = flags.out || path.join(process.cwd(), 'simframe-recall.png');
|
|
303
366
|
fs.writeFileSync(out, res.png);
|
|
304
|
-
|
|
367
|
+
emit(
|
|
368
|
+
flags,
|
|
369
|
+
{ file: out, seq: res.seq, actualMsAgo: res.actualMsAgo, requestedMsAgo: res.requestedMsAgo, oldestMsAgo: res.oldestMsAgo },
|
|
305
370
|
`${out} — frame #${res.seq} from ${Math.round(res.actualMsAgo)}ms ago ` +
|
|
306
371
|
`(memory reaches back ${Math.round(res.oldestMsAgo / 1000)}s)`,
|
|
307
372
|
);
|
|
@@ -330,29 +395,32 @@ async function main() {
|
|
|
330
395
|
}
|
|
331
396
|
|
|
332
397
|
case 'ui': {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
+
);
|
|
356
424
|
return;
|
|
357
425
|
}
|
|
358
426
|
|
|
@@ -364,8 +432,19 @@ async function main() {
|
|
|
364
432
|
options,
|
|
365
433
|
});
|
|
366
434
|
const step = res.results[0];
|
|
367
|
-
if (!step.ok)
|
|
368
|
-
|
|
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
|
+
);
|
|
369
448
|
return;
|
|
370
449
|
}
|
|
371
450
|
|
|
@@ -381,11 +460,29 @@ async function main() {
|
|
|
381
460
|
continueOnError: Boolean(flags.continueOnError),
|
|
382
461
|
options,
|
|
383
462
|
});
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
+
);
|
|
389
486
|
process.exitCode = res.ok ? 0 : 1;
|
|
390
487
|
return;
|
|
391
488
|
}
|
|
@@ -398,53 +495,62 @@ async function main() {
|
|
|
398
495
|
timeoutMs: num(flags.timeoutMs, 8000),
|
|
399
496
|
options,
|
|
400
497
|
});
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
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
|
+
};
|
|
413
508
|
if (!res.ok && res.reason) {
|
|
414
|
-
|
|
509
|
+
emit(flags, res, refusal[res.reason] ?? `${res.reason}: cannot reach "${res.to ?? target}" from here`);
|
|
415
510
|
process.exitCode = 1;
|
|
416
511
|
return;
|
|
417
512
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
+
);
|
|
426
525
|
process.exitCode = res.ok ? 0 : 1;
|
|
427
526
|
return;
|
|
428
527
|
}
|
|
429
528
|
|
|
430
529
|
case 'screens': {
|
|
431
|
-
|
|
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);
|
|
432
535
|
const known = navigate.knownScreens(device.udid);
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
);
|
|
438
543
|
return;
|
|
439
544
|
}
|
|
440
545
|
|
|
441
546
|
case 'flow': {
|
|
442
547
|
const [sub, name] = positional;
|
|
443
|
-
|
|
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);
|
|
444
551
|
if (sub === 'list') {
|
|
445
552
|
const flows = navigate.listFlows(device.udid);
|
|
446
|
-
|
|
447
|
-
for (const f of flows) console.log(`${f.name} ${f.steps} steps`);
|
|
553
|
+
emit(flags, flows, flows.length ? flows.map((f) => `${f.name} ${f.steps} steps`) : 'no saved flows');
|
|
448
554
|
return;
|
|
449
555
|
}
|
|
450
556
|
if (sub === 'save') {
|
|
@@ -459,11 +565,11 @@ async function main() {
|
|
|
459
565
|
});
|
|
460
566
|
const saved = navigate.saveFlow(device.udid, name, res, { force: Boolean(flags.force) });
|
|
461
567
|
if (!saved.ok) {
|
|
462
|
-
|
|
568
|
+
emit(flags, saved, `not saved: ${saved.reason} (${(saved.verdicts ?? []).join(', ')}) — re-run, or pass --force`);
|
|
463
569
|
process.exitCode = 1;
|
|
464
570
|
return;
|
|
465
571
|
}
|
|
466
|
-
|
|
572
|
+
emit(flags, saved, `saved ${saved.name} — ${saved.steps} steps`);
|
|
467
573
|
return;
|
|
468
574
|
}
|
|
469
575
|
if (sub === 'run') {
|
|
@@ -474,14 +580,14 @@ async function main() {
|
|
|
474
580
|
options,
|
|
475
581
|
});
|
|
476
582
|
if (res.reason === 'unknown-flow') {
|
|
477
|
-
|
|
583
|
+
emit(flags, res, `no flow "${name}". known: ${res.known.join(', ') || '(none)'}`);
|
|
478
584
|
process.exitCode = 1;
|
|
479
585
|
return;
|
|
480
586
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
587
|
+
emit(flags, res, [
|
|
588
|
+
...(res.results ?? []).map(stepLine),
|
|
589
|
+
`${res.ok ? 'flow completed' : 'FLOW FAILED'} — ${res.ranSteps}/${res.totalSteps} steps`,
|
|
590
|
+
]);
|
|
485
591
|
process.exitCode = res.ok ? 0 : 1;
|
|
486
592
|
return;
|
|
487
593
|
}
|
|
@@ -496,6 +602,14 @@ async function main() {
|
|
|
496
602
|
const input = await import('./input.js');
|
|
497
603
|
const dev = await resolveDevice(flags.device);
|
|
498
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
|
+
}
|
|
499
613
|
const t0 = Date.now();
|
|
500
614
|
switch (command) {
|
|
501
615
|
case 'tapAt': {
|
|
@@ -525,7 +639,33 @@ async function main() {
|
|
|
525
639
|
await input.pressButton(dev.udid, positional[0]);
|
|
526
640
|
}
|
|
527
641
|
const driver = await input.driverFor(dev.udid);
|
|
528
|
-
|
|
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
|
+
);
|
|
529
669
|
return;
|
|
530
670
|
}
|
|
531
671
|
|
|
@@ -534,14 +674,18 @@ async function main() {
|
|
|
534
674
|
if (!intent) throw new Error('usage: simframe find "<intent>"');
|
|
535
675
|
try {
|
|
536
676
|
const r = await api.locate(flags.device, intent, { options });
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
],
|
|
540
686
|
);
|
|
541
|
-
if (r.reasons?.length) console.log(` because: ${r.reasons.join(', ')}`);
|
|
542
|
-
for (const a of r.alternatives ?? []) console.log(` also considered: "${a.label}" ${a.score}`);
|
|
543
687
|
} catch (err) {
|
|
544
|
-
|
|
688
|
+
emit(flags, { ok: false, error: err.message }, err.message);
|
|
545
689
|
process.exitCode = 1;
|
|
546
690
|
}
|
|
547
691
|
return;
|
|
@@ -690,10 +834,14 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
690
834
|
{ key: 'input.driver', value: driver.available ? driver.name : null });
|
|
691
835
|
add(`text recognition (${d.name})`, 'ok',
|
|
692
836
|
daemon ? 'simframed (in-process, off the framebuffer)' : 'sips + helper binary');
|
|
693
|
-
const ax = await input.
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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 });
|
|
697
845
|
}
|
|
698
846
|
if (booted.length) {
|
|
699
847
|
const t0 = Date.now();
|
|
@@ -747,6 +895,14 @@ async function doctor({ json = false, strict = false, device } = {}) {
|
|
|
747
895
|
}
|
|
748
896
|
|
|
749
897
|
main().catch((err) => {
|
|
750
|
-
|
|
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
|
+
}
|
|
751
907
|
process.exitCode = 1;
|
|
752
908
|
});
|
package/src/control.js
CHANGED
|
@@ -66,6 +66,7 @@ export const type = (udid, text) => request(udid, { action: 'type', text });
|
|
|
66
66
|
export const paste = (udid, text) => request(udid, { action: 'paste', text });
|
|
67
67
|
export const press = (udid, button) => request(udid, { action: 'press', button });
|
|
68
68
|
export const status = (udid) => request(udid, { action: 'status' });
|
|
69
|
+
export const resetInput = (udid) => request(udid, { action: 'resetInput' });
|
|
69
70
|
export const longPress = (udid, x, y, opts = {}) => request(udid, { action: 'longPress', x, y, ...opts });
|
|
70
71
|
export const drag = (udid, from, to, opts = {}) =>
|
|
71
72
|
request(udid, { action: 'drag', x1: from.x, y1: from.y, x2: to.x, y2: to.y, ...opts });
|
package/src/fingerprint.js
CHANGED
|
@@ -64,6 +64,38 @@ function bucket(n) {
|
|
|
64
64
|
|
|
65
65
|
const normLabel = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A value, not a name.
|
|
69
|
+
*
|
|
70
|
+
* Phase 6d removed a date banner from one screen's identity after that
|
|
71
|
+
* fingerprint would have expired at midnight. It came back through a different
|
|
72
|
+
* door: measured across twenty screens of a real app, three carried content in
|
|
73
|
+
* their identity because the positional region bands had called it chrome — a
|
|
74
|
+
* store address, a phone number, and a nav title reading "Tuesday, September 8".
|
|
75
|
+
* That last one is a screen whose identity has until midnight to live.
|
|
76
|
+
*
|
|
77
|
+
* The band misclassification is the root cause and is fixed by clustering, not
|
|
78
|
+
* by another threshold (docs/DEFERRED.md). What can be fixed here without
|
|
79
|
+
* guessing at geometry is the narrower question: is this text a name for the
|
|
80
|
+
* screen, or is it today's value? A name is words. A date, a phone number, a
|
|
81
|
+
* price and a bare count are not, and every one of them changes while the
|
|
82
|
+
* screen stays the same screen.
|
|
83
|
+
*/
|
|
84
|
+
const MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
|
|
85
|
+
const WEEKDAYS = /\b(mon|tue|wed|thu|fri|sat|sun)[a-z]*day?\b/i;
|
|
86
|
+
const DATE_LIKE = /\d{1,4}[/.-]\d{1,2}([/.-]\d{1,4})?|\b\d{1,2}:\d{2}\b/;
|
|
87
|
+
|
|
88
|
+
export function isVolatileLabel(label) {
|
|
89
|
+
const text = String(label ?? '').trim();
|
|
90
|
+
if (!text) return true;
|
|
91
|
+
if (MONTHS.test(text) || WEEKDAYS.test(text) || DATE_LIKE.test(text)) return true;
|
|
92
|
+
const letters = (text.match(/\p{L}/gu) ?? []).length;
|
|
93
|
+
const digits = (text.match(/\p{N}/gu) ?? []).length;
|
|
94
|
+
// Mostly digits: a count, a price, a phone number, an ID. "1020" and
|
|
95
|
+
// "+1 (111) 111-1111" are both this; "Assets" is not.
|
|
96
|
+
return digits > 0 && digits >= letters;
|
|
97
|
+
}
|
|
98
|
+
|
|
67
99
|
/**
|
|
68
100
|
* The canonical tokens this screen is made of.
|
|
69
101
|
*
|
|
@@ -100,6 +132,7 @@ export function tokens(targets, screen) {
|
|
|
100
132
|
// tab label is narrow; content that merely fell into the band is not a name.
|
|
101
133
|
const labelWorthKeeping = CHROME.has(region)
|
|
102
134
|
&& t.label
|
|
135
|
+
&& !isVolatileLabel(t.label)
|
|
103
136
|
&& (region !== 'tab-bar' || (frame.width ?? 0) <= screen.width * TAB_LABEL_MAX_WIDTH_FRACTION);
|
|
104
137
|
if (labelWorthKeeping) parts.push(`"${normLabel(t.label)}"`);
|
|
105
138
|
const key = parts.join(':');
|
package/src/graph.js
CHANGED
|
@@ -205,8 +205,10 @@ export function describe(node) {
|
|
|
205
205
|
// that happens to sit in the nav bar is not a name for anything.
|
|
206
206
|
const title = labels(/:nav-bar:@title:/);
|
|
207
207
|
if (title.length) return title.join(' ');
|
|
208
|
+
// Three at most. A screen named after seven tab-bar fragments — several of
|
|
209
|
+
// them OCR reading a divider — is not a name anybody can type into `goto`.
|
|
208
210
|
const tabs = labels(/:tab-bar:/);
|
|
209
|
-
if (tabs.length) return tabs.join(' / ');
|
|
211
|
+
if (tabs.length) return tabs.slice(0, 3).join(' / ');
|
|
210
212
|
const anyChrome = labels(/:(nav-bar|tab-bar):/);
|
|
211
213
|
if (anyChrome.length) return anyChrome.slice(0, 3).join(' ');
|
|
212
214
|
return node.hash.slice(0, 8);
|