simframe 0.8.0 → 0.10.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 +31 -3
- package/flows/hpi-suite.json +68 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +49 -1
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +7 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +11 -0
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +48 -0
- package/native/simframed/Sources/simframed/main.swift +128 -73
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +40 -0
- package/package.json +2 -1
- package/scripts/bench-hpi.mjs +254 -0
- package/scripts/check-package.mjs +7 -0
- package/scripts/check-private.mjs +143 -0
- package/scripts/eval-perception.mjs +248 -0
- package/src/actions.js +333 -14
- package/src/analyze.js +70 -0
- package/src/baseline.js +333 -0
- package/src/cli.js +410 -4
- package/src/daemon.js +9 -0
- package/src/fingerprint.js +7 -1
- package/src/graph.js +262 -1
- package/src/index.js +335 -22
- package/src/input.js +155 -1
- package/src/intent.js +11 -2
- package/src/matching.js +136 -4
- package/src/mcp.js +14 -1
- package/src/metrics.js +596 -0
- package/src/navigate.js +47 -7
- package/src/platform/android.js +16 -1
- package/src/platform/index.js +3 -0
- package/src/platform/ios.js +40 -0
- package/src/screenmap.js +55 -14
- package/src/view.js +65 -3
package/src/input.js
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
// capability layered on top, so every entry point here has to answer "is this
|
|
3
3
|
// even available?" before it answers anything else.
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
import * as control from './control.js';
|
|
6
|
-
import
|
|
8
|
+
import * as store from './store.js';
|
|
9
|
+
import { bootedAtFor, capabilitiesFor, geometryFor, inputDriverFor, setPasteboard } from './platform/index.js';
|
|
7
10
|
import { promisify } from 'node:util';
|
|
8
11
|
|
|
9
12
|
const run = promisify(execFile);
|
|
@@ -245,6 +248,11 @@ export function elementToNode(e) {
|
|
|
245
248
|
type: e.role ?? null,
|
|
246
249
|
identifier: e.identifier ?? null,
|
|
247
250
|
enabled: e.state?.enabled ?? null,
|
|
251
|
+
// The daemon batches AXSelected and AXFocused alongside AXEnabled and has
|
|
252
|
+
// since 0.6.0. This converter took one of the three, and it is the one on
|
|
253
|
+
// the path that actually runs — `normalizeNode` below is the idb fallback.
|
|
254
|
+
selected: e.state?.selected ?? null,
|
|
255
|
+
focused: e.state?.focused ?? null,
|
|
248
256
|
frame: e.frame ?? null,
|
|
249
257
|
raw: e,
|
|
250
258
|
};
|
|
@@ -270,6 +278,12 @@ function normalizeNode(node) {
|
|
|
270
278
|
type: node.type ?? node.AXType ?? null,
|
|
271
279
|
identifier: node.AXUniqueId ?? node.identifier ?? null,
|
|
272
280
|
enabled: node.AXEnabled ?? node.enabled ?? null,
|
|
281
|
+
// The daemon has asked the tree for AXSelected and AXFocused since 0.6.0 —
|
|
282
|
+
// they are two of the eight attributes in its batched round trip — and this
|
|
283
|
+
// function dropped both. `view.renderRow` has printed `selected` for as
|
|
284
|
+
// long as it has existed, against a field nobody set.
|
|
285
|
+
selected: node.AXSelected ?? node.selected ?? null,
|
|
286
|
+
focused: node.AXFocused ?? node.focused ?? null,
|
|
273
287
|
frame: frame
|
|
274
288
|
? {
|
|
275
289
|
x: frame.x ?? frame.X ?? 0,
|
|
@@ -323,6 +337,7 @@ export function centerOf(node) {
|
|
|
323
337
|
}
|
|
324
338
|
|
|
325
339
|
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
|
340
|
+
await ensureFreshSession(udid);
|
|
326
341
|
const point = { x: Math.round(x), y: Math.round(y) };
|
|
327
342
|
const own = inputDriverFor(udid);
|
|
328
343
|
if (own) {
|
|
@@ -347,6 +362,7 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
|
347
362
|
}
|
|
348
363
|
|
|
349
364
|
export async function typeText(udid, value) {
|
|
365
|
+
await ensureFreshSession(udid);
|
|
350
366
|
const own = inputDriverFor(udid);
|
|
351
367
|
if (own) {
|
|
352
368
|
// No pasteboard on Android (docs/DEFERRED.md), so exact text goes through
|
|
@@ -379,6 +395,7 @@ export async function typeText(udid, value) {
|
|
|
379
395
|
* to say so; `type` still works on every device.
|
|
380
396
|
*/
|
|
381
397
|
export async function pasteText(udid, value) {
|
|
398
|
+
await ensureFreshSession(udid);
|
|
382
399
|
const own = inputDriverFor(udid);
|
|
383
400
|
if (own?.key) {
|
|
384
401
|
// The clipboard goes over gRPC; KEYCODE_PASTE is what puts it in the field.
|
|
@@ -413,6 +430,7 @@ export async function typeKeys(udid, value) {
|
|
|
413
430
|
}
|
|
414
431
|
|
|
415
432
|
export async function pressKey(udid, keycode) {
|
|
433
|
+
await ensureFreshSession(udid);
|
|
416
434
|
const own = inputDriverFor(udid);
|
|
417
435
|
if (own) {
|
|
418
436
|
await own.key(udid, keycode);
|
|
@@ -433,10 +451,144 @@ export async function pressKey(udid, keycode) {
|
|
|
433
451
|
*
|
|
434
452
|
* @returns {Promise<boolean>} whether a session was actually reset.
|
|
435
453
|
*/
|
|
454
|
+
/**
|
|
455
|
+
* Is the daemon's HID session older than the device it talks to?
|
|
456
|
+
*
|
|
457
|
+
* Pure, so the comparison is testable without a device. `graceMs` covers the
|
|
458
|
+
* ordinary case where a daemon is started immediately after a boot and the two
|
|
459
|
+
* timestamps land within milliseconds of each other in either order.
|
|
460
|
+
*/
|
|
461
|
+
export function sessionStaleness({ bootedAt, sessionSince, graceMs = 2000 }) {
|
|
462
|
+
if (!Number.isFinite(bootedAt)) {
|
|
463
|
+
return { stale: false, reason: 'cannot tell when the device booted' };
|
|
464
|
+
}
|
|
465
|
+
if (!Number.isFinite(sessionSince)) {
|
|
466
|
+
return { stale: false, reason: 'no capture daemon has recorded a start time, so there is no session to compare against' };
|
|
467
|
+
}
|
|
468
|
+
if (bootedAt <= sessionSince + graceMs) return { stale: false, reason: null };
|
|
469
|
+
return {
|
|
470
|
+
stale: true,
|
|
471
|
+
reason: `the device booted ${Math.round((bootedAt - sessionSince) / 1000)}s after the capture daemon started, `
|
|
472
|
+
+ 'so the daemon holds an HID session for a device session that no longer exists',
|
|
473
|
+
bootedAt,
|
|
474
|
+
sessionSince,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* What state the input path is in, for doctor and sim_state.
|
|
480
|
+
*
|
|
481
|
+
* Reads two timestamps off disk — the device's boot marker and the daemon's
|
|
482
|
+
* own `startedAt` — and costs a stat each. No input is dispatched to find out,
|
|
483
|
+
* because the whole failure being detected is input that reports success and
|
|
484
|
+
* does nothing.
|
|
485
|
+
*/
|
|
486
|
+
const bootCache = new Map();
|
|
487
|
+
/**
|
|
488
|
+
* Boot time, cached for a moment.
|
|
489
|
+
*
|
|
490
|
+
* iOS answers with a stat; Android runs `adb shell cat /proc/uptime`, a
|
|
491
|
+
* subprocess of 20-40 ms, and `getState` runs twice per flow step. A few
|
|
492
|
+
* seconds of staleness in the staleness detector costs nothing — a device that
|
|
493
|
+
* rebooted three seconds ago is still rebooted at the next check.
|
|
494
|
+
*/
|
|
495
|
+
async function bootedAtCached(udid, ttlMs = 3000) {
|
|
496
|
+
const hit = bootCache.get(udid);
|
|
497
|
+
if (hit && Date.now() - hit.at < ttlMs) return hit.value;
|
|
498
|
+
const value = await bootedAtFor(udid);
|
|
499
|
+
bootCache.set(udid, { at: Date.now(), value });
|
|
500
|
+
return value;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function sessionHealth(udid) {
|
|
504
|
+
if (!udid || !control.available(udid)) return { stale: false, reason: null };
|
|
505
|
+
let bootedAt = null;
|
|
506
|
+
try {
|
|
507
|
+
bootedAt = await bootedAtCached(udid);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
return { stale: false, reason: `cannot tell when the device booted: ${err.message}` };
|
|
510
|
+
}
|
|
511
|
+
const meta = store.readJson(store.paths(udid).meta);
|
|
512
|
+
const rebuilt = store.readJson(sessionFile(udid))?.rebuiltAt;
|
|
513
|
+
// The newer of the two: the daemon starting creates a session, and rebuilding
|
|
514
|
+
// it replaces one. Either makes the session current as of that moment.
|
|
515
|
+
const sessionSince = Math.max(meta?.startedAt ?? 0, rebuilt ?? 0) || undefined;
|
|
516
|
+
return sessionStaleness({ bootedAt, sessionSince });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Should this staleness be acted on, given what has already been rebuilt?
|
|
521
|
+
*
|
|
522
|
+
* Pure, and separate from the check because the *key* is the whole bug. This
|
|
523
|
+
* gate used to be a set of udids — "once per process, per device" — and the
|
|
524
|
+
* reasoning was to avoid statting on every action. What it actually bought was
|
|
525
|
+
* that the feature could not fire in the one process that matters. A CLI
|
|
526
|
+
* command is a new process every time, so per-process is per-call there and
|
|
527
|
+
* the gate never showed; the MCP server is a single process that lives for a
|
|
528
|
+
* whole session, so it checked once, at the first action, and then never
|
|
529
|
+
* again — and a device that reboots *mid-session* is precisely the case this
|
|
530
|
+
* exists to catch. Reported from a real session: capture kept working, input
|
|
531
|
+
* died, every tap returned `ok`, and about ten calls went into two wrong
|
|
532
|
+
* conclusions about the app.
|
|
533
|
+
*
|
|
534
|
+
* The right key is the boot the rebuild was for. One attempt per device boot:
|
|
535
|
+
* enough that a failed rebuild does not retry on every tap forever, and not so
|
|
536
|
+
* much that the next boot is invisible.
|
|
537
|
+
*/
|
|
538
|
+
export function shouldRebuildSession({ stale, bootedAt }, rebuiltFor) {
|
|
539
|
+
if (!stale) return false;
|
|
540
|
+
// A boot we cannot date cannot be memoised against, and re-attempting on
|
|
541
|
+
// every action would be worse than not detecting it. sessionStaleness only
|
|
542
|
+
// reports stale with a finite bootedAt, so this is a belt, not a case.
|
|
543
|
+
if (!Number.isFinite(bootedAt)) return false;
|
|
544
|
+
return rebuiltFor !== bootedAt;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Rebuild the session if the device outlived it. Once per device boot.
|
|
549
|
+
*
|
|
550
|
+
* Rebuild, and retry nothing: this runs *before* the action, so the action is
|
|
551
|
+
* delivered on a session known to be current. Retrying afterwards is how an
|
|
552
|
+
* action fires twice, which is the hazard the verify barrier exists to
|
|
553
|
+
* prevent — and it is why the existing recovery covers hardware buttons only.
|
|
554
|
+
*
|
|
555
|
+
* The check now runs on every dispatch rather than once. It costs two small
|
|
556
|
+
* `readJson`s and, at most every three seconds, one stat — `bootedAtCached`
|
|
557
|
+
* already caps the part that was expensive, which is what made the
|
|
558
|
+
* once-per-process gate unnecessary as well as wrong.
|
|
559
|
+
*/
|
|
560
|
+
const rebuiltForBoot = new Map();
|
|
561
|
+
export async function ensureFreshSession(udid) {
|
|
562
|
+
if (!udid) return null;
|
|
563
|
+
const health = await sessionHealth(udid);
|
|
564
|
+
if (!shouldRebuildSession(health, rebuiltForBoot.get(udid))) return null;
|
|
565
|
+
rebuiltForBoot.set(udid, health.bootedAt);
|
|
566
|
+
const rebuilt = await resetSession(udid);
|
|
567
|
+
return { ...health, rebuilt };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* When the HID session was last rebuilt, if it has been.
|
|
572
|
+
*
|
|
573
|
+
* The daemon's `startedAt` is the wrong clock on its own: rebuilding the
|
|
574
|
+
* session makes it current again without restarting the daemon, so comparing
|
|
575
|
+
* against the daemon's start left `doctor` reporting `stale` about a session
|
|
576
|
+
* that had just been rebuilt and was demonstrably working. It is a file rather
|
|
577
|
+
* than a variable because every CLI command is a new process and the daemon
|
|
578
|
+
* holding the session outlives all of them.
|
|
579
|
+
*/
|
|
580
|
+
const sessionFile = (udid) => path.join(store.deviceDir(udid), 'input-session.json');
|
|
581
|
+
|
|
436
582
|
export async function resetSession(udid) {
|
|
437
583
|
if (!control.available(udid)) return false;
|
|
438
584
|
try {
|
|
439
585
|
await control.resetInput(udid);
|
|
586
|
+
try {
|
|
587
|
+
fs.mkdirSync(store.deviceDir(udid), { recursive: true });
|
|
588
|
+
store.writeAtomic(sessionFile(udid), JSON.stringify({ rebuiltAt: Date.now() }));
|
|
589
|
+
} catch {
|
|
590
|
+
/* the rebuild happened; failing to write it down only costs a stale report */
|
|
591
|
+
}
|
|
440
592
|
return true;
|
|
441
593
|
} catch {
|
|
442
594
|
return false;
|
|
@@ -444,6 +596,7 @@ export async function resetSession(udid) {
|
|
|
444
596
|
}
|
|
445
597
|
|
|
446
598
|
export async function pressButton(udid, name) {
|
|
599
|
+
await ensureFreshSession(udid);
|
|
447
600
|
const own = inputDriverFor(udid);
|
|
448
601
|
if (own) {
|
|
449
602
|
// Android's whole key vocabulary is safe to offer: `input keyevent` takes
|
|
@@ -464,6 +617,7 @@ export async function pressButton(udid, name) {
|
|
|
464
617
|
}
|
|
465
618
|
|
|
466
619
|
export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
620
|
+
await ensureFreshSession(udid);
|
|
467
621
|
const own = inputDriverFor(udid);
|
|
468
622
|
if (own) {
|
|
469
623
|
await own.swipe(udid, from, to, { durationMs });
|
package/src/intent.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// instinct removes the expensive part of driving a UI with an agent: the model
|
|
6
6
|
// round trip spent reasoning about controls it has never seen.
|
|
7
7
|
import * as input from './input.js';
|
|
8
|
+
import * as metrics from './metrics.js';
|
|
8
9
|
|
|
9
10
|
/** Confirming words, most specific first. The first tier with a hit wins. */
|
|
10
11
|
const CONFIRM_TIERS = [
|
|
@@ -77,7 +78,11 @@ export function findOptions(nodes, geo) {
|
|
|
77
78
|
export async function chooseAny(udid, { prefer, geo } = {}) {
|
|
78
79
|
const nodes = await input.describeAll(udid);
|
|
79
80
|
const options = findOptions(nodes, geo);
|
|
80
|
-
if (!options.length)
|
|
81
|
+
if (!options.length) {
|
|
82
|
+
throw metrics.tag(new Error('no selectable options found on screen'), 'novel_dialog', {
|
|
83
|
+
candidates: nodes.filter((n) => n.label).slice(0, 8).map((n) => ({ label: n.label })),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
81
86
|
const chosen =
|
|
82
87
|
(prefer && options.find((o) => o.label.toLowerCase().includes(String(prefer).toLowerCase()))) ||
|
|
83
88
|
options[0];
|
|
@@ -89,7 +94,11 @@ export async function chooseAny(udid, { prefer, geo } = {}) {
|
|
|
89
94
|
export async function confirm(udid, { geo } = {}) {
|
|
90
95
|
const nodes = await input.describeAll(udid);
|
|
91
96
|
const target = findConfirm(nodes, geo);
|
|
92
|
-
if (!target)
|
|
97
|
+
if (!target) {
|
|
98
|
+
throw metrics.tag(new Error('no confirming control on screen'), 'novel_dialog', {
|
|
99
|
+
candidates: nodes.filter((n) => n.label).slice(0, 8).map((n) => ({ label: n.label })),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
93
102
|
const point = input.centerOf(target);
|
|
94
103
|
await input.tapPoint(udid, point.x, point.y);
|
|
95
104
|
return { label: target.label, point, enabled: target.enabled };
|
package/src/matching.js
CHANGED
|
@@ -59,7 +59,25 @@ export function nameScore(name, query) {
|
|
|
59
59
|
const q = norm(query);
|
|
60
60
|
if (!n || !q) return 0;
|
|
61
61
|
if (n === q) return 1;
|
|
62
|
-
|
|
62
|
+
// A prefix match is only as good as the share it covers, and this branch had
|
|
63
|
+
// to be taught that twice.
|
|
64
|
+
//
|
|
65
|
+
// `n.startsWith(q)` — the name begins with the query, "Acce" for
|
|
66
|
+
// "Accessibility" — is the ordinary case and keeps most of its score: a
|
|
67
|
+
// prefix of a name is how people abbreviate. `q.startsWith(n)` is the
|
|
68
|
+
// opposite direction, where the *name* is a fragment of the query, and it
|
|
69
|
+
// returned the same flat 0.86 no matter how little of the query it was. So
|
|
70
|
+
// the section-index letter "S" scored 0.86 against the query "Search" and
|
|
71
|
+
// beat the search field's own label "Q Search" at 0.585 — and simframe
|
|
72
|
+
// tapped a scrubber and typed into it.
|
|
73
|
+
//
|
|
74
|
+
// The sibling branch below already carries this lesson in a comment about
|
|
75
|
+
// "back" matching a list row. Only one of the two had learned it. Both scale
|
|
76
|
+
// now, and the floor differs by direction on purpose: a query that is a
|
|
77
|
+
// prefix of a name is usually deliberate, while a name that is a fragment of
|
|
78
|
+
// the query is usually a coincidence, and one character is always one.
|
|
79
|
+
if (n.startsWith(q)) return 0.86 * Math.max(PREFIX_FLOOR, Math.min(1, q.length / n.length + 0.35));
|
|
80
|
+
if (q.startsWith(n)) return 0.8 * Math.max(0.15, n.length / q.length);
|
|
63
81
|
// A substring match is only as good as the share of the name it covers.
|
|
64
82
|
// Without this, "back" scores 0.78 against a two-hundred-character list row
|
|
65
83
|
// that happens to contain "Back of House", and beats the actual back button.
|
|
@@ -76,7 +94,31 @@ export function nameScore(name, query) {
|
|
|
76
94
|
if (distance > cap) return 0;
|
|
77
95
|
const longest = Math.max(n.length, q.length);
|
|
78
96
|
const similarity = 1 - distance / longest;
|
|
79
|
-
|
|
97
|
+
if (similarity >= 0.7) return similarity * 0.72;
|
|
98
|
+
// Last tier: OCR read a confusable character.
|
|
99
|
+
//
|
|
100
|
+
// Language correction is deliberately off, which is right for labels and
|
|
101
|
+
// wrong for exactly this. Measured on a real app, `(All)` reads back as
|
|
102
|
+
// `(AII)` and would fail an assert against the string it is; capital-I,
|
|
103
|
+
// lowercase-l, the digit one and a pipe are one shape in most UI fonts, as
|
|
104
|
+
// are capital-O and zero.
|
|
105
|
+
//
|
|
106
|
+
// Deliberately the *last* tier and discounted, not part of `norm`. Folding
|
|
107
|
+
// in `norm` would make it change what an exact match means — "Log in" and
|
|
108
|
+
// "1og in" would become the same string everywhere — and identity is not
|
|
109
|
+
// something to be fuzzy about. Here it only ever rescues a comparison that
|
|
110
|
+
// had already scored zero.
|
|
111
|
+
const foldedScore = confusableFold(n) === confusableFold(q) ? 0.62 : 0;
|
|
112
|
+
return foldedScore;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One shape per glyph family, for comparison only. Never for identity. */
|
|
116
|
+
export function confusableFold(s) {
|
|
117
|
+
return String(s ?? '')
|
|
118
|
+
.replace(/[il1|!]/gi, '1')
|
|
119
|
+
.replace(/[o0]/gi, '0')
|
|
120
|
+
.replace(/[s5]/gi, '5')
|
|
121
|
+
.replace(/[b8]/gi, '8');
|
|
80
122
|
}
|
|
81
123
|
|
|
82
124
|
function synonymGroup(query) {
|
|
@@ -159,6 +201,17 @@ export function rank(targets, intent, { screen } = {}) {
|
|
|
159
201
|
export const AMBIGUITY_MARGIN = 0.08;
|
|
160
202
|
/** Below this, no candidate is worth acting on. */
|
|
161
203
|
export const MINIMUM_SCORE = 0.45;
|
|
204
|
+
/**
|
|
205
|
+
* How much of a name's score survives scaling a prefix by its coverage.
|
|
206
|
+
*
|
|
207
|
+
* Tied to `MINIMUM_SCORE` rather than chosen: at 0.5 the shortest useful
|
|
208
|
+
* abbreviation — "Ac" for "Accessibility" — scored 0.433 and fell *below* the
|
|
209
|
+
* threshold to resolve at all, which would have turned a ranking fix into a
|
|
210
|
+
* feature removal. 0.6 puts the worst case at 0.516, comfortably resolvable and
|
|
211
|
+
* still well under a fuller match. The unit test asserts the relationship so
|
|
212
|
+
* the cliff cannot come back by someone tuning one of the two numbers.
|
|
213
|
+
*/
|
|
214
|
+
export const PREFIX_FLOOR = 0.6;
|
|
162
215
|
|
|
163
216
|
/**
|
|
164
217
|
* How close two tap points have to be to mean the same control.
|
|
@@ -182,6 +235,77 @@ export const SAME_CONTROL_POINTS = 12;
|
|
|
182
235
|
*/
|
|
183
236
|
const INTERACTIVE_ROLE = /button|field|cell|row|link|switch|slider|tab|menu|segment|checkbox/i;
|
|
184
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Is this target the accessibility tree's reading of a control?
|
|
240
|
+
*
|
|
241
|
+
* A merged target carries `ax|ocr`, because both sensors saw it. Every
|
|
242
|
+
* comparison against the string `'ax'` had to become this: an element does not
|
|
243
|
+
* stop being the tree's element because OCR agreed with it, and treating
|
|
244
|
+
* `ax|ocr` as "not ax" would have quietly demoted exactly the elements the
|
|
245
|
+
* merge is most confident about.
|
|
246
|
+
*/
|
|
247
|
+
export const isAxTarget = (t) => /(^|\|)ax(\||$)/.test(t?.source ?? '');
|
|
248
|
+
|
|
249
|
+
/** How much of `inner` lies inside `outer`, as a fraction of inner's own area. */
|
|
250
|
+
export function containedFraction(inner, outer) {
|
|
251
|
+
if (!inner || !outer) return 0;
|
|
252
|
+
const iw = Math.max(0, inner.width ?? 0);
|
|
253
|
+
const ih = Math.max(0, inner.height ?? 0);
|
|
254
|
+
const innerArea = iw * ih;
|
|
255
|
+
if (innerArea <= 0) return 0;
|
|
256
|
+
const x = Math.max(inner.x, outer.x);
|
|
257
|
+
const y = Math.max(inner.y, outer.y);
|
|
258
|
+
const right = Math.min(inner.x + iw, outer.x + (outer.width ?? 0));
|
|
259
|
+
const bottom = Math.min(inner.y + ih, outer.y + (outer.height ?? 0));
|
|
260
|
+
const overlap = Math.max(0, right - x) * Math.max(0, bottom - y);
|
|
261
|
+
// Clamped: float arithmetic on sub-pixel OCR frames put a fully contained
|
|
262
|
+
// box at 1.0000000000000007, and a fraction of an area cannot exceed 1.
|
|
263
|
+
return Math.min(1, overlap / innerArea);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* How much of the smaller box must sit inside the larger one to be the same
|
|
268
|
+
* element. OCR boxes sit a pixel or two outside the row they are printed on
|
|
269
|
+
* often enough that 1.0 would miss them.
|
|
270
|
+
*/
|
|
271
|
+
export const CONTAINMENT = 0.9;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Do these two strings name the same thing?
|
|
275
|
+
*
|
|
276
|
+
* The discriminator that makes containment safe. A tab bar contains all five
|
|
277
|
+
* of its tab labels, and merging a container with its contents is the failure
|
|
278
|
+
* the old size cap was defending against — but a tab bar's own label is not
|
|
279
|
+
* "Assets", so the text test refuses that merge while allowing a row labelled
|
|
280
|
+
* "Kate Bell" to absorb OCR's reading of "Kate Bell".
|
|
281
|
+
*
|
|
282
|
+
* Substring counts because iOS labels carry state the printed text does not:
|
|
283
|
+
* a row reads "Larger Text" on screen and publishes "Larger Text, Off".
|
|
284
|
+
*/
|
|
285
|
+
export function sameText(a, b) {
|
|
286
|
+
const x = norm(a);
|
|
287
|
+
const y = norm(b);
|
|
288
|
+
if (!x || !y) return false;
|
|
289
|
+
if (x === y) return true;
|
|
290
|
+
if (x.includes(y) || y.includes(x)) return Math.min(x.length, y.length) >= 3;
|
|
291
|
+
// Fuzzy, because OCR misreads a letter or two — measured: "Location (AII)"
|
|
292
|
+
// for "Location (All)", and a Cyrillic К for a K in a monogram.
|
|
293
|
+
return nameScore(x, y) >= 0.5;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The same element, seen by both sensors.
|
|
298
|
+
*
|
|
299
|
+
* `ax` is the tree's element, `ocr` a text box. True when the text sits
|
|
300
|
+
* (almost) wholly inside the element AND says the same thing as its label or
|
|
301
|
+
* value.
|
|
302
|
+
*/
|
|
303
|
+
export function sameElementSeenTwice(ax, ocr) {
|
|
304
|
+
if (!ax?.frame || !ocr?.frame) return false;
|
|
305
|
+
if (containedFraction(ocr.frame, ax.frame) < CONTAINMENT) return false;
|
|
306
|
+
return sameText(ax.label, ocr.label ?? ocr.text) || sameText(ax.value, ocr.label ?? ocr.text);
|
|
307
|
+
}
|
|
308
|
+
|
|
185
309
|
const contains = (frame, target) =>
|
|
186
310
|
Boolean(frame)
|
|
187
311
|
&& target.x >= frame.x && target.x <= frame.x + (frame.width ?? 0)
|
|
@@ -205,6 +329,14 @@ function sameControl(a, b) {
|
|
|
205
329
|
// tab bar from absorbing its own tabs.
|
|
206
330
|
if (INTERACTIVE_ROLE.test(a.type ?? '') && contains(a.frame, b)) return true;
|
|
207
331
|
if (INTERACTIVE_ROLE.test(b.type ?? '') && contains(b.frame, a)) return true;
|
|
332
|
+
// And a labelled accessibility element that is not an interactive role —
|
|
333
|
+
// a list row published as StaticText — with OCR's reading of its own label
|
|
334
|
+
// inside it. This is the pair that made `tap "Kate Bell"` refuse on every
|
|
335
|
+
// Contacts list: 16 escalations in the first instrumented run, all one
|
|
336
|
+
// screen. Defence in depth: the screen map now merges this pair at fusion,
|
|
337
|
+
// and a map built before that still resolves.
|
|
338
|
+
if (isAxTarget(a) && !isAxTarget(b) && sameElementSeenTwice(a, b)) return true;
|
|
339
|
+
if (isAxTarget(b) && !isAxTarget(a) && sameElementSeenTwice(b, a)) return true;
|
|
208
340
|
return false;
|
|
209
341
|
}
|
|
210
342
|
|
|
@@ -219,8 +351,8 @@ function collapseSamePlace(ranked) {
|
|
|
219
351
|
// Prefer the real hit target: an accessibility element over OCR's reading of
|
|
220
352
|
// it, and an interactive role over a caption sitting inside it.
|
|
221
353
|
const better = (candidate, incumbent) => {
|
|
222
|
-
if (candidate.target
|
|
223
|
-
if (candidate.target
|
|
354
|
+
if (isAxTarget(candidate.target) && !isAxTarget(incumbent.target)) return true;
|
|
355
|
+
if (!isAxTarget(candidate.target) && isAxTarget(incumbent.target)) return false;
|
|
224
356
|
return INTERACTIVE_ROLE.test(candidate.target.type ?? '')
|
|
225
357
|
&& !INTERACTIVE_ROLE.test(incumbent.target.type ?? '');
|
|
226
358
|
};
|
package/src/mcp.js
CHANGED
|
@@ -480,7 +480,7 @@ async function look(target, args, options) {
|
|
|
480
480
|
async function state(target, args, options) {
|
|
481
481
|
const { device } = await api.ensureDaemon(target, options);
|
|
482
482
|
const requested = baselineFor(device.udid, args.since);
|
|
483
|
-
const res = await api.getState(target, { since: requested, options });
|
|
483
|
+
const res = await api.getState(target, { since: requested, options, inputHealth: true });
|
|
484
484
|
const s = res.state;
|
|
485
485
|
const lines = [
|
|
486
486
|
header(res.device, s, res.ageMs),
|
|
@@ -500,6 +500,19 @@ async function state(target, args, options) {
|
|
|
500
500
|
}
|
|
501
501
|
const warn = livenessLine(res.live);
|
|
502
502
|
if (warn) lines.unshift(warn);
|
|
503
|
+
// An agent that cannot see this spends five turns wondering why a correct
|
|
504
|
+
// tap on a correct element did nothing. The repair happens before the next
|
|
505
|
+
// action either way; this is so the cause is visible when it does.
|
|
506
|
+
if (res.input?.stale) lines.unshift(`input: stale — ${res.input.reason}`);
|
|
507
|
+
// §7's timing line. Worth a line because "is it still coming or is it done"
|
|
508
|
+
// is a question an agent otherwise answers by waiting and guessing.
|
|
509
|
+
if (res.timing?.samples) {
|
|
510
|
+
lines.push(
|
|
511
|
+
`timing: usually ${res.timing.edge_p50}ms to arrive here (p95 ${res.timing.edge_p95}ms over `
|
|
512
|
+
+ `${res.timing.samples} samples), ${res.timing.elapsed_ms}ms since the last change`
|
|
513
|
+
+ (res.timing.slower_than_usual ? ` — ${res.timing.note}` : ''),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
503
516
|
remember(device.udid, s);
|
|
504
517
|
return { content: [text(lines.filter(Boolean).join('\n'))] };
|
|
505
518
|
}
|