simframe 0.6.2 → 0.7.2
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 +112 -8
- 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 +4 -2
- package/scripts/analyse-fingerprint.mjs +149 -0
- package/scripts/bench-flow.mjs +1 -1
- package/scripts/check-package.mjs +15 -0
- package/scripts/ci-memory.mjs +12 -0
- 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 +64 -14
- package/src/cli.js +125 -32
- package/src/daemon.js +42 -1
- package/src/engine.js +19 -6
- package/src/fingerprint.js +29 -1
- package/src/index.js +92 -13
- package/src/input.js +93 -0
- package/src/mcp.js +6 -6
- package/src/navigate.js +8 -1
- package/src/platform/android.js +997 -0
- package/src/platform/host.js +15 -0
- package/src/platform/index.js +263 -0
- package/src/{simctl.js → platform/ios.js} +111 -18
- package/src/store.js +32 -0
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 { capabilitiesFor, geometryFor, inputDriverFor, setPasteboard } 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
|
|
@@ -191,6 +207,14 @@ export async function describeAll(udid) {
|
|
|
191
207
|
/* daemon went away mid-call; fall through to idb */
|
|
192
208
|
}
|
|
193
209
|
}
|
|
210
|
+
// A platform with no accessibility tree is not a machine missing idb.
|
|
211
|
+
// `doctor` learned that when it reported "input driver: idb" for an emulator;
|
|
212
|
+
// this path had not, so every Android screen map carried a note telling the
|
|
213
|
+
// reader to brew-install a tool that has never spoken to an Android device —
|
|
214
|
+
// and on a machine where idb *is* installed, spawned it against an emulator
|
|
215
|
+
// serial on every map build.
|
|
216
|
+
const ax = capabilitiesFor(udid).ax;
|
|
217
|
+
if (!ax.supported) throw new Error(`no accessibility tree on this device — ${ax.note}`);
|
|
194
218
|
// Passing --json here yields empty output; the default already emits JSON.
|
|
195
219
|
const out = await idb(['ui', 'describe-all', '--udid', udid]);
|
|
196
220
|
const nodes = [];
|
|
@@ -300,6 +324,11 @@ export function centerOf(node) {
|
|
|
300
324
|
|
|
301
325
|
export async function tapPoint(udid, x, y, { durationMs } = {}) {
|
|
302
326
|
const point = { x: Math.round(x), y: Math.round(y) };
|
|
327
|
+
const own = inputDriverFor(udid);
|
|
328
|
+
if (own) {
|
|
329
|
+
await own.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
|
|
330
|
+
return point;
|
|
331
|
+
}
|
|
303
332
|
if (control.available(udid)) {
|
|
304
333
|
await control.tap(udid, point.x, point.y, durationMs ? { durationMs } : {});
|
|
305
334
|
return point;
|
|
@@ -318,6 +347,15 @@ export async function tapLabel(udid, query, { index, durationMs } = {}) {
|
|
|
318
347
|
}
|
|
319
348
|
|
|
320
349
|
export async function typeText(udid, value) {
|
|
350
|
+
const own = inputDriverFor(udid);
|
|
351
|
+
if (own) {
|
|
352
|
+
// No pasteboard on Android (docs/DEFERRED.md), so exact text goes through
|
|
353
|
+
// the same keystroke path as everything else. `event text` carries
|
|
354
|
+
// characters rather than key positions, so a non-Latin host layout does not
|
|
355
|
+
// reinterpret them — which is the reason the pasteboard exists on iOS.
|
|
356
|
+
await own.text(udid, String(value));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
321
359
|
if (control.available(udid)) {
|
|
322
360
|
// The daemon's paste path carries characters rather than key positions, so
|
|
323
361
|
// it is not reinterpreted by the device's keyboard layout.
|
|
@@ -327,8 +365,46 @@ export async function typeText(udid, value) {
|
|
|
327
365
|
await idb(['ui', 'text', '--udid', udid, String(value)]);
|
|
328
366
|
}
|
|
329
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Put text on the pasteboard **and deliver it** into the focused field.
|
|
370
|
+
*
|
|
371
|
+
* The delivery is the whole point, and it is what was missing: the `paste` step
|
|
372
|
+
* used to set the pasteboard, long-press the field, and report success while
|
|
373
|
+
* the field stayed empty — on both platforms, not just the one it was filed
|
|
374
|
+
* against. iOS had the mechanism and did not use it (the daemon's `paste` is
|
|
375
|
+
* pbcopy *plus* Cmd-V) and Android had the keycode sitting unused in `KEYS`.
|
|
376
|
+
*
|
|
377
|
+
* When nothing can deliver the keystroke this throws rather than returning a
|
|
378
|
+
* cheerful description of half the job. A step that cannot do what it says has
|
|
379
|
+
* to say so; `type` still works on every device.
|
|
380
|
+
*/
|
|
381
|
+
export async function pasteText(udid, value) {
|
|
382
|
+
const own = inputDriverFor(udid);
|
|
383
|
+
if (own?.key) {
|
|
384
|
+
// The clipboard goes over gRPC; KEYCODE_PASTE is what puts it in the field.
|
|
385
|
+
await setPasteboard(udid, String(value));
|
|
386
|
+
await own.key(udid, 'paste');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (control.available(udid)) {
|
|
390
|
+
// One round trip: the daemon copies and presses Cmd-V.
|
|
391
|
+
await control.paste(udid, String(value));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
await setPasteboard(udid, String(value));
|
|
395
|
+
throw new Error(
|
|
396
|
+
'the text is on the pasteboard but nothing here can paste it: the keystroke needs the simframe ' +
|
|
397
|
+
'daemon (start it with `simframe start`), or use `type`, which carries the characters itself',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
330
401
|
/** Key events rather than text: for shortcuts and search-as-you-type. */
|
|
331
402
|
export async function typeKeys(udid, value) {
|
|
403
|
+
const own = inputDriverFor(udid);
|
|
404
|
+
if (own) {
|
|
405
|
+
await own.text(udid, String(value));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
332
408
|
if (control.available(udid)) {
|
|
333
409
|
await control.type(udid, String(value));
|
|
334
410
|
return;
|
|
@@ -337,6 +413,11 @@ export async function typeKeys(udid, value) {
|
|
|
337
413
|
}
|
|
338
414
|
|
|
339
415
|
export async function pressKey(udid, keycode) {
|
|
416
|
+
const own = inputDriverFor(udid);
|
|
417
|
+
if (own) {
|
|
418
|
+
await own.key(udid, keycode);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
340
421
|
await idb(['ui', 'key', '--udid', udid, String(keycode)]);
|
|
341
422
|
}
|
|
342
423
|
|
|
@@ -363,6 +444,13 @@ export async function resetSession(udid) {
|
|
|
363
444
|
}
|
|
364
445
|
|
|
365
446
|
export async function pressButton(udid, name) {
|
|
447
|
+
const own = inputDriverFor(udid);
|
|
448
|
+
if (own) {
|
|
449
|
+
// Android's whole key vocabulary is safe to offer: `input keyevent` takes
|
|
450
|
+
// names through a public API, so unlike Indigo there is nothing to guess.
|
|
451
|
+
await own.key(udid, name);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
366
454
|
if (control.available(udid)) {
|
|
367
455
|
try {
|
|
368
456
|
await control.press(udid, String(name).toLowerCase());
|
|
@@ -376,6 +464,11 @@ export async function pressButton(udid, name) {
|
|
|
376
464
|
}
|
|
377
465
|
|
|
378
466
|
export async function swipe(udid, from, to, { durationMs = 300 } = {}) {
|
|
467
|
+
const own = inputDriverFor(udid);
|
|
468
|
+
if (own) {
|
|
469
|
+
await own.swipe(udid, from, to, { durationMs });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
379
472
|
if (control.available(udid)) {
|
|
380
473
|
await control.swipe(udid, from, to, { durationMs });
|
|
381
474
|
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
|
};
|
package/src/navigate.js
CHANGED
|
@@ -51,6 +51,13 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
|
|
|
51
51
|
return { ok: true, already: true, screen: found.name, steps: [] };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// `hashTokens` returns null for an empty token set on purpose — a constant
|
|
55
|
+
// hash for "I could read nothing" is the self-confirming-emptiness bug. So a
|
|
56
|
+
// screen with no identity has to be reported, not sliced: this threw
|
|
57
|
+
// `Cannot read properties of null (reading 'slice')` instead of answering.
|
|
58
|
+
// Not hypothetical on Android, where README's own table puts the launcher at
|
|
59
|
+
// one token.
|
|
60
|
+
if (!here.hash) return { ok: false, reason: 'no-identity', to: found.name };
|
|
54
61
|
const path_ = graph.route(udid, { hash: here.hash, tokens: here.tokens }, found.node.hash);
|
|
55
62
|
if (!path_) return { ok: false, reason: 'no-route', from: here.hash.slice(0, 8), to: found.name };
|
|
56
63
|
|
|
@@ -65,7 +72,7 @@ export async function goto(deviceQuery, target, { options, ...runOptions } = {})
|
|
|
65
72
|
steps,
|
|
66
73
|
ranSteps: result.ranSteps,
|
|
67
74
|
results: result.results,
|
|
68
|
-
arrived: arrived.hash.slice(0, 8),
|
|
75
|
+
arrived: arrived.hash ? arrived.hash.slice(0, 8) : null,
|
|
69
76
|
};
|
|
70
77
|
}
|
|
71
78
|
|