simframe 0.7.2 → 0.9.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.
- package/README.md +21 -4
- package/flows/hpi-suite.json +68 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +49 -1
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +7 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +11 -0
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +48 -0
- package/native/simframed/Sources/simframed/main.swift +128 -73
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +40 -0
- package/package.json +2 -1
- package/scripts/bench-hpi.mjs +254 -0
- package/scripts/check-package.mjs +7 -0
- package/scripts/ci-memory.mjs +15 -2
- package/src/actions.js +184 -4
- package/src/baseline.js +333 -0
- package/src/cli.js +336 -2
- package/src/daemon.js +9 -0
- package/src/fingerprint.js +7 -1
- package/src/graph.js +162 -1
- package/src/index.js +118 -20
- package/src/input.js +111 -1
- package/src/intent.js +11 -2
- package/src/matching.js +81 -2
- package/src/mcp.js +14 -1
- package/src/metrics.js +499 -0
- package/src/navigate.js +44 -7
- package/src/platform/android.js +27 -0
- package/src/platform/index.js +3 -0
- package/src/platform/ios.js +63 -0
- package/src/screenmap.js +36 -14
- package/src/view.js +4 -3
package/src/platform/ios.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// and reachable only through the `platform` object at the bottom — the
|
|
6
6
|
// JavaScript counterpart of the `SimulatorPlatform` protocol in Swift.
|
|
7
7
|
import { execFile, execFileSync } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
8
11
|
import { promisify } from 'node:util';
|
|
9
12
|
|
|
10
13
|
const run = promisify(execFile);
|
|
@@ -66,6 +69,29 @@ async function resolveDevice(query, opts) {
|
|
|
66
69
|
const booted = all.filter((d) => d.state === 'Booted');
|
|
67
70
|
if (!query) {
|
|
68
71
|
if (booted.length === 0) throw new Error('no booted simulator (open Simulator.app or run `xcrun simctl boot <udid>`)');
|
|
72
|
+
// `booted[0]` was the wrong-device bug, and it was worse than it looked.
|
|
73
|
+
// simctl's order is not "yours" by any definition, so on a machine with
|
|
74
|
+
// more than one booted simulator a bare `simframe ui` read whichever came
|
|
75
|
+
// first — and a bare `simframe tap` would have *injected input* into it. A
|
|
76
|
+
// reviewer reproduced it deterministically against a colleague's simulator.
|
|
77
|
+
//
|
|
78
|
+
// `doctor` got a guard for its own fan-out and this default did not, which
|
|
79
|
+
// is how the same command set could pick two different devices in one
|
|
80
|
+
// moment. Refusing is the only safe answer here: the seam cannot see which
|
|
81
|
+
// device simframe is already driving (that is store state, above the
|
|
82
|
+
// boundary), and a backend must never guess when the cost of guessing wrong
|
|
83
|
+
// is a tap on somebody else's screen. `ambiguous` so that a second platform
|
|
84
|
+
// matching cleanly cannot override this — see resolveAcross.
|
|
85
|
+
if (booted.length > 1) {
|
|
86
|
+
throw Object.assign(
|
|
87
|
+
new Error(
|
|
88
|
+
`${booted.length} simulators are booted and none was named: ` +
|
|
89
|
+
`${booted.map((d) => `${d.name} (${d.udid})`).join(', ')} — name one with --device, ` +
|
|
90
|
+
'or set SIMFRAME_DEVICE to pick a default for this shell',
|
|
91
|
+
),
|
|
92
|
+
{ ambiguous: true },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
69
95
|
return booted[0];
|
|
70
96
|
}
|
|
71
97
|
const q = query.toLowerCase();
|
|
@@ -249,6 +275,42 @@ function geometry() {
|
|
|
249
275
|
* *is* the platform's — the emulator console — which is why this is a question
|
|
250
276
|
* a backend gets asked at all.
|
|
251
277
|
*/
|
|
278
|
+
/**
|
|
279
|
+
* When this device last booted, in epoch ms, or null if it cannot be told.
|
|
280
|
+
*
|
|
281
|
+
* Why it matters: the HID session lives in the daemon, and a device restart
|
|
282
|
+
* kills it while leaving the daemon perfectly healthy. Every tap after that is
|
|
283
|
+
* dispatched successfully and moves nothing — measured, five runs in a row,
|
|
284
|
+
* on the correct coordinates for the correct element. Only hardware buttons
|
|
285
|
+
* recover on their own, deliberately, because retrying a tap can act twice.
|
|
286
|
+
*
|
|
287
|
+
* The signal is a stat, not a `simctl` call: CoreSimulator writes
|
|
288
|
+
* `data/var/run/syslog.pid` when the device's syslogd starts, and touches
|
|
289
|
+
* `device.plist` on every state change. Both read 21:21:26 on a device booted
|
|
290
|
+
* at 21:21:26. A stat costs microseconds, which matters because this is
|
|
291
|
+
* checked before input.
|
|
292
|
+
*
|
|
293
|
+
* A false positive costs one session rebuild and no action, so the ordering
|
|
294
|
+
* prefers the most boot-specific marker and falls back rather than guessing.
|
|
295
|
+
*/
|
|
296
|
+
function bootedAt(udid) {
|
|
297
|
+
const dir = path.join(
|
|
298
|
+
os.homedir(), 'Library', 'Developer', 'CoreSimulator', 'Devices', udid,
|
|
299
|
+
);
|
|
300
|
+
for (const marker of [
|
|
301
|
+
path.join(dir, 'data', 'var', 'run', 'syslog.pid'),
|
|
302
|
+
path.join(dir, 'data', 'var', 'run'),
|
|
303
|
+
path.join(dir, 'device.plist'),
|
|
304
|
+
]) {
|
|
305
|
+
try {
|
|
306
|
+
return fs.statSync(marker).mtimeMs;
|
|
307
|
+
} catch {
|
|
308
|
+
/* try the next marker */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
252
314
|
function inputDriver() {
|
|
253
315
|
return null;
|
|
254
316
|
}
|
|
@@ -278,6 +340,7 @@ export const platform = {
|
|
|
278
340
|
isBootedSync,
|
|
279
341
|
ownsUdid,
|
|
280
342
|
geometry,
|
|
343
|
+
bootedAt,
|
|
281
344
|
inputDriver,
|
|
282
345
|
screenshot,
|
|
283
346
|
launchApp,
|
package/src/screenmap.js
CHANGED
|
@@ -12,11 +12,12 @@ import * as control from './control.js';
|
|
|
12
12
|
import * as fingerprint from './fingerprint.js';
|
|
13
13
|
import * as input from './input.js';
|
|
14
14
|
import * as ocr from './ocr.js';
|
|
15
|
+
import * as matching from './matching.js';
|
|
15
16
|
import * as regions from './regions.js';
|
|
16
17
|
import { informative } from './refs.js';
|
|
17
18
|
import * as store from './store.js';
|
|
18
19
|
|
|
19
|
-
const MAP_VERSION =
|
|
20
|
+
const MAP_VERSION = 8; // an OCR reading contained in a labelled ax element merges into it
|
|
20
21
|
|
|
21
22
|
function mapDir(udid) {
|
|
22
23
|
return path.join(store.deviceDir(udid), 'screens');
|
|
@@ -232,22 +233,43 @@ export async function build(udid, {
|
|
|
232
233
|
const point = { x: w.centerX, y: w.centerY };
|
|
233
234
|
// If an accessibility element already covers this text, it is the same
|
|
234
235
|
// control: keep the element and record the visible text as an alias.
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
236
|
+
//
|
|
237
|
+
// Two rules, and the second one cost 16 escalations and half of
|
|
238
|
+
// HPI_accuracy. The first is a size test: an element close to the
|
|
239
|
+
// text's own size, containing it, is that text. It exists to stop a
|
|
240
|
+
// tab bar from swallowing all five of its tab labels — containing text
|
|
241
|
+
// is not the same as being that control.
|
|
242
|
+
//
|
|
243
|
+
// But a full-width list row is 19× the area of the words printed in
|
|
244
|
+
// it, so the size test could never fire for the shape it matters most
|
|
245
|
+
// on: every Contacts and Settings row arrived as an ax element AND as
|
|
246
|
+
// an OCR text box, both scoring 1.00 for the same query, and `tap
|
|
247
|
+
// "Kate Bell"` refused as ambiguous on all five runs of the
|
|
248
|
+
// instrumented flow suite. The second rule is the one the size test
|
|
249
|
+
// was standing in for: near-total containment AND the same text. A tab
|
|
250
|
+
// bar contains "Assets" but is not labelled "Assets", so it is still
|
|
251
|
+
// refused; a row labelled "Kate Bell" containing OCR's "Kate Bell" is
|
|
252
|
+
// one element that two sensors saw.
|
|
238
253
|
const textArea = Math.max(1, w.width * w.height);
|
|
254
|
+
const box = { x: w.x, y: w.y, width: w.width, height: w.height };
|
|
255
|
+
const eligible = (t) =>
|
|
256
|
+
matching.isAxTarget(t) &&
|
|
257
|
+
t.frame &&
|
|
258
|
+
!/^(Group|Application|ScrollView|Table|Collection)$/i.test(t.type || '');
|
|
239
259
|
const covering = targets
|
|
240
|
-
.filter(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
!/^(Group|Application|ScrollView|Table|Collection)$/i.test(t.type || '') &&
|
|
246
|
-
area(t.frame) <= textArea * 8,
|
|
247
|
-
)
|
|
248
|
-
.sort((a, b) => area(a.frame) - area(b.frame))[0];
|
|
260
|
+
.filter((t) => eligible(t) && inside(point, t.frame) && area(t.frame) <= textArea * 8)
|
|
261
|
+
.sort((a, b) => area(a.frame) - area(b.frame))[0]
|
|
262
|
+
?? targets
|
|
263
|
+
.filter((t) => eligible(t) && matching.sameElementSeenTwice(t, { ...w, frame: box, label: w.text }))
|
|
264
|
+
.sort((a, b) => area(a.frame) - area(b.frame))[0];
|
|
249
265
|
if (covering) {
|
|
250
266
|
covering.aliases = [...(covering.aliases || []), w.text];
|
|
267
|
+
// Keep the ax role and frame — it is the hit target — and record that
|
|
268
|
+
// both sensors saw it. Anything asking "is this the tree's element?"
|
|
269
|
+
// must ask matching.isAxTarget, not `=== 'ax'`.
|
|
270
|
+
if (!String(covering.source ?? '').includes('ocr')) {
|
|
271
|
+
covering.source = `${covering.source ?? 'ax'}|ocr`;
|
|
272
|
+
}
|
|
251
273
|
continue;
|
|
252
274
|
}
|
|
253
275
|
targets.push({
|
|
@@ -323,7 +345,7 @@ export function rank(entry, query) {
|
|
|
323
345
|
? exact
|
|
324
346
|
: entry.targets.filter((t) => names(t).some((n) => n.includes(q)));
|
|
325
347
|
return pool
|
|
326
|
-
.map((t) => ({ target: t, score: (isInteractive(t) ? 2 : 0) + (t
|
|
348
|
+
.map((t) => ({ target: t, score: (isInteractive(t) ? 2 : 0) + (matching.isAxTarget(t) ? 1 : 0) }))
|
|
327
349
|
.sort((a, b) => b.score - a.score)
|
|
328
350
|
.map((r) => r.target);
|
|
329
351
|
}
|
package/src/view.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import * as api from './index.js';
|
|
15
15
|
import * as graph from './graph.js';
|
|
16
16
|
import { writeRefs } from './refs.js';
|
|
17
|
+
import * as matching from './matching.js';
|
|
17
18
|
|
|
18
19
|
/** Reading order. Chrome frames the screen, so it reads first and last. */
|
|
19
20
|
const REGION_ORDER = ['nav-bar', 'content', 'tab-bar', 'keyboard', 'status-bar'];
|
|
@@ -78,7 +79,7 @@ function isHost(t) {
|
|
|
78
79
|
// An accessibility element the app gave a label to is a unit the app itself
|
|
79
80
|
// considers one thing — a dashboard tile reading "WOs past ETA, 1910" is one
|
|
80
81
|
// tap target whose parts OCR happens to read separately.
|
|
81
|
-
return t
|
|
82
|
+
return matching.isAxTarget(t) && Boolean(t.label);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/**
|
|
@@ -152,7 +153,7 @@ function dropContainers(targets, screen) {
|
|
|
152
153
|
* an ellipsis menu, a chevron it decided was a period. Nothing can be tapped by
|
|
153
154
|
* that name, so listing it is pure cost.
|
|
154
155
|
*/
|
|
155
|
-
const isNoise = (t) => t.source === 'ocr' && !alnum(t.label);
|
|
156
|
+
const isNoise = (t) => !matching.isAxTarget(t) && t.source === 'ocr' && !alnum(t.label);
|
|
156
157
|
|
|
157
158
|
const trim = (text) => {
|
|
158
159
|
const one = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
@@ -203,7 +204,7 @@ export function rowsFor(entry, { screen, filter, interactive, all = false, limit
|
|
|
203
204
|
|
|
204
205
|
function renderRow(r) {
|
|
205
206
|
const name = [
|
|
206
|
-
trim(r.label) || (r
|
|
207
|
+
trim(r.label) || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)'),
|
|
207
208
|
aliasNote(r),
|
|
208
209
|
].filter(Boolean).join(' ');
|
|
209
210
|
const state = [
|