simframe 0.6.2 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -8
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +34 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +20 -0
- package/native/simframed/Sources/simframed/main.swift +30 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +39 -0
- package/package.json +4 -2
- package/scripts/analyse-fingerprint.mjs +149 -0
- package/scripts/bench-flow.mjs +1 -1
- package/scripts/check-package.mjs +15 -0
- package/scripts/ci-memory.mjs +12 -0
- package/scripts/eval-ax-tier.mjs +191 -0
- package/scripts/eval-fingerprint.mjs +161 -6
- package/skills/simframe/SKILL.md +10 -3
- package/src/actions.js +64 -14
- package/src/cli.js +125 -32
- package/src/daemon.js +42 -1
- package/src/engine.js +19 -6
- package/src/fingerprint.js +29 -1
- package/src/index.js +92 -13
- package/src/input.js +93 -0
- package/src/mcp.js +6 -6
- package/src/navigate.js +8 -1
- package/src/platform/android.js +997 -0
- package/src/platform/host.js +15 -0
- package/src/platform/index.js +263 -0
- package/src/{simctl.js → platform/ios.js} +111 -18
- package/src/store.js +32 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// What is the accessibility tier actually worth?
|
|
3
|
+
//
|
|
4
|
+
// Phase 2a moved the tree host-side and made it fast; the tree is also absent
|
|
5
|
+
// on Android and will stay absent until an APK is worth shipping
|
|
6
|
+
// (docs/DEFERRED.md, Phase 8b). Both of those decisions rest on the same
|
|
7
|
+
// unmeasured quantity: how much of what simframe knows about a screen comes
|
|
8
|
+
// from the tree rather than from OCR and CV.
|
|
9
|
+
//
|
|
10
|
+
// So this walks a tour and, on every screen, builds the map twice — once with
|
|
11
|
+
// the tree and once without — from the same frame. Neither map is persisted, so
|
|
12
|
+
// measuring does not teach the graph anything.
|
|
13
|
+
//
|
|
14
|
+
// Three questions, because they have different answers:
|
|
15
|
+
//
|
|
16
|
+
// provenance of the elements on a screen, and of the *interactive* ones,
|
|
17
|
+
// how many only the tree can see
|
|
18
|
+
// recognition would a revisit be recognised as the same screen, per tier —
|
|
19
|
+
// the graph's own rule, `similarity >= SIMILARITY_THRESHOLD`
|
|
20
|
+
// intents given a name a user would type, does the right element come
|
|
21
|
+
// back, per tier
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import * as actions from '../src/actions.js';
|
|
24
|
+
import * as api from '../src/index.js';
|
|
25
|
+
import * as fingerprint from '../src/fingerprint.js';
|
|
26
|
+
import * as graph from '../src/graph.js';
|
|
27
|
+
import * as matching from '../src/matching.js';
|
|
28
|
+
import * as screenmap from '../src/screenmap.js';
|
|
29
|
+
|
|
30
|
+
const arg = (name, fallback) => {
|
|
31
|
+
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
|
|
32
|
+
return hit ? hit.slice(name.length + 3) : fallback;
|
|
33
|
+
};
|
|
34
|
+
const tourFile = arg('tour');
|
|
35
|
+
const rounds = Number(arg('rounds', 2));
|
|
36
|
+
const device = arg('device');
|
|
37
|
+
const outFile = arg('out');
|
|
38
|
+
const listLabels = process.argv.includes('--labels');
|
|
39
|
+
|
|
40
|
+
if (!tourFile) {
|
|
41
|
+
console.error('usage: node scripts/eval-ax-tier.mjs --tour=<tour.json> [--rounds=2] [--device=<udid>] [--labels] [--out=<file>]');
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
const tour = JSON.parse(fs.readFileSync(tourFile, 'utf8'));
|
|
45
|
+
|
|
46
|
+
const { device: dev } = await api.ensureDaemon(device);
|
|
47
|
+
console.log(`device: ${dev.name} (${dev.runtime})`);
|
|
48
|
+
console.log(`tour: ${tour.length} screens x ${rounds} rounds, each screen read twice — with the tree and without\n`);
|
|
49
|
+
|
|
50
|
+
/** Roles a user can act on. Text is not one: it is what a screen says, not what it offers. */
|
|
51
|
+
const INTERACTIVE = new Set(['button', 'field', 'switch', 'cell', 'link']);
|
|
52
|
+
const isInteractive = (t) => INTERACTIVE.has(fingerprint.roleOf(t));
|
|
53
|
+
const sourcesOf = (t) => String(t.source ?? '').split(/[+,]/).filter(Boolean);
|
|
54
|
+
|
|
55
|
+
const readings = [];
|
|
56
|
+
for (let round = 1; round <= rounds; round += 1) {
|
|
57
|
+
for (const screen of tour) {
|
|
58
|
+
if (screen.steps?.length) await actions.runScript(device, { steps: screen.steps, verify: false });
|
|
59
|
+
// One frame, two maps. `api.screenIdentity` is not used here because it
|
|
60
|
+
// decides for itself which layers to read; this has to pin them.
|
|
61
|
+
const withTree = await api.readScreenWith(device, { useAx: true });
|
|
62
|
+
const withoutTree = await api.readScreenWith(device, { useAx: false });
|
|
63
|
+
readings.push({ name: screen.name, round, withTree, withoutTree, intents: screen.intents ?? [] });
|
|
64
|
+
const t = withTree.entry.targets ?? [];
|
|
65
|
+
const o = withoutTree.entry.targets ?? [];
|
|
66
|
+
console.log(
|
|
67
|
+
` round ${round} ${screen.name.padEnd(22)} tree: ${String(t.length).padStart(3)} elements ` +
|
|
68
|
+
`(${t.filter(isInteractive).length} interactive) no tree: ${String(o.length).padStart(3)} ` +
|
|
69
|
+
`(${o.filter(isInteractive).length} interactive)`,
|
|
70
|
+
);
|
|
71
|
+
if (listLabels) {
|
|
72
|
+
for (const e of t) {
|
|
73
|
+
console.log(` [${String(e.source ?? '?').padEnd(6)}] ${fingerprint.roleOf(e).padEnd(7)} ${JSON.stringify(String(e.label ?? '').slice(0, 40))}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- provenance -------------------------------------------------------------
|
|
80
|
+
// Agreement between the two sensors is not recorded in `source`. When OCR text
|
|
81
|
+
// falls inside an accessibility element the map keeps the element and files the
|
|
82
|
+
// text as an *alias* on it, so `source` stays 'ax' — which means counting
|
|
83
|
+
// sources alone reports that the two sensors never see the same thing, and they
|
|
84
|
+
// do. An alias is the evidence that they agreed.
|
|
85
|
+
let all = 0;
|
|
86
|
+
let axAndOcr = 0;
|
|
87
|
+
let axAlone = 0;
|
|
88
|
+
let ocrOnly = 0;
|
|
89
|
+
let inter = 0;
|
|
90
|
+
let interAxOnly = 0;
|
|
91
|
+
let interAxInvisible = 0;
|
|
92
|
+
for (const r of readings) {
|
|
93
|
+
for (const t of r.withTree.entry.targets ?? []) {
|
|
94
|
+
const src = sourcesOf(t);
|
|
95
|
+
all += 1;
|
|
96
|
+
const hasAx = src.includes('ax');
|
|
97
|
+
const alsoSeen = (t.aliases ?? []).length > 0;
|
|
98
|
+
if (hasAx && alsoSeen) axAndOcr += 1;
|
|
99
|
+
else if (hasAx) axAlone += 1;
|
|
100
|
+
else ocrOnly += 1;
|
|
101
|
+
if (isInteractive(t)) {
|
|
102
|
+
inter += 1;
|
|
103
|
+
if (hasAx) interAxOnly += 1;
|
|
104
|
+
// The interesting class: a control the tree knows about that OCR cannot
|
|
105
|
+
// see at all, because it has no text — an icon. This is what a platform
|
|
106
|
+
// without a tree simply cannot offer.
|
|
107
|
+
if (hasAx && !alsoSeen) interAxInvisible += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const both = axAndOcr;
|
|
112
|
+
const axOnly = axAlone;
|
|
113
|
+
const pct = (n, of) => (of ? `${((n / of) * 100).toFixed(0)}%` : '—');
|
|
114
|
+
|
|
115
|
+
console.log('\n--- provenance, over every element on every reading ---');
|
|
116
|
+
console.log(` elements ${all}`);
|
|
117
|
+
console.log(` from the tree, OCR saw nothing ${axOnly} ${pct(axOnly, all)}`);
|
|
118
|
+
console.log(` from OCR/CV alone ${ocrOnly} ${pct(ocrOnly, all)}`);
|
|
119
|
+
console.log(` from the tree, OCR agreed ${both} ${pct(both, all)} (text found inside the element)`);
|
|
120
|
+
console.log(` interactive elements ${inter}`);
|
|
121
|
+
console.log(` interactive, from the tree ${interAxOnly} ${pct(interAxOnly, inter)}`);
|
|
122
|
+
console.log(` interactive with no text at all ${interAxInvisible} ${pct(interAxInvisible, inter)} (icons — OCR cannot see these)`);
|
|
123
|
+
|
|
124
|
+
// --- recognition ------------------------------------------------------------
|
|
125
|
+
function recognition(pick) {
|
|
126
|
+
const byName = new Map();
|
|
127
|
+
for (const r of readings) byName.set(r.name, [...(byName.get(r.name) ?? []), pick(r)]);
|
|
128
|
+
let pairs = 0;
|
|
129
|
+
let recognised = 0;
|
|
130
|
+
const worst = [];
|
|
131
|
+
for (const [name, list] of byName) {
|
|
132
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
133
|
+
for (let j = i + 1; j < list.length; j += 1) {
|
|
134
|
+
const s = fingerprint.similarity(list[i].entry.structuralTokens ?? [], list[j].entry.structuralTokens ?? []);
|
|
135
|
+
pairs += 1;
|
|
136
|
+
if (s >= graph.SIMILARITY_THRESHOLD) recognised += 1;
|
|
137
|
+
worst.push({ name, s });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
worst.sort((a, b) => a.s - b.s);
|
|
142
|
+
return { pairs, recognised, worst: worst[0] };
|
|
143
|
+
}
|
|
144
|
+
const treeRec = recognition((r) => r.withTree);
|
|
145
|
+
const bareRec = recognition((r) => r.withoutTree);
|
|
146
|
+
|
|
147
|
+
// --- intents ----------------------------------------------------------------
|
|
148
|
+
function intents(pick) {
|
|
149
|
+
let asked = 0;
|
|
150
|
+
let right = 0;
|
|
151
|
+
const misses = [];
|
|
152
|
+
for (const r of readings) {
|
|
153
|
+
const entry = pick(r);
|
|
154
|
+
for (const want of r.intents) {
|
|
155
|
+
asked += 1;
|
|
156
|
+
const hit = matching.resolve(entry.entry.targets ?? [], want, { screen: entry.points });
|
|
157
|
+
// `ambiguous` is not a resolution: a caller gets an error and has to say
|
|
158
|
+
// which, so counting it as correct would flatter the tier that produced it.
|
|
159
|
+
const label = hit.status === 'ok' ? String(hit.target.label ?? '') : '';
|
|
160
|
+
if (label.toLowerCase().includes(String(want).toLowerCase())) right += 1;
|
|
161
|
+
else misses.push(`${r.name} r${r.round}: "${want}" → ${label ? JSON.stringify(label.slice(0, 30)) : hit.status}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { asked, right, misses };
|
|
165
|
+
}
|
|
166
|
+
const treeInt = intents((r) => r.withTree);
|
|
167
|
+
const bareInt = intents((r) => r.withoutTree);
|
|
168
|
+
|
|
169
|
+
console.log('\n--- with the tree, and without ---');
|
|
170
|
+
console.log(`${''.padEnd(30)} ${'tree + OCR'.padStart(12)} ${'OCR/CV only'.padStart(12)}`);
|
|
171
|
+
console.log(`${'elements per reading'.padEnd(30)} ${(all / readings.length).toFixed(1).padStart(12)} ${((readings.reduce((n, r) => n + (r.withoutTree.entry.targets ?? []).length, 0)) / readings.length).toFixed(1).padStart(12)}`);
|
|
172
|
+
console.log(`${'screens recognised on revisit'.padEnd(30)} ${`${treeRec.recognised}/${treeRec.pairs}`.padStart(12)} ${`${bareRec.recognised}/${bareRec.pairs}`.padStart(12)}`);
|
|
173
|
+
console.log(`${'weakest revisit similarity'.padEnd(30)} ${treeRec.worst?.s.toFixed(2).padStart(12)} ${bareRec.worst?.s.toFixed(2).padStart(12)}`);
|
|
174
|
+
console.log(`${'intents resolved correctly'.padEnd(30)} ${`${treeInt.right}/${treeInt.asked}`.padStart(12)} ${`${bareInt.right}/${bareInt.asked}`.padStart(12)}`);
|
|
175
|
+
|
|
176
|
+
for (const [label, res] of [['tree + OCR', treeInt], ['OCR/CV only', bareInt]]) {
|
|
177
|
+
if (res.misses.length) {
|
|
178
|
+
console.log(`\nintents ${label} got wrong:`);
|
|
179
|
+
for (const m of res.misses.slice(0, 12)) console.log(` ${m}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (outFile) {
|
|
184
|
+
fs.writeFileSync(outFile, JSON.stringify({
|
|
185
|
+
device: dev.name, runtime: dev.runtime, rounds, at: Date.now(),
|
|
186
|
+
provenance: { all, axOnly, ocrOnly, both, interactive: inter, interactiveAxOnly: interAxOnly, interactiveIconOnly: interAxInvisible },
|
|
187
|
+
recognition: { tree: treeRec, bare: bareRec },
|
|
188
|
+
intents: { tree: treeInt, bare: bareInt },
|
|
189
|
+
}, null, 2));
|
|
190
|
+
console.log(`\nwrote ${outFile}`);
|
|
191
|
+
}
|
|
@@ -30,6 +30,24 @@ const arg = (name, fallback) => {
|
|
|
30
30
|
|
|
31
31
|
const tourFile = arg('tour');
|
|
32
32
|
const rounds = Number(arg('rounds', 3));
|
|
33
|
+
/**
|
|
34
|
+
* How much room the threshold must have on each side.
|
|
35
|
+
*
|
|
36
|
+
* `gap > 0` was the only bar until the margin narrowed, and a gap can be wide
|
|
37
|
+
* while the threshold sits at the edge of it — which is the state that actually
|
|
38
|
+
* misclassifies a screen. So the bar is stated as clearance around the
|
|
39
|
+
* threshold itself: every same-screen revisit must score at least
|
|
40
|
+
* `threshold + CLEARANCE`, and every different-screen pair at most
|
|
41
|
+
* `threshold - CLEARANCE`.
|
|
42
|
+
*
|
|
43
|
+
* 0.10 is chosen against measurement, not taste. Clean runs on this machine
|
|
44
|
+
* put same-min at 0.67-0.75 and different-max at 0.05, so the clearance in
|
|
45
|
+
* hand is roughly 0.3 either way; requiring 0.10 fails well before a
|
|
46
|
+
* misclassification and does not fire on ordinary variation. Raise it when the
|
|
47
|
+
* recorded distributions say it can be raised.
|
|
48
|
+
*/
|
|
49
|
+
const CLEARANCE = 0.1;
|
|
50
|
+
|
|
33
51
|
/**
|
|
34
52
|
* Above this, two consecutive tour screens are the same screen and the
|
|
35
53
|
* navigation between them failed. Deliberately well above the identity
|
|
@@ -103,6 +121,20 @@ for (let round = 1; round <= rounds; round += 1) {
|
|
|
103
121
|
tokens: id.tokens ?? [],
|
|
104
122
|
count: (id.tokens ?? []).length,
|
|
105
123
|
settled: id.settled,
|
|
124
|
+
// The elements the tokens were computed from, and the screen they were
|
|
125
|
+
// measured in. Kept so a candidate change to the token rules can be
|
|
126
|
+
// simulated against recorded readings by re-running the real tokeniser,
|
|
127
|
+
// instead of by transforming its output and hoping that is equivalent.
|
|
128
|
+
targets: id.entry?.targets ?? [],
|
|
129
|
+
screen: id.points,
|
|
130
|
+
// Which sensors answered. Two readings of one screen taken with
|
|
131
|
+
// different sensors are *known* not to agree — the tree and OCR share
|
|
132
|
+
// 0.33-0.47 of a screen's structural tokens (docs/DEFERRED.md) — and
|
|
133
|
+
// absorbing that is the graph's job, through aliasing, not the
|
|
134
|
+
// fingerprint's. So the distributions below are split by sensor mix
|
|
135
|
+
// rather than averaged over it, which is what made a mixed pair look
|
|
136
|
+
// like fingerprint drift.
|
|
137
|
+
sources: id.entry?.sources ?? [],
|
|
106
138
|
});
|
|
107
139
|
process.stdout.write(
|
|
108
140
|
` round ${round} ${screen.name.padEnd(14)} ${String(id.hash).slice(0, 10)} ${String((id.tokens ?? []).length).padStart(3)} tokens${id.settled ? '' : ' (never settled)'}\n`,
|
|
@@ -120,14 +152,68 @@ if (arrivalFailures.length) {
|
|
|
120
152
|
process.exit(1);
|
|
121
153
|
}
|
|
122
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A reading taken somewhere other than where the tour meant to be.
|
|
157
|
+
*
|
|
158
|
+
* The arrival check above compares each reading with the one before it, which
|
|
159
|
+
* catches "the navigation did not happen" and misses "the navigation went
|
|
160
|
+
* somewhere else". It missed exactly that: a reading labelled
|
|
161
|
+
* settings-accessibility was in fact the Settings root list, and being unlike
|
|
162
|
+
* its own screen at one end and like a different screen at the other, it alone
|
|
163
|
+
* moved the same-screen minimum to 0.00 and the different-screen maximum to
|
|
164
|
+
* 0.40 across 30 pairs. Both distributions were then measuring the tour.
|
|
165
|
+
*
|
|
166
|
+
* So each reading is also checked against its own siblings: a reading that
|
|
167
|
+
* resembles no other reading of its own screen, while resembling some other
|
|
168
|
+
* screen at least as much, was not where it says it was. That needs at least
|
|
169
|
+
* two siblings to be an outlier test rather than a coin toss, so it only
|
|
170
|
+
* applies from three rounds up.
|
|
171
|
+
*/
|
|
172
|
+
function findStrays(all) {
|
|
173
|
+
const strays = [];
|
|
174
|
+
const byName = new Map();
|
|
175
|
+
for (const r of all) byName.set(r.name, [...(byName.get(r.name) ?? []), r]);
|
|
176
|
+
for (const r of all) {
|
|
177
|
+
const siblings = byName.get(r.name).filter((o) => o !== r);
|
|
178
|
+
if (siblings.length < 2) continue;
|
|
179
|
+
const bestSelf = Math.max(...siblings.map((o) => fingerprint.similarity(r.tokens, o.tokens)));
|
|
180
|
+
const others = all.filter((o) => o.name !== r.name);
|
|
181
|
+
const bestOther = others.length
|
|
182
|
+
? Math.max(...others.map((o) => fingerprint.similarity(r.tokens, o.tokens)))
|
|
183
|
+
: 0;
|
|
184
|
+
if (bestSelf < graph.SIMILARITY_THRESHOLD && bestOther >= bestSelf) {
|
|
185
|
+
strays.push({ reading: r, bestSelf, bestOther });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return strays;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const strays = findStrays(readings);
|
|
192
|
+
if (strays.length) {
|
|
193
|
+
console.error(`\nFAIL ${strays.length} reading(s) were taken on a screen other than the one named:`);
|
|
194
|
+
for (const { reading, bestSelf, bestOther } of strays) {
|
|
195
|
+
console.error(` ${reading.name} r${reading.round}: resembles its own screen ${bestSelf.toFixed(2)}, `
|
|
196
|
+
+ `another screen ${bestOther.toFixed(2)} (${reading.count} tokens, sources ${reading.sources.join('+') || 'none'})`);
|
|
197
|
+
}
|
|
198
|
+
console.error('\nThat is the tour going somewhere unintended, not the fingerprint drifting, and');
|
|
199
|
+
console.error('measuring it as either distribution poisons both ends. Fix the tour — a tap that');
|
|
200
|
+
console.error('missed, or a screen that needs longer than its pause — and re-run.');
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Which sensors produced a reading, as a comparable key. */
|
|
205
|
+
const mixOf = (r) => (r.sources ?? []).join('+') || 'unknown';
|
|
206
|
+
|
|
123
207
|
const same = [];
|
|
208
|
+
const mixed = [];
|
|
124
209
|
const different = [];
|
|
125
210
|
for (let i = 0; i < readings.length; i += 1) {
|
|
126
211
|
for (let j = i + 1; j < readings.length; j += 1) {
|
|
127
212
|
const s = fingerprint.similarity(readings[i].tokens, readings[j].tokens);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
213
|
+
const pair = { a: readings[i], b: readings[j], similarity: s };
|
|
214
|
+
if (readings[i].name !== readings[j].name) different.push(pair);
|
|
215
|
+
else if (mixOf(readings[i]) === mixOf(readings[j])) same.push(pair);
|
|
216
|
+
else mixed.push(pair);
|
|
131
217
|
}
|
|
132
218
|
}
|
|
133
219
|
|
|
@@ -143,6 +229,7 @@ const stats = (rows) => {
|
|
|
143
229
|
};
|
|
144
230
|
|
|
145
231
|
const s = stats(same);
|
|
232
|
+
const m = stats(mixed);
|
|
146
233
|
const d = stats(different);
|
|
147
234
|
const gap = s && d ? s.min - d.max : null;
|
|
148
235
|
const threshold = graph.SIMILARITY_THRESHOLD;
|
|
@@ -150,7 +237,13 @@ const threshold = graph.SIMILARITY_THRESHOLD;
|
|
|
150
237
|
const f = (x) => (x == null ? '—' : x.toFixed(2));
|
|
151
238
|
console.log(`\n${'distribution'.padEnd(26)} ${'n'.padStart(4)} ${'min'.padStart(6)} ${'median'.padStart(7)} ${'max'.padStart(6)}`);
|
|
152
239
|
console.log(`${'same screen, revisited'.padEnd(26)} ${String(s?.n ?? 0).padStart(4)} ${f(s?.min).padStart(6)} ${f(s?.median).padStart(7)} ${f(s?.max).padStart(6)}`);
|
|
240
|
+
console.log(`${'same screen, mixed sensors'.padEnd(26)} ${String(m?.n ?? 0).padStart(4)} ${f(m?.min).padStart(6)} ${f(m?.median).padStart(7)} ${f(m?.max).padStart(6)}`);
|
|
153
241
|
console.log(`${'different screens'.padEnd(26)} ${String(d?.n ?? 0).padStart(4)} ${f(d?.min).padStart(6)} ${f(d?.median).padStart(7)} ${f(d?.max).padStart(6)}`);
|
|
242
|
+
if (m) {
|
|
243
|
+
console.log('\nthe mixed-sensor row is not a fingerprint failure: the tree and OCR see a screen');
|
|
244
|
+
console.log('differently by design, and the graph absorbs it by aliasing. It is here so that');
|
|
245
|
+
console.log('it cannot be mistaken for drift, which is what happened when the rows were one.');
|
|
246
|
+
}
|
|
154
247
|
console.log(`\ngap (same-min − different-max): ${f(gap)}`);
|
|
155
248
|
console.log(`threshold in use: ${threshold}`);
|
|
156
249
|
|
|
@@ -169,6 +262,51 @@ if (worstDifferent) {
|
|
|
169
262
|
console.log(`closest different-screen pair: ${worstDifferent.a.name} vs ${worstDifferent.b.name} = ${f(worstDifferent.similarity)}`);
|
|
170
263
|
}
|
|
171
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Why a pair is as far apart as it is, token by token.
|
|
267
|
+
*
|
|
268
|
+
* A distribution is not actionable and neither is a similarity: 0.42 says the
|
|
269
|
+
* margin narrowed and nothing about what moved. The token grammar is
|
|
270
|
+
* `role:region[:@slot]:w:h["label"]:x:y#count`, so a diff of two token sets
|
|
271
|
+
* names the cause directly — a label that changed is a label token, a role that
|
|
272
|
+
* flipped is the same geometry under two roles, a bucket that straddled is the
|
|
273
|
+
* same key with `#1` against `#many`, and a shifted anchor is the same key at a
|
|
274
|
+
* different `x`/`y`.
|
|
275
|
+
*/
|
|
276
|
+
function explainPair(pair) {
|
|
277
|
+
const a = new Set(pair.a.tokens);
|
|
278
|
+
const b = new Set(pair.b.tokens);
|
|
279
|
+
const onlyA = [...a].filter((t) => !b.has(t)).sort();
|
|
280
|
+
const onlyB = [...b].filter((t) => !a.has(t)).sort();
|
|
281
|
+
const shared = [...a].filter((t) => b.has(t)).length;
|
|
282
|
+
console.log(`
|
|
283
|
+
shared ${shared}, only in r${pair.a.round} ${onlyA.length}, only in r${pair.b.round} ${onlyB.length}`);
|
|
284
|
+
// The same structural key under two different tails is a drift; a key present
|
|
285
|
+
// on one side only is an element that came or went. Telling those apart is
|
|
286
|
+
// the whole diagnosis, so they are printed apart.
|
|
287
|
+
const keyOf = (t) => t.replace(/:x-?\d+:y-?\d+#(1|many)$/, '');
|
|
288
|
+
const tailOf = (t) => t.slice(keyOf(t).length);
|
|
289
|
+
const keysA = new Map(onlyA.map((t) => [keyOf(t), tailOf(t)]));
|
|
290
|
+
const keysB = new Map(onlyB.map((t) => [keyOf(t), tailOf(t)]));
|
|
291
|
+
const drifted = [...keysA.keys()].filter((k) => keysB.has(k));
|
|
292
|
+
if (drifted.length) {
|
|
293
|
+
console.log(' same structure, moved or re-counted:');
|
|
294
|
+
for (const k of drifted) console.log(` ${k} r${pair.a.round}${keysA.get(k)} r${pair.b.round}${keysB.get(k)}`);
|
|
295
|
+
}
|
|
296
|
+
const goneA = onlyA.filter((t) => !keysB.has(keyOf(t)));
|
|
297
|
+
const goneB = onlyB.filter((t) => !keysA.has(keyOf(t)));
|
|
298
|
+
if (goneA.length) {
|
|
299
|
+
console.log(` only in r${pair.a.round}:`);
|
|
300
|
+
for (const t of goneA) console.log(` ${t}`);
|
|
301
|
+
}
|
|
302
|
+
if (goneB.length) {
|
|
303
|
+
console.log(` only in r${pair.b.round}:`);
|
|
304
|
+
for (const t of goneB) console.log(` ${t}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (worstSame) explainPair(worstSame);
|
|
309
|
+
|
|
172
310
|
// Chrome labels are the only text in a fingerprint, so which of them got in is
|
|
173
311
|
// the thing a band change actually moves.
|
|
174
312
|
const labels = new Set();
|
|
@@ -182,11 +320,28 @@ console.log(`\n${labels.size} distinct chrome label(s) entered identity: ${[...l
|
|
|
182
320
|
if (outFile) {
|
|
183
321
|
fs.writeFileSync(outFile, JSON.stringify({
|
|
184
322
|
label, device: dev.name, runtime: dev.runtime, rounds, at: Date.now(),
|
|
185
|
-
threshold, same: s, different: d, gap, separated, thresholdInGap,
|
|
323
|
+
threshold, same: s, mixed: m, different: d, gap, separated, thresholdInGap,
|
|
186
324
|
labels: [...labels].sort(),
|
|
187
|
-
|
|
325
|
+
// Tokens are kept. They were stripped here, and the first time the margin
|
|
326
|
+
// narrowed the run could not be diagnosed from its own output.
|
|
327
|
+
readings,
|
|
188
328
|
}, null, 2));
|
|
189
329
|
console.log(`\nwrote ${outFile}`);
|
|
190
330
|
}
|
|
191
331
|
|
|
192
|
-
|
|
332
|
+
// The stated margin, checked rather than eyeballed. A person noticing that a
|
|
333
|
+
// number moved is not a test; this is the machine that re-measures it.
|
|
334
|
+
const floor = threshold + CLEARANCE;
|
|
335
|
+
const ceiling = threshold - CLEARANCE;
|
|
336
|
+
const sameOk = s != null && s.min >= floor;
|
|
337
|
+
const differentOk = d != null && d.max <= ceiling;
|
|
338
|
+
console.log(`${sameOk ? 'ok ' : 'FAIL'} every same-screen revisit scores at least ${floor.toFixed(2)} (worst ${f(s?.min)})`);
|
|
339
|
+
console.log(`${differentOk ? 'ok ' : 'FAIL'} every different-screen pair scores at most ${ceiling.toFixed(2)} (worst ${f(d?.max)})`);
|
|
340
|
+
if (!sameOk || !differentOk) {
|
|
341
|
+
console.error('\nThe threshold no longer has the clearance this bar states. Diagnose before');
|
|
342
|
+
console.error('moving it: `node scripts/analyse-fingerprint.mjs <the --out file>` classifies every');
|
|
343
|
+
console.error('divergent token by cause, and the causes have different fixes. A threshold moved to');
|
|
344
|
+
console.error('make a run pass is a threshold that means nothing.');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
process.exit(separated && sameOk && differentOk ? 0 : 1);
|
package/skills/simframe/SKILL.md
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: simframe
|
|
3
|
-
description: Drive and inspect the iOS Simulator with eyes, hands and memory. Use for any task that involves running, testing, navigating or verifying an
|
|
3
|
+
description: Drive and inspect the iOS Simulator or an Android emulator with eyes, hands and memory. Use for any task that involves running, testing, navigating or verifying an app on a simulator or emulator — "does this screen look right", "tap through the signup flow", "why is this button not working", "is the list loading". Reads screens as text rather than screenshots, batches whole flows into one command, and verifies each step against what it did last time.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# simframe
|
|
7
7
|
|
|
8
|
-
A background daemon keeps the
|
|
8
|
+
A background daemon keeps the device's framebuffer warm, reads the screen
|
|
9
9
|
through the accessibility tree and on-device OCR, and remembers which action
|
|
10
10
|
leads from which screen to which. So the three things that make simulator work
|
|
11
11
|
expensive — waiting for screenshots, spending tokens on images, and re-deriving
|
|
12
12
|
the same screen every time — are already paid for.
|
|
13
13
|
|
|
14
|
+
Both an iOS simulator and an Android emulator are driven the same way, by the
|
|
15
|
+
same commands, and `simframe devices` lists both. The one difference worth
|
|
16
|
+
knowing: **Android has no accessibility tree**, so its screens are read by OCR
|
|
17
|
+
and CV alone. Tapping by label works; screen recognition is thinner, so prefer
|
|
18
|
+
naming a device explicitly and re-reading the screen after a step you are unsure
|
|
19
|
+
about.
|
|
20
|
+
|
|
14
21
|
## Read the screen as text, not as an image
|
|
15
22
|
|
|
16
23
|
```bash
|
|
@@ -161,7 +168,7 @@ daemon.
|
|
|
161
168
|
|
|
162
169
|
```bash
|
|
163
170
|
simframe start [device] # capture starts on first use anyway
|
|
164
|
-
simframe devices # booted simulators
|
|
171
|
+
simframe devices # booted simulators and emulators
|
|
165
172
|
simframe recall # what happened in the last ~60s, as text
|
|
166
173
|
simframe strip # recent frames tiled into one image, for an animation
|
|
167
174
|
simframe find "the save button" # resolve an intent without acting on it
|
package/src/actions.js
CHANGED
|
@@ -6,12 +6,33 @@ import * as api from './index.js';
|
|
|
6
6
|
import * as graph from './graph.js';
|
|
7
7
|
import * as input from './input.js';
|
|
8
8
|
import * as intent from './intent.js';
|
|
9
|
-
import { launchApp, openUrl,
|
|
9
|
+
import { launchApp, openUrl, setPermission, terminateApp } from './platform/index.js';
|
|
10
10
|
|
|
11
11
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
12
12
|
const MAX_PAUSE_MS = 5000;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A tapped field is typed into once the screen has settled, not after a fixed
|
|
15
|
+
* wait.
|
|
16
|
+
*
|
|
17
|
+
* It was 150 ms, which is enough for a keyboard to rise over the screen you are
|
|
18
|
+
* already on and nowhere near enough for a tap that opens a whole activity.
|
|
19
|
+
* Measured on Android: tapping Settings' search box starts a separate search
|
|
20
|
+
* screen, the text went before its field had focus, and the step reported
|
|
21
|
+
* success while nothing had been typed — the worst shape a failure can take.
|
|
22
|
+
*
|
|
23
|
+
* `settle` needs a change before it will report stillness, so a tap that
|
|
24
|
+
* visibly does nothing (a field that already had focus) cannot satisfy it and
|
|
25
|
+
* falls out at `reaction` instead. That bounds the cost of the honest case
|
|
26
|
+
* rather than the broken one.
|
|
27
|
+
*/
|
|
28
|
+
const FOCUS_STABLE_MS = 250;
|
|
29
|
+
/**
|
|
30
|
+
* Long enough for a slow capture loop to produce a frame or two. The screenshot
|
|
31
|
+
* engine idles at 1.5 fps — 667 ms between frames — so anything under that is a
|
|
32
|
+
* verdict reached before there was anything to look at.
|
|
33
|
+
*/
|
|
34
|
+
const FOCUS_REACTION_MS = 900;
|
|
35
|
+
const FOCUS_TIMEOUT_MS = 3000;
|
|
15
36
|
const POLL_MS = 250;
|
|
16
37
|
/** A list that has not produced the target in this many screens does not contain it. */
|
|
17
38
|
const MAX_SCROLLS = 20;
|
|
@@ -251,6 +272,33 @@ export async function runScript(
|
|
|
251
272
|
};
|
|
252
273
|
}
|
|
253
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Tap a field and wait for it to take focus, for the steps that then put text
|
|
277
|
+
* in it.
|
|
278
|
+
*
|
|
279
|
+
* `locate`, not `tapLabel`: tapLabel asks the accessibility tree directly, so a
|
|
280
|
+
* field only OCR can see was untypeable and a selector (`#4`, `@x,y`) meant
|
|
281
|
+
* nothing here. The returned `quiet` says when the field never visibly took
|
|
282
|
+
* focus — usually fine, since a field that already had focus does not move, but
|
|
283
|
+
* also exactly what a tap that missed looks like, so the caller should be told.
|
|
284
|
+
*/
|
|
285
|
+
async function focusField(deviceQuery, udid, step, ctx) {
|
|
286
|
+
const found = await api.locate(deviceQuery, step.into, { index: step.index, refresh: step.refresh });
|
|
287
|
+
await input.tapPoint(udid, found.target.x, found.target.y);
|
|
288
|
+
const focused = await api.waitFor(deviceQuery, {
|
|
289
|
+
mode: 'settle',
|
|
290
|
+
stableMs: FOCUS_STABLE_MS,
|
|
291
|
+
reactionMs: FOCUS_REACTION_MS,
|
|
292
|
+
timeoutMs: FOCUS_TIMEOUT_MS,
|
|
293
|
+
options: ctx.options,
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
found,
|
|
297
|
+
where: `"${found.target.label}" at ${found.target.x},${found.target.y}`,
|
|
298
|
+
quiet: focused.satisfied ? '' : ' [the field did not visibly take focus]',
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
254
302
|
async function runStep(deviceQuery, udid, step, ctx) {
|
|
255
303
|
switch (step.action) {
|
|
256
304
|
case 'tap': {
|
|
@@ -277,23 +325,25 @@ async function runStep(deviceQuery, udid, step, ctx) {
|
|
|
277
325
|
}
|
|
278
326
|
case 'type': {
|
|
279
327
|
if (step.into) {
|
|
280
|
-
|
|
281
|
-
// so a field that only OCR can see was untypeable, and a selector
|
|
282
|
-
// (`#4`, `@x,y`) meant nothing here.
|
|
283
|
-
const found = await api.locate(deviceQuery, step.into, { index: step.index, refresh: step.refresh });
|
|
284
|
-
await input.tapPoint(udid, found.target.x, found.target.y);
|
|
285
|
-
await sleep(FOCUS_SETTLE_MS);
|
|
328
|
+
const field = await focusField(deviceQuery, udid, step, ctx);
|
|
286
329
|
await input.typeText(udid, step.text ?? step.value);
|
|
287
|
-
return `typed into
|
|
330
|
+
return `typed into ${field.where}${field.quiet}`;
|
|
288
331
|
}
|
|
289
332
|
await input.typeText(udid, step.text ?? step.value);
|
|
290
333
|
return 'typed text';
|
|
291
334
|
}
|
|
292
335
|
case 'paste': {
|
|
293
|
-
// Long strings are much faster on the pasteboard than through the
|
|
294
|
-
|
|
295
|
-
if
|
|
296
|
-
|
|
336
|
+
// Long strings are much faster on the pasteboard than through the
|
|
337
|
+
// keyboard. `pasteText` delivers the keystroke as well as setting the
|
|
338
|
+
// pasteboard, and throws if it cannot — this step used to do neither and
|
|
339
|
+
// report success anyway.
|
|
340
|
+
if (step.into) {
|
|
341
|
+
const field = await focusField(deviceQuery, udid, step, ctx);
|
|
342
|
+
await input.pasteText(udid, step.text ?? step.value);
|
|
343
|
+
return `pasted into ${field.where}${field.quiet}`;
|
|
344
|
+
}
|
|
345
|
+
await input.pasteText(udid, step.text ?? step.value);
|
|
346
|
+
return 'pasted into the focused field';
|
|
297
347
|
}
|
|
298
348
|
case 'swipe': {
|
|
299
349
|
const from = { x: step.from?.[0] ?? step.from?.x, y: step.from?.[1] ?? step.from?.y };
|