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.
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +49 -2
- package/native/simframed/Sources/SimframeCore/Element.swift +19 -2
- package/package.json +1 -1
- package/scripts/analyse-escalations.mjs +89 -0
- package/scripts/analyse-routes.mjs +96 -0
- package/scripts/check-published.mjs +121 -0
- package/scripts/ci-device-guard.mjs +18 -0
- package/scripts/classify-stray.mjs +63 -0
- package/scripts/eval-fingerprint.mjs +135 -10
- package/src/actions.js +256 -14
- package/src/cli.js +39 -2
- package/src/index.js +15 -4
- package/src/input.js +33 -1
- package/src/matching.js +11 -1
- package/src/mcp.js +43 -2
- package/src/storage.js +55 -6
- package/src/view.js +1 -1
|
@@ -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.
|
|
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
|
+
}
|