@avasapp/agent-bridge 0.4.2 → 0.5.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.
@@ -76,11 +76,11 @@ async function listCdpTargets(metro, signal) {
76
76
  );
77
77
  return await res.json();
78
78
  }
79
- function pickOne(items, filter, label) {
79
+ function pickOne(items, filter, label2) {
80
80
  const matching = filter ? items.filter(
81
- (item) => label(item).toLowerCase().includes(filter.toLowerCase())
81
+ (item) => label2(item).toLowerCase().includes(filter.toLowerCase())
82
82
  ) : items;
83
- const connected = items.map(label).join("; ") || "none";
83
+ const connected = items.map(label2).join("; ") || "none";
84
84
  if (matching.length === 0) {
85
85
  throw new Error(
86
86
  filter ? `No app matches "${filter}". Connected: ${connected}` : "No app is connected to Metro."
@@ -93,11 +93,11 @@ function pickOne(items, filter, label) {
93
93
  }
94
94
  if (matching.length > 1) {
95
95
  const exact = matching.filter(
96
- (item) => label(item).toLowerCase() === filter?.toLowerCase()
96
+ (item) => label2(item).toLowerCase() === filter?.toLowerCase()
97
97
  );
98
98
  if (exact.length === 1) return exact[0];
99
99
  throw new Error(
100
- `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label).join("; ")}`
100
+ `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label2).join("; ")}`
101
101
  );
102
102
  }
103
103
  return matching[0];
@@ -295,6 +295,206 @@ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
295
295
  };
296
296
  }
297
297
 
298
+ // src/client/session/state.ts
299
+ var import_node_crypto = require("crypto");
300
+ var import_node_fs = require("fs");
301
+ var import_node_os = require("os");
302
+ var import_node_path = require("path");
303
+ function projectRoot(from = process.cwd()) {
304
+ for (let dir = (0, import_node_path.resolve)(from); ; dir = (0, import_node_path.dirname)(dir)) {
305
+ if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"))) return dir;
306
+ if ((0, import_node_path.dirname)(dir) === dir) return (0, import_node_path.resolve)(from);
307
+ }
308
+ }
309
+ var uid = () => process.getuid?.() ?? (0, import_node_os.userInfo)().username;
310
+ function privateDir(dir) {
311
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true, mode: 448 });
312
+ const stat = (0, import_node_fs.lstatSync)(dir);
313
+ if (!stat.isDirectory())
314
+ throw new Error(`${dir} is not a directory; remove it and retry`);
315
+ if (process.getuid && stat.uid !== process.getuid())
316
+ throw new Error(`${dir} belongs to another user; refusing to use it`);
317
+ if (process.platform !== "win32" && (stat.mode & 63) !== 0)
318
+ (0, import_node_fs.chmodSync)(dir, 448);
319
+ return dir;
320
+ }
321
+ function stateDir() {
322
+ return privateDir(
323
+ process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? (0, import_node_path.join)(process.env.XDG_RUNTIME_DIR, "agent-bridge") : (0, import_node_path.join)((0, import_node_os.tmpdir)(), `agent-bridge-${uid()}`))
324
+ );
325
+ }
326
+ var shortHash = (text) => (0, import_node_crypto.createHash)("sha256").update(text).digest("hex").slice(0, 16);
327
+ function socketPath(name, dir) {
328
+ if (process.platform === "win32")
329
+ return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
330
+ const path = (0, import_node_path.join)(dir, `${name}.sock`);
331
+ if (Buffer.byteLength(path) <= 100) return path;
332
+ const short = privateDir(`/tmp/agent-bridge-${uid()}`);
333
+ return (0, import_node_path.join)(short, `${shortHash(path)}.sock`);
334
+ }
335
+ var sessionFiles = (name, dir = stateDir()) => ({
336
+ state: (0, import_node_path.join)(dir, `${name}.json`),
337
+ error: (0, import_node_path.join)(dir, `${name}.error`),
338
+ log: (0, import_node_path.join)(dir, `${name}.log`),
339
+ socket: socketPath(name, dir)
340
+ });
341
+ function checkName(name) {
342
+ if (!/^[\w.-]{1,40}$/.test(name))
343
+ throw new Error(
344
+ `Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
345
+ );
346
+ return name;
347
+ }
348
+ function isAlive(pid) {
349
+ try {
350
+ process.kill(pid, 0);
351
+ return true;
352
+ } catch (error) {
353
+ return error.code === "EPERM";
354
+ }
355
+ }
356
+ function removeSessionFiles(name, dir = stateDir()) {
357
+ const files = sessionFiles(name, dir);
358
+ (0, import_node_fs.rmSync)(files.state, { force: true });
359
+ if (process.platform !== "win32") (0, import_node_fs.rmSync)(files.socket, { force: true });
360
+ }
361
+ function readState(file) {
362
+ try {
363
+ return JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+ function listSessions(dir = stateDir()) {
369
+ const live = [];
370
+ for (const entry of (0, import_node_fs.readdirSync)(dir)) {
371
+ if (!entry.endsWith(".json")) continue;
372
+ const state = readState((0, import_node_path.join)(dir, entry));
373
+ if (!state) continue;
374
+ if (isAlive(state.pid)) live.push(state);
375
+ else removeSessionFiles(state.name, dir);
376
+ }
377
+ return live.sort((a, b) => a.startedAt - b.startedAt);
378
+ }
379
+ function readSession(name, dir = stateDir()) {
380
+ return listSessions(dir).find((s) => s.name === name) ?? null;
381
+ }
382
+ function pickSession(filter) {
383
+ if (filter.name) {
384
+ const named = readSession(checkName(filter.name));
385
+ if (!named) throw new Error(`No session named "${filter.name}" is running`);
386
+ return named;
387
+ }
388
+ const device = filter.device?.toLowerCase();
389
+ const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
390
+ const project = projectRoot();
391
+ const matching = listSessions().filter(
392
+ (s) => (explicit || s.project === project) && (!filter.metro || s.metro === filter.metro) && (!device || s.device.name.toLowerCase().includes(device) || s.device.deviceId.toLowerCase().includes(device)) && (!filter.transport || filter.transport === "auto" || filter.transport === s.transport)
393
+ );
394
+ if (matching.length > 1)
395
+ throw new Error(
396
+ `Several sessions match (${matching.map((s) => `${s.name}: ${s.device.name} on ${s.metro}`).join("; ")}); pick one by name (--session, or --name for session stop/status), or narrow with --metro/--device`
397
+ );
398
+ return matching[0] ?? null;
399
+ }
400
+
401
+ // src/client/metro-hint.ts
402
+ var range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
403
+ var probe = {
404
+ ports: [...range(8081, 8090), ...range(8100, 8199)],
405
+ eachMs: 300,
406
+ totalMs: 1e3
407
+ };
408
+ var hostOf = (metro) => {
409
+ const colon = metro.lastIndexOf(":");
410
+ return colon > 0 && !metro.endsWith("]") ? metro.slice(0, colon) : metro;
411
+ };
412
+ async function answers(metro, signal) {
413
+ try {
414
+ const res = await fetch(`http://${metro}/status`, { signal });
415
+ return (await res.text()).includes("packager-status:running");
416
+ } catch {
417
+ return false;
418
+ }
419
+ }
420
+ async function appsOn(metro, signal) {
421
+ try {
422
+ const res = await fetch(`http://${metro}/json/list`, { signal });
423
+ const targets = await res.json();
424
+ const names = targets.map(
425
+ (t) => `${t.title ?? "?"}${t.deviceName ? ` [${t.deviceName}]` : ""}`
426
+ );
427
+ return [...new Set(names)];
428
+ } catch {
429
+ return [];
430
+ }
431
+ }
432
+ async function findMetros(metro) {
433
+ const host = hostOf(metro);
434
+ const candidates = new Set(probe.ports.map((port) => `${host}:${port}`));
435
+ try {
436
+ for (const s of listSessions()) candidates.add(s.metro);
437
+ } catch {
438
+ }
439
+ candidates.delete(metro);
440
+ const deadline = AbortSignal.timeout(probe.totalMs);
441
+ const within2 = () => AbortSignal.any([deadline, AbortSignal.timeout(probe.eachMs)]);
442
+ const running = (await Promise.all(
443
+ [...candidates].map(async (m) => await answers(m, within2()) ? m : null)
444
+ )).filter((m) => m !== null);
445
+ return Promise.all(
446
+ running.map(async (m) => ({ metro: m, apps: await appsOn(m, within2()) }))
447
+ );
448
+ }
449
+ var label = (found) => `${found.metro} (${found.apps.length ? `${found.apps.length === 1 ? "app" : "apps"}: ${found.apps.join(", ")}` : "no app connected"})`;
450
+ var howToSet = (metro) => `Pass --metro ${metro} or set AGENT_BRIDGE_METRO=${metro}.`;
451
+ function elsewhere(found) {
452
+ const [only] = found;
453
+ if (found.length === 1 && only)
454
+ return `Metro is running on ${label(only)}. ${howToSet(only.metro)}`;
455
+ return `Metro is running on ${found.map(label).join("; ")}. ${howToSet("<host:port>")}`;
456
+ }
457
+ var errorText = (error) => {
458
+ if (!(error instanceof Error)) return String(error);
459
+ const code = error.code ?? "";
460
+ return `${error.message} ${code} ${error.cause ? errorText(error.cause) : ""}`;
461
+ };
462
+ var isRefused = (error) => /ECONNREFUSED|ConnectionRefused/.test(errorText(error));
463
+ var NO_APP = /No app is connected to Metro|No app matches/;
464
+ async function refusedMessage(metro) {
465
+ const found = await findMetros(metro);
466
+ if (found.length)
467
+ return `Nothing is listening on ${metro}. ${elsewhere(found)}`;
468
+ const host = hostOf(metro);
469
+ return `Nothing is listening on ${metro}, and no Metro answered on ${host} ports 8081-8090 or 8100-8199. Start Metro, or if it runs elsewhere, pass --metro <host:port> or set AGENT_BRIDGE_METRO=<host:port>.`;
470
+ }
471
+ async function noAppHint(metro, device) {
472
+ const causes = `Likely: the app isn't open or hasn't reloaded since the bridge was mounted, it uses another Metro${device ? `, or --device "${device}" doesn't match` : ""}.`;
473
+ const withApps = (await findMetros(metro)).filter((f) => f.apps.length);
474
+ return withApps.length ? `${causes} ${elsewhere(withApps)}` : causes;
475
+ }
476
+ async function refused(metro) {
477
+ try {
478
+ await fetch(`http://${metro}/status`, {
479
+ signal: AbortSignal.timeout(probe.eachMs)
480
+ });
481
+ return false;
482
+ } catch (error) {
483
+ return isRefused(error);
484
+ }
485
+ }
486
+ async function explainConnectError(error, metro, device) {
487
+ const message = error instanceof Error ? error.message : String(error);
488
+ if (NO_APP.test(message))
489
+ return new Error(`${message}
490
+ ${await noAppHint(metro, device)}`, {
491
+ cause: error
492
+ });
493
+ if (await refused(metro))
494
+ return new Error(await refusedMessage(metro), { cause: error });
495
+ return error;
496
+ }
497
+
298
498
  // src/client/cdp.ts
299
499
  var import_ws2 = __toESM(require("ws"), 1);
300
500
  var CannotEvaluate = class extends Error {
@@ -529,7 +729,7 @@ async function runFlow(bridge, flow, options = {}) {
529
729
  }
530
730
  };
531
731
  const call = async (tool, ...args) => (await timed(tool, ...args)).value;
532
- const step = async (label, tool, ...given) => {
732
+ const step = async (label2, tool, ...given) => {
533
733
  const args = given.length === 1 && Array.isArray(given[0]) ? given[0] : given;
534
734
  const { value, ms, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
535
735
  report(failedLogs(error), failedNotice(error));
@@ -537,7 +737,7 @@ async function runFlow(bridge, flow, options = {}) {
537
737
  });
538
738
  total += ms;
539
739
  print(
540
- `${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
740
+ `${String(++n).padStart(2, "0")} ${label2.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
541
741
  );
542
742
  report(logs, notice);
543
743
  return value;
@@ -589,109 +789,6 @@ var import_node_readline = require("readline");
589
789
  var encodeLine = (message) => `${JSON.stringify(message).replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`)}
590
790
  `;
591
791
 
592
- // src/client/session/state.ts
593
- var import_node_crypto = require("crypto");
594
- var import_node_fs = require("fs");
595
- var import_node_os = require("os");
596
- var import_node_path = require("path");
597
- function projectRoot(from = process.cwd()) {
598
- for (let dir = (0, import_node_path.resolve)(from); ; dir = (0, import_node_path.dirname)(dir)) {
599
- if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"))) return dir;
600
- if ((0, import_node_path.dirname)(dir) === dir) return (0, import_node_path.resolve)(from);
601
- }
602
- }
603
- var uid = () => process.getuid?.() ?? (0, import_node_os.userInfo)().username;
604
- function privateDir(dir) {
605
- (0, import_node_fs.mkdirSync)(dir, { recursive: true, mode: 448 });
606
- const stat = (0, import_node_fs.lstatSync)(dir);
607
- if (!stat.isDirectory())
608
- throw new Error(`${dir} is not a directory; remove it and retry`);
609
- if (process.getuid && stat.uid !== process.getuid())
610
- throw new Error(`${dir} belongs to another user; refusing to use it`);
611
- if (process.platform !== "win32" && (stat.mode & 63) !== 0)
612
- (0, import_node_fs.chmodSync)(dir, 448);
613
- return dir;
614
- }
615
- function stateDir() {
616
- return privateDir(
617
- process.env.AGENT_BRIDGE_STATE_DIR ?? (process.env.XDG_RUNTIME_DIR ? (0, import_node_path.join)(process.env.XDG_RUNTIME_DIR, "agent-bridge") : (0, import_node_path.join)((0, import_node_os.tmpdir)(), `agent-bridge-${uid()}`))
618
- );
619
- }
620
- var shortHash = (text) => (0, import_node_crypto.createHash)("sha256").update(text).digest("hex").slice(0, 16);
621
- function socketPath(name, dir) {
622
- if (process.platform === "win32")
623
- return `\\\\.\\pipe\\agent-bridge-${shortHash(dir)}-${name}`;
624
- const path = (0, import_node_path.join)(dir, `${name}.sock`);
625
- if (Buffer.byteLength(path) <= 100) return path;
626
- const short = privateDir(`/tmp/agent-bridge-${uid()}`);
627
- return (0, import_node_path.join)(short, `${shortHash(path)}.sock`);
628
- }
629
- var sessionFiles = (name, dir = stateDir()) => ({
630
- state: (0, import_node_path.join)(dir, `${name}.json`),
631
- error: (0, import_node_path.join)(dir, `${name}.error`),
632
- log: (0, import_node_path.join)(dir, `${name}.log`),
633
- socket: socketPath(name, dir)
634
- });
635
- function checkName(name) {
636
- if (!/^[\w.-]{1,40}$/.test(name))
637
- throw new Error(
638
- `Session name "${name}" must be 1-40 letters, digits, ".", "_" or "-"`
639
- );
640
- return name;
641
- }
642
- function isAlive(pid) {
643
- try {
644
- process.kill(pid, 0);
645
- return true;
646
- } catch (error) {
647
- return error.code === "EPERM";
648
- }
649
- }
650
- function removeSessionFiles(name, dir = stateDir()) {
651
- const files = sessionFiles(name, dir);
652
- (0, import_node_fs.rmSync)(files.state, { force: true });
653
- if (process.platform !== "win32") (0, import_node_fs.rmSync)(files.socket, { force: true });
654
- }
655
- function readState(file) {
656
- try {
657
- return JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
658
- } catch {
659
- return null;
660
- }
661
- }
662
- function listSessions(dir = stateDir()) {
663
- const live = [];
664
- for (const entry of (0, import_node_fs.readdirSync)(dir)) {
665
- if (!entry.endsWith(".json")) continue;
666
- const state = readState((0, import_node_path.join)(dir, entry));
667
- if (!state) continue;
668
- if (isAlive(state.pid)) live.push(state);
669
- else removeSessionFiles(state.name, dir);
670
- }
671
- return live.sort((a, b) => a.startedAt - b.startedAt);
672
- }
673
- function readSession(name, dir = stateDir()) {
674
- return listSessions(dir).find((s) => s.name === name) ?? null;
675
- }
676
- function pickSession(filter) {
677
- if (filter.name) {
678
- const named = readSession(checkName(filter.name));
679
- if (!named) throw new Error(`No session named "${filter.name}" is running`);
680
- return named;
681
- }
682
- const device = filter.device?.toLowerCase();
683
- const explicit = !!(filter.metro || device || filter.transport && filter.transport !== "auto");
684
- const project = projectRoot();
685
- const matching = listSessions().filter(
686
- (s) => (explicit || s.project === project) && (!filter.metro || s.metro === filter.metro) && (!device || s.device.name.toLowerCase().includes(device) || s.device.deviceId.toLowerCase().includes(device)) && (!filter.transport || filter.transport === "auto" || filter.transport === s.transport)
687
- );
688
- if (matching.length > 1)
689
- throw new Error(
690
- `Several sessions match (${matching.map((s) => `${s.name}: ${s.device.name} on ${s.metro}`).join("; ")}); pick one by name (--session, or --name for session stop/status), or narrow with --metro/--device`
691
- );
692
- return matching[0] ?? null;
693
- }
694
-
695
792
  // src/client/session/client.ts
696
793
  function openSessionLink(state) {
697
794
  return new Promise((resolve2, reject) => {
@@ -813,7 +910,9 @@ Warning: ${notice}` : ""}`);
813
910
  };
814
911
  async function connect(options = {}) {
815
912
  const timeoutMs = options.timeoutMs ?? 1e4;
816
- const conn = await openConnection(options);
913
+ const conn = await openConnection(options).catch(async (error) => {
914
+ throw await explainConnectError(error, metroHost(options.metro), options.device);
915
+ });
817
916
  const timed = async (tool, ...args) => {
818
917
  const t0 = performance.now();
819
918
  const result = await conn.call(tool, args, timeoutMs);
@@ -5,7 +5,7 @@ import {
5
5
  listDevices,
6
6
  listSessions,
7
7
  runFlow
8
- } from "../chunk-TA2OLPPT.js";
8
+ } from "../chunk-3QIG7A5W.js";
9
9
  export {
10
10
  AgentBridgeCallError,
11
11
  connect,
@@ -515,7 +515,12 @@ var SWITCH_HOSTS = /* @__PURE__ */ new Set(["RCTSwitch", "AndroidSwitch"]);
515
515
  var isFn = (v) => typeof v === "function";
516
516
  var isInputProps = (p) => isFn(p.onChangeText) || isFn(p.onChange) && typeof p.value !== "boolean" && ("value" in p || "defaultValue" in p || "placeholder" in p);
517
517
  var labelOf = (p) => p.accessibilityLabel ?? p["aria-label"];
518
- var interesting = (p) => isFn(p.onPress) || isFn(p.onValueChange) || isInputProps(p) || typeof p.testID === "string" || typeof labelOf(p) === "string" || webViewNameOf(p) !== void 0;
518
+ function actionNamesOf(p) {
519
+ if (!isFn(p.onAccessibilityAction) || !Array.isArray(p.accessibilityActions)) return null;
520
+ return p.accessibilityActions.map((a) => a?.name).filter((name) => typeof name === "string");
521
+ }
522
+ var activates = (rec) => !!rec.actions && !!rec.element.actions?.includes("activate");
523
+ var interesting = (p) => isFn(p.onPress) || actionNamesOf(p) !== null || isFn(p.onValueChange) || isInputProps(p) || typeof p.testID === "string" || typeof labelOf(p) === "string" || webViewNameOf(p) !== void 0;
519
524
  var ICON_GLYPHS = /[\uE000-\uF8FF]/g;
520
525
  function firstHost(fiber) {
521
526
  if (fiber.tag === HOST_COMPONENT) return fiber;
@@ -532,6 +537,11 @@ function absorb(rec, fiber, p) {
532
537
  const e = rec.element;
533
538
  if (!rec.input && isInputProps(p)) rec.input = fiber;
534
539
  if (!rec.press && isFn(p.onPress)) rec.press = fiber;
540
+ const actions = actionNamesOf(p);
541
+ if (!rec.actions && actions) {
542
+ rec.actions = fiber;
543
+ if (actions.length) e.actions = actions;
544
+ }
535
545
  const fill = (key, v) => {
536
546
  if (e[key] === void 0 && typeof v === "string") e[key] = v;
537
547
  };
@@ -617,8 +627,8 @@ var round = (r) => r && {
617
627
  var handles = (control, p, name) => !!control && isFn(p[name]) && propsOf(control)?.[name] === p[name];
618
628
  function sameControl(rec, p) {
619
629
  for (let r = rec; r; r = r.parent) {
620
- if (!r.press && !r.input) continue;
621
- const same = handles(r.press, p, "onPress") || handles(r.input, p, "onChangeText") || handles(r.input, p, "onChange");
630
+ if (!r.press && !r.input && !r.actions) continue;
631
+ const same = handles(r.press, p, "onPress") || handles(r.actions, p, "onAccessibilityAction") || handles(r.input, p, "onChangeText") || handles(r.input, p, "onChange");
622
632
  return same ? r : null;
623
633
  }
624
634
  return null;
@@ -648,6 +658,7 @@ function collectScreen(roots, window) {
648
658
  host,
649
659
  press: null,
650
660
  input: null,
661
+ actions: null,
651
662
  hidden,
652
663
  scroller,
653
664
  modal,
@@ -661,7 +672,7 @@ function collectScreen(roots, window) {
661
672
  return rec;
662
673
  };
663
674
  const buttonOf = (rec) => {
664
- for (let r = rec; r; r = r.parent) if (r.press) return r;
675
+ for (let r = rec; r; r = r.parent) if (r.press || activates(r)) return r;
665
676
  return null;
666
677
  };
667
678
  const textHost = (host) => {
@@ -752,7 +763,7 @@ function collectScreen(roots, window) {
752
763
  rec.hidden || (rec.hidden = covers2(rec));
753
764
  const text = rec.parts.map((part) => part.text.replace(ICON_GLYPHS, "").replace(/\s+/g, " ").trim()).filter(Boolean).join(" ");
754
765
  if (text) e.text = text;
755
- e.kind = e.webview !== void 0 ? "webview" : rec.input ? "input" : rec.press ? "button" : rec.parts.length ? "text" : "view";
766
+ e.kind = e.webview !== void 0 ? "webview" : rec.input ? "input" : rec.press || activates(rec) ? "button" : rec.parts.length ? "text" : "view";
756
767
  if (e.kind === "input" && e.editable === void 0) e.editable = true;
757
768
  const rect = measureHost(rec.host);
758
769
  e.rect = round(rect);
@@ -777,10 +788,21 @@ function collectScreen(roots, window) {
777
788
  }
778
789
  var focused = (found) => found.filter((f) => !f.hidden);
779
790
  function pressFiberOf(found) {
780
- if (found.press) return found.press;
791
+ if (found.press) return { fiber: found.press, via: "onPress" };
792
+ if (activates(found)) return { fiber: found.actions, via: "accessibilityAction" };
793
+ for (let f = found.fiber.return; f; f = f.return) {
794
+ const p = propsOf(f);
795
+ if (!p) continue;
796
+ if (isFn(p.onPress)) return { fiber: f, via: "onPress" };
797
+ if (actionNamesOf(p)?.includes("activate")) return { fiber: f, via: "accessibilityAction" };
798
+ }
799
+ return null;
800
+ }
801
+ function actionFiberOf(found, name) {
802
+ if (found.actions && found.element.actions?.includes(name)) return found.actions;
781
803
  for (let f = found.fiber.return; f; f = f.return) {
782
804
  const p = propsOf(f);
783
- if (p && isFn(p.onPress)) return f;
805
+ if (p && actionNamesOf(p)?.includes(name)) return f;
784
806
  }
785
807
  return null;
786
808
  }
@@ -824,7 +846,7 @@ function tiers(target) {
824
846
  (e) => fields(e) && !!e.text?.includes(text)
825
847
  ];
826
848
  }
827
- var labelOnly = ({ element: e, press, input }) => e.kind === "view" && !e.testID && !e.text && !e.placeholder && !press && !input;
849
+ var labelOnly = ({ element: e, press, input, actions }) => e.kind === "view" && !e.testID && !e.text && !e.placeholder && !press && !input && !actions;
828
850
  function matchTarget(found, target) {
829
851
  checkTarget(target);
830
852
  for (const pool of [found.filter((f) => !labelOnly(f)), found.filter(labelOnly)]) {
@@ -942,13 +964,36 @@ function fillInput(found, text, options = {}) {
942
964
  return value;
943
965
  }
944
966
  var center = (rect) => rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : { x: 0, y: 0 };
967
+ var disabledProps = (fiber) => {
968
+ const p = propsOf(fiber);
969
+ const state = p?.accessibilityState;
970
+ return p?.disabled === true || state?.disabled === true || p?.["aria-disabled"] === true;
971
+ };
972
+ function pressTargetOf(found, action) {
973
+ if (action === void 0) return pressFiberOf(found);
974
+ const fiber = actionFiberOf(found, action);
975
+ return fiber && { fiber, via: "accessibilityAction" };
976
+ }
945
977
  function pressElement(found, options = {}) {
946
- const fiber = pressFiberOf(found);
947
- if (!fiber) throw new Error(`${describe(found.element)} has no onPress`);
948
- if (!options.force && (found.element.disabled || propsOf(fiber)?.disabled === true))
978
+ const { action } = options;
979
+ const target = pressTargetOf(found, action);
980
+ if (!target) {
981
+ const names = found.element.actions ?? [];
982
+ const has = names.length ? `; it has ${names.map((a) => JSON.stringify(a)).join(", ")}` : "";
983
+ throw new Error(
984
+ action === void 0 ? `${describe(found.element)} has no onPress or "activate" accessibility action` : `${describe(found.element)} has no accessibility action ${JSON.stringify(action)}${has}`
985
+ );
986
+ }
987
+ const { fiber, via } = target;
988
+ if (!options.force && (found.element.disabled || disabledProps(fiber)))
949
989
  throw new Error(
950
990
  `${describe(found.element)} is disabled; pass {"force":true} as the last argument to press anyway`
951
991
  );
992
+ if (via === "accessibilityAction") {
993
+ const actionName = action ?? "activate";
994
+ handler(fiber, "onAccessibilityAction")?.(event({ actionName }));
995
+ return `accessibilityAction:${actionName}`;
996
+ }
952
997
  const { x, y } = center(found.element.rect);
953
998
  const e = () => event({
954
999
  locationX: found.element.rect ? found.element.rect.width / 2 : 0,
@@ -960,6 +1005,7 @@ function pressElement(found, options = {}) {
960
1005
  handler(fiber, "onPressIn")?.(e());
961
1006
  handler(fiber, "onPress")?.(e());
962
1007
  handler(fiber, "onPressOut")?.(e());
1008
+ return "onPress";
963
1009
  }
964
1010
 
965
1011
  // src/runtime/screen/find-text.ts
@@ -1424,10 +1470,17 @@ function createScreen(env) {
1424
1470
  return { filled, element: reread(found).element };
1425
1471
  },
1426
1472
  async press(target, options = {}) {
1427
- const found = await resolve(target, (f) => !!f.press, options);
1428
- pressElement(found, options);
1473
+ const { action } = options;
1474
+ if (action !== void 0 && typeof action !== "string")
1475
+ throw new Error(`"action" must be an accessibility action name; got ${JSON.stringify(action)}`);
1476
+ const found = await resolve(
1477
+ target,
1478
+ (f) => action === void 0 ? !!f.press || f.element.kind === "button" : !!f.element.actions?.includes(action),
1479
+ options
1480
+ );
1481
+ const via = pressElement(found, options);
1429
1482
  await settle();
1430
- return found.element;
1483
+ return via === "onPress" ? found.element : { ...found.element, via };
1431
1484
  },
1432
1485
  /**
1433
1486
  * Scrolls the nearest scrollable of a target (or `within`, or the main one
@@ -1525,7 +1578,7 @@ function screenTools() {
1525
1578
  run: (text, options) => screen.findText(text, options)
1526
1579
  },
1527
1580
  "screen.snapshot": {
1528
- description: "Buttons, inputs, text, and testID or labelled views on screen, with rects and checked/selected/expanded state (a Switch's value is checked). Only the focused screen: unfocused tabs and stack screens, content under an open modal, display:none and accessibility-hidden subtrees are left out, and `hidden` counts the elements skipped. {all:true} lists everything, marking off-screen (onScreen:false) and hidden (hidden:true) ones. A WebView registered with the react-native-webview adapter shows as one `webview` element with its name: use webview.* inside it.",
1581
+ description: 'Buttons, inputs, text, and testID or labelled views on screen, with rects and checked/selected/expanded state (a Switch\'s value is checked). `actions` lists the accessibility actions an element handles; one with "activate" and no onPress (a swipe-to-confirm) is a button screen.press can press. Only the focused screen: unfocused tabs and stack screens, content under an open modal, display:none and accessibility-hidden subtrees are left out, and `hidden` counts the elements skipped. {all:true} lists everything, marking off-screen (onScreen:false) and hidden (hidden:true) ones. A WebView registered with the react-native-webview adapter shows as one `webview` element with its name: use webview.* inside it.',
1529
1582
  run: (options) => screen.snapshot(options)
1530
1583
  },
1531
1584
  "screen.fill": {
@@ -1533,7 +1586,7 @@ function screenTools() {
1533
1586
  run: (target, text, options) => screen.fill(target, text, options)
1534
1587
  },
1535
1588
  "screen.press": {
1536
- description: `Press a button, then wait for the render. ${TARGET_HELP} Disabled elements are refused; {force:true} presses anyway, to see what the app does on tap. {scroll:true} scrolls an off-screen target into view first.`,
1589
+ description: `Press a button, then wait for the render. ${TARGET_HELP} Disabled elements are refused; {force:true} presses anyway, to see what the app does on tap. {scroll:true} scrolls an off-screen target into view first. With no onPress it sends the "activate" accessibility action, as a screen reader's double tap does (swipe-to-confirm buttons, sliders), and the reply says via:"accessibilityAction:activate". {action:"increment"} (or any name in the element's snapshot \`actions\`) sends that action instead.`,
1537
1590
  run: (target, options) => screen.press(target, options)
1538
1591
  },
1539
1592
  "screen.scroll": {
@@ -82,6 +82,8 @@ type ScreenElement = {
82
82
  expanded?: boolean;
83
83
  /** A WebView registered with the react-native-webview adapter: drive it with `webview.*`. */
84
84
  webview?: string;
85
+ /** `accessibilityActions` it handles with `onAccessibilityAction`, e.g. `activate` on a swipe button. */
86
+ actions?: string[];
85
87
  rect: Rect | null;
86
88
  /** Only in `screen.snapshot({ all: true })`. */
87
89
  onScreen?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avasapp/agent-bridge",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Let coding agents drive a running React Native app directly: seed state, flip flags, navigate and check the screen in about a millisecond per call, with no taps.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -103,8 +103,9 @@
103
103
  "build": "bun run generate && rm -rf dist && tsup",
104
104
  "test": "bun run generate && bun test src/",
105
105
  "typecheck": "bun run generate && tsc -p tsconfig.json --noEmit",
106
- "check": "bun run typecheck && bun run test && bun run build && bun run check:gates",
106
+ "check": "bun run typecheck && bun run test && bun run build && bun run check:gates && bun run check:consumer-types",
107
107
  "check:gates": "node scripts/check-gates.mjs",
108
+ "check:consumer-types": "node scripts/check-consumer-types.mjs",
108
109
  "changeset": "changeset",
109
110
  "release": "bun run check && changeset publish",
110
111
  "prepublishOnly": "bun run check"