@avasapp/agent-bridge 0.3.0 → 0.4.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.
@@ -23,13 +23,14 @@ __export(runtime_exports, {
23
23
  RUNTIME_MARKER: () => RUNTIME_MARKER,
24
24
  cdpTransport: () => cdpTransport,
25
25
  createGate: () => createGate,
26
+ onRestore: () => onRestore,
26
27
  settle: () => settle,
27
28
  startAgentBridge: () => startAgentBridge,
28
29
  useAgentBridge: () => useAgentBridge
29
30
  });
30
31
  module.exports = __toCommonJS(runtime_exports);
31
32
  var import_react = require("react");
32
- var import_react_native2 = require("react-native");
33
+ var import_react_native3 = require("react-native");
33
34
 
34
35
  // src/shared/protocol.ts
35
36
  var PROTOCOL_VERSION = 1;
@@ -43,6 +44,73 @@ function toAsciiJson(value) {
43
44
  );
44
45
  }
45
46
 
47
+ // src/runtime/builtin-tools.ts
48
+ var import_react_native2 = require("react-native");
49
+
50
+ // src/runtime/undo.ts
51
+ var undos = [];
52
+ function onRestore(fn, label) {
53
+ if (typeof fn !== "function") throw new Error("onRestore takes a function");
54
+ undos.push({ fn, label });
55
+ }
56
+ function pendingUndos() {
57
+ return undos.map((u) => u.label ?? "undo").reverse();
58
+ }
59
+ async function runUndos() {
60
+ const batch = undos.splice(0).reverse();
61
+ const errors = [];
62
+ for (const { fn, label } of batch) {
63
+ try {
64
+ await fn();
65
+ } catch (error) {
66
+ const why = error instanceof Error ? error.message : String(error);
67
+ errors.push(label ? `${label}: ${why}` : why);
68
+ }
69
+ }
70
+ return { ran: batch.length, errors };
71
+ }
72
+
73
+ // src/runtime/tools/app.ts
74
+ function selectReload(host, devSettingsReload) {
75
+ return () => {
76
+ const expo = host.expo;
77
+ if (typeof expo?.reloadAppAsync !== "function") return devSettingsReload();
78
+ try {
79
+ Promise.resolve(expo.reloadAppAsync("agent-bridge app.reload")).catch(
80
+ () => devSettingsReload()
81
+ );
82
+ } catch {
83
+ devSettingsReload();
84
+ }
85
+ };
86
+ }
87
+ function appTools(reload) {
88
+ return {
89
+ "app.reload": {
90
+ description: "Reload the app's JS (Expo's reloadAppAsync, else DevSettings.reload). Pending restores (store, query, mocks, pins) are lost. Returns before it reloads; a session waits for the new bridge and reports what was lost.",
91
+ run: () => {
92
+ setTimeout(reload, 50);
93
+ return { reloading: true };
94
+ }
95
+ },
96
+ "app.restore": {
97
+ maxArgs: 0,
98
+ // The labels, so bridge.pending shows what the app's own tools changed.
99
+ pending: () => {
100
+ const labels = pendingUndos();
101
+ return labels.length ? labels : false;
102
+ },
103
+ description: "Undo what the app's own tools changed: runs the undos they registered with onRestore, newest first, once. Returns how many ran.",
104
+ run: async () => {
105
+ const { ran, errors } = await runUndos();
106
+ if (errors.length)
107
+ throw new Error(`${errors.join("; ")} (${ran - errors.length} of ${ran} undone)`);
108
+ return { undone: ran };
109
+ }
110
+ }
111
+ };
112
+ }
113
+
46
114
  // src/runtime/tools/bridge.ts
47
115
  function bridgeTools(listTools) {
48
116
  return {
@@ -57,6 +125,42 @@ function bridgeTools(listTools) {
57
125
  };
58
126
  }
59
127
 
128
+ // src/runtime/tools/capture.ts
129
+ var MAX_BASE64_CHARS = 2e5;
130
+ var UNAVAILABLE = "screen.capture needs react-native-view-shot, which this app does not have. Take the screenshot with agent-device, `xcrun simctl io booted screenshot <file>` (iOS simulator) or `adb exec-out screencap -p > <file>` (Android) instead.";
131
+ function loadViewShot() {
132
+ try {
133
+ const mod = require("react-native-view-shot");
134
+ return typeof mod?.captureScreen === "function" ? mod : void 0;
135
+ } catch {
136
+ return void 0;
137
+ }
138
+ }
139
+ function captureTools(load) {
140
+ return {
141
+ "screen.capture": {
142
+ description: "Screenshot the app as a PNG. Returns {path} of a file on the device. `{base64:true}` returns {base64} instead when small enough (else {path}). Only works if the app already has react-native-view-shot; otherwise errors and you use agent-device, simctl or adb.",
143
+ maxArgs: 1,
144
+ run: async (options) => {
145
+ const viewShot = load();
146
+ if (!viewShot) throw new Error(UNAVAILABLE);
147
+ if (options?.base64) {
148
+ const base64 = await viewShot.captureScreen({
149
+ format: "png",
150
+ result: "base64"
151
+ });
152
+ if (base64.length <= MAX_BASE64_CHARS) return { base64 };
153
+ }
154
+ const path2 = await viewShot.captureScreen({
155
+ format: "png",
156
+ result: "tmpfile"
157
+ });
158
+ return { path: path2 };
159
+ }
160
+ }
161
+ };
162
+ }
163
+
60
164
  // src/runtime/tools/logs.ts
61
165
  function logTools(logs) {
62
166
  return {
@@ -75,8 +179,28 @@ function logTools(logs) {
75
179
  var SUFFIX = ".restore";
76
180
  var SELF = "bridge.restore";
77
181
  var LAST = "scenario.restore";
182
+ function pendingByArea(tools, detail = false) {
183
+ const out = {};
184
+ for (const [name, d] of Object.entries(tools)) {
185
+ if (!name.endsWith(SUFFIX) || name === SELF || typeof d === "function")
186
+ continue;
187
+ const area3 = name.slice(0, -SUFFIX.length);
188
+ try {
189
+ const found = d.pending?.({ detail });
190
+ if (found) out[area3] = found;
191
+ } catch (error) {
192
+ out[area3] = `pending check failed: ${error instanceof Error ? error.message : String(error)}`;
193
+ }
194
+ }
195
+ return out;
196
+ }
78
197
  function restoreTools(getTools) {
79
198
  return {
199
+ "bridge.pending": {
200
+ maxArgs: 0,
201
+ description: "What bridge.restore would undo right now, per area (store, query, net, app, ...): the value is true, or detail such as a store's snapshot against its current value. Empty when nothing is pending. Changes nothing.",
202
+ run: () => pendingByArea(getTools(), true)
203
+ },
80
204
  [SELF]: {
81
205
  description: "Undo what the agent changed: runs every *.restore tool in name order, scenario.restore last, and returns each result or error.",
82
206
  run: async () => {
@@ -161,7 +285,7 @@ function checkOptions(schema, options) {
161
285
  // src/runtime/tools/scenario.ts
162
286
  var active = /* @__PURE__ */ new Map();
163
287
  var message = (error) => error instanceof Error ? error.message : String(error);
164
- async function runUndos(entry) {
288
+ async function runUndos2(entry) {
165
289
  const errors = [];
166
290
  for (const undo2 of entry.undos.splice(0).reverse()) {
167
291
  try {
@@ -176,7 +300,7 @@ async function undo(name) {
176
300
  const entry = active.get(name);
177
301
  if (!entry) return [];
178
302
  active.delete(name);
179
- return (await runUndos(entry)).map((e) => `${name}: ${e}`);
303
+ return (await runUndos2(entry)).map((e) => `${name}: ${e}`);
180
304
  }
181
305
  function scenarioTools(getScenarios, getTools) {
182
306
  const find = (name) => {
@@ -241,6 +365,7 @@ scenario.list shows its options schema.`
241
365
  }
242
366
  },
243
367
  "scenario.restore": {
368
+ pending: () => active.size > 0,
244
369
  description: "Undo every active scenario, newest first. bridge.restore runs this after every other restorer.",
245
370
  run: async () => {
246
371
  const names = [...active.keys()].reverse();
@@ -296,40 +421,101 @@ function textOf(fiber) {
296
421
  }
297
422
  return null;
298
423
  }
299
- function inActiveScreen(fiber) {
300
- for (let f = fiber; f; f = f.return) {
301
- if (f.tag !== HOST_COMPONENT) continue;
302
- const props = f.memoizedProps;
303
- if (props?.activityState === 0) return false;
424
+
425
+ // src/runtime/screen/webview-mark.ts
426
+ var MARK = /^\/\*agent-bridge:webview:([^*]*)\*\//;
427
+ function webViewNameOf(props) {
428
+ const script = props.injectedJavaScriptBeforeContentLoaded;
429
+ const found = typeof script === "string" ? MARK.exec(script) : null;
430
+ if (!found) return void 0;
431
+ try {
432
+ return decodeURIComponent(found[1]);
433
+ } catch {
434
+ return found[1];
304
435
  }
436
+ }
437
+
438
+ // src/runtime/screen/elements.ts
439
+ var propsOf = (fiber) => fiber.memoizedProps && typeof fiber.memoizedProps === "object" ? fiber.memoizedProps : null;
440
+ var nodeOf = (fiber) => {
441
+ const n = fiber.stateNode;
442
+ return n && typeof n === "object" ? n : null;
443
+ };
444
+ var SCROLL_METHODS = ["scrollTo", "scrollToOffset"];
445
+ function scrolls(fiber) {
446
+ const node = nodeOf(fiber);
447
+ if (!node || !SCROLL_METHODS.some((m) => isFn(node[m]))) return false;
448
+ if (typeof node.scrollHeight === "number" && typeof node.clientHeight === "number")
449
+ return node.scrollHeight > node.clientHeight + 1 || node.scrollWidth > node.clientWidth + 1;
305
450
  return true;
306
451
  }
307
- function findTextInTree(roots, text, window, options = {}) {
308
- const stack = [...roots];
309
- const matches = [];
310
- while (stack.length) {
311
- const fiber = stack.pop();
312
- const found = textOf(fiber);
313
- if (found && (options.exact ? found.text === text : found.text.includes(text))) {
314
- const rect = found.host ? measureHost(found.host) : null;
315
- const onScreen = !!rect && inActiveScreen(fiber) && rect.width > 0 && rect.height > 0 && rect.x < window.width && rect.y < window.height && rect.x + rect.width > 0 && rect.y + rect.height > 0;
316
- matches.push({ text: found.text, rect, onScreen });
452
+ var displayNone = (style) => displayOf(style) === "none";
453
+ function displayOf(style) {
454
+ if (Array.isArray(style)) {
455
+ for (let i = style.length - 1; i >= 0; i--) {
456
+ const d = displayOf(style[i]);
457
+ if (d !== void 0) return d;
317
458
  }
318
- if (fiber.child) stack.push(fiber.child);
319
- if (fiber.sibling) stack.push(fiber.sibling);
459
+ return void 0;
460
+ }
461
+ return style && typeof style === "object" ? style.display : void 0;
462
+ }
463
+ var hiddenBy = (p) => p.activityState === 0 || displayNone(p.style) || p["aria-hidden"] === true || p.accessibilityElementsHidden === true || p.importantForAccessibility === "no-hide-descendants";
464
+ var MODAL_HOSTS = /* @__PURE__ */ new Set(["RCTModalHostView", "ModalHostView"]);
465
+ var OVERLAY_HOST = "RNSFullWindowOverlay";
466
+ var SCREEN_STACK = "RNSScreenStack";
467
+ var STACK_SCREENS = /* @__PURE__ */ new Set(["RNSScreen", "RNSModalScreen"]);
468
+ var SEE_THROUGH = /* @__PURE__ */ new Set(["transparentModal", "containedTransparentModal", "overFullScreen"]);
469
+ var undimmedSheet = (p) => {
470
+ const at = p.sheetLargestUndimmedDetentIndex;
471
+ return p.stackPresentation === "formSheet" && at !== void 0 && at !== null && at !== "none" && at !== -1;
472
+ };
473
+ function presentationOf(screen) {
474
+ const p = propsOf(screen);
475
+ if (typeof p?.stackPresentation === "string") return p.stackPresentation;
476
+ return screen.type === "RNSModalScreen" ? "modal" : null;
477
+ }
478
+ var seeThrough = (screen) => {
479
+ const presentation = presentationOf(screen);
480
+ return presentation !== null && SEE_THROUGH.has(presentation) || undimmedSheet(propsOf(screen) ?? {});
481
+ };
482
+ var coversWindow = (screen) => {
483
+ const presentation = presentationOf(screen);
484
+ return presentation !== null && presentation !== "push" && !undimmedSheet(propsOf(screen) ?? {});
485
+ };
486
+ function stackScreens(stack) {
487
+ const screens = [];
488
+ const todo = stack.child ? [stack.child] : [];
489
+ while (todo.length) {
490
+ const f = todo.pop();
491
+ if (f.sibling) todo.push(f.sibling);
492
+ if (f.tag === HOST_COMPONENT && STACK_SCREENS.has(f.type)) {
493
+ if (propsOf(f)?.activityState !== 0) screens.push(f);
494
+ } else if (f.child) todo.push(f.child);
320
495
  }
496
+ let base = screens.length - 1;
497
+ while (base > 0 && seeThrough(screens[base])) base--;
321
498
  return {
322
- found: matches.length,
323
- onScreen: matches.filter((m) => m.onScreen).length,
324
- matches
499
+ covered: screens.slice(0, Math.max(0, base)),
500
+ presented: screens.slice(1).filter(coversWindow)
325
501
  };
326
502
  }
327
-
328
- // src/runtime/screen/elements.ts
329
- var propsOf = (fiber) => fiber.memoizedProps && typeof fiber.memoizedProps === "object" ? fiber.memoizedProps : null;
503
+ function hostAbove(host) {
504
+ let up = host.return;
505
+ while (up && up.tag !== HOST_COMPONENT) up = up.return;
506
+ return up ?? host;
507
+ }
508
+ var REFRESH_HOST = /refresh/i;
509
+ var refreshTwin = (rec, host) => rec && REFRESH_HOST.test(String(rec.host.type)) && hostAbove(host) === rec.host ? rec : null;
510
+ var inside = (modal, top) => {
511
+ for (let m = modal; m; m = m.up) if (m === top) return true;
512
+ return false;
513
+ };
514
+ var SWITCH_HOSTS = /* @__PURE__ */ new Set(["RCTSwitch", "AndroidSwitch"]);
330
515
  var isFn = (v) => typeof v === "function";
331
- var isInputProps = (p) => isFn(p.onChangeText) || isFn(p.onChange) && ("value" in p || "defaultValue" in p || "placeholder" in p);
332
- var interesting = (p) => isFn(p.onPress) || isInputProps(p) || typeof p.testID === "string";
516
+ var isInputProps = (p) => isFn(p.onChangeText) || isFn(p.onChange) && typeof p.value !== "boolean" && ("value" in p || "defaultValue" in p || "placeholder" in p);
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;
333
519
  var ICON_GLYPHS = /[\uE000-\uF8FF]/g;
334
520
  function firstHost(fiber) {
335
521
  if (fiber.tag === HOST_COMPONENT) return fiber;
@@ -350,22 +536,77 @@ function absorb(rec, fiber, p) {
350
536
  if (e[key] === void 0 && typeof v === "string") e[key] = v;
351
537
  };
352
538
  fill("testID", p.testID);
353
- fill("label", p.accessibilityLabel ?? p["aria-label"]);
539
+ fill("label", labelOf(p));
354
540
  fill("role", p.accessibilityRole ?? p.role);
355
541
  fill("placeholder", p.placeholder);
356
- if (e.value === void 0 && ("value" in p || "defaultValue" in p)) {
542
+ const webview = webViewNameOf(p);
543
+ if (e.webview === void 0 && webview !== void 0) e.webview = webview;
544
+ const isSwitch = SWITCH_HOSTS.has(fiber.type) || (p.accessibilityRole ?? p.role ?? e.role) === "switch";
545
+ if (isSwitch && typeof p.value === "boolean") {
546
+ if (e.checked === void 0) e.checked = p.value;
547
+ if (e.role === void 0) e.role = "switch";
548
+ } else if (e.value === void 0 && !(typeof p.value === "boolean" && isFn(p.onValueChange)) && ("value" in p || "defaultValue" in p)) {
357
549
  const v = p.value ?? p.defaultValue;
358
- if (v != null) e.value = String(v);
550
+ if (v != null && typeof v !== "object" && !isFn(v)) e.value = String(v);
359
551
  }
360
552
  if (rec.maxLength === void 0 && typeof p.maxLength === "number")
361
553
  rec.maxLength = p.maxLength;
362
554
  if (p.editable === false || p.readOnly === true) e.editable = false;
363
555
  const state = p.accessibilityState;
556
+ const checked = state?.checked ?? p["aria-checked"];
557
+ if (e.checked === void 0 && (typeof checked === "boolean" || checked === "mixed"))
558
+ e.checked = checked;
559
+ const flag = (key) => {
560
+ const v = state?.[key] ?? p[`aria-${key}`];
561
+ if (e[key] === void 0 && typeof v === "boolean") e[key] = v;
562
+ };
563
+ flag("selected");
564
+ flag("expanded");
364
565
  if (p.disabled === true || state?.disabled === true || p["aria-disabled"] === true)
365
566
  e.disabled = true;
366
567
  }
367
- function isOnScreen(rect, window) {
368
- return !!rect && rect.width > 0 && rect.height > 0 && rect.x < window.width && rect.y < window.height && rect.x + rect.width > 0 && rect.y + rect.height > 0;
568
+ var intersect = (a, b) => {
569
+ const x = Math.max(a.x, b.x);
570
+ const y = Math.max(a.y, b.y);
571
+ const right = Math.min(a.x + a.width, b.x + b.width);
572
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
573
+ return right > x && bottom > y ? { x, y, width: right - x, height: bottom - y } : null;
574
+ };
575
+ var usable = (r) => !!r && r.width > 0 && r.height > 0;
576
+ var within = (host, ancestor) => {
577
+ for (let f = host; f; f = f.return) if (f === ancestor) return true;
578
+ return false;
579
+ };
580
+ function visibleArea(base, host, bars, window) {
581
+ let area3 = base;
582
+ const clip2 = (r) => {
583
+ area3 = area3 && intersect(area3, r);
584
+ };
585
+ for (const bar of bars) {
586
+ if (within(host, bar)) continue;
587
+ const r = measureHost(bar);
588
+ if (!usable(r) || r.width < window.width * 0.9) continue;
589
+ clip2(
590
+ r.y + r.height / 2 > window.height / 2 ? { x: -1e9, y: -1e9, width: 2e9, height: 1e9 + r.y } : { x: -1e9, y: r.y + r.height, width: 2e9, height: 1e9 }
591
+ );
592
+ }
593
+ return area3;
594
+ }
595
+ var viewportOf = (s, view, window) => usable(view) ? visibleArea(view, s.host, s.modal ? [] : s.bars, window) : null;
596
+ function isOnScreen(rec, rect, window, bars) {
597
+ if (!usable(rect)) return false;
598
+ const shown = intersect(rect, { x: 0, y: 0, ...window });
599
+ if (!shown) return false;
600
+ const cx = shown.x + shown.width / 2;
601
+ const cy = shown.y + shown.height / 2;
602
+ const holds = (r) => !!r && cx >= r.x && cx <= r.x + r.width && cy >= r.y && cy <= r.y + r.height;
603
+ if (!holds(visibleArea(shown, rec.host, rec.modal ? [] : bars, window))) return false;
604
+ for (let s = rec.scroller; s; s = s.parent) {
605
+ if (s.host === rec.host || s.modal !== rec.modal) continue;
606
+ const view = measureHost(s.host);
607
+ if (usable(view) && !holds(viewportOf(s, view, window))) return false;
608
+ }
609
+ return true;
369
610
  }
370
611
  var round = (r) => r && {
371
612
  x: Math.round(r.x),
@@ -389,10 +630,15 @@ function absorbChain(rec, fiber, host) {
389
630
  if (f === host) break;
390
631
  }
391
632
  }
392
- function collectElements(roots, window) {
633
+ function collectScreen(roots, window) {
393
634
  const byHost = /* @__PURE__ */ new Map();
394
635
  const order = [];
395
- const recFor = (fiber, host, parent) => {
636
+ const scrollByHost = /* @__PURE__ */ new Map();
637
+ const covered = /* @__PURE__ */ new Set();
638
+ const presented = /* @__PURE__ */ new Set();
639
+ const bars = [];
640
+ let topModal = null;
641
+ const recFor = (fiber, host, parent, hidden, scroller, modal, overlay) => {
396
642
  let rec = byHost.get(host);
397
643
  if (rec) return rec;
398
644
  rec = {
@@ -402,6 +648,10 @@ function collectElements(roots, window) {
402
648
  host,
403
649
  press: null,
404
650
  input: null,
651
+ hidden,
652
+ scroller,
653
+ modal,
654
+ overlay,
405
655
  parent,
406
656
  parts: []
407
657
  };
@@ -414,49 +664,118 @@ function collectElements(roots, window) {
414
664
  for (let r = rec; r; r = r.parent) if (r.press) return r;
415
665
  return null;
416
666
  };
417
- const stack = roots.map((fiber) => ({ fiber, rec: null }));
667
+ const textHost = (host) => {
668
+ let h = host;
669
+ for (; ; ) {
670
+ let up = h.return;
671
+ while (up && up.tag !== HOST_COMPONENT) up = up.return;
672
+ if (!up) return h;
673
+ if (h.type !== "RCTVirtualText") return h;
674
+ h = up;
675
+ }
676
+ };
677
+ const stack = roots.map((fiber) => ({
678
+ fiber,
679
+ rec: null,
680
+ hidden: false,
681
+ scroller: null,
682
+ modal: null,
683
+ overlay: false
684
+ }));
418
685
  while (stack.length) {
419
- const { fiber, rec: parentRec } = stack.pop();
420
- if (fiber.sibling) stack.push({ fiber: fiber.sibling, rec: parentRec });
686
+ const item = stack.pop();
687
+ const { fiber, rec: parentRec } = item;
688
+ if (fiber.sibling) stack.push({ ...item, fiber: fiber.sibling });
421
689
  const p = propsOf(fiber);
422
- if (fiber.tag === HOST_COMPONENT && p?.activityState === 0) continue;
690
+ let { hidden, scroller, modal, overlay } = item;
691
+ if (fiber.tag === HOST_COMPONENT) {
692
+ if (p && hiddenBy(p)) hidden = true;
693
+ if (covered.has(fiber)) hidden = true;
694
+ if (fiber.type === SCREEN_STACK) {
695
+ const screens = stackScreens(fiber);
696
+ for (const s of screens.covered) covered.add(s);
697
+ for (const s of screens.presented) presented.add(s);
698
+ }
699
+ if (fiber.type === OVERLAY_HOST) overlay = true;
700
+ if (p && !hidden && (p.accessibilityRole ?? p.role) === "tablist") bars.push(fiber);
701
+ if (!hidden && (MODAL_HOSTS.has(fiber.type) || presented.has(fiber))) {
702
+ modal = { host: fiber, up: modal };
703
+ topModal = modal;
704
+ }
705
+ }
706
+ if (scrolls(fiber)) {
707
+ const host = firstHost(fiber);
708
+ if (host) {
709
+ let found2 = scrollByHost.get(host);
710
+ if (!found2) {
711
+ found2 = { host, fibers: [], hidden, parent: scroller, modal, overlay, bars };
712
+ scrollByHost.set(host, found2);
713
+ }
714
+ found2.fibers.push(fiber);
715
+ scroller = found2;
716
+ }
717
+ }
423
718
  let rec = parentRec;
424
719
  if (p && interesting(p)) {
425
720
  const host = firstHost(fiber);
426
- const same = host && !byHost.has(host) ? sameControl(parentRec, p) : null;
427
- if (host && same) {
721
+ const twin = host && !byHost.has(host) ? refreshTwin(parentRec, host) : null;
722
+ const same = host && !twin && !byHost.has(host) ? sameControl(parentRec, p) : null;
723
+ if (host && twin) {
724
+ byHost.set(host, twin);
725
+ rec = twin;
726
+ } else if (host && same) {
428
727
  byHost.set(host, same);
429
728
  same.host = host;
430
729
  absorbChain(same, fiber, host);
431
730
  rec = same;
432
731
  } else if (host) {
433
- rec = recFor(fiber, host, parentRec);
732
+ rec = recFor(fiber, host, parentRec, hidden, scroller, modal, overlay);
434
733
  absorb(rec, fiber, p);
435
734
  }
436
735
  }
437
736
  const found = textOf(fiber);
438
737
  if (found?.host) {
439
- const owner = buttonOf(rec) ?? recFor(found.host, found.host, rec);
738
+ const host = textHost(found.host);
739
+ const owner = buttonOf(rec) ?? recFor(host, host, rec, hidden, scroller, modal, overlay);
440
740
  const last = owner.parts[owner.parts.length - 1];
441
- if (last?.host === found.host) last.text += found.text;
442
- else owner.parts.push({ host: found.host, text: found.text });
741
+ if (last?.host === host) last.text += found.text;
742
+ else owner.parts.push({ host, text: found.text });
443
743
  }
444
- if (fiber.child) stack.push({ fiber: fiber.child, rec });
744
+ if (fiber.child) stack.push({ fiber: fiber.child, rec, hidden, scroller, modal, overlay });
445
745
  }
746
+ const top = topModal;
747
+ const covers2 = (x) => !!top && !x.overlay && !inside(x.modal, top);
748
+ for (const scrollable of scrollByHost.values())
749
+ scrollable.hidden || (scrollable.hidden = covers2(scrollable));
446
750
  for (const rec of order) {
447
751
  const e = rec.element;
752
+ rec.hidden || (rec.hidden = covers2(rec));
448
753
  const text = rec.parts.map((part) => part.text.replace(ICON_GLYPHS, "").replace(/\s+/g, " ").trim()).filter(Boolean).join(" ");
449
754
  if (text) e.text = text;
450
- e.kind = rec.input ? "input" : rec.press ? "button" : rec.parts.length ? "text" : "view";
755
+ e.kind = e.webview !== void 0 ? "webview" : rec.input ? "input" : rec.press ? "button" : rec.parts.length ? "text" : "view";
451
756
  if (e.kind === "input" && e.editable === void 0) e.editable = true;
452
757
  const rect = measureHost(rec.host);
453
758
  e.rect = round(rect);
454
- rec.onScreen = isOnScreen(rect, window);
759
+ const own = scrollByHost.get(rec.host) ?? scrollByHost.get(hostAbove(rec.host));
760
+ if (own) rec.scroller = own;
761
+ rec.onScreen = isOnScreen(rec, rect, window, bars);
455
762
  }
456
- return order.filter(
457
- (rec) => rec.element.kind !== "text" || rec.element.text !== void 0
458
- );
763
+ const named = /* @__PURE__ */ new Set();
764
+ const single = (rec) => {
765
+ const name = rec.element.webview;
766
+ if (name === void 0) return true;
767
+ if (named.has(name)) return false;
768
+ named.add(name);
769
+ return true;
770
+ };
771
+ return {
772
+ found: order.filter(
773
+ (rec) => (rec.element.kind !== "text" || rec.element.text !== void 0) && single(rec)
774
+ ),
775
+ scrollables: [...scrollByHost.values()]
776
+ };
459
777
  }
778
+ var focused = (found) => found.filter((f) => !f.hidden);
460
779
  function pressFiberOf(found) {
461
780
  if (found.press) return found.press;
462
781
  for (let f = found.fiber.return; f; f = f.return) {
@@ -467,6 +786,26 @@ function pressFiberOf(found) {
467
786
  }
468
787
 
469
788
  // src/runtime/screen/targets.ts
789
+ var TARGET_KEYS = ["testID", "label", "placeholder", "text", "at", "index"];
790
+ function checkTarget(target) {
791
+ if (typeof target === "string") return;
792
+ if (target === null || typeof target !== "object" || Array.isArray(target))
793
+ throw new Error(
794
+ `A target is a string or an object with ${TARGET_KEYS.join(", ")}; got ${JSON.stringify(target)}`
795
+ );
796
+ const unknown = Object.keys(target).filter((k) => !TARGET_KEYS.includes(k));
797
+ if (unknown.length)
798
+ throw new Error(
799
+ `Unknown target key ${unknown.map((k) => JSON.stringify(k)).join(", ")}. Valid keys: ${TARGET_KEYS.join(", ")}`
800
+ );
801
+ const { at, index } = target;
802
+ if (at !== void 0 && !(Array.isArray(at) && at.length === 2 && at.every(Number.isFinite)))
803
+ throw new Error(`Target "at" must be [x, y] in window points; got ${JSON.stringify(at)}`);
804
+ if (index !== void 0 && !Number.isInteger(index))
805
+ throw new Error(`Target "index" must be an integer; got ${JSON.stringify(index)}`);
806
+ }
807
+ var covers = (e, [x, y]) => !!e.rect && x >= e.rect.x && x <= e.rect.x + e.rect.width && y >= e.rect.y && y <= e.rect.y + e.rect.height;
808
+ var area = (f) => f.element.rect ? f.element.rect.width * f.element.rect.height : 0;
470
809
  function tiers(target) {
471
810
  if (typeof target === "string") {
472
811
  return [
@@ -477,59 +816,77 @@ function tiers(target) {
477
816
  (e) => !!e.text?.includes(target)
478
817
  ];
479
818
  }
480
- const { testID, label, placeholder, text } = target;
481
- const fields = (e) => (testID === void 0 || e.testID === testID) && (label === void 0 || e.label === label) && (placeholder === void 0 || e.placeholder === placeholder);
819
+ const { testID, label, placeholder, text, at } = target;
820
+ const fields = (e) => (testID === void 0 || e.testID === testID) && (at === void 0 || covers(e, at)) && (label === void 0 || e.label === label) && (placeholder === void 0 || e.placeholder === placeholder);
482
821
  if (text === void 0) return [fields];
483
822
  return [
484
823
  (e) => fields(e) && e.text === text,
485
824
  (e) => fields(e) && !!e.text?.includes(text)
486
825
  ];
487
826
  }
827
+ var labelOnly = ({ element: e, press, input }) => e.kind === "view" && !e.testID && !e.text && !e.placeholder && !press && !input;
488
828
  function matchTarget(found, target) {
489
- for (const test of tiers(target)) {
490
- const matches = found.filter((f) => test(f.element));
491
- if (matches.length) return matches;
829
+ checkTarget(target);
830
+ for (const pool of [found.filter((f) => !labelOnly(f)), found.filter(labelOnly)]) {
831
+ for (const test of tiers(target)) {
832
+ const matches = pool.filter((f) => test(f.element));
833
+ if (matches.length)
834
+ return isAt(target) ? matches.sort((a, b) => area(a) - area(b)) : matches;
835
+ }
492
836
  }
493
837
  return [];
494
838
  }
839
+ var isAt = (target) => typeof target === "object" && target.at !== void 0;
495
840
  var indexOf = (target) => typeof target === "object" ? target.index : void 0;
496
841
  var clip = (s) => s.length > 40 ? `${s.slice(0, 39)}\u2026` : s;
497
842
  function describe(e) {
498
- const bits = [e.kind];
843
+ const stated = e.checked !== void 0 || e.selected !== void 0 || e.expanded !== void 0;
844
+ const bits = [stated && e.role ? e.role : e.kind];
845
+ if (e.webview !== void 0) bits.push(JSON.stringify(e.webview));
499
846
  if (e.testID) bits.push(`#${e.testID}`);
500
847
  if (e.text) bits.push(JSON.stringify(clip(e.text)));
501
848
  if (e.label && e.label !== e.text) bits.push(`label=${JSON.stringify(clip(e.label))}`);
502
849
  if (e.placeholder) bits.push(`placeholder=${JSON.stringify(clip(e.placeholder))}`);
503
850
  if (e.value !== void 0) bits.push(`value=${JSON.stringify(clip(e.value))}`);
851
+ if (e.checked !== void 0) bits.push(e.checked === true ? "checked" : e.checked === false ? "unchecked" : "mixed");
852
+ if (e.selected !== void 0) bits.push(e.selected ? "selected" : "unselected");
853
+ if (e.expanded !== void 0) bits.push(e.expanded ? "expanded" : "collapsed");
504
854
  if (e.disabled) bits.push("disabled");
505
855
  return bits.join(" ");
506
856
  }
507
857
  var showTarget = (target) => JSON.stringify(target);
508
858
  function onScreenSummary(found, max = 15) {
509
- const on = found.filter((f) => f.onScreen);
859
+ const on = found.filter((f) => f.onScreen && !f.hidden);
510
860
  if (!on.length) return "nothing";
511
861
  const shown = on.slice(0, max).map((f) => describe(f.element));
512
862
  if (on.length > max) shown.push(`+${on.length - max} more`);
513
863
  return shown.join("; ");
514
864
  }
515
- function resolveTarget(found, target, prefer) {
865
+ function resolveTarget(found, target, prefer, options) {
866
+ const reachable = found.filter((f) => !f.hidden);
516
867
  let matches = matchTarget(
517
- found.filter((f) => f.onScreen),
868
+ reachable.filter((f) => f.onScreen),
518
869
  target
519
870
  );
871
+ if (!matches.length && options?.offscreen) matches = matchTarget(reachable, target);
520
872
  if (!matches.length) {
521
873
  const hidden = matchTarget(found, target)[0];
874
+ if (hidden?.hidden)
875
+ throw new Error(
876
+ `${showTarget(target)} matches ${describe(hidden.element)}, which is on a screen that is not in front (an unfocused tab or stack screen, or behind a modal). screen.snapshot {all:true} lists it`
877
+ );
522
878
  if (hidden)
523
879
  throw new Error(
524
- `${showTarget(target)} matches ${describe(hidden.element)}, which is not on screen`
880
+ `${showTarget(target)} matches ${describe(hidden.element)}, which is not on screen. Pass {scroll:true} to scroll it into view, or use screen.scroll`
525
881
  );
882
+ const unrendered = options?.offscreen ? ". A list renders only the rows near its window: for a row it has not rendered yet, screen.scroll {toEnd:true} or {by: points} first" : "";
526
883
  throw new Error(
527
- `Nothing on screen matches ${showTarget(target)}. On screen: ${onScreenSummary(found)}`
884
+ `Nothing on screen matches ${showTarget(target)}. On screen: ${onScreenSummary(found)}${unrendered}`
528
885
  );
529
886
  }
530
887
  const preferred = prefer ? matches.filter(prefer) : [];
531
888
  if (preferred.length) matches = preferred;
532
- const index = indexOf(target);
889
+ const index = options?.index ?? indexOf(target) ?? (isAt(target) ? 0 : void 0);
533
890
  if (index !== void 0) {
534
891
  const pick = matches[index];
535
892
  if (!pick)
@@ -540,8 +897,9 @@ function resolveTarget(found, target, prefer) {
540
897
  }
541
898
  if (matches.length > 1) {
542
899
  const list = matches.slice(0, 10).map((f, i) => `${i}: ${describe(f.element)}`).join("; ");
900
+ const example = typeof target === "string" ? `${JSON.stringify(options?.afterText ? [target, "<text>", { index: 1 }] : [target, { index: 1 }])} (index as the last argument)` : `${JSON.stringify({ ...target, index: 1 })} (index inside the target)`;
543
901
  throw new Error(
544
- `${showTarget(target)} matches ${matches.length} elements; pass { index } or a narrower target. ${list}`
902
+ `${showTarget(target)} matches ${matches.length} elements; pick one with an index, e.g. ${example}, or use a narrower target. ${list}`
545
903
  );
546
904
  }
547
905
  return matches[0];
@@ -584,11 +942,13 @@ function fillInput(found, text, options = {}) {
584
942
  return value;
585
943
  }
586
944
  var center = (rect) => rect ? { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 } : { x: 0, y: 0 };
587
- function pressElement(found) {
945
+ function pressElement(found, options = {}) {
588
946
  const fiber = pressFiberOf(found);
589
947
  if (!fiber) throw new Error(`${describe(found.element)} has no onPress`);
590
- if (found.element.disabled || propsOf(fiber)?.disabled === true)
591
- throw new Error(`${describe(found.element)} is disabled`);
948
+ if (!options.force && (found.element.disabled || propsOf(fiber)?.disabled === true))
949
+ throw new Error(
950
+ `${describe(found.element)} is disabled; pass {"force":true} as the last argument to press anyway`
951
+ );
592
952
  const { x, y } = center(found.element.rect);
593
953
  const e = () => event({
594
954
  locationX: found.element.rect ? found.element.rect.width / 2 : 0,
@@ -602,6 +962,236 @@ function pressElement(found) {
602
962
  handler(fiber, "onPressOut")?.(e());
603
963
  }
604
964
 
965
+ // src/runtime/screen/find-text.ts
966
+ var normalize = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
967
+ function nearMisses(found, text, max = 5) {
968
+ const want = normalize(text);
969
+ if (!want) return [];
970
+ const close = (s) => {
971
+ const have = s && normalize(s);
972
+ return !!have && (have.includes(want) || have.length >= 3 && want.includes(have));
973
+ };
974
+ return found.filter((f) => !f.hidden).filter((f) => close(f.element.text) || close(f.element.label)).slice(0, max).map((f) => `${describe(f.element)}${f.onScreen ? "" : " (off screen)"}`);
975
+ }
976
+ function findText(found, text, options = {}) {
977
+ const test = (s) => s !== void 0 && (options.exact ? s === text : s.includes(text));
978
+ const matches = [];
979
+ let hidden = 0;
980
+ for (const f of found) {
981
+ const { element } = f;
982
+ const field = test(element.text) ? "text" : options.labels !== false && test(element.label) ? "label" : null;
983
+ if (!field) continue;
984
+ if (f.hidden) {
985
+ hidden += 1;
986
+ continue;
987
+ }
988
+ matches.push({
989
+ text: field === "text" ? element.text : element.label,
990
+ field,
991
+ rect: element.rect,
992
+ onScreen: f.onScreen
993
+ });
994
+ }
995
+ const result = {
996
+ found: matches.length,
997
+ onScreen: matches.filter((m) => m.onScreen).length,
998
+ matches
999
+ };
1000
+ if (hidden) result.hidden = hidden;
1001
+ if (!matches.length) result.nearMisses = nearMisses(found, text);
1002
+ return result;
1003
+ }
1004
+
1005
+ // src/runtime/screen/scroll.ts
1006
+ var COMMANDS = ["to", "toEnd", "toStart", "by"];
1007
+ function parseScroll(arg) {
1008
+ if (arg === null || typeof arg !== "object" || Array.isArray(arg) || !COMMANDS.some((k) => k in arg))
1009
+ return { kind: "to", target: arg };
1010
+ const keys = Object.keys(arg);
1011
+ if (keys.length !== 1)
1012
+ throw new Error(
1013
+ `screen.scroll takes one of {to: target}, {toEnd:true}, {toStart:true} or {by: points}; got ${JSON.stringify(arg)}`
1014
+ );
1015
+ const [key] = keys;
1016
+ const value = arg[key];
1017
+ if (key === "to") return { kind: "to", target: value };
1018
+ if (key === "by") {
1019
+ if (typeof value !== "number" || !Number.isFinite(value))
1020
+ throw new Error(`{by} is a number of points (negative scrolls back); got ${JSON.stringify(value)}`);
1021
+ return { kind: "by", amount: value };
1022
+ }
1023
+ if (value !== true) throw new Error(`{${key}} must be true; got ${JSON.stringify(value)}`);
1024
+ return { kind: key };
1025
+ }
1026
+ var nodeOf2 = (fiber) => {
1027
+ const n = fiber.stateNode;
1028
+ return n && typeof n === "object" ? n : null;
1029
+ };
1030
+ var method = (fiber, name) => {
1031
+ const node = nodeOf2(fiber);
1032
+ const fn = node?.[name];
1033
+ return typeof fn === "function" ? fn.bind(node) : null;
1034
+ };
1035
+ var isHorizontal = (s) => s.fibers.some((f) => propsOf(f)?.horizontal === true);
1036
+ function scrollNode(s) {
1037
+ if (!REFRESH_HOST.test(String(s.host.type))) return s.host;
1038
+ const todo = s.host.child ? [s.host.child] : [];
1039
+ while (todo.length) {
1040
+ const f = todo.pop();
1041
+ if (f.sibling) todo.push(f.sibling);
1042
+ if (f.tag === 5) return f;
1043
+ if (f.child) todo.push(f.child);
1044
+ }
1045
+ return s.host;
1046
+ }
1047
+ function contentHost(node) {
1048
+ const todo = node.child ? [node.child] : [];
1049
+ while (todo.length) {
1050
+ const f = todo.pop();
1051
+ if (f.sibling) todo.push(f.sibling);
1052
+ if (f.tag === 5) {
1053
+ if (!REFRESH_HOST.test(String(f.type))) return f;
1054
+ } else if (f.child) todo.push(f.child);
1055
+ }
1056
+ return null;
1057
+ }
1058
+ function isInverted(s, horizontal) {
1059
+ const axis = horizontal ? "scaleX" : "scaleY";
1060
+ return s.fibers.some((f) => {
1061
+ const p = propsOf(f);
1062
+ if (p?.inverted === true) return true;
1063
+ const style = [].concat(p?.style ?? []).flat(Infinity);
1064
+ return style.some((st) => {
1065
+ const t = st?.transform;
1066
+ return Array.isArray(t) && t.some(
1067
+ (x) => x && typeof x === "object" && (x[axis] === -1 || x.scale === -1)
1068
+ );
1069
+ });
1070
+ });
1071
+ }
1072
+ function metricsOf(s) {
1073
+ const horizontal = isHorizontal(s);
1074
+ const host = scrollNode(s);
1075
+ const node = nodeOf2(host);
1076
+ const view = measureHost(host);
1077
+ if (node && typeof node.scrollTop === "number" && typeof node.scrollHeight === "number") {
1078
+ const size = horizontal ? [node.scrollLeft, node.scrollWidth, node.clientWidth] : [node.scrollTop, node.scrollHeight, node.clientHeight];
1079
+ const [offset, total, shown2] = size;
1080
+ return {
1081
+ offset,
1082
+ max: Math.max(0, total - shown2),
1083
+ view,
1084
+ inverted: false
1085
+ };
1086
+ }
1087
+ const content = contentHost(host);
1088
+ const box = content && measureHost(content);
1089
+ if (!view || !box) return null;
1090
+ const max = Math.max(0, horizontal ? box.width - view.width : box.height - view.height);
1091
+ const shown = horizontal ? view.x - box.x : view.y - box.y;
1092
+ const inverted = isInverted(s, horizontal);
1093
+ return { offset: inverted ? max - shown : shown, max, view, inverted };
1094
+ }
1095
+ var round2 = (n) => Math.round(n * 100) / 100;
1096
+ function scrollToOffset(s, offset) {
1097
+ const horizontal = isHorizontal(s);
1098
+ const x = horizontal ? offset : 0;
1099
+ const y = horizontal ? 0 : offset;
1100
+ const target = { x, y, left: x, top: y, animated: false, behavior: "instant" };
1101
+ for (const f of [...s.fibers].reverse()) {
1102
+ const scrollTo = method(f, "scrollTo");
1103
+ if (scrollTo) return void scrollTo(target);
1104
+ }
1105
+ for (const f of s.fibers) {
1106
+ const toOffset = method(f, "scrollToOffset");
1107
+ if (toOffset) return void toOffset({ offset, animated: false });
1108
+ }
1109
+ throw new Error("This scrollable has no scrollTo or scrollToOffset");
1110
+ }
1111
+ function scrollToEnd(s) {
1112
+ for (const f of s.fibers) {
1113
+ const toEnd = method(f, "scrollToEnd");
1114
+ if (toEnd) return void toEnd({ animated: false });
1115
+ }
1116
+ const max = metricsOf(s)?.max;
1117
+ if (max == null) throw new Error("This scrollable has no scrollToEnd and its size is unknown");
1118
+ scrollToOffset(s, max);
1119
+ }
1120
+ var scrollEnabled = (s) => !s.fibers.some((f) => propsOf(f)?.scrollEnabled === false);
1121
+ function offsetFor(s, found, window) {
1122
+ const rect = found.element.rect;
1123
+ const m = metricsOf(s);
1124
+ if (!rect || !m?.view) return null;
1125
+ const view = viewportOf(s, m.view, window) ?? m.view;
1126
+ const [start, size, viewStart, viewSize] = isHorizontal(s) ? [rect.x, rect.width, view.x, view.width] : [rect.y, rect.height, view.y, view.height];
1127
+ if (start >= viewStart && start + size <= viewStart + viewSize) return null;
1128
+ const delta = size >= viewSize ? start - viewStart : start + size / 2 - (viewStart + viewSize / 2);
1129
+ const next = Math.max(0, Math.min(m.max ?? Infinity, m.offset + (m.inverted ? -delta : delta)));
1130
+ return Math.abs(next - m.offset) < 1 ? null : round2(next);
1131
+ }
1132
+ function scrollerFor(found, window) {
1133
+ for (let s = found.scroller; s; s = s.parent) {
1134
+ if (s.hidden || s.host === found.host || !scrollEnabled(s)) continue;
1135
+ const offset = offsetFor(s, found, window);
1136
+ if (offset !== null) return { scrollable: s, offset };
1137
+ }
1138
+ return null;
1139
+ }
1140
+ var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1141
+ async function awaitScroll(s, before, want) {
1142
+ const near = (x, to) => !!x && to !== void 0 && Math.abs(x.offset - to) < 1;
1143
+ let m = metricsOf(s);
1144
+ if (!m || near(m, want)) return m;
1145
+ let idle = 0;
1146
+ for (let waited = 0; waited < 300; waited += 16) {
1147
+ await wait(16);
1148
+ const next = metricsOf(s);
1149
+ if (!next || near(next, want)) return next;
1150
+ idle = Math.abs(next.offset - m.offset) < 1 ? idle + 1 : 0;
1151
+ m = next;
1152
+ if (idle >= 2 && !near(m, before)) break;
1153
+ }
1154
+ return m;
1155
+ }
1156
+ function refreshHandler(s) {
1157
+ for (const f of s.fibers) {
1158
+ const p = propsOf(f);
1159
+ const control = p?.refreshControl?.props;
1160
+ if (typeof control?.onRefresh === "function") return control.onRefresh;
1161
+ if (typeof p?.onRefresh === "function") return p.onRefresh;
1162
+ }
1163
+ return null;
1164
+ }
1165
+ function draggable(s) {
1166
+ for (let x = s; x; x = x.parent) if (!x.hidden && scrollEnabled(x)) return x;
1167
+ return null;
1168
+ }
1169
+ function layoutOf(scrollables, window) {
1170
+ let ready = true;
1171
+ const parts = [];
1172
+ for (const s of scrollables) {
1173
+ if (s.hidden) continue;
1174
+ const view = measureHost(s.host);
1175
+ if (!view) continue;
1176
+ const m = metricsOf(s);
1177
+ if (view.width <= 0 || view.height <= 0 || !m?.view) ready = false;
1178
+ const limit = viewportOf(s, view, window);
1179
+ parts.push(JSON.stringify([view, m && [m.max, m.offset], limit]));
1180
+ }
1181
+ return { ready, sig: parts.join("|") };
1182
+ }
1183
+ var area2 = (r) => r ? r.width * r.height : 0;
1184
+ var onWindow = (r, w) => !!r && r.width > 0 && r.height > 0 && r.x < w.width && r.y < w.height && r.x + r.width > 0 && r.y + r.height > 0;
1185
+ function mainScrollable(scrollables, window, need) {
1186
+ const candidates = scrollables.filter((s) => !s.hidden && (need === "refresh" ? refreshHandler(s) : scrollEnabled(s))).map((s) => ({ s, rect: measureHost(s.host) })).filter(({ rect }) => onWindow(rect, window)).map(({ s, rect }) => ({ s, rect: viewportOf(s, rect, window) })).filter(({ rect }) => !!rect).sort((a, b) => area2(b.rect) - area2(a.rect));
1187
+ const [best] = candidates;
1188
+ if (!best)
1189
+ throw new Error(
1190
+ need === "refresh" ? "No scrollable on screen has a RefreshControl (or an onRefresh prop)" : "No scrollable on screen"
1191
+ );
1192
+ return best.s;
1193
+ }
1194
+
605
1195
  // src/runtime/screen/settle.ts
606
1196
  var STATE = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/commits");
607
1197
  var UPDATE_LANES = (1 << 22) - 1;
@@ -673,6 +1263,11 @@ async function settle(options = {}) {
673
1263
 
674
1264
  // src/runtime/screen/wait-for.ts
675
1265
  var POLL_MS = 50;
1266
+ function nearMissNote(found, target) {
1267
+ const text = typeof target === "string" ? target : target.text;
1268
+ const near = text ? nearMisses(found, text) : [];
1269
+ return near.length ? `. Near misses: ${near.join("; ")}` : "";
1270
+ }
676
1271
  function waitForTarget(collect, target, options = {}) {
677
1272
  const timeoutMs = options.timeoutMs ?? 5e3;
678
1273
  const t0 = performance.now();
@@ -691,7 +1286,7 @@ function waitForTarget(collect, target, options = {}) {
691
1286
  try {
692
1287
  const found = collect();
693
1288
  const matches = matchTarget(
694
- found.filter((f) => f.onScreen),
1289
+ found.filter((f) => f.onScreen && !f.hidden),
695
1290
  target
696
1291
  );
697
1292
  const hit = matches[indexOf(target) ?? 0];
@@ -703,7 +1298,7 @@ function waitForTarget(collect, target, options = {}) {
703
1298
  const what = options.gone ? "to disappear" : "to appear";
704
1299
  reject(
705
1300
  new Error(
706
- `Timed out after ${timeoutMs} ms waiting for ${showTarget(target)} ${what}. On screen: ${onScreenSummary(found)}`
1301
+ `Timed out after ${timeoutMs} ms waiting for ${showTarget(target)} ${what}. On screen: ${onScreenSummary(found)}${nearMissNote(found, target)}`
707
1302
  )
708
1303
  );
709
1304
  }
@@ -724,41 +1319,184 @@ function waitForTarget(collect, target, options = {}) {
724
1319
 
725
1320
  // src/runtime/screen/index.ts
726
1321
  function createScreen(env) {
727
- const collect = () => collectElements(env.roots(), env.window());
728
- const refresh = (found) => {
1322
+ const collectAll = () => collectScreen(env.roots(), env.window());
1323
+ const collect = () => collectAll().found;
1324
+ const collectSettled = async () => {
1325
+ let prev = null;
1326
+ let same = 0;
1327
+ for (let waited = 0; ; waited += 16) {
1328
+ const all = collectAll();
1329
+ const { ready, sig } = layoutOf(all.scrollables, env.window());
1330
+ same = sig === prev ? same + 1 : 0;
1331
+ if (waited >= 320 || (ready ? same >= 3 : same >= 6)) return all;
1332
+ prev = sig;
1333
+ await wait(16);
1334
+ }
1335
+ };
1336
+ const reread = (found) => {
729
1337
  const node = found.host.stateNode;
730
1338
  const again = collect().find(
731
1339
  (f) => f.host === found.host || node != null && f.host.stateNode === node
732
1340
  );
733
- return (again ?? found).element;
1341
+ return again ?? found;
1342
+ };
1343
+ const scrollableOf = (found, what) => {
1344
+ if (!found.scroller)
1345
+ throw new Error(`${describe(found.element)} ${what}`);
1346
+ return found.scroller;
1347
+ };
1348
+ const scrolled = async (scrollable, act) => {
1349
+ const before = metricsOf(scrollable);
1350
+ const want = act();
1351
+ await settle();
1352
+ return awaitScroll(scrollable, before?.offset, want ?? before?.max ?? void 0);
1353
+ };
1354
+ const bringIntoView = async (found) => {
1355
+ let current = found;
1356
+ let metrics = null;
1357
+ for (let i = 0; i < 4; i++) {
1358
+ const plan = scrollerFor(current, env.window());
1359
+ if (!plan) break;
1360
+ metrics = await scrolled(plan.scrollable, () => {
1361
+ scrollToOffset(plan.scrollable, plan.offset);
1362
+ return plan.offset;
1363
+ });
1364
+ current = reread(current);
1365
+ }
1366
+ if (!metrics && current.scroller) metrics = metricsOf(current.scroller);
1367
+ return { found: current, metrics };
1368
+ };
1369
+ const resolve = async (target, prefer, options) => {
1370
+ let found = resolveTarget(collect(), target, prefer, {
1371
+ ...options,
1372
+ offscreen: options.scroll
1373
+ });
1374
+ if (found.onScreen) return found;
1375
+ found = (await bringIntoView(found)).found;
1376
+ if (!found.onScreen)
1377
+ throw new Error(
1378
+ `${describe(found.element)} is not on screen and no scrollable around it can bring it into view`
1379
+ );
1380
+ return found;
734
1381
  };
735
1382
  return {
736
1383
  snapshot(options = {}) {
737
1384
  const found = collect();
738
- if (!options.all)
739
- return { elements: found.filter((f) => f.onScreen).map((f) => f.element) };
740
- return {
741
- elements: found.map((f) => ({ ...f.element, onScreen: f.onScreen }))
742
- };
1385
+ if (options.all)
1386
+ return {
1387
+ elements: found.map((f) => ({
1388
+ ...f.element,
1389
+ onScreen: f.onScreen,
1390
+ ...f.hidden && { hidden: true }
1391
+ }))
1392
+ };
1393
+ const shown = focused(found);
1394
+ const elements = shown.filter((f) => f.onScreen).map((f) => f.element);
1395
+ const hidden = found.length - shown.length;
1396
+ return hidden ? { elements, hidden } : { elements };
743
1397
  },
1398
+ findText: (text, options) => findText(collect(), text, options),
744
1399
  async fill(target, text, options = {}) {
745
- const found = resolveTarget(collect(), target, (f) => !!f.input);
1400
+ const found = await resolve(target, (f) => !!f.input, {
1401
+ index: options.index,
1402
+ afterText: true,
1403
+ scroll: options.scroll
1404
+ });
746
1405
  const filled = fillInput(found, String(text), options);
747
1406
  await settle();
748
- return { filled, element: refresh(found) };
1407
+ return { filled, element: reread(found).element };
749
1408
  },
750
- async press(target) {
751
- const found = resolveTarget(collect(), target, (f) => !!f.press);
752
- pressElement(found);
1409
+ async press(target, options = {}) {
1410
+ const found = await resolve(target, (f) => !!f.press, options);
1411
+ pressElement(found, options);
753
1412
  await settle();
754
1413
  return found.element;
755
1414
  },
1415
+ /**
1416
+ * Scrolls the nearest scrollable of a target (or `within`, or the main one
1417
+ * on screen) to the target, to either end, or by a distance in points.
1418
+ */
1419
+ async scroll(arg, options = {}) {
1420
+ const command = parseScroll(arg);
1421
+ const { found: all, scrollables } = await collectSettled();
1422
+ const named = options.within ? scrollableOf(
1423
+ resolveTarget(all, options.within, void 0, { offscreen: true }),
1424
+ "is not a scrollable or inside one"
1425
+ ) : null;
1426
+ const within2 = named && draggable(named);
1427
+ if (named && !within2)
1428
+ throw new Error(
1429
+ `${describe(resolveTarget(all, options.within, void 0, { offscreen: true }).element)} can't be scrolled (scrollEnabled is false, or it is on a hidden screen) and no scrollable around it can`
1430
+ );
1431
+ const target = command.kind === "to" ? resolveTarget(all, command.target, void 0, {
1432
+ index: options.index,
1433
+ offscreen: true
1434
+ }) : null;
1435
+ let metrics;
1436
+ let again = null;
1437
+ if (target && !within2) {
1438
+ scrollableOf(target, "is not inside a scrollable");
1439
+ ({ found: again, metrics } = await bringIntoView(target));
1440
+ } else if (target && within2) {
1441
+ const offset = offsetFor(within2, target, env.window());
1442
+ metrics = offset === null ? metricsOf(within2) : await scrolled(within2, () => (scrollToOffset(within2, offset), offset));
1443
+ again = reread(target);
1444
+ } else {
1445
+ const scrollable = within2 ?? mainScrollable(scrollables, env.window(), "scroll");
1446
+ metrics = await scrolled(scrollable, () => {
1447
+ if (command.kind === "toEnd") {
1448
+ scrollToEnd(scrollable);
1449
+ return metricsOf(scrollable)?.max ?? void 0;
1450
+ }
1451
+ if (command.kind === "toStart") {
1452
+ scrollToOffset(scrollable, 0);
1453
+ return 0;
1454
+ }
1455
+ const now2 = metricsOf(scrollable);
1456
+ if (!now2)
1457
+ throw new Error("Can not read the scroll position, so can not scroll by a distance");
1458
+ const next = Math.max(
1459
+ 0,
1460
+ Math.min(now2.max ?? Infinity, now2.offset + command.amount)
1461
+ );
1462
+ scrollToOffset(scrollable, next);
1463
+ return next;
1464
+ });
1465
+ }
1466
+ const result = {};
1467
+ if (metrics) {
1468
+ result.offset = Math.round(metrics.offset * 100) / 100;
1469
+ if (metrics.max != null) result.max = Math.round(metrics.max * 100) / 100;
1470
+ }
1471
+ if (again) {
1472
+ result.element = again.element;
1473
+ result.onScreen = again.onScreen;
1474
+ }
1475
+ return result;
1476
+ },
1477
+ /** Calls the `onRefresh` of a scrollable's RefreshControl, as a pull would. */
1478
+ async refresh(target, options = {}) {
1479
+ const { found: all, scrollables } = collectAll();
1480
+ const scrollable = target ? scrollableOf(
1481
+ resolveTarget(all, target, void 0, {
1482
+ index: options.index,
1483
+ offscreen: true
1484
+ }),
1485
+ "is not a scrollable or inside one"
1486
+ ) : mainScrollable(scrollables, env.window(), "refresh");
1487
+ const onRefresh = refreshHandler(scrollable);
1488
+ if (!onRefresh) throw new Error("That scrollable has no RefreshControl (or onRefresh prop)");
1489
+ onRefresh();
1490
+ await settle();
1491
+ return { refreshed: true };
1492
+ },
756
1493
  waitFor: (target, options) => waitForTarget(collect, target, options)
757
1494
  };
758
1495
  }
759
1496
 
760
1497
  // src/runtime/tools/screen.ts
761
1498
  var devToolsHook = () => globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
1499
+ var TARGET_HELP = "target is a string (testID, label, placeholder or text) or an object that must match every key it sets: {testID, label, placeholder, text, at:[x,y], index}. Unknown keys are errors. {at:[x,y]} hits the smallest element covering that point, for icon-only buttons with no label. Several matches: add an index, either in the target or as a trailing {index:n} argument.";
762
1500
  function screenTools() {
763
1501
  const screen = createScreen({
764
1502
  roots: () => fiberRoots(devToolsHook()),
@@ -766,28 +1504,31 @@ function screenTools() {
766
1504
  });
767
1505
  return {
768
1506
  "screen.findText": {
769
- description: "Find rendered text (substring, or exact with {exact:true}) and whether it is on screen. Ignores inactive tabs and screens.",
770
- run: (text, options) => findTextInTree(
771
- fiberRoots(devToolsHook()),
772
- text,
773
- import_react_native.Dimensions.get("window"),
774
- options
775
- )
1507
+ description: "Find text on screen (substring, or exact with {exact:true}) and whether each match is on screen. Searches the same joined text screen.snapshot shows (nested Text concatenated) and accessibility labels; {labels:false} searches text only. Each match says which field matched; a miss lists near misses. Ignores screens the user can't reach (unfocused tabs and stack screens, under a modal); `hidden` counts matches there.",
1508
+ run: (text, options) => screen.findText(text, options)
776
1509
  },
777
1510
  "screen.snapshot": {
778
- description: "Buttons, inputs, text and testID views on screen, with rects. {all:true} adds off-screen ones.",
1511
+ 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.",
779
1512
  run: (options) => screen.snapshot(options)
780
1513
  },
781
1514
  "screen.fill": {
782
- description: "Type into an input by testID, label, placeholder or text, then wait for the render. {submit:true} also submits.",
1515
+ description: `Type into an input, then wait for the render. ${TARGET_HELP} {submit:true} also submits.`,
783
1516
  run: (target, text, options) => screen.fill(target, text, options)
784
1517
  },
785
1518
  "screen.press": {
786
- description: "Press a button by testID, label or text, then wait for the render.",
787
- run: (target) => screen.press(target)
1519
+ 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.`,
1520
+ run: (target, options) => screen.press(target, options)
1521
+ },
1522
+ "screen.scroll": {
1523
+ description: `Scroll the nearest ScrollView/FlatList/FlashList, then wait for the render. Takes a target (or {to: target}) to bring into the middle of its scrollable, {toEnd:true}, {toStart:true}, or {by: points} (negative scrolls back); the last argument may be {within: target, index} to name the scrollable (a target inside it, or its testID). Without a target it scrolls the largest scrollable on screen. Only rendered elements can be targeted: rows a FlatList has not rendered yet need {toEnd:true} or {by} first. Returns the offset and, for a target, whether it is now on screen. ${TARGET_HELP}`,
1524
+ run: (arg, options) => screen.scroll(arg, options)
1525
+ },
1526
+ "screen.refresh": {
1527
+ description: "Pull to refresh: call the onRefresh of the RefreshControl of the scrollable holding a target (or the largest one with a RefreshControl on screen), then wait for the render. It does not wait for the data: follow with screen.waitFor.",
1528
+ run: (target, options) => screen.refresh(target, options)
788
1529
  },
789
1530
  "screen.waitFor": {
790
- description: "Wait until a target is on screen, or gone with {gone:true}. Default timeout 5000 ms.",
1531
+ description: "Wait until a target is on screen, or gone with {gone:true}. Matches testID, label, placeholder, then the same joined text screen.snapshot shows. Default timeout 5000 ms; a timeout lists near misses.",
791
1532
  run: (target, options) => screen.waitFor(target, options)
792
1533
  }
793
1534
  };
@@ -797,9 +1538,13 @@ function screenTools() {
797
1538
  function builtinTools(listTools, getTools, logs, getScenarios) {
798
1539
  return {
799
1540
  ...bridgeTools(listTools),
1541
+ ...appTools(
1542
+ selectReload(globalThis, () => import_react_native2.DevSettings.reload())
1543
+ ),
800
1544
  ...logTools(logs),
801
1545
  ...restoreTools(getTools),
802
1546
  ...screenTools(),
1547
+ ...captureTools(loadViewShot),
803
1548
  // Only when the app defines scenarios, so `tools` doesn't list empty ones.
804
1549
  ...getScenarios && scenarioTools(getScenarios, getTools)
805
1550
  };
@@ -862,6 +1607,9 @@ function createGate(name) {
862
1607
  };
863
1608
  }
864
1609
 
1610
+ // src/runtime/load-id.ts
1611
+ var loadId = Math.random().toString(36).slice(2, 10);
1612
+
865
1613
  // src/runtime/logs.ts
866
1614
  var CAPACITY = 200;
867
1615
  var STACK_LINES = 10;
@@ -1043,7 +1791,7 @@ function toJson(value) {
1043
1791
  function unwrap(definition) {
1044
1792
  return typeof definition === "function" ? { run: definition } : definition;
1045
1793
  }
1046
- function createRegistry(getTools, logs) {
1794
+ function createRegistry(getTools, logs, loadId2) {
1047
1795
  const list = () => Object.entries(getTools()).map(([name, definition]) => ({
1048
1796
  name,
1049
1797
  description: unwrap(definition).description
@@ -1052,7 +1800,12 @@ function createRegistry(getTools, logs) {
1052
1800
  const end = logs?.begin(call.tool);
1053
1801
  const result = await run(call, from).finally(end);
1054
1802
  const errors = logs?.takeErrors();
1055
- return errors?.length ? { ...result, logs: errors } : result;
1803
+ const stamped = loadId2 ? { ...result, loadId: loadId2, ...pending() } : result;
1804
+ return errors?.length ? { ...stamped, logs: errors } : stamped;
1805
+ }
1806
+ function pending() {
1807
+ const areas = Object.keys(pendingByArea(getTools()));
1808
+ return areas.length ? { pending: areas } : {};
1056
1809
  }
1057
1810
  async function run(call, from) {
1058
1811
  const t0 = performance.now();
@@ -1063,7 +1816,13 @@ function createRegistry(getTools, logs) {
1063
1816
  const known = list().map((t) => t.name).join(", ");
1064
1817
  throw new Error(`Unknown tool "${call.tool}". Known: ${known}`);
1065
1818
  }
1066
- const value = await unwrap(definition).run(...call.args ?? []);
1819
+ const { run: tool, maxArgs } = unwrap(definition);
1820
+ const args = call.args ?? [];
1821
+ if (maxArgs !== void 0 && args.length > maxArgs)
1822
+ throw new Error(
1823
+ `${call.tool} takes at most ${maxArgs} argument${maxArgs === 1 ? "" : "s"}, got ${args.length}. A JSON array is spread into the arguments; wrap it once more to pass an array as one argument.`
1824
+ );
1825
+ const value = await tool(...args);
1067
1826
  return { id: call.id, from, ok: true, value: toJson(value), ms: ms() };
1068
1827
  } catch (error) {
1069
1828
  const message2 = error instanceof Error ? error.message : String(error);
@@ -1085,12 +1844,13 @@ function startAgentBridge(options = {}) {
1085
1844
  ...builtinTools(() => registry.list(), allTools, logs.capture, getScenarios),
1086
1845
  ...userTools()
1087
1846
  });
1088
- const registry = createRegistry(allTools, logs.capture);
1847
+ const registry = createRegistry(allTools, logs.capture, loadId);
1089
1848
  const info = () => ({
1090
1849
  deviceId,
1091
- name: options.name ?? import_react_native2.Platform.OS,
1092
- platform: import_react_native2.Platform.OS,
1850
+ name: options.name ?? import_react_native3.Platform.OS,
1851
+ platform: import_react_native3.Platform.OS,
1093
1852
  protocol: PROTOCOL_VERSION,
1853
+ loadId,
1094
1854
  tools: registry.list()
1095
1855
  });
1096
1856
  const context = {