simframe 0.8.0 → 0.10.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 +31 -3
- 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/check-private.mjs +143 -0
- package/scripts/eval-perception.mjs +248 -0
- package/src/actions.js +333 -14
- package/src/analyze.js +70 -0
- package/src/baseline.js +333 -0
- package/src/cli.js +410 -4
- package/src/daemon.js +9 -0
- package/src/fingerprint.js +7 -1
- package/src/graph.js +262 -1
- package/src/index.js +335 -22
- package/src/input.js +155 -1
- package/src/intent.js +11 -2
- package/src/matching.js +136 -4
- package/src/mcp.js +14 -1
- package/src/metrics.js +596 -0
- package/src/navigate.js +47 -7
- package/src/platform/android.js +16 -1
- package/src/platform/index.js +3 -0
- package/src/platform/ios.js +40 -0
- package/src/screenmap.js +55 -14
- package/src/view.js +65 -3
package/src/platform/android.js
CHANGED
|
@@ -827,7 +827,7 @@ async function launchApp(udid, bundleId, { args = [], env = {}, terminateFirst =
|
|
|
827
827
|
async function terminateApp(udid, bundleId) {
|
|
828
828
|
// `am` reports failure on stdout and still exits 0 — the same trap launchApp
|
|
829
829
|
// and openUrl already check for. Without this, terminating a package that is
|
|
830
|
-
// not installed answered "terminated com.
|
|
830
|
+
// not installed answered "terminated com.example.mistyped".
|
|
831
831
|
const { stdout, stderr } = await adb(udid, ['shell', 'am', 'force-stop', bundleId]);
|
|
832
832
|
const error = /^Error:.*$/m.exec(`${stdout}${stderr}`);
|
|
833
833
|
if (error) {
|
|
@@ -974,6 +974,16 @@ function toolchain() {
|
|
|
974
974
|
* driver's name. `uiautomator dump` costs 2,012 ms a read, which is why it is
|
|
975
975
|
* not the answer; see docs/DEFERRED.md for the shape of the one that would be.
|
|
976
976
|
*/
|
|
977
|
+
async function bootedAt(serial) {
|
|
978
|
+
try {
|
|
979
|
+
const out = await adb(serial, ['shell', 'cat', '/proc/uptime']);
|
|
980
|
+
const seconds = Number(String(out.stdout ?? out).trim().split(/\s+/)[0]);
|
|
981
|
+
return Number.isFinite(seconds) ? Date.now() - seconds * 1000 : null;
|
|
982
|
+
} catch {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
977
987
|
function capabilities() {
|
|
978
988
|
return {
|
|
979
989
|
captureEngines: ['screenshot'],
|
|
@@ -1004,6 +1014,11 @@ export const platform = {
|
|
|
1004
1014
|
setPasteboard,
|
|
1005
1015
|
getPasteboard,
|
|
1006
1016
|
permissionServices: () => PERMISSION_SERVICES,
|
|
1017
|
+
// Uptime, because there is no CoreSimulator directory to stat. `/proc/uptime`
|
|
1018
|
+
// is seconds since boot, so boot is now minus that — and it is a real
|
|
1019
|
+
// answer rather than the other platform's vocabulary, which is the rule a
|
|
1020
|
+
// backend that cannot answer has to follow.
|
|
1021
|
+
bootedAt,
|
|
1007
1022
|
capabilities,
|
|
1008
1023
|
toolchain,
|
|
1009
1024
|
};
|
package/src/platform/index.js
CHANGED
|
@@ -63,6 +63,7 @@ export const PLATFORM_SURFACE = Object.freeze([
|
|
|
63
63
|
'geometry', 'inputDriver',
|
|
64
64
|
'screenshot', 'launchApp', 'terminateApp', 'openUrl',
|
|
65
65
|
'setPermission', 'setPasteboard', 'permissionServices', 'capabilities', 'toolchain',
|
|
66
|
+
'bootedAt',
|
|
66
67
|
]);
|
|
67
68
|
|
|
68
69
|
/** @type {Record<string, Platform>} */
|
|
@@ -237,6 +238,8 @@ export function permissionServices(udid) {
|
|
|
237
238
|
* knows. Tap points computed from that are wrong, and nothing says so.
|
|
238
239
|
*/
|
|
239
240
|
export const geometryFor = (udid) => platformFor(udid).geometry(udid);
|
|
241
|
+
/** When the device last booted, epoch ms, or null. Both backends answer; neither guesses. */
|
|
242
|
+
export const bootedAtFor = (udid) => platformFor(udid).bootedAt(udid);
|
|
240
243
|
|
|
241
244
|
/**
|
|
242
245
|
* The backend's own input path, or null when input comes from above the
|
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);
|
|
@@ -272,6 +275,42 @@ function geometry() {
|
|
|
272
275
|
* *is* the platform's — the emulator console — which is why this is a question
|
|
273
276
|
* a backend gets asked at all.
|
|
274
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
|
+
|
|
275
314
|
function inputDriver() {
|
|
276
315
|
return null;
|
|
277
316
|
}
|
|
@@ -301,6 +340,7 @@ export const platform = {
|
|
|
301
340
|
isBootedSync,
|
|
302
341
|
ownsUdid,
|
|
303
342
|
geometry,
|
|
343
|
+
bootedAt,
|
|
304
344
|
inputDriver,
|
|
305
345
|
screenshot,
|
|
306
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 = 9; // ax targets carry value, selected and focused
|
|
20
21
|
|
|
21
22
|
function mapDir(udid) {
|
|
22
23
|
return path.join(store.deviceDir(udid), 'screens');
|
|
@@ -184,11 +185,30 @@ export async function build(udid, {
|
|
|
184
185
|
if (!n.frame || !n.label || isContainer(n)) continue;
|
|
185
186
|
targets.push({
|
|
186
187
|
label: n.label,
|
|
188
|
+
// What the control *contains*, whether it is on, and whether it has
|
|
189
|
+
// focus. All three come off the accessibility tree, the daemon has
|
|
190
|
+
// asked for all three since 0.6.0, and all three were dropped before
|
|
191
|
+
// this — `value` here and the other two one layer up in
|
|
192
|
+
// `normalizeNode` — so nothing above this line ever saw them.
|
|
193
|
+
//
|
|
194
|
+
// The cost was not theoretical. A real session could not verify the
|
|
195
|
+
// contents of a text field at all: they reached a row only as the OCR
|
|
196
|
+
// alias, which made them as old as the map and unauthoritative. An
|
|
197
|
+
// assert failed against a field that did contain the string, the
|
|
198
|
+
// operator retyped, and the field ended up with a doubled value and a
|
|
199
|
+
// validation error.
|
|
200
|
+
//
|
|
201
|
+
// `focused` is worth naming separately: it is a direct answer to "did
|
|
202
|
+
// this field take focus", which the focus wait in actions.js infers
|
|
203
|
+
// from elapsed time because it had nothing better to use.
|
|
204
|
+
value: n.value ?? undefined,
|
|
187
205
|
x: input.centerOf(n).x,
|
|
188
206
|
y: input.centerOf(n).y,
|
|
189
207
|
frame: n.frame,
|
|
190
208
|
type: n.type,
|
|
191
209
|
enabled: n.enabled,
|
|
210
|
+
selected: n.selected ?? undefined,
|
|
211
|
+
focused: n.focused ?? undefined,
|
|
192
212
|
source: 'ax',
|
|
193
213
|
});
|
|
194
214
|
}
|
|
@@ -232,22 +252,43 @@ export async function build(udid, {
|
|
|
232
252
|
const point = { x: w.centerX, y: w.centerY };
|
|
233
253
|
// If an accessibility element already covers this text, it is the same
|
|
234
254
|
// control: keep the element and record the visible text as an alias.
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
255
|
+
//
|
|
256
|
+
// Two rules, and the second one cost 16 escalations and half of
|
|
257
|
+
// HPI_accuracy. The first is a size test: an element close to the
|
|
258
|
+
// text's own size, containing it, is that text. It exists to stop a
|
|
259
|
+
// tab bar from swallowing all five of its tab labels — containing text
|
|
260
|
+
// is not the same as being that control.
|
|
261
|
+
//
|
|
262
|
+
// But a full-width list row is 19× the area of the words printed in
|
|
263
|
+
// it, so the size test could never fire for the shape it matters most
|
|
264
|
+
// on: every Contacts and Settings row arrived as an ax element AND as
|
|
265
|
+
// an OCR text box, both scoring 1.00 for the same query, and `tap
|
|
266
|
+
// "Kate Bell"` refused as ambiguous on all five runs of the
|
|
267
|
+
// instrumented flow suite. The second rule is the one the size test
|
|
268
|
+
// was standing in for: near-total containment AND the same text. A tab
|
|
269
|
+
// bar contains "Assets" but is not labelled "Assets", so it is still
|
|
270
|
+
// refused; a row labelled "Kate Bell" containing OCR's "Kate Bell" is
|
|
271
|
+
// one element that two sensors saw.
|
|
238
272
|
const textArea = Math.max(1, w.width * w.height);
|
|
273
|
+
const box = { x: w.x, y: w.y, width: w.width, height: w.height };
|
|
274
|
+
const eligible = (t) =>
|
|
275
|
+
matching.isAxTarget(t) &&
|
|
276
|
+
t.frame &&
|
|
277
|
+
!/^(Group|Application|ScrollView|Table|Collection)$/i.test(t.type || '');
|
|
239
278
|
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];
|
|
279
|
+
.filter((t) => eligible(t) && inside(point, t.frame) && area(t.frame) <= textArea * 8)
|
|
280
|
+
.sort((a, b) => area(a.frame) - area(b.frame))[0]
|
|
281
|
+
?? targets
|
|
282
|
+
.filter((t) => eligible(t) && matching.sameElementSeenTwice(t, { ...w, frame: box, label: w.text }))
|
|
283
|
+
.sort((a, b) => area(a.frame) - area(b.frame))[0];
|
|
249
284
|
if (covering) {
|
|
250
285
|
covering.aliases = [...(covering.aliases || []), w.text];
|
|
286
|
+
// Keep the ax role and frame — it is the hit target — and record that
|
|
287
|
+
// both sensors saw it. Anything asking "is this the tree's element?"
|
|
288
|
+
// must ask matching.isAxTarget, not `=== 'ax'`.
|
|
289
|
+
if (!String(covering.source ?? '').includes('ocr')) {
|
|
290
|
+
covering.source = `${covering.source ?? 'ax'}|ocr`;
|
|
291
|
+
}
|
|
251
292
|
continue;
|
|
252
293
|
}
|
|
253
294
|
targets.push({
|
|
@@ -323,7 +364,7 @@ export function rank(entry, query) {
|
|
|
323
364
|
? exact
|
|
324
365
|
: entry.targets.filter((t) => names(t).some((n) => n.includes(q)));
|
|
325
366
|
return pool
|
|
326
|
-
.map((t) => ({ target: t, score: (isInteractive(t) ? 2 : 0) + (t
|
|
367
|
+
.map((t) => ({ target: t, score: (isInteractive(t) ? 2 : 0) + (matching.isAxTarget(t) ? 1 : 0) }))
|
|
327
368
|
.sort((a, b) => b.score - a.score)
|
|
328
369
|
.map((r) => r.target);
|
|
329
370
|
}
|
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();
|
|
@@ -201,9 +202,69 @@ export function rowsFor(entry, { screen, filter, interactive, all = false, limit
|
|
|
201
202
|
return { rows: rows.slice(0, limit), truncated: Math.max(0, rows.length - limit), collapsed };
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Say when the rows below were remembered rather than looked at.
|
|
207
|
+
*
|
|
208
|
+
* Screen memory is deliberately keyed on the pixel layout hash, because a list
|
|
209
|
+
* with new rows is the same screen and re-perceiving it per step is the cost
|
|
210
|
+
* Phase 13 exists to remove. That is right for *identity* and wrong for
|
|
211
|
+
* *contents*, and the map made no distinction: a field's text reaches a row as
|
|
212
|
+
* an OCR alias, so a recalled map reports the text the field held when the map
|
|
213
|
+
* was built. Reported from a real session — a picker described the previous
|
|
214
|
+
* sheet's options and did it in 23 ms, which is the giveaway, because 23 ms is
|
|
215
|
+
* not enough time to have looked.
|
|
216
|
+
*
|
|
217
|
+
* This does not fix that. It stops it being invisible, which is the part that
|
|
218
|
+
* cost two wrong conclusions about an app.
|
|
219
|
+
*
|
|
220
|
+
* Only past a second, because a map built by this very call is not a
|
|
221
|
+
* recollection and saying so on every screen is how a real warning gets
|
|
222
|
+
* skimmed.
|
|
223
|
+
*/
|
|
224
|
+
export const RECALL_NOTE_FLOOR_MS = 1000;
|
|
225
|
+
|
|
226
|
+
export function recalledNote(identity, now = Date.now()) {
|
|
227
|
+
const at = identity?.entry?.at;
|
|
228
|
+
if (!Number.isFinite(at)) return null;
|
|
229
|
+
const age = now - at;
|
|
230
|
+
if (age < RECALL_NOTE_FLOOR_MS) return null;
|
|
231
|
+
const ago = age < 60_000 ? `${Math.round(age / 1000)}s` : `${Math.round(age / 60_000)}m`;
|
|
232
|
+
return `elements recalled from ${ago} ago — pass refresh for what is there now`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* What a control *contains*, from the sensor that actually knows.
|
|
237
|
+
*
|
|
238
|
+
* A field's text reached a row only as the OCR alias, which means it was as old
|
|
239
|
+
* as the map and had no authoritative source at all. Reported from a real
|
|
240
|
+
* session: an `assert` on a field's contents failed against a field that did
|
|
241
|
+
* contain the string, the operator retyped, and the field ended up with a
|
|
242
|
+
* doubled value and a validation error. A character counter read `0/1000` in
|
|
243
|
+
* the map and `56/1000` in a screenshot of the same frame.
|
|
244
|
+
*
|
|
245
|
+
* The accessibility tree carries `value` and always has —
|
|
246
|
+
* `input.elementToNode` sets it on every node — and the renderer simply never
|
|
247
|
+
* printed it. Printed as `= <value>` and *alongside* the OCR alias rather than
|
|
248
|
+
* instead of it, so when the two disagree that is visible instead of resolved
|
|
249
|
+
* by whichever one the renderer preferred. Disagreement is the signal.
|
|
250
|
+
*
|
|
251
|
+
* Skipped when the label already says it, which is most switches and rows: iOS
|
|
252
|
+
* labels a settings row "Larger Text, Off" and printing `= Off` after that is
|
|
253
|
+
* noise.
|
|
254
|
+
*/
|
|
255
|
+
function valueNote(r) {
|
|
256
|
+
if (r.value == null || r.value === '') return null;
|
|
257
|
+
const v = trim(String(r.value));
|
|
258
|
+
if (!v) return null;
|
|
259
|
+
const said = alnum(r.label);
|
|
260
|
+
if (said && alnum(v) && said.includes(alnum(v))) return null;
|
|
261
|
+
return `= ${v}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
204
264
|
function renderRow(r) {
|
|
205
265
|
const name = [
|
|
206
|
-
trim(r.label) || (r
|
|
266
|
+
trim(r.label) || (matching.isAxTarget(r) ? '(unlabelled)' : '(no text)'),
|
|
267
|
+
valueNote(r),
|
|
207
268
|
aliasNote(r),
|
|
208
269
|
].filter(Boolean).join(' ');
|
|
209
270
|
const state = [
|
|
@@ -319,6 +380,7 @@ export function render({ device, identity, rows, truncated, collapsed, screen, n
|
|
|
319
380
|
: 'screen unidentified',
|
|
320
381
|
identity?.keyboard ? 'keyboard up' : null,
|
|
321
382
|
identity?.settled === false ? 'STILL MOVING' : null,
|
|
383
|
+
recalledNote(identity),
|
|
322
384
|
].filter(Boolean).join(' · ');
|
|
323
385
|
|
|
324
386
|
const lines = [head];
|