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/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, PERMISSION_SERVICES } from './simctl.js';
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: 'Simulator UDID or name substring. Defaults to the booted simulator.',
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 simulator — the fastest way to reach a screen when the app has a link for it.',
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 iOS versions. Services: ${PERMISSION_SERVICES.join(', ')}.`,
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 that simframe can capture.',
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 simulators')] };
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