simframe 0.14.1 → 0.14.3
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/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +49 -2
- package/native/simframed/Sources/SimframeCore/Element.swift +19 -2
- package/package.json +1 -1
- package/scripts/analyse-escalations.mjs +89 -0
- package/scripts/analyse-routes.mjs +96 -0
- package/scripts/check-published.mjs +121 -0
- package/scripts/ci-device-guard.mjs +18 -0
- package/scripts/classify-stray.mjs +63 -0
- package/scripts/eval-fingerprint.mjs +135 -10
- package/src/actions.js +256 -14
- package/src/cli.js +39 -2
- package/src/index.js +15 -4
- package/src/input.js +33 -1
- package/src/matching.js +11 -1
- package/src/mcp.js +43 -2
- package/src/storage.js +55 -6
- package/src/view.js +1 -1
package/src/index.js
CHANGED
|
@@ -1489,6 +1489,17 @@ export function sensorMode(options) {
|
|
|
1489
1489
|
return raw === 'ax-first' || raw === 'axfirst' ? 'ax-first' : 'full';
|
|
1490
1490
|
}
|
|
1491
1491
|
|
|
1492
|
+
/**
|
|
1493
|
+
* What to call an element in a "Visible:" list.
|
|
1494
|
+
*
|
|
1495
|
+
* The label, or the accessibility identifier when there is no label. Both are
|
|
1496
|
+
* now matchable, and the list had been filtered to `t.label` alone — so an
|
|
1497
|
+
* element `sim_ui` had just printed by identifier was missing from the list of
|
|
1498
|
+
* what is on screen, in the same reply that refused to resolve it. Reported
|
|
1499
|
+
* from the field, three times in one session.
|
|
1500
|
+
*/
|
|
1501
|
+
const nameFor = (t) => t.label || t.identifier || null;
|
|
1502
|
+
|
|
1492
1503
|
export async function locate(deviceQuery, query, opts = {}) {
|
|
1493
1504
|
if (sensorMode(opts.options) !== 'ax-first' || opts.useOcr === false || opts.escalated) {
|
|
1494
1505
|
return locateWith(deviceQuery, query, opts);
|
|
@@ -1701,8 +1712,8 @@ async function locateWith(
|
|
|
1701
1712
|
// here undoes every guard above — it has no off-screen filter and no
|
|
1702
1713
|
// coverage weighting, and it is what returned a scrolled-away list row for
|
|
1703
1714
|
// "back". "Not found" is the correct answer.
|
|
1704
|
-
const visible = entry.targets.filter((t) => t
|
|
1705
|
-
const sample = visible.slice(0, 12).map((t) => t.
|
|
1715
|
+
const visible = entry.targets.filter((t) => nameFor(t) && !regions.offViewport(t, points));
|
|
1716
|
+
const sample = visible.slice(0, 12).map((t) => nameFor(t).slice(0, 24)).join(', ');
|
|
1706
1717
|
// "Not on this screen" and "not in view" are different answers, and giving
|
|
1707
1718
|
// the first for the second cost a reported 15 seconds: a `waitFor REVIEW`
|
|
1708
1719
|
// burned its whole timeout while REVIEW sat one scroll below the fold, and
|
|
@@ -1734,8 +1745,8 @@ async function locateWith(
|
|
|
1734
1745
|
const candidates = screenmap.rank(entry, query);
|
|
1735
1746
|
const target = index != null ? candidates[index] : candidates[0];
|
|
1736
1747
|
if (!target) {
|
|
1737
|
-
const visible = entry.targets.filter((t) => t
|
|
1738
|
-
const shown = visible.slice(0, 12).map((t) => t
|
|
1748
|
+
const visible = entry.targets.filter((t) => nameFor(t));
|
|
1749
|
+
const shown = visible.slice(0, 12).map((t) => nameFor(t));
|
|
1739
1750
|
// Say when the list is cut. A field report found `"Work Orders" is not on
|
|
1740
1751
|
// this screen. Visible: …` on a screen whose own element map, three lines
|
|
1741
1752
|
// below in the same reply, listed `#25 text 200,836 Work Orders` — it was
|
package/src/input.js
CHANGED
|
@@ -254,6 +254,14 @@ export function elementToNode(e) {
|
|
|
254
254
|
selected: e.state?.selected ?? null,
|
|
255
255
|
focused: e.state?.focused ?? null,
|
|
256
256
|
frame: e.frame ?? null,
|
|
257
|
+
// Where the control actuates, when the app publishes it. See `centerOf`.
|
|
258
|
+
//
|
|
259
|
+
// Added here as well as in `normalizeNode`, and the comment four lines up
|
|
260
|
+
// is the reason: this is the converter that actually runs, and a field set
|
|
261
|
+
// only in the idb fallback is a field that is never set. That is exactly
|
|
262
|
+
// how AXSelected and AXFocused came to be batched by the daemon for four
|
|
263
|
+
// versions and dropped on the way in.
|
|
264
|
+
activationPoint: e.activationPoint ?? null,
|
|
257
265
|
raw: e,
|
|
258
266
|
};
|
|
259
267
|
}
|
|
@@ -292,6 +300,8 @@ function normalizeNode(node) {
|
|
|
292
300
|
height: frame.height ?? frame.Height ?? 0,
|
|
293
301
|
}
|
|
294
302
|
: null,
|
|
303
|
+
// Where the control actuates, when the app says so. See `centerOf`.
|
|
304
|
+
activationPoint: node.activationPoint ?? node.AXActivationPoint ?? null,
|
|
295
305
|
raw: node,
|
|
296
306
|
};
|
|
297
307
|
}
|
|
@@ -329,11 +339,33 @@ export function matchElement(nodes, query, { index } = {}) {
|
|
|
329
339
|
throw new Error(`no element matching "${query}" is on screen`);
|
|
330
340
|
}
|
|
331
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Where to aim at this element.
|
|
344
|
+
*
|
|
345
|
+
* The geometric centre, unless the app has published somewhere better. UIKit
|
|
346
|
+
* exposes `accessibilityActivationPoint` for controls whose hit target is not
|
|
347
|
+
* the middle of what they publish as their frame, and a switch is the case that
|
|
348
|
+
* forced this: it reports a row-wide frame, so the centre is the *label*, and
|
|
349
|
+
* iOS does not actuate a switch from there. Measured on Settings →
|
|
350
|
+
* Accessibility → Hover Text — the frame centre flipped it **0 of 3** times and
|
|
351
|
+
* the control itself **3 of 3**, while the step reported `[no visible change]`
|
|
352
|
+
* and was telling the truth.
|
|
353
|
+
*
|
|
354
|
+
* The app's answer is preferred whenever it lands inside the frame. Outside it
|
|
355
|
+
* is not trusted: a point that is not on the element is not a better guess than
|
|
356
|
+
* the middle of one, and this runs on the tap path, where a wrong guess is the
|
|
357
|
+
* one thing that does damage.
|
|
358
|
+
*/
|
|
332
359
|
export function centerOf(node) {
|
|
333
|
-
|
|
360
|
+
const middle = {
|
|
334
361
|
x: Math.round(node.frame.x + node.frame.width / 2),
|
|
335
362
|
y: Math.round(node.frame.y + node.frame.height / 2),
|
|
336
363
|
};
|
|
364
|
+
const p = node.activationPoint;
|
|
365
|
+
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y)) return middle;
|
|
366
|
+
const f = node.frame;
|
|
367
|
+
const inside = p.x >= f.x && p.x <= f.x + f.width && p.y >= f.y && p.y <= f.y + f.height;
|
|
368
|
+
return inside ? { x: Math.round(p.x), y: Math.round(p.y) } : middle;
|
|
337
369
|
}
|
|
338
370
|
|
|
339
371
|
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
package/src/matching.js
CHANGED
|
@@ -157,7 +157,17 @@ export function rank(targets, intent, { screen } = {}) {
|
|
|
157
157
|
|
|
158
158
|
const scored = [];
|
|
159
159
|
for (const t of visible) {
|
|
160
|
-
|
|
160
|
+
// The accessibility identifier is a name a caller can legitimately write,
|
|
161
|
+
// and this list did not contain it.
|
|
162
|
+
//
|
|
163
|
+
// `sim_ui` prints elements BY identifier — in React Native a `testID`
|
|
164
|
+
// becomes one, so it is most interactive controls in an RN app — and the
|
|
165
|
+
// resolver then rejected that exact string, in the same response that had
|
|
166
|
+
// just printed it, with the identifier also absent from the "Visible:"
|
|
167
|
+
// list. An external tester reproduced it three times and called it the
|
|
168
|
+
// single biggest friction of their session. `screenmap.rank` had learned
|
|
169
|
+
// this; this ranker, which is the one `resolve()` uses, had not.
|
|
170
|
+
const names = [t.label, t.identifier, ...(t.aliases ?? [])].filter(Boolean);
|
|
161
171
|
let base = 0;
|
|
162
172
|
let matched = null;
|
|
163
173
|
for (const name of names) {
|
package/src/mcp.js
CHANGED
|
@@ -405,7 +405,8 @@ const TOOLS = [
|
|
|
405
405
|
'What the app saved, as text: its UserDefaults and (for React Native) its AsyncStorage, read straight out of'
|
|
406
406
|
+ ' the data container. sim_ui says what is drawn; sim_storage says what the app believes — use it when the'
|
|
407
407
|
+ ' screen and the behaviour disagree, or to check a value without driving the UI to it.'
|
|
408
|
-
+ ' Works on a device that is NOT running, so it can answer before anything is booted
|
|
408
|
+
+ ' Works on a device that is NOT running, so it can answer before anything is booted —'
|
|
409
|
+
+ ' pass device when nothing is booted and the host has more than one simulator with data.'
|
|
409
410
|
+ ' Call with no bundleId to list the apps that have a container (match filters that list).',
|
|
410
411
|
inputSchema: {
|
|
411
412
|
type: 'object',
|
|
@@ -772,6 +773,23 @@ async function look(target, args, options) {
|
|
|
772
773
|
lines.push(`could not crop that region (${cropped.note}) — this is the whole screen`);
|
|
773
774
|
}
|
|
774
775
|
}
|
|
776
|
+
// Say what space the image is in, and the factor.
|
|
777
|
+
//
|
|
778
|
+
// A field report lost time to this and had to derive the number from
|
|
779
|
+
// landmarks: `sim_ui` and `sim_tap` speak 402x874 POINTS, the returned image
|
|
780
|
+
// is 322x700 PIXELS, and `simctl io screenshot` is a third space again at 3x.
|
|
781
|
+
// Reading a coordinate off this image and feeding it to a tap is silently
|
|
782
|
+
// wrong by 1.248x — silently, because nothing in the reply relates the two.
|
|
783
|
+
// The header already prints the frame size; it just never said what it was
|
|
784
|
+
// relative to.
|
|
785
|
+
const geometry = await api.screenIdentity(target, { options, confirmNovel: false }).catch(() => null);
|
|
786
|
+
const pts = geometry?.points;
|
|
787
|
+
if (pts?.width && res.state?.width) {
|
|
788
|
+
const factor = res.state.width / pts.width;
|
|
789
|
+
lines.push(`this image is ${res.state.width}x${res.state.height} px = `
|
|
790
|
+
+ `${Math.round(pts.width)}x${Math.round(pts.height)}pt — divide image coordinates by `
|
|
791
|
+
+ `${factor.toFixed(3)} before tapping (sim_tap and sim_ui speak points)`);
|
|
792
|
+
}
|
|
775
793
|
return { content: [text(lines.filter(Boolean).join('\n')), image(png)] };
|
|
776
794
|
}
|
|
777
795
|
|
|
@@ -1200,7 +1218,30 @@ function listStateDirs() {
|
|
|
1200
1218
|
* the host filesystem. That is what the backend does.
|
|
1201
1219
|
*/
|
|
1202
1220
|
async function appStorage({ bundleId, match: query, device } = {}) {
|
|
1203
|
-
|
|
1221
|
+
// Same correction as the CLI: this must not require a booted device, because
|
|
1222
|
+
// answering before a boot is the point of the tool. A bare call used to fail
|
|
1223
|
+
// with "no booted simulator" — reported from the field — so an unnamed device
|
|
1224
|
+
// falls back to the only one that has app data before it gives up.
|
|
1225
|
+
let resolved = device
|
|
1226
|
+
? await resolveDevice(String(device))
|
|
1227
|
+
: await resolveDevice(undefined).catch(() => null);
|
|
1228
|
+
if (!resolved) {
|
|
1229
|
+
const all = await listDevices({ all: true });
|
|
1230
|
+
const withApps = [];
|
|
1231
|
+
for (const d of all) {
|
|
1232
|
+
const apps = await storage.apps(d.udid).catch(() => []);
|
|
1233
|
+
if (apps.length) withApps.push({ device: d, apps: apps.length });
|
|
1234
|
+
}
|
|
1235
|
+
if (withApps.length === 1) resolved = withApps[0].device;
|
|
1236
|
+
else {
|
|
1237
|
+
throw new Error(
|
|
1238
|
+
withApps.length
|
|
1239
|
+
? 'storage reads a device that does not have to be running, so say which one: '
|
|
1240
|
+
+ withApps.map((w) => `${w.device.name} (${w.device.udid}, ${w.apps} app(s))`).join('; ')
|
|
1241
|
+
: 'no device on this host has any app data to read',
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1204
1245
|
if (!bundleId) {
|
|
1205
1246
|
const list = await storage.apps(resolved.udid);
|
|
1206
1247
|
const needle = query ? String(query).toLowerCase() : null;
|
package/src/storage.js
CHANGED
|
@@ -39,6 +39,33 @@ export const VALUE_PREVIEW_BYTES = 4096;
|
|
|
39
39
|
const ASYNC_STORAGE_DIR = 'RCTAsyncLocalStorage_V1';
|
|
40
40
|
const ASYNC_STORAGE_MANIFEST = 'manifest.json';
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Where AsyncStorage actually lives, newest layout first.
|
|
44
|
+
*
|
|
45
|
+
* **`Documents/` alone was wrong, and wrong in the worst available way.** That
|
|
46
|
+
* is the *legacy* React Native location. The community package every current RN
|
|
47
|
+
* app uses — `@react-native-async-storage/async-storage` — writes to
|
|
48
|
+
* `Library/Application Support/<bundle-id>/`, and on a real app measured by an
|
|
49
|
+
* external tester the `Documents/` path **did not exist at all**. So
|
|
50
|
+
* `readAsyncStorage` returned null, `format()` omitted the section, and the
|
|
51
|
+
* output read as "this app has no AsyncStorage" while 25 keys sat on disk,
|
|
52
|
+
* including a 1.5 MB MobX-State-Tree root store.
|
|
53
|
+
*
|
|
54
|
+
* Their diagnosis was exact: *"The decoder is correct; only the path is wrong."*
|
|
55
|
+
* Which makes this the precise class of confident wrong answer the whole feature
|
|
56
|
+
* exists to prevent, shipped inside it.
|
|
57
|
+
*
|
|
58
|
+
* Both are tried because both are real — an older app still writes to
|
|
59
|
+
* `Documents/` — and every path looked in is reported, because "found nothing"
|
|
60
|
+
* and "did not look there" are different facts and only one of them is about
|
|
61
|
+
* the app.
|
|
62
|
+
*/
|
|
63
|
+
const asyncStorageDirs = (container, bundleId) => [
|
|
64
|
+
path.join(container, 'Library', 'Application Support', String(bundleId ?? ''), ASYNC_STORAGE_DIR),
|
|
65
|
+
path.join(container, 'Library', 'Application Support', ASYNC_STORAGE_DIR),
|
|
66
|
+
path.join(container, 'Documents', ASYNC_STORAGE_DIR),
|
|
67
|
+
];
|
|
68
|
+
|
|
42
69
|
/** Files that are plainly a store but that nothing here can decode yet. */
|
|
43
70
|
const OPAQUE_STORES = /\.(sqlite3?|db|realm|leveldb|mmkv)$/i;
|
|
44
71
|
|
|
@@ -59,10 +86,13 @@ export async function apps(udid) {
|
|
|
59
86
|
* reporting it as one would be the exact class of wrong answer this feature
|
|
60
87
|
* exists to stop.
|
|
61
88
|
*/
|
|
62
|
-
export function readAsyncStorage(container) {
|
|
63
|
-
const
|
|
89
|
+
export function readAsyncStorage(container, bundleId) {
|
|
90
|
+
const looked = asyncStorageDirs(container, bundleId);
|
|
91
|
+
const dir = looked.find((d) => fs.existsSync(path.join(d, ASYNC_STORAGE_MANIFEST)));
|
|
92
|
+
// The paths travel with the miss. A reader told only "no AsyncStorage" cannot
|
|
93
|
+
// tell a bare app from a store we failed to find, and the second is ours.
|
|
94
|
+
if (!dir) return { missing: true, looked: looked.map((d) => path.relative(container, d)) };
|
|
64
95
|
const manifestPath = path.join(dir, ASYNC_STORAGE_MANIFEST);
|
|
65
|
-
if (!fs.existsSync(manifestPath)) return null;
|
|
66
96
|
const manifest = readJson(manifestPath);
|
|
67
97
|
const entries = [];
|
|
68
98
|
for (const [key, inline] of Object.entries(manifest)) {
|
|
@@ -151,9 +181,15 @@ export function typeOf(value) {
|
|
|
151
181
|
export async function read(udid, bundleId) {
|
|
152
182
|
const container = await platform.appContainer(udid, bundleId);
|
|
153
183
|
const stores = await readPreferences(udid, container, bundleId);
|
|
154
|
-
const async_ = readAsyncStorage(container);
|
|
155
|
-
if (async_) stores.push(async_);
|
|
156
|
-
return {
|
|
184
|
+
const async_ = readAsyncStorage(container, bundleId);
|
|
185
|
+
if (async_ && !async_.missing) stores.push(async_);
|
|
186
|
+
return {
|
|
187
|
+
bundleId,
|
|
188
|
+
container,
|
|
189
|
+
stores,
|
|
190
|
+
opaque: opaqueStores(container),
|
|
191
|
+
asyncStorageMissing: async_?.missing ? async_.looked : null,
|
|
192
|
+
};
|
|
157
193
|
}
|
|
158
194
|
|
|
159
195
|
/** One value, rendered for reading, saying so whenever it is not the whole thing. */
|
|
@@ -186,11 +222,24 @@ export function format(result) {
|
|
|
186
222
|
lines.push(` ${renderValue(e.value).split('\n').join('\n ')}`);
|
|
187
223
|
}
|
|
188
224
|
}
|
|
225
|
+
// Say where we looked and did not find it. See `asyncStorageDirs`.
|
|
226
|
+
if (result.asyncStorageMissing) {
|
|
227
|
+
lines.push('');
|
|
228
|
+
lines.push(' no AsyncStorage found. Looked in:');
|
|
229
|
+
for (const d of result.asyncStorageMissing) lines.push(` ${d}`);
|
|
230
|
+
lines.push(' (an app that does not use AsyncStorage will have none of these)');
|
|
231
|
+
}
|
|
189
232
|
if (result.opaque?.length) {
|
|
190
233
|
lines.push('');
|
|
191
234
|
lines.push(` ${result.opaque.length} store(s) present that this cannot decode yet:`);
|
|
192
235
|
for (const o of result.opaque) lines.push(` ${o.file} ${o.bytes} bytes`);
|
|
193
236
|
}
|
|
237
|
+
// Asked for by the external tester, and they were right to: mid-session the
|
|
238
|
+
// app restored a session from the Keychain and walked past its own login
|
|
239
|
+
// screen, so "logged out" as read from storage was not the whole truth.
|
|
240
|
+
// Saying what is NOT readable sets expectations that silence does not.
|
|
241
|
+
lines.push('');
|
|
242
|
+
lines.push(' Keychain is not readable from here — auth state may differ from what is above.');
|
|
194
243
|
return lines.join('\n');
|
|
195
244
|
}
|
|
196
245
|
|
package/src/view.js
CHANGED
|
@@ -579,7 +579,7 @@ export async function screenMap(deviceQuery, {
|
|
|
579
579
|
const anonymous = rows.filter(unaddressable).length;
|
|
580
580
|
const unnamed = anonymous
|
|
581
581
|
? `${anonymous} on-screen control(s) have no accessibility label — they are listed with their`
|
|
582
|
-
+ ' coordinates and can be tapped by
|
|
582
|
+
+ ' coordinates and can be tapped by #ref, or by point with tapAt. If what you are'
|
|
583
583
|
+ ' looking for is not in the list either, the app has views that were never declared'
|
|
584
584
|
+ ' accessible and only a screenshot will find those.'
|
|
585
585
|
: null;
|