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.
@@ -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
- (readings[i].name === readings[j].name ? same : different).push({
129
- a: readings[i], b: readings[j], similarity: s,
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
- readings: readings.map(({ tokens, ...r }) => ({ ...r, tokenCount: tokens.length })),
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
- process.exit(separated ? 0 : 1);
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);
@@ -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 iOS app on a simulator — "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.
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 simulator's framebuffer warm, reads the screen
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, setPasteboard, setPermission, terminateApp } from './simctl.js';
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
- /** How long a text field needs after being tapped before it holds the keyboard focus. */
14
- const FOCUS_SETTLE_MS = 150;
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
- // locate, not tapLabel: tapLabel asks the accessibility tree directly,
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 "${found.target.label}" at ${found.target.x},${found.target.y}`;
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 keyboard.
294
- await setPasteboard(udid, step.text ?? step.value);
295
- if (step.into) await input.tapLabel(udid, step.into, { index: step.index, durationMs: 900 });
296
- return 'placed text on the pasteboard';
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 };