simframe 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ // Muscle memory.
2
+ //
3
+ // A person does not re-read a screen they have seen before; they remember where
4
+ // things are. Screens repeat constantly while driving an app, and simframe
5
+ // already computes a stable hash per screen, so that hash is the natural key for
6
+ // "what is tappable here". First visit pays for a tree read and an OCR pass;
7
+ // every later visit is a file read.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { hashDistance } from './analyze.js';
11
+ import * as input from './input.js';
12
+ import * as ocr from './ocr.js';
13
+ import * as store from './store.js';
14
+
15
+ const MAP_VERSION = 2; // layout hash crop changed; old maps no longer comparable
16
+
17
+ function mapDir(udid) {
18
+ return path.join(store.deviceDir(udid), 'screens');
19
+ }
20
+
21
+ /**
22
+ * How many of the 288 layout bits may differ and still count as the same screen.
23
+ * Measured on a real app: revisiting a screen (with different list rows and a
24
+ * different clock) moved 0-3 bits; different screens were 77-96 apart. 12 sits
25
+ * well clear of both.
26
+ */
27
+ export const DEFAULT_TOLERANCE = 12;
28
+
29
+ export function recall(udid, hash) {
30
+ if (!hash) return null;
31
+ const entry = store.readJson(path.join(mapDir(udid), `${hash}.json`));
32
+ return entry && entry.version === MAP_VERSION ? entry : null;
33
+ }
34
+
35
+ function loadAll(udid) {
36
+ let files;
37
+ try {
38
+ files = fs.readdirSync(mapDir(udid)).filter((f) => f.endsWith('.json'));
39
+ } catch {
40
+ return [];
41
+ }
42
+ return files
43
+ .map((f) => store.readJson(path.join(mapDir(udid), f)))
44
+ .filter((e) => e && e.version === MAP_VERSION);
45
+ }
46
+
47
+ /**
48
+ * The closest screen we have seen, by layout rather than by content. A list with
49
+ * new rows in it is still the same screen, and should not cost another map build.
50
+ */
51
+ export function recallNearest(udid, layoutHash, { tolerance = DEFAULT_TOLERANCE } = {}) {
52
+ if (!layoutHash) return null;
53
+ let best = null;
54
+ let bestDistance = Infinity;
55
+ for (const entry of loadAll(udid)) {
56
+ const d = hashDistance(entry.layoutHash, layoutHash);
57
+ if (d < bestDistance) {
58
+ bestDistance = d;
59
+ best = entry;
60
+ }
61
+ }
62
+ return best && bestDistance <= tolerance ? { entry: best, distance: bestDistance } : null;
63
+ }
64
+
65
+ export function remember(udid, entry) {
66
+ const dir = mapDir(udid);
67
+ fs.mkdirSync(dir, { recursive: true });
68
+ store.writeAtomic(path.join(dir, `${entry.hash}.json`), JSON.stringify(entry));
69
+ return entry;
70
+ }
71
+
72
+ export function stats(udid) {
73
+ try {
74
+ const files = fs.readdirSync(mapDir(udid)).filter((f) => f.endsWith('.json'));
75
+ return { screens: files.length };
76
+ } catch {
77
+ return { screens: 0 };
78
+ }
79
+ }
80
+
81
+ export function forget(udid) {
82
+ try {
83
+ fs.rmSync(mapDir(udid), { recursive: true, force: true });
84
+ } catch {
85
+ /* nothing to forget */
86
+ }
87
+ }
88
+
89
+ const inside = (point, frame) =>
90
+ point.x >= frame.x &&
91
+ point.x <= frame.x + frame.width &&
92
+ point.y >= frame.y &&
93
+ point.y <= frame.y + frame.height;
94
+
95
+ /**
96
+ * Build the map for the screen currently showing. Accessibility elements are the
97
+ * real hit targets, so they win where they exist; OCR fills in everything the
98
+ * app never published — custom tab bars, unlabelled controls, plain text.
99
+ */
100
+ export async function build(udid, {
101
+ hash,
102
+ layoutHash,
103
+ fullFrame,
104
+ density = 3,
105
+ useAx = true,
106
+ useOcr = true,
107
+ screen,
108
+ } = {}) {
109
+ const targets = [];
110
+ const sources = [];
111
+ // Kick OCR off before reading the tree: they are independent, and the OCR
112
+ // pass is pure computation on a file that already exists.
113
+ const ocrPromise =
114
+ useOcr && fullFrame && fs.existsSync(fullFrame)
115
+ ? ocr.readText(fullFrame, { density }).catch((err) => err)
116
+ : null;
117
+ // With no geometry, treat every element as a potential control rather than
118
+ // guessing a screen size and mis-classifying containers.
119
+ const screenArea = screen?.width && screen?.height ? screen.width * screen.height : Infinity;
120
+ const area = (f) => (f ? Math.max(1, f.width) * Math.max(1, f.height) : Infinity);
121
+ // A root or full-bleed container is never what someone means by a label, and
122
+ // it contains every other element, so it must not swallow their text.
123
+ const isContainer = (n) =>
124
+ /^Application$/i.test(n.type || '') || area(n.frame) > screenArea * 0.7;
125
+
126
+ if (useAx) {
127
+ try {
128
+ const nodes = await input.describeAll(udid);
129
+ sources.push('ax');
130
+ for (const n of nodes) {
131
+ if (!n.frame || !n.label || isContainer(n)) continue;
132
+ targets.push({
133
+ label: n.label,
134
+ x: input.centerOf(n).x,
135
+ y: input.centerOf(n).y,
136
+ frame: n.frame,
137
+ type: n.type,
138
+ enabled: n.enabled,
139
+ source: 'ax',
140
+ });
141
+ }
142
+ } catch {
143
+ /* no idb, or the tree read failed; OCR alone is still useful */
144
+ }
145
+ }
146
+
147
+ if (ocrPromise) {
148
+ try {
149
+ const words = await ocrPromise;
150
+ if (words instanceof Error) throw words;
151
+ sources.push('ocr');
152
+ for (const w of words) {
153
+ if (!w.text.trim()) continue;
154
+ const point = { x: w.centerX, y: w.centerY };
155
+ // If an accessibility element already covers this text, it is the same
156
+ // control: keep the element and record the visible text as an alias.
157
+ // Containing text is not the same as being that control. A tab bar
158
+ // encloses all five tab labels but is not any of them, so only merge
159
+ // when the element is close to the text's own size.
160
+ const textArea = Math.max(1, w.width * w.height);
161
+ const covering = targets
162
+ .filter(
163
+ (t) =>
164
+ t.source === 'ax' &&
165
+ t.frame &&
166
+ inside(point, t.frame) &&
167
+ !/^(Group|Application|ScrollView|Table|Collection)$/i.test(t.type || '') &&
168
+ area(t.frame) <= textArea * 8,
169
+ )
170
+ .sort((a, b) => area(a.frame) - area(b.frame))[0];
171
+ if (covering) {
172
+ covering.aliases = [...(covering.aliases || []), w.text];
173
+ continue;
174
+ }
175
+ targets.push({
176
+ label: w.text,
177
+ x: w.centerX,
178
+ y: w.centerY,
179
+ frame: { x: w.x, y: w.y, width: w.width, height: w.height },
180
+ type: 'Text',
181
+ confidence: w.confidence,
182
+ source: 'ocr',
183
+ });
184
+ }
185
+ } catch (err) {
186
+ if (!sources.length) throw err;
187
+ }
188
+ }
189
+
190
+ return remember(udid, {
191
+ version: MAP_VERSION,
192
+ hash,
193
+ layoutHash,
194
+ at: Date.now(),
195
+ sources,
196
+ targets,
197
+ });
198
+ }
199
+
200
+ const norm = (s) => String(s ?? '').toLowerCase().trim();
201
+
202
+ const INTERACTIVE = /button|field|cell|link|checkbox|switch|slider|tab|menu|segment/i;
203
+
204
+ export function isInteractive(target) {
205
+ return INTERACTIVE.test(target.type || '');
206
+ }
207
+
208
+ /**
209
+ * Rank candidates for a label. Exact beats substring, and a real control beats
210
+ * a caption that happens to read the same — a screen title and a tab are often
211
+ * the same word, and tapping the title silently does nothing.
212
+ */
213
+ export function rank(entry, query) {
214
+ if (!entry) return [];
215
+ const q = norm(query);
216
+ const names = (t) => [t.label, ...(t.aliases || [])].map(norm);
217
+ const exact = entry.targets.filter((t) => names(t).includes(q));
218
+ const pool = exact.length
219
+ ? exact
220
+ : entry.targets.filter((t) => names(t).some((n) => n.includes(q)));
221
+ return pool
222
+ .map((t) => ({ target: t, score: (isInteractive(t) ? 2 : 0) + (t.source === 'ax' ? 1 : 0) }))
223
+ .sort((a, b) => b.score - a.score)
224
+ .map((r) => r.target);
225
+ }
226
+
227
+ export function match(entry, query) {
228
+ return rank(entry, query)[0] ?? null;
229
+ }
package/src/simctl.js CHANGED
@@ -36,7 +36,10 @@ async function fetchDevices() {
36
36
  out.push({
37
37
  udid: d.udid,
38
38
  name: d.name,
39
- runtime: runtime.replace('com.apple.CoreSimulator.SimRuntime.', '').replace(/-/g, '.'),
39
+ runtime: runtime
40
+ .replace('com.apple.CoreSimulator.SimRuntime.', '')
41
+ .replace(/-/, ' ')
42
+ .replace(/-/g, '.'),
40
43
  state: d.state,
41
44
  });
42
45
  }
@@ -102,3 +105,25 @@ export async function screenshot(udid, outFile, { mask = 'ignored' } = {}) {
102
105
  export async function resize(inFile, outFile, maxDim) {
103
106
  await run('sips', ['-Z', String(maxDim), inFile, '--out', outFile], { timeout: 10_000 });
104
107
  }
108
+
109
+ export async function launchApp(udid, bundleId) {
110
+ await run('xcrun', ['simctl', 'launch', udid, bundleId], { timeout: 20_000 });
111
+ }
112
+
113
+ export async function terminateApp(udid, bundleId) {
114
+ await run('xcrun', ['simctl', 'terminate', udid, bundleId], { timeout: 20_000 });
115
+ }
116
+
117
+ export async function openUrl(udid, url) {
118
+ await run('xcrun', ['simctl', 'openurl', udid, url], { timeout: 20_000 });
119
+ }
120
+
121
+ /** Put text on the device pasteboard — far faster than typing a long string. */
122
+ export async function setPasteboard(udid, value) {
123
+ const child = execFile('xcrun', ['simctl', 'pbcopy', udid], { timeout: 10_000 });
124
+ child.stdin.end(value);
125
+ await new Promise((resolve, reject) => {
126
+ child.on('error', reject);
127
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`pbcopy exited ${code}`))));
128
+ });
129
+ }