simframe 0.6.1 → 0.6.2
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.
|
@@ -237,8 +237,13 @@ public final class AccessibilityBridge {
|
|
|
237
237
|
cut = cut ?? "the read ran out of time"
|
|
238
238
|
return
|
|
239
239
|
}
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// Each node's reads hand back autoreleased objects, and a 4000-node
|
|
241
|
+
// cap means 4000 nodes' worth of them living until the whole walk
|
|
242
|
+
// returns. Draining per node keeps the peak flat.
|
|
243
|
+
autoreleasepool {
|
|
244
|
+
out.append(node(from: element, depth: depth))
|
|
245
|
+
}
|
|
246
|
+
guard let children = autoreleasepool(invoking: { attribute(element, "AXChildren") as? [NSObject] }) else { return }
|
|
242
247
|
for child in children {
|
|
243
248
|
walk(child, depth: depth + 1, into: &out, deadline: deadline, cut: &cut)
|
|
244
249
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"mcpName": "io.github.lvlrSajjad/simframe",
|
|
5
5
|
"description": "Always-warm iOS Simulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
|
|
6
6
|
"keywords": [
|
package/scripts/ci-memory.mjs
CHANGED
|
@@ -41,6 +41,15 @@ const CONVERGE_PASSES = 3;
|
|
|
41
41
|
const BETWEEN_PASSES_MS = 1200;
|
|
42
42
|
|
|
43
43
|
let failures = 0;
|
|
44
|
+
/**
|
|
45
|
+
* The device is gone, as distinct from having blinked. See `jsonRetry`, which
|
|
46
|
+
* is the only thing that sets it: a dropped frame is retryable and this file
|
|
47
|
+
* already treats it that way, so calling the first one fatal would fight the
|
|
48
|
+
* retry rather than help it. Exhausted attempts are the difference between a
|
|
49
|
+
* blink and a death.
|
|
50
|
+
*/
|
|
51
|
+
let deviceDied = null;
|
|
52
|
+
|
|
44
53
|
function check(ok, label, detail = '') {
|
|
45
54
|
if (!ok) failures += 1;
|
|
46
55
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
|
|
@@ -65,6 +74,10 @@ async function cli(args, { expectFail = false, allowFail = false } = {}) {
|
|
|
65
74
|
if (err.unexpectedSuccess) throw err;
|
|
66
75
|
if (expectFail || allowFail) return `${err.stdout ?? ''}${err.stderr ?? ''}`;
|
|
67
76
|
const why = (err.stdout || err.stderr || err.message || '').trim();
|
|
77
|
+
// Noted here, not in `check`: by the time a failure reaches a check its
|
|
78
|
+
// detail has been truncated for legibility, and the first version of this
|
|
79
|
+
// guard looked for "did not produce a frame" in a string that had been cut
|
|
80
|
+
// to "simframe daemon di". The full text only exists at this boundary.
|
|
68
81
|
throw new Error(`simframe ${full.join(' ')} failed: ${why.slice(0, 400)}`);
|
|
69
82
|
}
|
|
70
83
|
}
|
|
@@ -97,6 +110,23 @@ async function jsonRetry(args, opts, attempts = 3) {
|
|
|
97
110
|
await new Promise((r) => setTimeout(r, 1500));
|
|
98
111
|
}
|
|
99
112
|
}
|
|
113
|
+
// Out of attempts on a capture error: the device is not blinking, it is gone.
|
|
114
|
+
//
|
|
115
|
+
// Diagnosed and exited here rather than flagged for a later `check` to
|
|
116
|
+
// notice, because most call sites do not wrap this — the throw escapes, the
|
|
117
|
+
// run dies on an unhandled rejection, and the operator gets a stack trace
|
|
118
|
+
// pointing at this file instead of a sentence about their simulator. Which is
|
|
119
|
+
// exactly what the first version of this did.
|
|
120
|
+
if (TRANSIENT.test(last?.message ?? '')) {
|
|
121
|
+
deviceDied = last.message;
|
|
122
|
+
console.error(`\nFAIL the device stopped producing frames, and did not come back after ${attempts} attempts:`);
|
|
123
|
+
console.error(` ${String(last.message).split('\n')[0]}`);
|
|
124
|
+
console.error('\nEverything after this point would be testing a dead simulator, so the run');
|
|
125
|
+
console.error('stops here. This is not a memory-layer failure — it is the device-state');
|
|
126
|
+
console.error('problem in docs/DEFERRED.md. A device restart is the only known cure;');
|
|
127
|
+
console.error('on a hosted runner it means a retry.');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
100
130
|
throw last;
|
|
101
131
|
}
|
|
102
132
|
|
|
@@ -394,9 +424,14 @@ if (check(forced.saved?.ok === true, 'and --force saves it anyway', `${forced.sa
|
|
|
394
424
|
console.log('\n--- every command speaks JSON ---');
|
|
395
425
|
// The --json plumbing is per-command and hand-written, so one command quietly
|
|
396
426
|
// printing prose is exactly the kind of thing nothing else would catch.
|
|
427
|
+
// Through `jsonRetry` like everything else. This loop used to call `cli`
|
|
428
|
+
// directly, and it is the last section of a run that takes minutes — so a
|
|
429
|
+
// capture dropout here failed five checks about `--json` plumbing that was
|
|
430
|
+
// working perfectly, while every earlier section shrugged the same dropout off.
|
|
431
|
+
// The one place that did not retry was the one place most likely to need it.
|
|
397
432
|
for (const args of [['status'], ['state'], ['mark'], ['ui'], ['screens'], ['devices'], ['doctor'], ['flow', 'list'], ['recall']]) {
|
|
398
433
|
try {
|
|
399
|
-
const parsed =
|
|
434
|
+
const parsed = await jsonRetry([...args]);
|
|
400
435
|
check(parsed !== null && parsed !== undefined, `simframe ${args.join(' ')} --json`);
|
|
401
436
|
} catch (err) {
|
|
402
437
|
check(false, `simframe ${args.join(' ')} --json`, err.message.slice(0, 120));
|
package/src/fingerprint.js
CHANGED
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
import crypto from 'node:crypto';
|
|
12
12
|
import * as regions from './regions.js';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Bumped whenever the token rules change, and read by `graph.FINGERPRINT_VERSION`
|
|
16
|
+
* and `screenmap.MAP_VERSION` so stored hashes are discarded rather than
|
|
17
|
+
* compared against hashes computed by different rules. An old hash is a
|
|
18
|
+
* perfectly well-formed hash that never matches anything, which is the quietest
|
|
19
|
+
* kind of wrong.
|
|
20
|
+
*
|
|
21
|
+
* 2 — elements with no visible footprint, and containers holding two or more
|
|
22
|
+
* others, no longer enter identity: only one sensor can see either.
|
|
23
|
+
*/
|
|
24
|
+
export const TOKEN_RULES_VERSION = 2;
|
|
25
|
+
|
|
14
26
|
/** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
|
|
15
27
|
export const GRID = 24;
|
|
16
28
|
|
|
@@ -108,13 +120,31 @@ export function tokens(targets, screen) {
|
|
|
108
120
|
const keyboardTop = regions.detectKeyboardTop(targets, screen);
|
|
109
121
|
const groups = new Map();
|
|
110
122
|
|
|
123
|
+
// Identity is what the screen *is*, not which sensor happened to see it, so
|
|
124
|
+
// two things that only one sensor can produce must not enter it: an element
|
|
125
|
+
// with no visible footprint, and a container that exists to hold others.
|
|
126
|
+
// Pixels cannot see either, and the accessibility tree reports both.
|
|
127
|
+
const encloses = (frame) => targets.filter((o) => {
|
|
128
|
+
const f = o.frame;
|
|
129
|
+
if (!f || f === frame) return false;
|
|
130
|
+
const cx = f.x + (f.width ?? 0) / 2;
|
|
131
|
+
const cy = f.y + (f.height ?? 0) / 2;
|
|
132
|
+
return cx > frame.x && cx < frame.x + (frame.width ?? 0)
|
|
133
|
+
&& cy > frame.y && cy < frame.y + (frame.height ?? 0);
|
|
134
|
+
}).length;
|
|
135
|
+
|
|
111
136
|
for (const t of targets) {
|
|
112
137
|
const frame = t.frame ?? { x: t.x, y: t.y, width: 0, height: 0 };
|
|
113
138
|
// Off-screen elements are not part of what this screen looks like.
|
|
114
139
|
if (frame.y + (frame.height ?? 0) <= 0 || frame.y >= screen.height) continue;
|
|
140
|
+
// Nor is anything with no footprint to be seen.
|
|
141
|
+
if (!(frame.width > 0) || !(frame.height > 0)) continue;
|
|
115
142
|
const region = t.region ?? regions.regionFor(frame, screen, { keyboardTop });
|
|
116
143
|
if (region === 'status-bar') continue;
|
|
117
144
|
if (keyboardTop != null && frame.y >= keyboardTop) continue;
|
|
145
|
+
// A thing that holds two or more other things is scenery, and only the
|
|
146
|
+
// tree can see it. Its children are already in the fingerprint.
|
|
147
|
+
if (/group|other|generic/i.test(String(t.type ?? '')) && encloses(frame) >= 2) continue;
|
|
118
148
|
|
|
119
149
|
const role = roleOf(t);
|
|
120
150
|
// Group by what a thing IS and how big it is, not where it is. Repeated
|
package/src/graph.js
CHANGED
|
@@ -7,11 +7,28 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { hashDistance } from './analyze.js';
|
|
10
|
+
import { informative } from './refs.js';
|
|
10
11
|
import * as fingerprint from './fingerprint.js';
|
|
11
12
|
import * as matching from './matching.js';
|
|
12
13
|
import * as store from './store.js';
|
|
13
14
|
|
|
14
|
-
const GRAPH_VERSION =
|
|
15
|
+
const GRAPH_VERSION = 3;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Which fingerprint produced the hashes in these files.
|
|
19
|
+
*
|
|
20
|
+
* Separate from `GRAPH_VERSION` because it answers a different question: not
|
|
21
|
+
* "is this file shaped the way I expect" but "were these hashes computed by the
|
|
22
|
+
* same rules I am about to compare them with". A stored graph whose hashes came
|
|
23
|
+
* from an older fingerprint is not stale, it is *incomparable* — and the failure
|
|
24
|
+
* is silent, because an old hash is a perfectly well-formed hash that simply
|
|
25
|
+
* never matches anything.
|
|
26
|
+
*
|
|
27
|
+
* On a mismatch the graph is discarded and rebuilt, never translated. A rebuild
|
|
28
|
+
* costs a few hundred milliseconds per screen and happens once. A mis-merged
|
|
29
|
+
* graph costs a wrong tap, and costs it for as long as the file survives.
|
|
30
|
+
*/
|
|
31
|
+
export const FINGERPRINT_VERSION = fingerprint.TOKEN_RULES_VERSION;
|
|
15
32
|
/**
|
|
16
33
|
* Screens are matched by structural hash, exactly, and then by how alike their
|
|
17
34
|
* token sets are — which tolerates one optional element appearing (a badge, a
|
|
@@ -38,6 +55,14 @@ const GRAPH_VERSION = 2;
|
|
|
38
55
|
* Most revisits match on a hash outright and never reach this at all.
|
|
39
56
|
*/
|
|
40
57
|
export const SIMILARITY_THRESHOLD = 0.36;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How many of the 288 layout bits may differ and still be the same arrangement
|
|
61
|
+
* of light. The same number `screenmap` recalls maps by, and for the same
|
|
62
|
+
* reason: measured, a revisit is usually identical and different screens sit at
|
|
63
|
+
* 74 and above, so 20 is well inside the gap.
|
|
64
|
+
*/
|
|
65
|
+
export const SAME_SCREEN_LAYOUT_BITS = 20;
|
|
41
66
|
/**
|
|
42
67
|
* A screen with three async sections has a few settled structures, not endless
|
|
43
68
|
* ones. Capping this keeps a genuinely wrong merge bounded: if a node starts
|
|
@@ -105,11 +130,28 @@ function fingerprintsOf(node) {
|
|
|
105
130
|
function load(udid, screen) {
|
|
106
131
|
const key = typeof screen === 'string' ? { hash: screen, tokens: [] } : screen;
|
|
107
132
|
const entry = store.readJson(path.join(graphDir(udid), `${key.hash}.json`));
|
|
108
|
-
if (entry?.version === GRAPH_VERSION) return entry;
|
|
133
|
+
if (entry?.version === GRAPH_VERSION && entry?.fingerprintVersion === FINGERPRINT_VERSION) return entry;
|
|
109
134
|
// The hash may be a variant of a node filed under a different name.
|
|
110
135
|
const byVariant = allNodes(udid).find((n) => (n.variants ?? []).some((v) => v.hash === key.hash));
|
|
111
136
|
if (byVariant) return byVariant;
|
|
112
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
version: GRAPH_VERSION,
|
|
139
|
+
fingerprintVersion: FINGERPRINT_VERSION,
|
|
140
|
+
hash: key.hash,
|
|
141
|
+
tokens: key.tokens ?? [],
|
|
142
|
+
// What the pixels looked like here. Kept because it is the evidence that
|
|
143
|
+
// two structurally different readings are the same screen — see `record`.
|
|
144
|
+
layoutHash: key.layoutHash ?? null,
|
|
145
|
+
variants: [],
|
|
146
|
+
edges: [],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Keep the pixel baseline current for a screen we are standing on. */
|
|
151
|
+
function noteLayout(node, reading) {
|
|
152
|
+
const now = typeof reading === 'string' ? null : reading?.layoutHash;
|
|
153
|
+
if (now && informative(now)) node.layoutHash = now;
|
|
154
|
+
return node;
|
|
113
155
|
}
|
|
114
156
|
|
|
115
157
|
function save(udid, node) {
|
|
@@ -124,7 +166,7 @@ export function allNodes(udid) {
|
|
|
124
166
|
.readdirSync(graphDir(udid))
|
|
125
167
|
.filter((f) => f.endsWith('.json'))
|
|
126
168
|
.map((f) => store.readJson(path.join(graphDir(udid), f)))
|
|
127
|
-
.filter((n) => n?.version === GRAPH_VERSION);
|
|
169
|
+
.filter((n) => n?.version === GRAPH_VERSION && n?.fingerprintVersion === FINGERPRINT_VERSION);
|
|
128
170
|
} catch {
|
|
129
171
|
return [];
|
|
130
172
|
}
|
|
@@ -243,6 +285,10 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
243
285
|
// arrive as — that is what variants are for, and rewriting it here would let
|
|
244
286
|
// a node drift screen by screen into something it never was.
|
|
245
287
|
if (!node.tokens?.length && fromKey.tokens?.length) node.tokens = fromKey.tokens;
|
|
288
|
+
// The pixel baseline, on the other hand, *should* track: it is the evidence
|
|
289
|
+
// for "same arrangement of light as last time I stood here", and a stale one
|
|
290
|
+
// answers a question about a screen as it was weeks ago.
|
|
291
|
+
noteLayout(node, fromKey);
|
|
246
292
|
const to_ = toHash;
|
|
247
293
|
const signature = actionSignature(action);
|
|
248
294
|
const existing = node.edges.find((e) => e.action === signature);
|
|
@@ -272,9 +318,28 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
272
318
|
// So the reading has to positively look like the target before it is
|
|
273
319
|
// called a face of it. Unclaimed is a necessary condition, not a
|
|
274
320
|
// sufficient one.
|
|
275
|
-
|
|
321
|
+
// Two kinds of positive evidence that this reading is a face of the
|
|
322
|
+
// target, and either will do. What will not do is "nothing else claims
|
|
323
|
+
// it", which is an absence of evidence and used to be the whole test.
|
|
324
|
+
//
|
|
325
|
+
// * It looks like the target — the tokens overlap enough to be the same
|
|
326
|
+
// screen by the same measure used everywhere else.
|
|
327
|
+
// * It *looks like* the target on screen. The pixels are within the
|
|
328
|
+
// same-screen band of what was seen here before, and we arrived
|
|
329
|
+
// through an edge that has led here. Identity belongs to the graph as
|
|
330
|
+
// much as to the hash: the transition is evidence the fingerprint
|
|
331
|
+
// cannot supply, and it is exactly the evidence needed when one
|
|
332
|
+
// perception layer answered this time and not last time.
|
|
333
|
+
//
|
|
334
|
+
// That second route is what carries a screen whose structure genuinely
|
|
335
|
+
// differs between reads. Measured on four device-native screens, the
|
|
336
|
+
// accessibility tree and OCR agree on only 0.33–0.47 of a screen's
|
|
337
|
+
// tokens and never on its hash, and coarsening the vocabulary barely
|
|
338
|
+
// moved it — so this is not a residual case, it is the common one.
|
|
339
|
+
const looksLikeTarget = resembles(target, reading) || pixelsAgree(target, reading);
|
|
276
340
|
if (unclaimed && looksLikeTarget && target.hash !== to_ && reading.tokens?.length) {
|
|
277
341
|
addVariant(target, reading);
|
|
342
|
+
noteLayout(target, reading);
|
|
278
343
|
save(udid, target);
|
|
279
344
|
existing.count += 1;
|
|
280
345
|
existing.lastSeen = Date.now();
|
|
@@ -306,6 +371,25 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
306
371
|
}
|
|
307
372
|
|
|
308
373
|
/** What this action did last time, if we have ever seen it here. */
|
|
374
|
+
/**
|
|
375
|
+
* Do the pixels say this is the same screen we have stood on here before?
|
|
376
|
+
*
|
|
377
|
+
* The layout hash is a poor answer to "which screen is this" on its own — that
|
|
378
|
+
* is why identity is structural — but it is a good answer to "is this the same
|
|
379
|
+
* arrangement of light", and combined with having arrived through a known edge
|
|
380
|
+
* it is the evidence that two structurally different readings are one screen.
|
|
381
|
+
*
|
|
382
|
+
* Guarded by `informative`, because a dark or uniform screen hashes to almost
|
|
383
|
+
* nothing and two of those are within any tolerance of each other while being
|
|
384
|
+
* evidence of nothing at all.
|
|
385
|
+
*/
|
|
386
|
+
function pixelsAgree(node, reading) {
|
|
387
|
+
const before = node?.layoutHash;
|
|
388
|
+
const now = reading?.layoutHash;
|
|
389
|
+
if (!before || !now || !informative(before) || !informative(now)) return false;
|
|
390
|
+
return hashDistance(before, now) <= SAME_SCREEN_LAYOUT_BITS;
|
|
391
|
+
}
|
|
392
|
+
|
|
309
393
|
/**
|
|
310
394
|
* Does this reading look like a face of this screen, rather than a different
|
|
311
395
|
* screen we happen not to have stored yet?
|
package/src/screenmap.js
CHANGED
|
@@ -16,7 +16,7 @@ import * as regions from './regions.js';
|
|
|
16
16
|
import { informative } from './refs.js';
|
|
17
17
|
import * as store from './store.js';
|
|
18
18
|
|
|
19
|
-
const MAP_VERSION =
|
|
19
|
+
const MAP_VERSION = 7; // footprintless elements and containers no longer enter identity
|
|
20
20
|
|
|
21
21
|
function mapDir(udid) {
|
|
22
22
|
return path.join(store.deviceDir(udid), 'screens');
|