simframe 0.14.1 → 0.14.3

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.
@@ -16,11 +16,20 @@ public struct AXNode: Sendable {
16
16
  public var selected: Bool?
17
17
  public var focused: Bool?
18
18
  public var frame: CGRect
19
+ /// Where the control actuates, when the app says so.
20
+ ///
21
+ /// Not the same as the centre of `frame`, and a switch is the proof: UIKit
22
+ /// publishes a row-wide frame for it, so the geometric centre is the label
23
+ /// and iOS does not actuate a switch from there. Measured — tapping the
24
+ /// centre flipped it 0 of 3 times and the control itself 3 of 3. UIKit has
25
+ /// `accessibilityActivationPoint` for exactly this question, and nil means
26
+ /// the app did not answer it, never a guess of ours.
27
+ public var activationPoint: CGPoint?
19
28
  public var depth: Int
20
29
 
21
30
  public init(role: String, subrole: String? = nil, label: String? = nil, value: String? = nil,
22
31
  identifier: String? = nil, enabled: Bool? = nil, selected: Bool? = nil,
23
- focused: Bool? = nil, frame: CGRect, depth: Int) {
32
+ focused: Bool? = nil, frame: CGRect, activationPoint: CGPoint? = nil, depth: Int) {
24
33
  self.role = role
25
34
  self.subrole = subrole
26
35
  self.label = label
@@ -30,6 +39,7 @@ public struct AXNode: Sendable {
30
39
  self.selected = selected
31
40
  self.focused = focused
32
41
  self.frame = frame
42
+ self.activationPoint = activationPoint
33
43
  self.depth = depth
34
44
  }
35
45
  }
@@ -251,7 +261,8 @@ public final class AccessibilityBridge {
251
261
 
252
262
  /// Everything scalar about a node, asked for in one go.
253
263
  private static let batched = ["AXRole", "AXSubrole", "AXDescription", "AXValue",
254
- "AXIdentifier", "AXEnabled", "AXSelected", "AXFocused"]
264
+ "AXIdentifier", "AXEnabled", "AXSelected", "AXFocused",
265
+ "AXActivationPoint"]
255
266
 
256
267
  private func node(from element: NSObject, depth: Int) -> AXNode {
257
268
  // One bridge round trip for eight attributes, not eight.
@@ -283,6 +294,7 @@ public final class AccessibilityBridge {
283
294
  selected: (value("AXSelected") as? NSNumber)?.boolValue,
284
295
  focused: (value("AXFocused") as? NSNumber)?.boolValue,
285
296
  frame: frame(of: element),
297
+ activationPoint: point(value("AXActivationPoint")) ?? activationPoint(of: element),
286
298
  depth: depth)
287
299
  }
288
300
 
@@ -310,6 +322,41 @@ public final class AccessibilityBridge {
310
322
  return unsafeBitCast(imp, to: RectFn.self)(element, sel)
311
323
  }
312
324
 
325
+ /// The activation point as the attribute bag reports it.
326
+ ///
327
+ /// Defensive about the shape because this is a private bridge and the
328
+ /// answer arrives as whatever the translator chose: an NSValue wrapping a
329
+ /// CGPoint on one path, a stringified `{x, y}` on another. Anything it does
330
+ /// not recognise is nil, which costs nothing — the caller falls back to the
331
+ /// frame centre, which is what it did before this attribute was asked for.
332
+ private func point(_ value: Any?) -> CGPoint? {
333
+ switch value {
334
+ case let v as NSValue:
335
+ // `pointValue`, not `cgPointValue`: this daemon is a macOS binary
336
+ // driving a guest, and the iOS-only accessor does not exist here.
337
+ // NSPoint and CGPoint are the same type on 64-bit.
338
+ let p = v.pointValue
339
+ return p.x.isFinite && p.y.isFinite ? p : nil
340
+ case let s as String:
341
+ let parts = s.trimmingCharacters(in: CharacterSet(charactersIn: "{} "))
342
+ .split(separator: ",")
343
+ .compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
344
+ guard parts.count == 2 else { return nil }
345
+ return CGPoint(x: parts[0], y: parts[1])
346
+ default:
347
+ return nil
348
+ }
349
+ }
350
+
351
+ /// The direct selector, for elements that answer it but do not batch it.
352
+ private func activationPoint(of element: NSObject) -> CGPoint? {
353
+ let sel = NSSelectorFromString("accessibilityActivationPoint")
354
+ guard element.responds(to: sel), let imp = element.method(for: sel) else { return nil }
355
+ typealias PointFn = @convention(c) (AnyObject, Selector) -> CGPoint
356
+ let p = unsafeBitCast(imp, to: PointFn.self)(element, sel)
357
+ return p.x.isFinite && p.y.isFinite ? p : nil
358
+ }
359
+
313
360
  /// A value may be a string, a number, or something with no useful text.
314
361
  private func string(_ value: Any?) -> String? {
315
362
  switch value {
@@ -60,10 +60,20 @@ public struct Element: Sendable {
60
60
  public var state: ElementState
61
61
  public var source: ElementSource
62
62
  public var confidence: Double
63
+ /// Where the control actuates, when the app publishes it.
64
+ ///
65
+ /// Deliberately NOT folded into `center`. `center` is the element's place,
66
+ /// and two readings of one control are matched by how close their places
67
+ /// are — 12pt apart, per `SAME_CONTROL_POINTS` above the boundary. A switch
68
+ /// actuates at the far end of a row-wide frame, so making `center` mean
69
+ /// "where to tap" would move the tree's reading ~145pt away from OCR's and
70
+ /// stop the two collapsing into one control. Aiming and identity are
71
+ /// different questions; this is the answer to the first one only.
72
+ public var activationPoint: CGPoint?
63
73
 
64
74
  public init(id: Int, frame: CGRect, role: String, label: String? = nil, value: String? = nil,
65
75
  identifier: String? = nil, state: ElementState = ElementState(),
66
- source: ElementSource, confidence: Double = 1) {
76
+ source: ElementSource, confidence: Double = 1, activationPoint: CGPoint? = nil) {
67
77
  self.id = id
68
78
  self.frame = frame
69
79
  self.role = role
@@ -73,6 +83,7 @@ public struct Element: Sendable {
73
83
  self.state = state
74
84
  self.source = source
75
85
  self.confidence = confidence
86
+ self.activationPoint = activationPoint
76
87
  }
77
88
 
78
89
  public var center: CGPoint { CGPoint(x: frame.midX, y: frame.midY) }
@@ -89,6 +100,11 @@ public struct Element: Sendable {
89
100
  if let label { out["label"] = label }
90
101
  if let value { out["value"] = value }
91
102
  if let identifier { out["identifier"] = identifier }
103
+ // Only when the app answered. Absent means "no opinion", and the layer
104
+ // above then aims at the centre exactly as it always has.
105
+ if let activationPoint {
106
+ out["activationPoint"] = ["x": Int(activationPoint.x.rounded()), "y": Int(activationPoint.y.rounded())]
107
+ }
92
108
  let state = state.json
93
109
  if !state.isEmpty { out["state"] = state }
94
110
  return out
@@ -112,7 +128,8 @@ public extension Element {
112
128
  state: ElementState(enabled: node.enabled, selected: node.selected,
113
129
  checked: nil, focused: node.focused),
114
130
  source: .accessibility,
115
- confidence: 1)
131
+ confidence: 1,
132
+ activationPoint: node.activationPoint)
116
133
  }
117
134
  }
118
135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simframe",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "mcpName": "io.github.lvlrSajjad/simframe",
5
5
  "description": "Always-warm iOS Simulator and Android emulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
6
6
  "keywords": [
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Of the `verification_failed` escalations, how many happened on a
4
+ * `(screen, action)` the graph had already seen behave consistently?
5
+ * EXPERIMENTS §17. The answer was 3% and 0%, which is why this is kept: it is
6
+ * the measurement that stopped a phase being spent on remembering edge
7
+ * outcomes.
8
+ *
9
+ * Two thirds of those escalations are `no-visible-change`, and 83-100% of them
10
+ * are on a screen or an action the graph has never seen — memory cannot answer
11
+ * a question about a place it has never been.
12
+ *
13
+ * The graph is read as it is NOW, so an edge matched here may have been learned
14
+ * after the escalation. That makes the "addressable" figure an upper bound, and
15
+ * it is already near zero.
16
+ *
17
+ * Aggregate by design, and stricter than it looks: `detail` is app content, so
18
+ * verdicts are classified against a CLOSED vocabulary and the raw string is
19
+ * never printed. The first version of this printed the prefix and put a
20
+ * client's screen labels and a customer email into a terminal.
21
+ *
22
+ * Usage: node scripts/analyse-escalations.mjs <udid-prefix> [<udid-prefix>...] */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import os from 'node:os';
26
+
27
+ const root = process.env.SIMFRAME_HOME || path.join(os.homedir(), '.simframe');
28
+
29
+ for (const dev of process.argv.slice(2)) {
30
+ const dir = fs.readdirSync(root).find((d) => d.startsWith(dev));
31
+ if (!dir) continue;
32
+ const base = path.join(root, dir);
33
+ const gdir = path.join(base, 'graph');
34
+ if (!fs.existsSync(gdir)) continue;
35
+
36
+ const nodes = fs.readdirSync(gdir).filter((f) => f.endsWith('.json'))
37
+ .map((f) => { try { return JSON.parse(fs.readFileSync(path.join(gdir, f), 'utf8')); } catch { return null; } })
38
+ .filter(Boolean);
39
+ const byHash = new Map();
40
+ for (const n of nodes) { byHash.set(n.hash, n); for (const v of n.variants ?? []) if (!byHash.has(v.hash)) byHash.set(v.hash, n); }
41
+
42
+ const esc = [];
43
+ const p = path.join(base, 'escalations.jsonl');
44
+ if (!fs.existsSync(p)) continue;
45
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
46
+ const t = line.trim(); if (!t) continue;
47
+ try { esc.push(JSON.parse(t)); } catch {}
48
+ }
49
+ const vf = esc.filter((e) => e.reason === 'verification_failed');
50
+
51
+ const bucket = { noScreen: 0, noEdge: 0, seenOnce: 0, consistent: 0, nondet: 0 };
52
+ const verdicts = new Map();
53
+ for (const e of vf) {
54
+ // Classify against a CLOSED vocabulary and never emit the raw text. The
55
+ // first version split on ':' and printed the prefix, which put a client's
56
+ // screen labels and an email address into a terminal. `detail` is app
57
+ // content; only its shape is ours to report.
58
+ const d = String(e.detail ?? '').toLowerCase();
59
+ const v = d.includes('no-visible-change') || d.includes('did not change') ? 'no-visible-change'
60
+ : d.includes('unexpected-screen') ? 'unexpected-screen'
61
+ : d.includes('did not settle') ? 'settle-timeout'
62
+ : d.includes('waited') ? 'wait-timeout'
63
+ : d.includes('matches') && d.includes('things') ? 'ambiguous'
64
+ : d.includes('numbered on a different') ? 'stale-ref'
65
+ : d.includes('disabled') ? 'disabled-control'
66
+ : d ? 'other' : '(none)';
67
+ verdicts.set(v, (verdicts.get(v) ?? 0) + 1);
68
+ const node = byHash.get(e.screen_fingerprint ?? '');
69
+ if (!node) { bucket.noScreen++; continue; }
70
+ const want = String(e.intent ?? '').toLowerCase();
71
+ const hit = (node.edges ?? []).find((x) => {
72
+ const sig = String(x.action ?? '').toLowerCase();
73
+ return want && (sig.endsWith(`:${want}`) || sig.includes(want));
74
+ });
75
+ if (!hit) { bucket.noEdge++; continue; }
76
+ if ((hit.changedOutcomes ?? 0) > 0) bucket.nondet++;
77
+ else if ((hit.count ?? 0) > 1) bucket.consistent++;
78
+ else bucket.seenOnce++;
79
+ }
80
+ const pct = (n) => (vf.length ? Math.round((100 * n) / vf.length) : 0);
81
+ console.log(`--- ${dev} ---`);
82
+ console.log(` verification_failed: ${vf.length}`);
83
+ console.log(` screen not in graph at all: ${bucket.noScreen} (${pct(bucket.noScreen)}%)`);
84
+ console.log(` screen known, this action never seen: ${bucket.noEdge} (${pct(bucket.noEdge)}%)`);
85
+ console.log(` edge seen exactly once: ${bucket.seenOnce} (${pct(bucket.seenOnce)}%)`);
86
+ console.log(` edge NONDETERMINISTIC (rightly asked): ${bucket.nondet} (${pct(bucket.nondet)}%)`);
87
+ console.log(` edge repeated and CONSISTENT: ${bucket.consistent} (${pct(bucket.consistent)}%) <- addressable`);
88
+ console.log(` verdict words: ${[...verdicts].sort((a,b)=>b[1]-a[1]).slice(0,6).map(([k,n])=>`${k}=${n}`).join(', ')}`);
89
+ }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Would routing by measured time differ from routing by hop count, and by how
4
+ * much? EXPERIMENTS §16, kept runnable because the answer was "barely" and that
5
+ * is the kind of result that gets re-litigated by someone's intuition.
6
+ *
7
+ * `route()` in graph.js is a plain BFS over hop count, while `record()` has
8
+ * been storing a settle sample on nearly every edge all along (97% and 85% of
9
+ * edges on the two real-app graphs measured). This compares the two.
10
+ *
11
+ * Measured: the fastest route differs from the fewest-hops route on 9%, 0% and
12
+ * 7% of reachable pairs, saving a median of 1853ms, — and 615ms. Real, unused,
13
+ * and pointed at the wrong prize — it saves seconds, not round trips, and round
14
+ * trips are what the human-parity series exists to reduce.
15
+ *
16
+ * Output is aggregate by design: this reads a real device's memory of real
17
+ * third-party apps, so it prints counts and never a label, a screen or an app
18
+ * name. Same standing rule as scripts/phase17-corpus.mjs.
19
+ *
20
+ * Usage: node scripts/analyse-routes.mjs <udid-prefix> [<udid-prefix>...] */
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import os from 'node:os';
24
+
25
+ const root = process.env.SIMFRAME_HOME || path.join(os.homedir(), '.simframe');
26
+ const median = (a) => { const s=[...a].sort((x,y)=>x-y); return s.length? (s.length%2? s[(s.length-1)/2] : (s[s.length/2-1]+s[s.length/2])/2) : null; };
27
+
28
+ for (const dev of process.argv.slice(2)) {
29
+ const dir = fs.readdirSync(root).find((d) => d.startsWith(dev));
30
+ if (!dir) continue;
31
+ const gdir = path.join(root, dir, 'graph');
32
+ if (!fs.existsSync(gdir)) continue;
33
+ const nodes = fs.readdirSync(gdir).filter(f=>f.endsWith('.json'))
34
+ .map(f => { try { return JSON.parse(fs.readFileSync(path.join(gdir,f),'utf8')); } catch { return null; } })
35
+ .filter(Boolean);
36
+
37
+ const byHash = new Map();
38
+ for (const n of nodes) { byHash.set(n.hash, n); for (const v of n.variants ?? []) if(!byHash.has(v.hash)) byHash.set(v.hash, n); }
39
+ const canonical = (h) => byHash.get(h)?.hash ?? h;
40
+
41
+ // Edge costs: the median settle actually measured on that edge.
42
+ const all = [];
43
+ for (const n of nodes) for (const e of n.edges ?? []) { const m = median(e.settles ?? []); if (m!=null) all.push(m); }
44
+ const fallback = median(all) ?? 500;
45
+ const cost = (e) => median(e.settles ?? []) ?? fallback;
46
+
47
+ // BFS exactly as graph.js does it: first path found, insertion order.
48
+ const bfs = (start, goal) => {
49
+ const seen = new Set([start]); const q = [{h:start, p:[]}];
50
+ while (q.length) { const {h,p} = q.shift();
51
+ if (p.length >= 12) continue;
52
+ for (const e of byHash.get(h)?.edges ?? []) {
53
+ const to = canonical(e.to); const next=[...p,e];
54
+ if (to === goal) return next;
55
+ if (seen.has(to)) continue; seen.add(to); q.push({h:to,p:next});
56
+ } }
57
+ return null;
58
+ };
59
+ // Dijkstra on measured time.
60
+ const fastest = (start, goal) => {
61
+ const dist = new Map([[start,0]]); const prev = new Map(); const done = new Set();
62
+ while (true) {
63
+ let cur=null, best=Infinity;
64
+ for (const [h,d] of dist) if (!done.has(h) && d<best) { best=d; cur=h; }
65
+ if (cur==null) break;
66
+ if (cur===goal) break;
67
+ done.add(cur);
68
+ for (const e of byHash.get(cur)?.edges ?? []) {
69
+ const to = canonical(e.to); const nd = best + cost(e);
70
+ if (nd < (dist.get(to) ?? Infinity)) { dist.set(to,nd); prev.set(to,[cur,e]); }
71
+ } }
72
+ if (!dist.has(goal)) return null;
73
+ const out=[]; let at=goal;
74
+ while (prev.has(at)) { const [from,e]=prev.get(at); out.unshift(e); at=from; }
75
+ return out;
76
+ };
77
+
78
+ const canon = [...new Set(nodes.map(n=>n.hash))];
79
+ let pairs=0, differ=0, saved=[], hopsUp=0;
80
+ for (const s of canon) for (const g of canon) {
81
+ if (s===g) continue;
82
+ const b = bfs(s,g); if (!b) continue;
83
+ const f = fastest(s,g); if (!f) continue;
84
+ pairs++;
85
+ const cb = b.reduce((t,e)=>t+cost(e),0), cf = f.reduce((t,e)=>t+cost(e),0);
86
+ if (cf < cb - 1) { differ++; saved.push(cb-cf); if (f.length > b.length) hopsUp++; }
87
+ }
88
+ saved.sort((a,b)=>a-b);
89
+ console.log(`--- ${dev} ---`);
90
+ console.log(` reachable pairs: ${pairs}`);
91
+ console.log(` where the fastest route differs from the fewest-hops route: ${differ} (${pairs?Math.round(100*differ/pairs):0}%)`);
92
+ if (saved.length) {
93
+ console.log(` time saved per such route — median ${Math.round(median(saved))}ms, max ${Math.round(saved[saved.length-1])}ms`);
94
+ console.log(` ...of those, routes that take MORE hops to be faster: ${hopsUp}`);
95
+ }
96
+ }
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Does the version on npm contain the code you think it does?
4
+ *
5
+ * **DEFERRED 149.** `0.14.1` was tagged at one commit and a change landed
6
+ * immediately after it, so `npm install simframe@0.14.1` shipped the previous
7
+ * `centerOf()`. An external tester was asked to evaluate that change, diffed
8
+ * the package against the checkout themselves, and wrote: *"I came within one
9
+ * command of filing this report against the wrong binary."*
10
+ *
11
+ * Nothing in the pipeline compared what was tagged against what was being asked
12
+ * for, and the release workflow cannot: it checks that the tag, `package.json`
13
+ * and `server.json` agree, which they did. The gap is between the tag and
14
+ * whatever arrived afterwards.
15
+ *
16
+ * So this is the check to run **before handing someone a version to test**, and
17
+ * before cutting the next one. It fetches the published tarball and compares
18
+ * every shipped source file against the working tree.
19
+ *
20
+ * It deliberately compares against the WORKING TREE rather than a tag: the
21
+ * question being asked is "is what I am about to ask someone to install the code
22
+ * I am looking at", and a tag cannot answer that.
23
+ *
24
+ * Usage:
25
+ * node scripts/check-published.mjs # the version in package.json
26
+ * node scripts/check-published.mjs 0.14.1 # any published version
27
+ * node scripts/check-published.mjs --latest # whatever npm serves as latest
28
+ *
29
+ * Exit 0 when every shipped file matches, 1 when any differs, 2 when the
30
+ * version is not published or npm could not be reached — which is a different
31
+ * answer and must not read as "it matches".
32
+ */
33
+ import { execFileSync } from 'node:child_process';
34
+ import { createHash } from 'node:crypto';
35
+ import fs from 'node:fs';
36
+ import os from 'node:os';
37
+ import path from 'node:path';
38
+
39
+ const md5 = (buf) => createHash('md5').update(buf).digest('hex');
40
+ const here = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
41
+
42
+ const arg = process.argv.slice(2).find((a) => !a.startsWith('-'));
43
+ const wantLatest = process.argv.includes('--latest');
44
+ const local = JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8'));
45
+
46
+ let version = arg ?? local.version;
47
+ if (wantLatest) {
48
+ try {
49
+ version = execFileSync('npm', ['view', local.name, 'version'], { encoding: 'utf8' }).trim();
50
+ } catch (err) {
51
+ console.error(`could not ask npm for the latest ${local.name}: ${err.message}`);
52
+ process.exit(2);
53
+ }
54
+ }
55
+
56
+ console.log(`comparing ${local.name}@${version} on npm against this working tree`);
57
+
58
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'simframe-published-'));
59
+ let tarball;
60
+ try {
61
+ tarball = execFileSync('npm', ['pack', `${local.name}@${version}`, '--silent', '--pack-destination', tmp], {
62
+ encoding: 'utf8',
63
+ }).trim().split('\n').pop();
64
+ } catch (err) {
65
+ // Not published, unpublished, or no network. None of those is a match.
66
+ console.error(`could not fetch ${local.name}@${version} from npm.`);
67
+ console.error('That is not the same answer as "it differs" — nothing was compared.');
68
+ console.error(String(err.stderr || err.message).trim().split('\n').slice(-3).join('\n'));
69
+ process.exit(2);
70
+ }
71
+
72
+ execFileSync('tar', ['-xzf', path.join(tmp, tarball), '-C', tmp]);
73
+ const root = path.join(tmp, 'package');
74
+
75
+ /** Every file the package ships, relative to the package root. */
76
+ const shipped = [];
77
+ const walk = (dir) => {
78
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
79
+ const full = path.join(dir, e.name);
80
+ if (e.isDirectory()) walk(full);
81
+ else shipped.push(path.relative(root, full));
82
+ }
83
+ };
84
+ walk(root);
85
+
86
+ // Only compare what this repo is the source of. `package.json` is rewritten by
87
+ // npm on publish (it adds `_id`, `dist`, and normalises fields), so byte
88
+ // equality there is not the question and would be a permanent false alarm.
89
+ const comparable = shipped.filter((f) => /^(src|scripts|native)\//.test(f) && !f.includes('/.build/'));
90
+
91
+ const same = [];
92
+ const differ = [];
93
+ const missing = [];
94
+ for (const rel of comparable) {
95
+ const mine = path.join(here, rel);
96
+ if (!fs.existsSync(mine)) { missing.push(rel); continue; }
97
+ if (md5(fs.readFileSync(mine)) === md5(fs.readFileSync(path.join(root, rel)))) same.push(rel);
98
+ else differ.push(rel);
99
+ }
100
+
101
+ const publishedVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
102
+ console.log(` published version: ${publishedVersion}`);
103
+ console.log(` files compared: ${comparable.length}`);
104
+ console.log(` identical: ${same.length}`);
105
+
106
+ if (missing.length) {
107
+ console.log(` shipped but absent here: ${missing.length}`);
108
+ for (const f of missing) console.log(` ${f}`);
109
+ }
110
+
111
+ if (!differ.length && !missing.length) {
112
+ console.log(`\nok — ${local.name}@${version} is the code in this tree`);
113
+ process.exit(0);
114
+ }
115
+
116
+ console.error(`\nFAIL ${differ.length} shipped file(s) differ from this tree:`);
117
+ for (const f of differ) console.error(` ${f}`);
118
+ console.error('\nIf you are about to ask someone to test a change, they would be testing');
119
+ console.error('the published bytes above, not what you are reading. Publish first, or point');
120
+ console.error('them at the checkout and say so.');
121
+ process.exit(1);
@@ -31,6 +31,24 @@ const DEVICE_STATE = [
31
31
  [/Timeout waiting for screen surfaces|display surface is not answering|display surface could not be read/i, 'the display surface is wedged'],
32
32
  [/no frames buffered|capture is wedged/i, 'capture stopped'],
33
33
  [/the second app never launched|could not be dispatched/i, 'an app would not launch'],
34
+ // A launched app that never comes to the front, seen as the tour waiting for
35
+ // one of its landmarks on a screen that is showing a clock and nothing else.
36
+ //
37
+ // Measured on a runner: `ok launch — launched com.apple.Preferences
38
+ // (relaunched)` followed by `waited 8000ms for General: "General" is not on
39
+ // this screen. Visible: 10:50, .?o (the screen has not moved for 6181ms)`.
40
+ // Two labels, one of them a clock, on a still screen — the device is not
41
+ // presenting the app, and the guard called that a check failing on its
42
+ // merits and declined to revive.
43
+ //
44
+ // Deliberately narrow. It requires the wait to have failed AND the screen to
45
+ // have been still AND almost nothing readable: a tour that genuinely asks for
46
+ // the wrong label has a screen full of other labels, and must keep failing
47
+ // rather than being retried into a pass.
48
+ [
49
+ /never arrived[\s\S]*?Visible:[^\n]{0,24}\(the screen has not moved for \d+ms/i,
50
+ 'a launched app never came to the front (the screen shows a clock and nothing else)',
51
+ ],
34
52
  ];
35
53
 
36
54
  const udid = process.argv[2];
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Why does a fingerprint reading not resemble its own screen?
3
+ *
4
+ * Its own module, with no side effects, for one reason: this logic has failed
5
+ * at RUNTIME twice — once reaching for a variable local to another function,
6
+ * once on declaration order — while being correct both times. `eval-fingerprint.mjs`
7
+ * runs the whole eval on import, so nothing could test it there, and the only
8
+ * thing that ever exercised it was a hosted runner at the end of a
9
+ * fifteen-minute job, in the middle of a report. That is the most expensive
10
+ * place in this project to find a typo.
11
+ *
12
+ * Three causes, and they have different remedies — which is the whole reason to
13
+ * tell them apart rather than print one sentence about all three:
14
+ *
15
+ * - **collided** — neither reading carries a chrome label, so both are
16
+ * structure with no name and the fingerprint has nothing left to separate two
17
+ * list screens. That is this harness's own subject, and a real finding.
18
+ * - **wrongScreen** — the tokens are identical to another screen AND this
19
+ * screen reads differently in its other rounds, so it is demonstrably
20
+ * distinguishable and the tour was simply somewhere else. A tap that missed.
21
+ * - **underRead** — the reading stayed under the token floor and the screen
22
+ * cannot tell itself apart in any round, so we never looked long enough. Ours
23
+ * to fix, and nothing about the tour or the fingerprint.
24
+ *
25
+ * The middle case is the correction that prompted this. Sparseness alone used
26
+ * to claim `underRead`, and a wrong turn onto a screen that *legitimately*
27
+ * reads sparse — the Settings root, at 4 tokens on a runner — is flagged sparse
28
+ * too. So a genuine tour failure was reported as our instrument's fault, and
29
+ * the harness's original and correct message had been silenced by an
30
+ * "improvement".
31
+ */
32
+ import * as fingerprint from '../src/fingerprint.js';
33
+
34
+ /**
35
+ * @param {object} o
36
+ * @param {object} o.reading the stray
37
+ * @param {object|null} o.match the other screen's reading it most resembles
38
+ * @param {number} o.bestOther how much it resembles that one, 0..1
39
+ * @param {object[]} o.siblings every reading of the stray's own screen
40
+ * @param {boolean} o.wasSparse did it stay under the token floor after retries
41
+ * @param {string[]} [o.named] chrome-labelled tokens in the stray
42
+ * @param {string[]} [o.matchNamed] chrome-labelled tokens in the match
43
+ */
44
+ export function classifyStray({
45
+ reading, match, bestOther, siblings, wasSparse, named = [], matchNamed = [],
46
+ }) {
47
+ // Does any other round of this same screen read differently from the screen
48
+ // we collided with? If so this screen CAN be told apart, and a round that
49
+ // matched the other one exactly was somewhere else.
50
+ const distinguishable = (siblings ?? [])
51
+ .some((o) => o !== reading && fingerprint.similarity(o.tokens, match?.tokens ?? []) < 0.99);
52
+ const identical = bestOther >= 0.99;
53
+ // Checked first and exclusively: a reading with no names at all cannot be
54
+ // said to have gone anywhere, because there is nothing in it that would have
55
+ // named a destination.
56
+ const collided = identical && named.length === 0 && matchNamed.length === 0;
57
+ return {
58
+ collided,
59
+ wrongScreen: !collided && identical && distinguishable,
60
+ underRead: !collided && Boolean(wasSparse) && !distinguishable,
61
+ distinguishable,
62
+ };
63
+ }