simframe 0.4.2 → 0.5.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.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. package/src/store.js +8 -0
package/src/engine.js ADDED
@@ -0,0 +1,99 @@
1
+ // Chooses and starts the capture engine.
2
+ //
3
+ // Two exist: `simframed`, a Swift daemon that reads the framebuffer directly,
4
+ // and `simctl`, the original loop that shells out for each screenshot. The
5
+ // daemon is the default because it is roughly thirty times faster, but the old
6
+ // loop stays reachable — a machine without a Swift toolchain, or an Xcode
7
+ // version where a private symbol has moved, still needs to work.
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { promisify } from 'node:util';
13
+ import * as store from './store.js';
14
+
15
+ const run = promisify(execFile);
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const PACKAGE = path.join(HERE, '..', 'native', 'simframed');
18
+ const BINARY = path.join(PACKAGE, '.build', 'release', 'simframed');
19
+
20
+ export const ENGINES = ['simframed', 'simctl'];
21
+
22
+ export function binaryPath() {
23
+ return BINARY;
24
+ }
25
+
26
+ /** Newest mtime across the Swift sources, so a stale binary is rebuilt. */
27
+ function sourceMtime() {
28
+ const roots = [path.join(PACKAGE, 'Sources'), path.join(PACKAGE, 'Package.swift')];
29
+ let newest = 0;
30
+ const walk = (p) => {
31
+ let stat;
32
+ try {
33
+ stat = fs.statSync(p);
34
+ } catch {
35
+ return;
36
+ }
37
+ if (stat.isDirectory()) {
38
+ for (const name of fs.readdirSync(p)) walk(path.join(p, name));
39
+ return;
40
+ }
41
+ if (stat.mtimeMs > newest) newest = stat.mtimeMs;
42
+ };
43
+ for (const r of roots) walk(r);
44
+ return newest;
45
+ }
46
+
47
+ export function status() {
48
+ const haveSource = fs.existsSync(path.join(PACKAGE, 'Package.swift'));
49
+ const haveBinary = fs.existsSync(BINARY);
50
+ const stale = haveBinary && haveSource && fs.statSync(BINARY).mtimeMs < sourceMtime();
51
+ return { haveSource, haveBinary, stale, binary: BINARY };
52
+ }
53
+
54
+ /**
55
+ * Make sure the daemon binary exists and is current.
56
+ * @returns {Promise<{ok: boolean, built: boolean, reason?: string}>}
57
+ */
58
+ export async function ensureBuilt({ rebuild = false } = {}) {
59
+ const state = status();
60
+ if (!state.haveSource) {
61
+ return { ok: false, built: false, reason: 'the simframed sources are not in this install' };
62
+ }
63
+ if (state.haveBinary && !state.stale && !rebuild) return { ok: true, built: false };
64
+ try {
65
+ // Release, because a debug build is several times slower per frame and
66
+ // this binary's whole purpose is latency.
67
+ await run('swift', ['build', '-c', 'release', '--package-path', PACKAGE], { timeout: 300_000 });
68
+ return { ok: fs.existsSync(BINARY), built: true };
69
+ } catch (err) {
70
+ const detail = (err.stderr || err.message || '').split('\n').filter(Boolean).slice(-2).join(' ');
71
+ return {
72
+ ok: false,
73
+ built: false,
74
+ reason:
75
+ err.code === 'ENOENT'
76
+ ? 'swift is not installed, so the daemon cannot be built (install Xcode command line tools)'
77
+ : `building simframed failed: ${detail}`,
78
+ };
79
+ }
80
+ }
81
+
82
+ /** Start the Swift daemon detached, the way the Node loop is started. */
83
+ export function spawnDaemon(udid, { maxDim, minIntervalMs, idleExitMs } = {}) {
84
+ const args = ['run', `--udid=${udid}`];
85
+ if (maxDim != null) args.push(`--max-dim=${maxDim}`);
86
+ if (minIntervalMs != null) args.push(`--min-interval-ms=${minIntervalMs}`);
87
+ if (idleExitMs != null) args.push(`--idle-exit-ms=${idleExitMs}`);
88
+ const log = fs.openSync(path.join(store.deviceDir(udid), 'simframed.log'), 'a');
89
+ const child = spawn(BINARY, args, { detached: true, stdio: ['ignore', log, log] });
90
+ child.unref();
91
+ return child;
92
+ }
93
+
94
+ /** Which engine wrote the state we are reading, according to meta.json. */
95
+ export function runningEngine(udid) {
96
+ const meta = store.readJson(path.join(store.deviceDir(udid), 'meta.json'));
97
+ if (!meta || !store.isProcessAlive(meta.pid)) return null;
98
+ return meta.options?.engine === 'simframed' ? 'simframed' : 'simctl';
99
+ }
@@ -0,0 +1,150 @@
1
+ // Identity, as distinct from change.
2
+ //
3
+ // The pixel dHash answers "did this move?", which is a question about pixels
4
+ // and which it answers well. It is a poor answer to "is this the same screen?",
5
+ // because content is pixels: a list whose rows changed drifts as far as a
6
+ // different screen does. Measured, same-screen revisits reached 62 bits against
7
+ // a different-screen floor of 74 — no threshold separates those.
8
+ //
9
+ // This fingerprints layout instead. Two screens are the same when the same
10
+ // kinds of thing sit in the same places, whatever they currently say.
11
+ import crypto from 'node:crypto';
12
+ import * as regions from './regions.js';
13
+
14
+ /** Frames are quantised to this, so sub-pixel drift and a nudged row do not matter. */
15
+ export const GRID = 24;
16
+
17
+ /** Regions whose labels identify the screen rather than describe its contents. */
18
+ const CHROME = new Set(['nav-bar', 'tab-bar']);
19
+
20
+ /**
21
+ * How wide a tab label can be before it is not a tab label.
22
+ *
23
+ * The region bands are positional, so anything low enough on the screen lands
24
+ * in `tab-bar` — including page content sitting just above the real tabs. One
25
+ * app put a date banner there, and because chrome labels go into the
26
+ * fingerprint, that screen's identity contained "sep 08, 2026" and would have
27
+ * become a different screen at midnight. Every stored map, node and route
28
+ * touching it would have broken overnight.
29
+ *
30
+ * A tab bar divides its width between its tabs, so a tab's label is a fraction
31
+ * of the screen: measured on that app, real tab labels ran 24-72 px against the
32
+ * banner's 144 px on a 402 px screen. The element still contributes its shape
33
+ * to the fingerprint — presence is structure — it just stops contributing text.
34
+ */
35
+ const TAB_LABEL_MAX_WIDTH_FRACTION = 0.3;
36
+
37
+ const quantise = (v) => Math.round((v ?? 0) / GRID);
38
+
39
+ /** Coarse role, so "Button" and "AXButton" and an OCR-inferred button agree. */
40
+ export function roleOf(target) {
41
+ const t = String(target.type ?? '').toLowerCase();
42
+ if (/button/.test(t)) return 'button';
43
+ if (/textfield|textview|searchfield|field/.test(t)) return 'field';
44
+ if (/switch|toggle|checkbox/.test(t)) return 'switch';
45
+ if (/cell|row/.test(t)) return 'cell';
46
+ if (/link/.test(t)) return 'link';
47
+ if (/image|icon/.test(t)) return 'image';
48
+ if (/statictext|text|label/.test(t)) return 'text';
49
+ if (/group|other|generic/.test(t)) return 'group';
50
+ return t || 'unknown';
51
+ }
52
+
53
+ /**
54
+ * One, or several.
55
+ *
56
+ * Finer buckets were tried and are worse: a list that grows from three rows to
57
+ * seven crosses a 2-4 / 5+ boundary and changes identity, which is exactly the
58
+ * instability the bucketing existed to prevent. "A group of these lives here"
59
+ * is the stable fact; how many there are today is content.
60
+ */
61
+ function bucket(n) {
62
+ return n <= 1 ? '1' : 'many';
63
+ }
64
+
65
+ const normLabel = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 40);
66
+
67
+ /**
68
+ * The canonical tokens this screen is made of.
69
+ *
70
+ * Deliberately excluded: the status bar (a clock is not identity), everything
71
+ * inside the keyboard when one is up (it is the same keyboard on every screen),
72
+ * and the text of anything in the content region (that is the content).
73
+ */
74
+ export function tokens(targets, screen) {
75
+ if (!screen?.width || !screen?.height) return { tokens: [], keyboard: false };
76
+ const keyboardTop = regions.detectKeyboardTop(targets, screen);
77
+ const groups = new Map();
78
+
79
+ for (const t of targets) {
80
+ const frame = t.frame ?? { x: t.x, y: t.y, width: 0, height: 0 };
81
+ // Off-screen elements are not part of what this screen looks like.
82
+ if (frame.y + (frame.height ?? 0) <= 0 || frame.y >= screen.height) continue;
83
+ const region = t.region ?? regions.regionFor(frame, screen, { keyboardTop });
84
+ if (region === 'status-bar') continue;
85
+ if (keyboardTop != null && frame.y >= keyboardTop) continue;
86
+
87
+ const role = roleOf(t);
88
+ // Group by what a thing IS and how big it is, not where it is. Repeated
89
+ // siblings — the rows of a list — differ only in position, and including
90
+ // position in the key makes a four-row list a different screen from a
91
+ // three-row one.
92
+ const parts = [role, region];
93
+ // Where in the nav bar a thing sits is structure, not content — and it is
94
+ // what tells a title apart from a button that happens to be up there.
95
+ if (CHROME.has(region) && t.navSlot) parts.push(`@${t.navSlot}`);
96
+ parts.push(`w${quantise(frame.width)}`, `h${quantise(frame.height)}`);
97
+ // Chrome labels are the only text that survives: two list screens with
98
+ // identical structure differ by their title, and nothing else says so. But
99
+ // only where the element is plausibly chrome — a nav bar has slots, and a
100
+ // tab label is narrow; content that merely fell into the band is not a name.
101
+ const labelWorthKeeping = CHROME.has(region)
102
+ && t.label
103
+ && (region !== 'tab-bar' || (frame.width ?? 0) <= screen.width * TAB_LABEL_MAX_WIDTH_FRACTION);
104
+ if (labelWorthKeeping) parts.push(`"${normLabel(t.label)}"`);
105
+ const key = parts.join(':');
106
+ const group = groups.get(key) ?? { count: 0, x: quantise(frame.x), y: quantise(frame.y) };
107
+ group.count += 1;
108
+ // Anchor the group at its topmost member, which is stable as a list grows.
109
+ if (quantise(frame.y) < group.y) {
110
+ group.x = quantise(frame.x);
111
+ group.y = quantise(frame.y);
112
+ }
113
+ groups.set(key, group);
114
+ }
115
+
116
+ const out = [...groups.entries()]
117
+ .map(([key, g]) => (g.count > 1
118
+ // A repeated group is identified by its anchor and how many of it there
119
+ // roughly are, never by an exact count.
120
+ ? `${key}:x${g.x}:y${g.y}#${bucket(g.count)}`
121
+ : `${key}:x${g.x}:y${g.y}#1`))
122
+ .sort();
123
+ return { tokens: out, keyboard: keyboardTop != null };
124
+ }
125
+
126
+ export function hashTokens(list) {
127
+ return crypto.createHash('sha256').update(list.join('\n')).digest('hex').slice(0, 32);
128
+ }
129
+
130
+ /** Structural fingerprint of a screen, plus the tokens it was built from. */
131
+ export function fingerprint(targets, screen) {
132
+ const { tokens: list, keyboard } = tokens(targets, screen);
133
+ return { hash: hashTokens(list), tokens: list, keyboard, count: list.length };
134
+ }
135
+
136
+ /**
137
+ * How alike two token sets are, 0 to 1.
138
+ *
139
+ * Jaccard rather than Hamming: the sets are of different sizes when an optional
140
+ * element appears — a badge, a banner — and that should cost a little, not
141
+ * everything.
142
+ */
143
+ export function similarity(a = [], b = []) {
144
+ if (!a.length && !b.length) return 1;
145
+ const setA = new Set(a);
146
+ const setB = new Set(b);
147
+ let shared = 0;
148
+ for (const token of setA) if (setB.has(token)) shared += 1;
149
+ return shared / (setA.size + setB.size - shared);
150
+ }
package/src/graph.js ADDED
@@ -0,0 +1,409 @@
1
+ // What happens when you do something here.
2
+ //
3
+ // Keyed by the same layout hash as screen memory, so a screen the map already
4
+ // recognises is a screen the graph already knows. Edges are observations, never
5
+ // predictions: an edge exists because an action was taken and the result was
6
+ // seen, and a screen with no edges is a screen we have nothing to say about.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { hashDistance } from './analyze.js';
10
+ import * as fingerprint from './fingerprint.js';
11
+ import * as matching from './matching.js';
12
+ import * as store from './store.js';
13
+
14
+ const GRAPH_VERSION = 2;
15
+ /**
16
+ * Screens are matched by structural hash, exactly, and then by how alike their
17
+ * token sets are — which tolerates one optional element appearing (a badge, a
18
+ * banner) without tolerating a different screen.
19
+ *
20
+ * The pixel layout hash is not used for identity here. Measured, a same-screen
21
+ * revisit with changed content reached 62 bits against a different-screen floor
22
+ * of 74; no threshold separates those. See docs/BENCHMARKS.md, Phase 6.
23
+ *
24
+ * Structurally the two distributions do separate, but not by much: measured
25
+ * with the screen map forced cold, revisits score 0.41 to 1.00 against a
26
+ * different-screen ceiling of 0.31. 0.36 is the middle of that gap.
27
+ *
28
+ * The gap is narrow because of one screen, and a settle gate did not fix it
29
+ * (docs/BENCHMARKS.md, Phase 6c): that screen loads its sections from different
30
+ * sources and genuinely has more than one settled structure. Two structures of
31
+ * one screen are as far apart as two different screens, so no threshold can
32
+ * express the difference — which is why a screen may hold several accepted
33
+ * fingerprints instead. See `variants` below.
34
+ *
35
+ * This still errs toward recording a duplicate screen, which costs a
36
+ * re-derivation, over merging two, which costs a tap on the wrong element.
37
+ *
38
+ * Most revisits match on a hash outright and never reach this at all.
39
+ */
40
+ export const SIMILARITY_THRESHOLD = 0.36;
41
+ /**
42
+ * A screen with three async sections has a few settled structures, not endless
43
+ * ones. Capping this keeps a genuinely wrong merge bounded: if a node starts
44
+ * collecting variants without limit, that is a signal the action is
45
+ * non-deterministic, not that the screen has many faces.
46
+ */
47
+ export const MAX_VARIANTS = 4;
48
+
49
+ /** Only for the legacy pixel path, kept so old graphs still load. */
50
+ export const TOLERANCE = 20;
51
+
52
+ function graphDir(udid) {
53
+ return path.join(store.deviceDir(udid), 'graph');
54
+ }
55
+
56
+ /** A stable name for an action, so the same step matches its own history. */
57
+ export function actionSignature(step) {
58
+ if (!step || typeof step !== 'object') return String(step ?? '');
59
+ // Normalized steps carry `{action, value}`, not `{tap: "..."}`, and every
60
+ // step reaching the graph has been normalized. Without this the shorthand
61
+ // branches below never matched and everything fell to the generic tail, so a
62
+ // tap on "Contacts" and a type of "Contacts" produced the SAME signature —
63
+ // two different actions sharing one edge — and a stray `index: undefined`
64
+ // key made the tail throw outright.
65
+ if (step.action) {
66
+ // Bookkeeping is not part of what the action IS: the same tap with a longer
67
+ // timeout is the same edge.
68
+ const { action, timeoutMs, stableMs, autoSettle, ...rest } = step;
69
+ const value = rest.value ?? rest.target ?? rest.label;
70
+ if (value != null && typeof value !== 'object') return `${action}:${String(value).toLowerCase()}`;
71
+ // Shapes like tapAt and swipe are spread inline, so they have no `value` —
72
+ // their coordinates ARE their identity and must stay in the signature, or
73
+ // two taps at different points share one edge.
74
+ const keys = Object.keys(rest).filter((k) => rest[k] !== undefined).sort();
75
+ if (!keys.length) return String(action);
76
+ return `${action}:${stableValue(Object.fromEntries(keys.map((k) => [k, rest[k]])))}`;
77
+ }
78
+ if (step.tap != null) return `tap:${String(step.tap).toLowerCase()}`;
79
+ if (step.tapAt) return `tapAt:${Math.round(step.tapAt.x)},${Math.round(step.tapAt.y)}`;
80
+ if (step.swipe) {
81
+ const { from = [], to = [] } = step.swipe;
82
+ return `swipe:${from.join(',')}->${to.join(',')}`;
83
+ }
84
+ if (step.scroll) return `scroll:${step.scroll}`;
85
+ if (step.button) return `button:${step.button}`;
86
+ if (step.launch) return `launch:${step.launch}`;
87
+ if (step.openUrl) return `openUrl:${step.openUrl}`;
88
+ // Last resort. Skip keys whose value is undefined: JSON.stringify(undefined)
89
+ // is undefined, and calling .slice on it threw before any action was sent.
90
+ const key = Object.keys(step).find((k) => step[k] !== undefined);
91
+ if (!key) return '';
92
+ return `${key}:${stableValue(step[key])}`;
93
+ }
94
+
95
+ /** JSON, but never undefined, and always short enough to use as a key. */
96
+ function stableValue(value) {
97
+ return String(JSON.stringify(value) ?? '').slice(0, 40);
98
+ }
99
+
100
+ /** Every fingerprint a node answers to: its canonical one, plus its variants. */
101
+ function fingerprintsOf(node) {
102
+ return [{ hash: node.hash, tokens: node.tokens ?? [] }, ...(node.variants ?? [])];
103
+ }
104
+
105
+ function load(udid, screen) {
106
+ const key = typeof screen === 'string' ? { hash: screen, tokens: [] } : screen;
107
+ const entry = store.readJson(path.join(graphDir(udid), `${key.hash}.json`));
108
+ if (entry?.version === GRAPH_VERSION) return entry;
109
+ // The hash may be a variant of a node filed under a different name.
110
+ const byVariant = allNodes(udid).find((n) => (n.variants ?? []).some((v) => v.hash === key.hash));
111
+ if (byVariant) return byVariant;
112
+ return { version: GRAPH_VERSION, hash: key.hash, tokens: key.tokens ?? [], variants: [], edges: [] };
113
+ }
114
+
115
+ function save(udid, node) {
116
+ const dir = graphDir(udid);
117
+ fs.mkdirSync(dir, { recursive: true });
118
+ store.writeAtomic(path.join(dir, `${node.hash}.json`), JSON.stringify(node));
119
+ }
120
+
121
+ export function allNodes(udid) {
122
+ try {
123
+ return fs
124
+ .readdirSync(graphDir(udid))
125
+ .filter((f) => f.endsWith('.json'))
126
+ .map((f) => store.readJson(path.join(graphDir(udid), f)))
127
+ .filter((n) => n?.version === GRAPH_VERSION);
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ /** The stored screen closest to `hash`, by layout rather than content. */
134
+ /**
135
+ * The stored screen matching this one.
136
+ *
137
+ * `screen` is `{ hash, tokens }` from the structural fingerprint. An exact hash
138
+ * match is the common case; the token comparison catches the screen that gained
139
+ * a badge since last time.
140
+ */
141
+ export function nearestScreen(udid, screen, { threshold = SIMILARITY_THRESHOLD } = {}) {
142
+ const key = typeof screen === 'string' ? { hash: screen, tokens: null } : screen;
143
+ if (!key?.hash) return null;
144
+ const nodes = allNodes(udid);
145
+ // Any of a node's accepted fingerprints matching exactly is still an exact
146
+ // match: a screen with two settled structures is one screen.
147
+ const exact = nodes.find((n) => fingerprintsOf(n).some((f) => f.hash === key.hash));
148
+ if (exact) return { node: exact, similarity: 1 };
149
+ if (!key.tokens?.length) return null;
150
+ let best = null;
151
+ let bestSimilarity = 0;
152
+ for (const node of nodes) {
153
+ for (const f of fingerprintsOf(node)) {
154
+ if (!f.tokens?.length) continue;
155
+ const s = fingerprint.similarity(f.tokens, key.tokens);
156
+ if (s > bestSimilarity) {
157
+ bestSimilarity = s;
158
+ best = node;
159
+ }
160
+ }
161
+ }
162
+ return best && bestSimilarity >= threshold ? { node: best, similarity: bestSimilarity } : null;
163
+ }
164
+
165
+ /**
166
+ * Teach a node that it also looks like this.
167
+ *
168
+ * Called only when a known edge has landed somewhere its target does not
169
+ * recognise — the edge is the evidence. A screen whose sections arrive from
170
+ * different sources has several genuine settled structures, and this is how the
171
+ * second one stops being a screen of its own.
172
+ */
173
+ function addVariant(node, reading) {
174
+ node.variants ??= [];
175
+ const existing = node.variants.find((v) => v.hash === reading.hash);
176
+ if (existing) {
177
+ existing.count += 1;
178
+ existing.lastSeen = Date.now();
179
+ return false;
180
+ }
181
+ if (node.variants.length >= MAX_VARIANTS) return false;
182
+ node.variants.push({ hash: reading.hash, tokens: reading.tokens ?? [], count: 1, lastSeen: Date.now() });
183
+ return true;
184
+ }
185
+
186
+ /** Only actions worth replaying — a launch or a URL open is a flow's start, not a step within it. */
187
+ function replayable(step) {
188
+ if (!step || typeof step !== 'object') return null;
189
+ return step.launch != null || step.openUrl != null ? null : step;
190
+ }
191
+
192
+ /**
193
+ * What to call this screen, for a human typing `goto`.
194
+ *
195
+ * Chrome labels are the only text in a fingerprint, which makes them the only
196
+ * thing available to name it by — and they are the right thing anyway: a screen
197
+ * is called what its nav bar says it is.
198
+ */
199
+ export function describe(node) {
200
+ const labels = (pattern) => (node.tokens ?? [])
201
+ .filter((t) => pattern.test(t) && t.includes('"'))
202
+ .map((t) => t.slice(t.indexOf('"') + 1, t.lastIndexOf('"')))
203
+ .filter(Boolean);
204
+ // The nav title first, because that is what the screen is called. A button
205
+ // that happens to sit in the nav bar is not a name for anything.
206
+ const title = labels(/:nav-bar:@title:/);
207
+ if (title.length) return title.join(' ');
208
+ const tabs = labels(/:tab-bar:/);
209
+ if (tabs.length) return tabs.join(' / ');
210
+ const anyChrome = labels(/:(nav-bar|tab-bar):/);
211
+ if (anyChrome.length) return anyChrome.slice(0, 3).join(' ');
212
+ return node.hash.slice(0, 8);
213
+ }
214
+
215
+ /** Find a known screen by what a human would call it. */
216
+ export function findScreen(udid, query) {
217
+ const wanted = String(query ?? '').trim();
218
+ if (!wanted) return null;
219
+ const scored = allNodes(udid)
220
+ .map((node) => ({ node, name: describe(node) }))
221
+ .map((c) => ({ ...c, score: matching.nameScore(c.name, wanted) }))
222
+ .filter((c) => c.score > 0)
223
+ .sort((a, b) => b.score - a.score);
224
+ const [best, next] = scored;
225
+ if (!best) return null;
226
+ // Two screens that fit the query equally well is a question for the caller,
227
+ // not a coin flip that navigates somewhere wrong.
228
+ if (next && best.score - next.score < 0.08) {
229
+ return { ambiguous: [best, next].map((c) => ({ name: c.name, hash: c.node.hash })) };
230
+ }
231
+ return { node: best.node, name: best.name, score: best.score };
232
+ }
233
+
234
+ /** Remember that doing `action` on `from` led to `to`. */
235
+ export function record(udid, { from, action, to, kind }) {
236
+ const fromKey = typeof from === 'string' ? { hash: from } : from;
237
+ const toHash = typeof to === 'string' ? to : to?.hash;
238
+ if (!fromKey?.hash || !toHash) return null;
239
+ const node = nearestScreen(udid, fromKey)?.node ?? load(udid, fromKey);
240
+ // Never overwrite the canonical fingerprint with the one we happened to
241
+ // arrive as — that is what variants are for, and rewriting it here would let
242
+ // a node drift screen by screen into something it never was.
243
+ if (!node.tokens?.length && fromKey.tokens?.length) node.tokens = fromKey.tokens;
244
+ const to_ = toHash;
245
+ const signature = actionSignature(action);
246
+ const existing = node.edges.find((e) => e.action === signature);
247
+ if (existing) {
248
+ // A different outcome from the same action is worth knowing about: it is
249
+ // how a screen that looks the same but behaves differently shows up.
250
+ if (existing.to !== to_) {
251
+ // A known edge has landed somewhere its target does not recognise. Either
252
+ // the action is genuinely non-deterministic, or this is the same screen
253
+ // wearing a different structure — and the edge is the only evidence that
254
+ // can tell them apart. If some *other* stored screen claims this reading,
255
+ // believe it: that is a real change of destination. If nothing claims it,
256
+ // the screen at the end of this edge has grown a second face.
257
+ const reading = typeof to === 'string' ? { hash: to, tokens: [] } : to;
258
+ const claimant = nearestScreen(udid, reading)?.node;
259
+ const target = load(udid, existing.to);
260
+ const unclaimed = !claimant || claimant.hash === target.hash;
261
+ if (unclaimed && target.hash !== to_ && reading.tokens?.length) {
262
+ addVariant(target, reading);
263
+ save(udid, target);
264
+ existing.count += 1;
265
+ existing.lastSeen = Date.now();
266
+ save(udid, node);
267
+ return node;
268
+ }
269
+ existing.previousTo = existing.to;
270
+ existing.changedOutcomes = (existing.changedOutcomes ?? 0) + 1;
271
+ }
272
+ existing.to = to_;
273
+ existing.step = replayable(action) ?? existing.step;
274
+ existing.kind = kind ?? existing.kind;
275
+ existing.count += 1;
276
+ existing.lastSeen = Date.now();
277
+ } else {
278
+ node.edges.push({
279
+ action: signature,
280
+ // The signature is lossy — it lowercases labels and truncates. Routing
281
+ // has to replay the action exactly, so keep the step that produced it.
282
+ step: replayable(action),
283
+ to: to_,
284
+ kind,
285
+ count: 1,
286
+ lastSeen: Date.now(),
287
+ });
288
+ }
289
+ save(udid, node);
290
+ return node;
291
+ }
292
+
293
+ /** What this action did last time, if we have ever seen it here. */
294
+ export function predict(udid, from, action) {
295
+ const found = nearestScreen(udid, from);
296
+ if (!found) return null;
297
+ const signature = actionSignature(action);
298
+ const edge = found.node.edges.find((e) => e.action === signature);
299
+ return edge ? { ...edge, fromDistance: found.distance } : null;
300
+ }
301
+
302
+ export function stats(udid) {
303
+ const nodes = allNodes(udid);
304
+ return { screens: nodes.length, edges: nodes.reduce((n, s) => n + s.edges.length, 0) };
305
+ }
306
+
307
+ export function forget(udid) {
308
+ try {
309
+ fs.rmSync(graphDir(udid), { recursive: true, force: true });
310
+ } catch {
311
+ /* nothing to forget */
312
+ }
313
+ }
314
+
315
+ /**
316
+ * A route of actions from one screen to another through edges we have taken.
317
+ *
318
+ * Breadth-first over observed edges only. An unknown screen has no path — the
319
+ * graph never guesses, because a guessed route taps real controls.
320
+ */
321
+ export function route(udid, fromHash, toHash, { maxDepth = 8 } = {}) {
322
+ const start = nearestScreen(udid, fromHash);
323
+ if (!start) return null;
324
+ const goal = (h) => h === toHash;
325
+ if (goal(start.node.hash)) return [];
326
+
327
+ const byHash = new Map(allNodes(udid).map((n) => [n.hash, n]));
328
+ const seen = new Set([start.node.hash]);
329
+ const queue = [{ hash: start.node.hash, path: [] }];
330
+ while (queue.length) {
331
+ const { hash, path: taken } = queue.shift();
332
+ if (taken.length >= maxDepth) continue;
333
+ const node = byHash.get(hash);
334
+ for (const edge of node?.edges ?? []) {
335
+ if (seen.has(edge.to)) continue;
336
+ const next = [...taken, edge];
337
+ if (goal(edge.to)) return next;
338
+ seen.add(edge.to);
339
+ queue.push({ hash: edge.to, path: next });
340
+ }
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /** Verdicts a verified step can produce. */
346
+ // `unexpected-transition` was removed: see verdict(). The transition kind is
347
+ // reported inside an `ok` verdict now, because the classifier is not reliable
348
+ // enough for a correct navigation to be called wrong by it.
349
+ export const VERDICTS = ['ok', 'no-visible-change', 'unexpected-screen', 'unverified'];
350
+
351
+ /**
352
+ * Compare what happened against what was expected.
353
+ *
354
+ * With no prediction the outcome is `unverified` rather than `ok`: not knowing
355
+ * what should have happened is not evidence that the right thing did.
356
+ */
357
+ /**
358
+ * Do these two fingerprints mean the same screen?
359
+ *
360
+ * String equality was right when a screen had exactly one fingerprint. Now that
361
+ * a node can answer to several — a list with an alert over it is the same
362
+ * screen — comparing hashes directly reports a wrong turn every time the
363
+ * variant is the one on screen. The variant mechanism fired correctly on a real
364
+ * app and the verdict still said `unexpected-screen`, because the verdict never
365
+ * asked the graph.
366
+ */
367
+ function sameScreen(udid, a, b) {
368
+ if (!a || !b) return false;
369
+ if (a === b) return true;
370
+ if (!udid) return false;
371
+ const nodeA = nearestScreen(udid, a)?.node;
372
+ const nodeB = nearestScreen(udid, b)?.node;
373
+ return Boolean(nodeA && nodeB && nodeA.hash === nodeB.hash);
374
+ }
375
+
376
+ export function verdict({ udid, prediction, before, after, kind }) {
377
+ if (!before || !after) return { verdict: 'unverified', detail: 'no state to compare' };
378
+ const moved = before !== after;
379
+ if (!prediction) {
380
+ if (!moved) return { verdict: 'no-visible-change', detail: 'the screen did not change, and nothing predicted it would' };
381
+ return { verdict: 'unverified', detail: 'this action has not been seen on this screen before' };
382
+ }
383
+ const expectedMove = !sameScreen(udid, prediction.to, before);
384
+ if (!moved && expectedMove) {
385
+ return { verdict: 'no-visible-change', detail: `expected to reach a different screen (seen ${prediction.count}x)` };
386
+ }
387
+ if (!sameScreen(udid, prediction.to, after)) {
388
+ return {
389
+ verdict: 'unexpected-screen',
390
+ detail: `expected the screen this action reached ${prediction.count}x before, and landed somewhere else`,
391
+ };
392
+ }
393
+ // The screen is where it was predicted to be. That is the reliable signal and
394
+ // it is what the verdict rests on.
395
+ //
396
+ // The transition *kind* is not reliable: Phase 4's classifier calls the same
397
+ // tab switch `replace` on one run and `pop` on the next, and measured against
398
+ // a real app it was the only thing producing non-ok verdicts on navigation
399
+ // that had gone exactly where predicted. A verdict that says something is
400
+ // wrong when nothing is wrong trains you to ignore verdicts, so a kind
401
+ // mismatch is reported alongside `ok` rather than overriding it.
402
+ const kindDiffers = Boolean(prediction.kind && kind && prediction.kind !== kind && kind !== 'none');
403
+ return {
404
+ verdict: 'ok',
405
+ detail: `matches the outcome seen ${prediction.count}x before`
406
+ + (kindDiffers ? ` (transition looked like ${kind}, not ${prediction.kind} — the classifier is noisy)` : ''),
407
+ ...(kindDiffers ? { kindDiffers: { predicted: prediction.kind, observed: kind } } : {}),
408
+ };
409
+ }