simframe 0.6.1 → 0.7.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 +93 -6
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +7 -2
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +34 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +20 -0
- package/native/simframed/Sources/simframed/main.swift +30 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +39 -0
- package/package.json +1 -1
- package/scripts/analyse-fingerprint.mjs +149 -0
- package/scripts/bench-flow.mjs +1 -1
- package/scripts/ci-memory.mjs +48 -1
- package/scripts/eval-ax-tier.mjs +191 -0
- package/scripts/eval-fingerprint.mjs +161 -6
- package/skills/simframe/SKILL.md +10 -3
- package/src/actions.js +36 -5
- package/src/cli.js +62 -26
- package/src/daemon.js +36 -1
- package/src/engine.js +19 -6
- package/src/fingerprint.js +58 -0
- package/src/graph.js +89 -5
- package/src/index.js +81 -12
- package/src/input.js +52 -0
- package/src/mcp.js +6 -6
- package/src/platform/android.js +968 -0
- package/src/platform/host.js +15 -0
- package/src/platform/index.js +248 -0
- package/src/{simctl.js → platform/ios.js} +111 -18
- package/src/screenmap.js +1 -1
- package/src/store.js +32 -0
package/src/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import * as graph from './graph.js';
|
|
|
19
19
|
import * as matching from './matching.js';
|
|
20
20
|
import * as refs from './refs.js';
|
|
21
21
|
import * as screenmap from './screenmap.js';
|
|
22
|
-
import { resolveDevice, resize, screenshot } from './
|
|
22
|
+
import { capabilitiesFor, resolveDevice, resize, screenshot } from './platform/index.js';
|
|
23
23
|
import * as store from './store.js';
|
|
24
24
|
|
|
25
25
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -186,15 +186,24 @@ export function fallbackReason(udid) {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* Start whichever engine was asked for
|
|
189
|
+
* Start whichever engine was asked for, out of the ones this device's platform
|
|
190
|
+
* has.
|
|
190
191
|
*
|
|
191
|
-
* simframed unless told otherwise: it reads the framebuffer
|
|
192
|
-
* roughly thirty times faster per frame
|
|
193
|
-
* `engine: '
|
|
194
|
-
*
|
|
192
|
+
* On iOS that is simframed unless told otherwise: it reads the framebuffer
|
|
193
|
+
* directly and is roughly thirty times faster per frame, and the screenshot
|
|
194
|
+
* loop stays reachable with `engine: 'screenshot'` for a machine with no Swift
|
|
195
|
+
* toolchain. On Android the loop is the only engine there is — and asking for
|
|
196
|
+
* simframed there is refused rather than attempted, because a Swift daemon
|
|
197
|
+
* built against CoreSimulator has nothing to say to an emulator, and the
|
|
198
|
+
* failure it produces says nothing useful about why.
|
|
195
199
|
*/
|
|
196
200
|
async function startEngine(udid, options) {
|
|
197
|
-
|
|
201
|
+
const supported = capabilitiesFor(udid).captureEngines;
|
|
202
|
+
const wanted = engine.normalizeEngine(options.engine ?? supported[0]);
|
|
203
|
+
if (!supported.includes(wanted)) {
|
|
204
|
+
throw new Error(`this device cannot run the ${wanted} capture engine — it supports ${supported.join(', ')}`);
|
|
205
|
+
}
|
|
206
|
+
if (wanted === 'simframed') {
|
|
198
207
|
const built = await engine.ensureBuilt();
|
|
199
208
|
if (built.ok) {
|
|
200
209
|
engineFallbackReason = null;
|
|
@@ -206,7 +215,7 @@ async function startEngine(udid, options) {
|
|
|
206
215
|
recordFallback(udid, engineFallbackReason);
|
|
207
216
|
}
|
|
208
217
|
spawnNodeDaemon(udid, options);
|
|
209
|
-
return '
|
|
218
|
+
return 'screenshot';
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
function spawnNodeDaemon(udid, options) {
|
|
@@ -289,12 +298,38 @@ export function changeLevel(diff) {
|
|
|
289
298
|
return 'none';
|
|
290
299
|
}
|
|
291
300
|
|
|
301
|
+
/** How long a stall has been going on, in words an agent can act on. */
|
|
302
|
+
function stallNote(health) {
|
|
303
|
+
const forMs = Math.max(0, Date.now() - (health.since ?? Date.now()));
|
|
304
|
+
const parts = [`capture: stalled — the display surface has been unreadable for ${Math.round(forMs / 1000)}s`];
|
|
305
|
+
if (health.reattaches) parts.push(`${health.reattaches} re-attach${health.reattaches === 1 ? '' : 'es'} did not help`);
|
|
306
|
+
if (health.reason) parts.push(String(health.reason).slice(0, 120));
|
|
307
|
+
// The cure is the user's to apply. Saying so is the difference between an
|
|
308
|
+
// agent that reports "the simulator is wedged" and one that retries a tap
|
|
309
|
+
// twenty times because nothing appeared to change.
|
|
310
|
+
parts.push('only restarting the device is known to cure it');
|
|
311
|
+
return parts.join('; ');
|
|
312
|
+
}
|
|
313
|
+
|
|
292
314
|
export function liveness(udid, state) {
|
|
293
315
|
const ageMs = Date.now() - state.capturedAt;
|
|
294
316
|
const { running } = daemonStatus(udid);
|
|
317
|
+
// A wedged device and a quiet one look identical from the frames alone: both
|
|
318
|
+
// produce nothing. The difference is that a wedged one is failing reads, and
|
|
319
|
+
// only the capture loop knows that, so it writes it down.
|
|
320
|
+
const health = store.captureHealth(udid);
|
|
321
|
+
const stalled = Boolean(health?.stalled);
|
|
295
322
|
if (!running) {
|
|
296
|
-
return {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
ageMs,
|
|
326
|
+
stalled,
|
|
327
|
+
note: stalled
|
|
328
|
+
? `the capture loop has died, and it was stalled before it did — ${stallNote(health)}`
|
|
329
|
+
: 'the capture loop has died; the frame you are looking at is the last one it wrote',
|
|
330
|
+
};
|
|
297
331
|
}
|
|
332
|
+
if (stalled) return { ok: false, ageMs, stalled: true, note: stallNote(health) };
|
|
298
333
|
// Frame age means "stalled" only for a fixed-rate loop.
|
|
299
334
|
//
|
|
300
335
|
// simframed captures on damage, so a screen that is genuinely still produces
|
|
@@ -306,9 +341,9 @@ export function liveness(udid, state) {
|
|
|
306
341
|
// daemon.
|
|
307
342
|
const damageDriven = engine.runningEngine(udid) === 'simframed';
|
|
308
343
|
if (!damageDriven && ageMs > STALE_FRAME_MS) {
|
|
309
|
-
return { ok: false, ageMs, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
|
|
344
|
+
return { ok: false, ageMs, stalled: false, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
|
|
310
345
|
}
|
|
311
|
-
return { ok: true, ageMs, note: null };
|
|
346
|
+
return { ok: true, ageMs, stalled: false, note: null };
|
|
312
347
|
}
|
|
313
348
|
|
|
314
349
|
/**
|
|
@@ -502,7 +537,16 @@ export async function waitFor(
|
|
|
502
537
|
// a button changing state. Waiting the full timeout for a change that
|
|
503
538
|
// will never be visible turns a 100ms action into a 12s one, so give up
|
|
504
539
|
// early and say so, rather than silently burning the clock.
|
|
505
|
-
|
|
540
|
+
//
|
|
541
|
+
// But "nothing changed" is a claim about something observed, and with no
|
|
542
|
+
// frame captured since this call began, nothing has been. The screenshot
|
|
543
|
+
// engine idles at 1.5 fps, so a 500 ms reaction window expired before the
|
|
544
|
+
// first new frame existed: a tap that opened a whole activity was
|
|
545
|
+
// reported as having no visible effect, and the text meant for the field
|
|
546
|
+
// it opened was typed into nothing. Damage-driven capture on iOS hid this
|
|
547
|
+
// by being fast.
|
|
548
|
+
const observedSomething = state.seq - startSeq >= 1;
|
|
549
|
+
if (!sawChange && observedSomething && Date.now() - startedAt > reactionMs && state.stableForMs >= stableMs) {
|
|
506
550
|
return done(false, { noVisibleChange: true });
|
|
507
551
|
}
|
|
508
552
|
|
|
@@ -724,6 +768,31 @@ export async function settledState(udid, { settleMs = MEMORY_SETTLE_MS, timeoutM
|
|
|
724
768
|
return { state, settled: false };
|
|
725
769
|
}
|
|
726
770
|
|
|
771
|
+
/**
|
|
772
|
+
* Read the screen with the perception layers pinned, and keep nothing.
|
|
773
|
+
*
|
|
774
|
+
* Every other path decides for itself which layers to read, which is right for
|
|
775
|
+
* doing work and useless for measuring: the question "what is the accessibility
|
|
776
|
+
* tier worth" needs the same frame read twice, once with it and once without.
|
|
777
|
+
* `persist: false` so measuring teaches the graph nothing.
|
|
778
|
+
*/
|
|
779
|
+
export async function readScreenWith(deviceQuery, { useAx = true, useOcr = true, options } = {}) {
|
|
780
|
+
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
781
|
+
const udid = device.udid;
|
|
782
|
+
const geo = await deviceGeometry(udid, state);
|
|
783
|
+
const entry = await screenmap.build(udid, {
|
|
784
|
+
hash: state.hash,
|
|
785
|
+
layoutHash: state.layoutHash,
|
|
786
|
+
fullFrame: await fullFrameFor(udid, state),
|
|
787
|
+
density: geo.density,
|
|
788
|
+
screen: { width: geo.pointWidth, height: geo.pointHeight },
|
|
789
|
+
useAx,
|
|
790
|
+
useOcr,
|
|
791
|
+
persist: false,
|
|
792
|
+
});
|
|
793
|
+
return { device, entry, points: { width: geo.pointWidth, height: geo.pointHeight } };
|
|
794
|
+
}
|
|
795
|
+
|
|
727
796
|
export async function locate(
|
|
728
797
|
deviceQuery,
|
|
729
798
|
query,
|
package/src/input.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// even available?" before it answers anything else.
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import * as control from './control.js';
|
|
6
|
+
import { geometryFor, inputDriverFor } from './platform/index.js';
|
|
6
7
|
import { promisify } from 'node:util';
|
|
7
8
|
|
|
8
9
|
const run = promisify(execFile);
|
|
@@ -20,6 +21,12 @@ let driverCache = null;
|
|
|
20
21
|
* iOS. idb remains the fallback so a machine without the daemon still works.
|
|
21
22
|
*/
|
|
22
23
|
export async function driverFor(udid) {
|
|
24
|
+
// A platform that carries its own input path answers first: there is no
|
|
25
|
+
// daemon to ask and no idb to fall back to, and reporting either for an
|
|
26
|
+
// Android emulator is how doctor came to claim "input driver: idb" about a
|
|
27
|
+
// tool that has never spoken to one.
|
|
28
|
+
const own = udid ? inputDriverFor(udid) : null;
|
|
29
|
+
if (own) return { name: own.id, available: true, version: own.detail, reason: null, viaSocket: false };
|
|
23
30
|
if (udid && control.available(udid)) {
|
|
24
31
|
try {
|
|
25
32
|
const status = await control.status(udid);
|
|
@@ -140,6 +147,15 @@ export async function screenInfo(udid, { refresh = false } = {}) {
|
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
async function readScreenInfo(udid) {
|
|
150
|
+
// The platform first, where it can answer at all: on Android it is the only
|
|
151
|
+
// source, and the alternative is `deviceGeometry`'s last-resort guess, which
|
|
152
|
+
// is an iPhone's numbers and silently wrong for everything else.
|
|
153
|
+
try {
|
|
154
|
+
const geo = await geometryFor(udid);
|
|
155
|
+
if (geo?.pointWidth && geo?.pointHeight) return geo;
|
|
156
|
+
} catch {
|
|
157
|
+
/* the backend could not say; the daemon or idb may still be able to */
|
|
158
|
+
}
|
|
143
159
|
// Ask the daemon first. It holds the device's own point size and scale, which
|
|
144
160
|
// makes it both authoritative and free — and it means geometry no longer
|
|
145
161
|
// needs idb at all. Going to idb first meant a machine without idb could
|
|
@@ -300,6 +316,11 @@ export function centerOf(node) {
|
|
|
300
316
|
|
|
301
317
|
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
|
302
318
|
const point = { x: Math.round(x), y: Math.round(y) };
|
|
319
|
+
const own = inputDriverFor(udid);
|
|
320
|
+
if (own) {
|
|
321
|
+
await own.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
|
|
322
|
+
return point;
|
|
323
|
+
}
|
|
303
324
|
if (control.available(udid)) {
|
|
304
325
|
await control.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
|
|
305
326
|
return point;
|
|
@@ -318,6 +339,15 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
|
318
339
|
}
|
|
319
340
|
|
|
320
341
|
export async function typeText(udid, value) {
|
|
342
|
+
const own = inputDriverFor(udid);
|
|
343
|
+
if (own) {
|
|
344
|
+
// No pasteboard on Android (docs/DEFERRED.md), so exact text goes through
|
|
345
|
+
// the same keystroke path as everything else. `event text` carries
|
|
346
|
+
// characters rather than key positions, so a non-Latin host layout does not
|
|
347
|
+
// reinterpret them — which is the reason the pasteboard exists on iOS.
|
|
348
|
+
await own.text(udid, String(value));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
321
351
|
if (control.available(udid)) {
|
|
322
352
|
// The daemon's paste path carries characters rather than key positions, so
|
|
323
353
|
// it is not reinterpreted by the device's keyboard layout.
|
|
@@ -329,6 +359,11 @@ export async function typeText(udid, value) {
|
|
|
329
359
|
|
|
330
360
|
/** Key events rather than text: for shortcuts and search-as-you-type. */
|
|
331
361
|
export async function typeKeys(udid, value) {
|
|
362
|
+
const own = inputDriverFor(udid);
|
|
363
|
+
if (own) {
|
|
364
|
+
await own.text(udid, String(value));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
332
367
|
if (control.available(udid)) {
|
|
333
368
|
await control.type(udid, String(value));
|
|
334
369
|
return;
|
|
@@ -337,6 +372,11 @@ export async function typeKeys(udid, value) {
|
|
|
337
372
|
}
|
|
338
373
|
|
|
339
374
|
export async function pressKey(udid, keycode) {
|
|
375
|
+
const own = inputDriverFor(udid);
|
|
376
|
+
if (own) {
|
|
377
|
+
await own.key(udid, keycode);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
340
380
|
await idb(['ui', 'key', '--udid', udid, String(keycode)]);
|
|
341
381
|
}
|
|
342
382
|
|
|
@@ -363,6 +403,13 @@ export async function resetSession(udid) {
|
|
|
363
403
|
}
|
|
364
404
|
|
|
365
405
|
export async function pressButton(udid, name) {
|
|
406
|
+
const own = inputDriverFor(udid);
|
|
407
|
+
if (own) {
|
|
408
|
+
// Android's whole key vocabulary is safe to offer: `input keyevent` takes
|
|
409
|
+
// names through a public API, so unlike Indigo there is nothing to guess.
|
|
410
|
+
await own.key(udid, name);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
366
413
|
if (control.available(udid)) {
|
|
367
414
|
try {
|
|
368
415
|
await control.press(udid, String(name).toLowerCase());
|
|
@@ -376,6 +423,11 @@ export async function pressButton(udid, name) {
|
|
|
376
423
|
}
|
|
377
424
|
|
|
378
425
|
export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
426
|
+
const own = inputDriverFor(udid);
|
|
427
|
+
if (own) {
|
|
428
|
+
await own.swipe(udid, from, to, { durationMs });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
379
431
|
if (control.available(udid)) {
|
|
380
432
|
await control.swipe(udid, from, to, { durationMs });
|
|
381
433
|
return;
|
package/src/mcp.js
CHANGED
|
@@ -14,14 +14,14 @@ import * as actions from './actions.js';
|
|
|
14
14
|
import * as api from './index.js';
|
|
15
15
|
import * as input from './input.js';
|
|
16
16
|
import * as navigate from './navigate.js';
|
|
17
|
-
import { bootedDevices,
|
|
17
|
+
import { bootedDevices, permissionServices } from './platform/index.js';
|
|
18
18
|
import * as store from './store.js';
|
|
19
19
|
import * as view from './view.js';
|
|
20
20
|
|
|
21
21
|
const deviceProp = {
|
|
22
22
|
device: {
|
|
23
23
|
type: 'string',
|
|
24
|
-
description: '
|
|
24
|
+
description: 'Device UDID or name substring — a simulator udid or an emulator serial. Defaults to the booted device.',
|
|
25
25
|
},
|
|
26
26
|
};
|
|
27
27
|
|
|
@@ -180,7 +180,7 @@ const TOOLS = [
|
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
182
|
name: 'sim_open_url',
|
|
183
|
-
description: 'Open a URL or deep link on the
|
|
183
|
+
description: 'Open a URL or deep link on the device — the fastest way to reach a screen when the app has a link for it.',
|
|
184
184
|
inputSchema: {
|
|
185
185
|
type: 'object',
|
|
186
186
|
properties: { ...deviceProp, url: { type: 'string' } },
|
|
@@ -189,7 +189,7 @@ const TOOLS = [
|
|
|
189
189
|
},
|
|
190
190
|
{
|
|
191
191
|
name: 'sim_permission',
|
|
192
|
-
description: `Grant, revoke or reset a privacy permission for an app. Do this instead of tapping the system alert: the alert is not part of the app under test, and its buttons move between
|
|
192
|
+
description: `Grant, revoke or reset a privacy permission for an app. Do this instead of tapping the system alert: the alert is not part of the app under test, and its buttons move between OS versions. Not every service exists on every platform — the device's own backend refuses one it does not have. Services: ${permissionServices().join(', ')}.`,
|
|
193
193
|
inputSchema: {
|
|
194
194
|
type: 'object',
|
|
195
195
|
properties: {
|
|
@@ -304,7 +304,7 @@ const TOOLS = [
|
|
|
304
304
|
},
|
|
305
305
|
{
|
|
306
306
|
name: 'sim_devices',
|
|
307
|
-
description: 'List booted iOS simulators
|
|
307
|
+
description: 'List the booted devices simframe can drive — iOS simulators and Android emulators.',
|
|
308
308
|
inputSchema: { type: 'object', properties: {} },
|
|
309
309
|
},
|
|
310
310
|
];
|
|
@@ -826,7 +826,7 @@ function listStateDirs() {
|
|
|
826
826
|
|
|
827
827
|
async function devices() {
|
|
828
828
|
const booted = await bootedDevices();
|
|
829
|
-
if (!booted.length) return { content: [text('no booted
|
|
829
|
+
if (!booted.length) return { content: [text('no booted devices')] };
|
|
830
830
|
return {
|
|
831
831
|
content: [text(booted.map((d) => `${d.name} · ${d.runtime} · ${d.udid}`).join('\n'))],
|
|
832
832
|
};
|