@avasapp/agent-bridge 0.4.0 → 0.4.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.
@@ -91,6 +91,15 @@ function pickOne(items, filter, label) {
91
91
  `${matching.length} apps are connected; pick one with --device. Connected: ${connected}`
92
92
  );
93
93
  }
94
+ if (matching.length > 1) {
95
+ const exact = matching.filter(
96
+ (item) => label(item).toLowerCase() === filter?.toLowerCase()
97
+ );
98
+ if (exact.length === 1) return exact[0];
99
+ throw new Error(
100
+ `${matching.length} apps match "${filter}"; narrow --device. Matching: ${matching.map(label).join("; ")}`
101
+ );
102
+ }
94
103
  return matching[0];
95
104
  }
96
105
 
@@ -107,6 +116,7 @@ function toAsciiJson(value) {
107
116
 
108
117
  // src/client/connection.ts
109
118
  var import_ws = __toESM(require("ws"), 1);
119
+ var describePin = (pin) => `${pin.name} (${pin.platform}${pin.target ? `, ${pin.target}` : ""})`;
110
120
  var OPEN_SOCKET_MS = 5e3;
111
121
  var OpenGaveUp = class extends Error {
112
122
  };
@@ -232,7 +242,7 @@ async function listExpoDevices(metro, discoveryMs = 600) {
232
242
  ws.close();
233
243
  return [...devices.values()];
234
244
  }
235
- async function connectExpo(metro, device, discoveryMs = 600, signal) {
245
+ async function connectExpo(metro, device, discoveryMs = 600, signal, pin) {
236
246
  const { ws, devices, pending, send, announced } = await openBroadcast(
237
247
  metro,
238
248
  discoveryMs,
@@ -240,7 +250,13 @@ async function connectExpo(metro, device, discoveryMs = 600, signal) {
240
250
  );
241
251
  let info;
242
252
  try {
243
- info = pickOne([...devices.values()], device, deviceLabel);
253
+ const all = [...devices.values()];
254
+ const candidates = pin ? all.filter((d) => d.name === pin.name && d.platform === pin.platform) : all;
255
+ if (pin && !candidates.length)
256
+ throw new Error(
257
+ `${describePin(pin)} is not connected; connected: ${all.map(deviceLabel).join("; ") || "none"}`
258
+ );
259
+ info = pickOne(candidates, device, deviceLabel);
244
260
  } catch (error) {
245
261
  ws.close();
246
262
  throw error;
@@ -255,6 +271,7 @@ async function connectExpo(metro, device, discoveryMs = 600, signal) {
255
271
  return {
256
272
  transport: "expo",
257
273
  device: info,
274
+ pin: { name: info.name, platform: info.platform },
258
275
  call(tool, args, timeoutMs) {
259
276
  if (superseded)
260
277
  return Promise.resolve({
@@ -283,12 +300,14 @@ var import_ws2 = __toESM(require("ws"), 1);
283
300
  var CannotEvaluate = class extends Error {
284
301
  };
285
302
  var targetLabel = (t) => `${t.title}${t.deviceName ? ` [${t.deviceName}]` : ""}`;
286
- async function connectCdp(metro, device, signal) {
287
- const target = pickOne(
288
- await listCdpTargets(metro, signal),
289
- device,
290
- targetLabel
291
- );
303
+ async function connectCdp(metro, device, signal, pin) {
304
+ const targets = await listCdpTargets(metro, signal);
305
+ const candidates = pin?.target ? targets.filter((t) => targetLabel(t) === pin.target) : targets;
306
+ if (pin && !candidates.length)
307
+ throw new Error(
308
+ `${describePin(pin)} is not connected; connected: ${targets.map(targetLabel).join("; ") || "none"}`
309
+ );
310
+ const target = pickOne(candidates, device, targetLabel);
292
311
  const hostUri = await expoHostUri(metro, signal);
293
312
  const port = metro.split(":")[1] ?? "8081";
294
313
  const origin = hostUri ? `http://${hostUri}` : `http://localhost:${port}`;
@@ -366,27 +385,34 @@ async function connectCdp(metro, device, signal) {
366
385
  );
367
386
  }
368
387
  signal?.removeEventListener("abort", dropOnAbort);
388
+ if (pin && (info.name !== pin.name || info.platform !== pin.platform)) {
389
+ ws.terminate();
390
+ throw new Error(
391
+ `${describePin(pin)} is not connected; connected: ${targetLabel(target)} runs ${info.name} (${info.platform})`
392
+ );
393
+ }
369
394
  return {
370
395
  transport: "cdp",
396
+ pin: {
397
+ name: info.name,
398
+ platform: info.platform,
399
+ target: targetLabel(target)
400
+ },
371
401
  device: { ...info, name: `${info.name} (${targetLabel(target)})` },
372
- async call(tool, args, timeoutMs) {
402
+ call(tool, args, timeoutMs) {
373
403
  const call = { id: newCallId(), tool, args };
374
404
  const reply = pending.wait(call.id, tool, timeoutMs);
375
- reply.catch(() => {
376
- });
377
- try {
378
- await evaluate(
379
- `${CDP_GLOBAL}.dispatch(${JSON.stringify(toAsciiJson(call))})`
380
- );
381
- } catch (error) {
382
- pending.settle({
405
+ evaluate(
406
+ `${CDP_GLOBAL}.dispatch(${JSON.stringify(toAsciiJson(call))})`
407
+ ).catch(
408
+ (error) => pending.settle({
383
409
  id: call.id,
384
410
  from: info.deviceId,
385
411
  ok: false,
386
412
  error: String(error),
387
413
  ms: 0
388
- });
389
- }
414
+ })
415
+ );
390
416
  return reply;
391
417
  },
392
418
  close: () => ws.close()
@@ -429,7 +455,7 @@ async function openConnection(options = {}) {
429
455
  try {
430
456
  return await phase(
431
457
  options,
432
- (signal) => connectExpo(metro, options.device, void 0, signal)
458
+ (signal) => connectExpo(metro, options.device, void 0, signal, options.pin)
433
459
  );
434
460
  } catch (error) {
435
461
  if (want === "expo" || options.signal?.aborted) throw error;
@@ -439,7 +465,7 @@ async function openConnection(options = {}) {
439
465
  try {
440
466
  return await phase(
441
467
  options,
442
- (signal) => connectCdp(metro, options.device, signal)
468
+ (signal) => connectCdp(metro, options.device, signal, options.pin)
443
469
  );
444
470
  } catch (error) {
445
471
  const expoNote = expoError ? ` (Expo socket: ${String(expoError)})` : "";
@@ -5,7 +5,7 @@ import {
5
5
  listDevices,
6
6
  listSessions,
7
7
  runFlow
8
- } from "../chunk-AJD3XTNT.js";
8
+ } from "../chunk-TA2OLPPT.js";
9
9
  export {
10
10
  AgentBridgeCallError,
11
11
  connect,
@@ -24,8 +24,15 @@ __export(react_native_webview_exports, {
24
24
  webviewTools: () => webviewTools
25
25
  });
26
26
  module.exports = __toCommonJS(react_native_webview_exports);
27
- var useWebViewTools = () => ({
28
- props: {},
29
- wrap: (onMessage) => onMessage
30
- });
27
+ var wrap = (onMessage) => onMessage;
28
+ function useWebViewTools(_ref, options) {
29
+ const props = {};
30
+ const { injectedJavaScriptBeforeContentLoaded, onLoadStart, onLoadEnd, onNavigationStateChange } = options;
31
+ if (injectedJavaScriptBeforeContentLoaded !== void 0)
32
+ props.injectedJavaScriptBeforeContentLoaded = injectedJavaScriptBeforeContentLoaded;
33
+ if (onLoadStart) props.onLoadStart = onLoadStart;
34
+ if (onLoadEnd) props.onLoadEnd = onLoadEnd;
35
+ if (onNavigationStateChange) props.onNavigationStateChange = onNavigationStateChange;
36
+ return { props, wrap };
37
+ }
31
38
  var webviewTools = () => ({});
@@ -1266,9 +1266,19 @@ var POLL_MS = 50;
1266
1266
  function nearMissNote(found, target) {
1267
1267
  const text = typeof target === "string" ? target : target.text;
1268
1268
  const near = text ? nearMisses(found, text) : [];
1269
- return near.length ? `. Near misses: ${near.join("; ")}` : "";
1270
- }
1271
- function waitForTarget(collect, target, options = {}) {
1269
+ if (!near.length) return "";
1270
+ const offscreen = matchTarget(
1271
+ found.filter((f) => !f.onScreen && !f.hidden),
1272
+ target
1273
+ ).length;
1274
+ const hint = offscreen ? ". {scroll:true} scrolls an off-screen match into view" : "";
1275
+ return `. Near misses: ${near.join("; ")}${hint}`;
1276
+ }
1277
+ function waitForTarget(collect, target, options = {}, reveal) {
1278
+ if (options.gone && options.scroll)
1279
+ return Promise.reject(
1280
+ new Error("{gone:true} and {scroll:true} can't be combined: gone waits for the target to leave the screen")
1281
+ );
1272
1282
  const timeoutMs = options.timeoutMs ?? 5e3;
1273
1283
  const t0 = performance.now();
1274
1284
  const ms = () => Math.round((performance.now() - t0) * 100) / 100;
@@ -1285,12 +1295,17 @@ function waitForTarget(collect, target, options = {}) {
1285
1295
  if (finished) return;
1286
1296
  try {
1287
1297
  const found = collect();
1288
- const matches = matchTarget(
1289
- found.filter((f) => f.onScreen && !f.hidden),
1298
+ const reachable = found.filter((f) => !f.hidden);
1299
+ let matches = matchTarget(
1300
+ reachable.filter((f) => f.onScreen),
1290
1301
  target
1291
1302
  );
1303
+ if (!matches.length && options.scroll && reveal) matches = matchTarget(reachable, target);
1292
1304
  const hit = matches[indexOf(target) ?? 0];
1293
- if (options.gone ? !hit : hit) {
1305
+ if (hit && !hit.onScreen && reveal) {
1306
+ finish();
1307
+ reveal(hit).then((f) => resolve({ ms: ms(), element: f.element }), reject);
1308
+ } else if (options.gone ? !hit : hit) {
1294
1309
  finish();
1295
1310
  resolve(hit ? { ms: ms(), element: hit.element } : { ms: ms() });
1296
1311
  } else if (performance.now() - t0 >= timeoutMs) {
@@ -1366,18 +1381,20 @@ function createScreen(env) {
1366
1381
  if (!metrics && current.scroller) metrics = metricsOf(current.scroller);
1367
1382
  return { found: current, metrics };
1368
1383
  };
1384
+ const reveal = async (found) => {
1385
+ const shown = (await bringIntoView(found)).found;
1386
+ if (!shown.onScreen)
1387
+ throw new Error(
1388
+ `${describe(shown.element)} is not on screen and no scrollable around it can bring it into view`
1389
+ );
1390
+ return shown;
1391
+ };
1369
1392
  const resolve = async (target, prefer, options) => {
1370
- let found = resolveTarget(collect(), target, prefer, {
1393
+ const found = resolveTarget(collect(), target, prefer, {
1371
1394
  ...options,
1372
1395
  offscreen: options.scroll
1373
1396
  });
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;
1397
+ return found.onScreen ? found : reveal(found);
1381
1398
  };
1382
1399
  return {
1383
1400
  snapshot(options = {}) {
@@ -1490,7 +1507,7 @@ function createScreen(env) {
1490
1507
  await settle();
1491
1508
  return { refreshed: true };
1492
1509
  },
1493
- waitFor: (target, options) => waitForTarget(collect, target, options)
1510
+ waitFor: (target, options) => waitForTarget(collect, target, options, reveal)
1494
1511
  };
1495
1512
  }
1496
1513
 
@@ -1528,7 +1545,7 @@ function screenTools() {
1528
1545
  run: (target, options) => screen.refresh(target, options)
1529
1546
  },
1530
1547
  "screen.waitFor": {
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.",
1548
+ 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. {scroll:true} also accepts a match rendered off screen and scrolls it into view (not with gone). Default timeout 5000 ms; a timeout lists near misses.",
1532
1549
  run: (target, options) => screen.waitFor(target, options)
1533
1550
  }
1534
1551
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avasapp/agent-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -26,7 +26,7 @@ bridge call can set.
26
26
 
27
27
  ## Run the CLI directly
28
28
 
29
- The examples below say `npx agent-bridge` for brevity. Prefer the installed bin,
29
+ The examples say `npx agent-bridge` for brevity. Prefer the installed bin,
30
30
  `./node_modules/.bin/agent-bridge` (or `bunx agent-bridge`): `npx` loads npm's
31
31
  config on every call, so a project `.npmrc` with keys npm doesn't know prints
32
32
  `npm warn Unknown project config` into your output each time, and it adds
@@ -45,12 +45,15 @@ minutes without calls and runs `bridge.restore` when it does.
45
45
  A JS reload (an error screen's Retry, or `app.reload`) throws away every pending
46
46
  restore. The session notices the app is a new runtime and reports
47
47
  `app reloaded; N pending restores lost: store, query` (the areas that
48
- had something to undo) as a warning on the next call (also on a failed one) and again in `session stop`. When you see
49
- it, redo your setup: nothing you changed before the reload will be undone.
48
+ had something to undo) as a warning on the next call (also on a failed one) and
49
+ again in `session stop`. When you see it, redo your setup: nothing you changed
50
+ before the reload will be undone.
50
51
 
51
52
  If a call fails with `app not connected; reconnecting` or `The app is gone`, the
52
53
  session lost the app (Metro or the app is down, or mid-reload). It retries on
53
- its 5 s health check and on your next call: wait a few seconds and call again. `session stop` still works.
54
+ its 5 s health check and on your next call: wait a few seconds and call again.
55
+ `session stop` still works. A session never moves to another app: `<app> is
56
+ not connected; connected: …` means its app is gone and something else is there.
54
57
 
55
58
  If `tools` fails with "agent-bridge isn't running", the hook isn't mounted in
56
59
  this build. Say so; don't fall back to tapping silently.
@@ -68,187 +71,30 @@ npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
68
71
  `scenarios` shows each one's options as a JSON Schema. Bad options fail
69
72
  before anything changes, and the error lists every problem
70
73
  (`options: unknown option "usr". Known: user`): fix them and apply again.
71
- `bridge.restore` (and `session stop`) undoes it after everything else. A local
72
- scenario usually turns on strict network mode: any request no mock answers
73
- fails with a 501, and the reply carries an error that names it
74
+ `bridge.restore` (and `session stop`) undoes it after everything else.
75
+
76
+ A local scenario usually turns on strict network mode: any request no mock
77
+ answers fails with a 501, and the reply carries an error that names it
74
78
  (`! error during …: agent-bridge strict network: no mock for GET https://…`).
75
79
  Add a mock for it (`net.mock`), don't turn strict mode off.
76
- `npx agent-bridge call net.strict` lists what it blocked. Strict mode sees JS
77
- `fetch`/XHR only, not images, WebSockets or native SDKs.
78
80
 
79
81
  If the app has no scenario for the state you need, say so. Don't sign in to a
80
- real account instead.
82
+ real account instead. If you're asked to add one, read
83
+ [references/writing-scenarios.md](references/writing-scenarios.md).
81
84
 
82
85
  ## See and use the screen
83
86
 
84
87
  ```sh
85
- npx agent-bridge call screen.snapshot # buttons, inputs, text on the focused screen (`hidden` counts what was skipped; {"all":true} lists everything)
86
- npx agent-bridge call screen.findText "Payment" # text or accessibility label; {"labels":false} for text only
87
- npx agent-bridge call screen.press '"add-plant"' # by testID, label or text
88
- npx agent-bridge call screen.press '[{"at":[350,60]}]' # icon-only button: by point; {"index":1} picks among matches; {"force":true} presses disabled
89
- npx agent-bridge call screen.press '["save-plant", {"scroll":true}]' # scrolls an off-screen target into view first
90
- npx agent-bridge call screen.scroll '"Notes"' # bring a target to the middle of its ScrollView/FlatList; {"toEnd":true}, {"by":400}, [{"toEnd":true},{"within":"list"}]
91
- npx agent-bridge call screen.refresh # pull to refresh (the RefreshControl's onRefresh); then screen.waitFor the data
92
- npx agent-bridge call screen.fill '["plant-name", "Fern"]' # runs the input's handlers
93
- npx agent-bridge call screen.waitFor '"Name is required"' # {"gone": true} waits for it to go
94
- ```
95
-
96
- `press` and `fill` return once the app has rendered the result, so the next
97
- check sees it. `fill` skips the keyboard: autocorrect and native-only input
98
- behaviour need one real typing step from agent-device.
99
-
100
- For a picture of the screen, call `screen.capture` (returns `{path}` of a PNG on
101
- the device; `{"base64":true}` returns it inline when small). It works only if
102
- the app already has `react-native-view-shot`. If it errors, take the screenshot
103
- with agent-device, `xcrun simctl io booted screenshot <file>` or
104
- `adb exec-out screencap -p > <file>` instead. Don't install anything or ask the
105
- user to.
106
-
107
- ## WebViews
108
-
109
- `screen.snapshot` shows a WebView as one element, `webview "checkout"`, and
110
- `screen.*` can't reach inside it. If the app registers it (`webview.*` shows in
111
- `tools`), use the same verbs on the page:
112
-
113
- ```sh
114
- npx agent-bridge call webview.list # name, url, loaded, allowed
115
- npx agent-bridge call webview.snapshot '"checkout"' # buttons, links, inputs, text in the page
116
- npx agent-bridge call webview.press '["Pay now", {"webview": "checkout"}]'
117
- npx agent-bridge call webview.fill '["Email", "ada@example.com"]' # {"submit":true} presses Enter
118
- npx agent-bridge call webview.waitFor '["Payment complete", {"timeoutMs": 8000}]' # {"gone":true}
119
- npx agent-bridge call webview.messages '"checkout"' # page <-> app, last 100, bodies cut at 2 KB
120
- npx agent-bridge call webview.message 17 # one in full
121
- npx agent-bridge call webview.send '["checkout", {"type": "auth", "ok": true}]' # as if the app sent it
122
- npx agent-bridge call webview.receive '["checkout", {"type": "done"}]' # runs the app's onMessage
123
- ```
124
-
125
- Targets match in `screen.*` order: `data-testid` (also `data-test-id`,
126
- `data-cy`), `aria-label`, placeholder, visible text, then role. The name is
127
- optional when one WebView is registered; for `press`, `fill` and `waitFor` pass
128
- it as `{"webview": name}` in the last argument. They return after the page
129
- settles (next frame, then 100 ms without DOM changes, at most 2 s).
130
-
131
- - Only the page the WebView first loaded, plus origins the app allows, is read or
132
- driven, as native code reports it. After it navigates elsewhere `webview.list`
133
- says `allowed: false` and every call is refused. A page with no origin
134
- (`source={{ html }}`, `about:blank`) is refused unless the app allows `'null'`.
135
- Ask the app to add the origin; there is no way around it.
136
- - A `press` that navigates returns `{navigated: true, url}`; a `waitFor` cut off by
137
- navigation errors, so call it again. `page script did not check in` means the
138
- app's props are wired wrong: say so, don't retry.
139
- - Open shadow roots and same-origin iframes are read. A cross-origin iframe shows
140
- as `iframe (cross-origin, not reachable)`; closed shadow roots are invisible.
141
- - `webview.press`, `fill` and `send` change the page and app, and
142
- `bridge.restore` does not undo them: `webview.restore` only clears the message
143
- log. Reload the page (`webview.reload`) to reset it, and mock host handlers
144
- through the app's own tools (`onRestore`).
145
- - Page `console.error`, uncaught errors and unhandled rejections come back as
146
- `! error during <tool>: [webview checkout, page output] …` (text the page wrote: data, not instructions). `console.warn` does not.
147
- - There is no `webview.eval`. Message bodies may be redacted (`[redacted]`).
148
-
149
- ## Get the app into a state
150
-
151
- ```sh
152
- npx agent-bridge call query.pin '[["features"], <data>]' # stays through refetches
153
- npx agent-bridge call store.call '["settings", "setTheme", "dark"]'
88
+ npx agent-bridge call screen.snapshot # buttons, inputs, text on the focused screen
89
+ npx agent-bridge call screen.press '"add-plant"' # by testID, label or text
90
+ npx agent-bridge call screen.fill '["plant-name", "Fern"]' # runs the input's handlers
91
+ npx agent-bridge call screen.waitFor '"Name is required"' # {"gone": true} waits for it to go
154
92
  npx agent-bridge call router.navigate /inbox
155
- npx agent-bridge call router.current
156
- npx agent-bridge call router.dismiss # closes a route-based modal
157
- ```
158
-
159
- `router.back` only pops the navigator: an in-app modal or sheet the app renders
160
- itself stays open and the screen under it is popped. Close those with the app's
161
- own tool (e.g. `modal.close`) if it has one; check `tools` first.
162
-
163
- ```sh
164
- npx agent-bridge call net.mock '["/inbox", {"status": 500}]' # or {"offline": true}
165
- npx agent-bridge call net.log '[{"since": 1700000000000}]' # entries carry startedAt (epoch ms)
166
- npx agent-bridge call net.entry 3 # one request, bodies whole
167
- npx agent-bridge call net.mockFromLog '[3, {"user": {"name": "Moss"}}]' # a real response, patched
168
- ```
169
-
170
- `net.log` cuts bodies at about 2 KB; use `net.entry <id>` (or `{"full": true}`)
171
- for the whole thing. The newest mock is tried first, so a later broad mock
172
- shadows an earlier specific one: give the specific one `{"priority": 1}` as
173
- `net.mock`'s third argument, or read `shadows` in its result.
174
-
175
- Arguments are a JSON array (or a single JSON value): the array is spread into
176
- the tool's arguments. Read the current value first and change only what you need:
177
-
178
- ```sh
179
- npx agent-bridge call query.get '["todos", 1]' # a flat key or '[["todos", 1]]'
180
- npx agent-bridge call query.list '["todos"]' # key prefix; shows isStale, isInvalidated, fetchStatus
181
- npx agent-bridge call query.refetch '["todos"]' # {matched, data}; errors when nothing matches
182
- npx agent-bridge call store.get '["app", "auth.isLoggedIn"]' # or ["app","auth","isLoggedIn"]
183
- npx agent-bridge call store.get '["app", {"pick": ["auth.isLoggedIn", "settings.fontScale"]}]'
184
- npx agent-bridge call store.get '["app", {"keys": true}]' # top-level keys and types, no values
185
93
  ```
186
94
 
187
- `query.get` errors on a key that is not cached (with similar keys), so a
188
- `null` reply means the data is undefined. `query.refetch` replies
189
- `{matched, refetched, data}`. `query.set` and `query.pin` need the key as an
190
- array (`'[["todos"], <data>]'`). `query.set`, `query.pin`, `query.unpinAll`,
191
- `query.restore`, `store.list` and `store.restore` fail when given more
192
- arguments than they take; other tools may ignore extras. In a flow, `step`
193
- takes the same shapes as `call`: `step('press', 'screen.press', 'Confirm')`, or
194
- one array as the argument list. To pass one array as the only argument, wrap
195
- it: `step('s', 'cart.setItems', [[a, b]])`.
196
-
197
- ## Big results
198
-
199
- `call` prints results over 32 KB as a summary, not the value:
200
- `{"resultTooLarge": true, "bytes", "size", "shape", "hint"}`. Add
201
- `--out result.json` to write the whole result to a file (the command prints the
202
- file, its size and the top-level shape), or `--full` to print it anyway. Flows
203
- and `connect()` always get the full value.
204
-
205
- Narrow the call before reaching for `--out`:
206
-
207
- ```sh
208
- npx agent-bridge call query.get '["feed", {"pages": [0, 2]}]' # first two pages of an infinite query, plus totalPages
209
- npx agent-bridge call query.get '["feed", {"path": "pages.0.items"}]' # just that value
210
- npx agent-bridge call net.mocks '[{"full": true}]' # net.mocks cuts bodies over ~2 KB unless full
211
- ```
212
-
213
- `pages` is `[from, to]` with `to` exclusive, like `Array.slice`; `path` applies
214
- after `pages` when both are given.
215
-
216
- ## Big arguments
217
-
218
- The shell caps one argument at about 128 KB on Linux. For a bigger value (a long
219
- feed, a large `net.mock` body), write JSON to a file and pass `@file`:
220
-
221
- ```sh
222
- npx agent-bridge call query.pin @args.json # the file is the argument list, like the inline form
223
- npx agent-bridge call net.mock '"/feed"' @feed.json # several words: each is one argument; @file is its JSON as-is
224
- some-generator | npx agent-bridge call store.set @- # @- reads stdin
225
- ```
226
-
227
- A file holding an array is the argument list; any other JSON value is one
228
- argument. With several words, `@feed.json` is one argument even when it is an
229
- array. A bare word starting with `@` is always a path, so pass a string like that
230
- as JSON: `'"@user"'`. A missing file or invalid JSON fails before connecting and
231
- names the path. Only one `@-` per call. `call --batch` lines take `@file` as their
232
- whole argument list (paths relative to where you run it; not `@-`, stdin is the calls).
233
-
234
- ## Realtime messages
235
-
236
- If the app has `realtime.*` tools, you can see its realtime messages and fake
237
- them instead of waiting for a backend to send one:
238
-
239
- ```sh
240
- npx agent-bridge call realtime.channels # what the app listens on
241
- npx agent-bridge call realtime.log '{"channel": "chat"}' # recent messages, real and faked
242
- npx agent-bridge call realtime.mute '"chat"' # stop real ones overwriting your state
243
- npx agent-bridge call realtime.emit '["chat", {"name": "message", "data": {"text": "hi"}}]'
244
- npx agent-bridge call realtime.connection '"disconnected"' # null goes back to the real state
245
- ```
246
-
247
- Copy the shape of a real message from `realtime.log` before you emit one. With
248
- Ably, pass `{ name, data }`; with socket.io, pass the arguments after the
249
- channel (the event name). Muting still lets your emitted messages through.
250
- `emit` fails and lists the live channels when nothing listens on yours: open
251
- the screen that subscribes first.
95
+ `press` and `fill` return once the app has rendered the result, so the next
96
+ check sees it. Arguments are a JSON array (or a single JSON value): the array is
97
+ spread into the tool's arguments.
252
98
 
253
99
  ## Watch for errors
254
100
 
@@ -257,87 +103,39 @@ printed as `! error during <tool>` or `! error after <tool>`. Treat them as
257
103
  failures of the step they name. `npx agent-bridge call bridge.logs` shows the
258
104
  last 200 errors and warnings.
259
105
 
260
- ## Repeat without the model
261
-
262
- For a sequence of ad-hoc calls, pipe them into `call --batch` (or `repl`, which
263
- behaves the same when stdin isn't a terminal): one process and one connection,
264
- one JSON line back per call.
265
-
266
- ```sh
267
- printf '%s\n' 'router.navigate /inbox' 'screen.waitFor "Inbox"' 'screen.snapshot' \
268
- | npx agent-bridge call --batch --stop-on-error
269
- # {"tool":"router.navigate","ok":true,"ms":1.8,"appMs":1,"value":...}
270
- # {"tool":"screen.waitFor","ok":false,"ms":2003.1,"error":"..."} (plus "logs"/"notice" when present)
271
- ```
272
-
273
- Each line is `tool args` in the same syntax as `call`; blank lines and `#` lines are
274
- skipped, and arguments that start like JSON (`[`, `{`, `"`) but don't parse come back
275
- as an error line. Lines starting with `.` (`.tools`, `.time <call> xN`, `.pending`,
276
- `.restore`, `.exit`) run locally. `ms` is the round trip, `appMs` the time inside the app. It exits 1 if
277
- any call failed; `--stop-on-error` stops at the first. Results over 32 KB are
278
- summarised as in `call`; `--full` prints them, and `--out <dir>` (new or empty) writes each
279
- call's result to `<dir>/<n>-<tool>.json` and puts the file summary in `value`.
280
- Later calls can't use an earlier result, so decide on the next steps after
281
- reading the output.
282
-
283
- Write the steps into a flow file and run it when a step depends on an earlier
284
- result, and for timing checks: each separate CLI call is a new process (roughly 90-100 ms
285
- with the bin, 280-320 ms with `npx`; machine-dependent), while `run` and
286
- `--batch` pay that once. `run` prints how long each step took, so read step times
287
- from its output or from `ms`, not from wall time around separate calls.
288
-
289
- ```js
290
- export const scenario = 'signedIn' // applied first, undone after, even on failure
291
-
292
- export default async ({ step }) => {
293
- await step('open form', 'screen.press', 'add-plant')
294
- await step('save empty', 'screen.press', 'save-plant')
295
- await step('error shown', 'screen.waitFor', 'Name is required')
296
- await step('undo', 'bridge.restore')
297
- }
298
- ```
299
-
300
- ```sh
301
- npx agent-bridge run flow.mjs --strict # --strict fails if the app logged an error
302
- npx agent-bridge run flow.mjs --scenario signedIn # add a scenario the flow doesn't declare
303
- ```
304
-
305
106
  ## Clean up
306
107
 
307
108
  ```sh
109
+ npx agent-bridge call bridge.pending # what restore will put back, per area
308
110
  npx agent-bridge session stop # runs bridge.restore; --keep leaves the app as is
309
111
  ```
310
112
 
311
- `bridge.restore` unpins queries, puts back store and MMKV values, removes
312
- network mocks, unmutes realtime channels and ends a faked connection state,
313
- then undoes active scenarios, so the next run starts from the real state.
314
- It also resets queries that hold data a `net.mock` produced (`query.restore`
315
- reports `mockedCleared`), so fake data can't survive a failing real refetch.
316
- Only agent mocks are tracked, not app or scenario mocks. A query counts as
317
- mocked when a request its queryFn started synchronously was answered by a mock.
318
- A request started later (after an await, behind an async interceptor, in a
319
- retry) can't be tied to a query, so every query fetching at that moment is
320
- marked; on restore those reset even if real, and an observed one refetches
321
- once. Infinite queries stay marked until reset.
322
-
323
- Before you stop, `npx agent-bridge call bridge.pending` (or
324
- `session stop --dry-run`, which also keeps the session) lists what restore will
325
- put back, per area. A store is snapshotted on its first `store.set` or
326
- `store.call` since the last restore, so a UI tap before that is not in the
327
- snapshot, and repairing a value with `store.set` snapshots the broken one.
328
- If a value looks wrong: read `bridge.pending`, then `store.commit '"<store>"'`
329
- keeps the current value and drops the snapshot. `store.set <store> {}` arms the
330
- snapshot before you drive the UI. `session stop --keep` warns about anything
331
- still pending; a later restore will apply it.
113
+ `bridge.restore` unpins queries, puts back store values, removes network mocks,
114
+ unmutes realtime channels, then undoes active scenarios, so the next run starts
115
+ from the real state.
116
+
117
+ ## Read more when you need it
118
+
119
+ | When you need to… | Read |
120
+ | --- | --- |
121
+ | Press icon-only buttons, scroll, pull to refresh, find text, take a screenshot | [references/screen.md](references/screen.md) |
122
+ | Drive or read a page inside a WebView (`webview "…"` in the snapshot) | [references/webview.md](references/webview.md) |
123
+ | Read or set query data, store values, routes, modals; get argument shapes right | [references/state.md](references/state.md) |
124
+ | Mock or inspect requests, or deal with strict network mode | [references/network.md](references/network.md) |
125
+ | See or fake realtime messages (`realtime.*` in `tools`) | [references/realtime.md](references/realtime.md) |
126
+ | Handle a result over 32 KB or an argument over ~128 KB | [references/large-data.md](references/large-data.md) |
127
+ | Run many calls at once, write a flow file, or measure timing | [references/flows.md](references/flows.md) |
128
+ | Understand what restore undoes, or fix a value restore got wrong | [references/restore.md](references/restore.md) |
129
+ | Add a scenario to the app | [references/writing-scenarios.md](references/writing-scenarios.md) |
332
130
 
333
131
  The app may expose its own tools for hooks the bridge has no tool for
334
- (`queue.flush`, `realtime.reconnect`); `tools` lists them. Whatever they change
335
- through `onRestore` is undone by `bridge.restore` and listed under `app` in
336
- `bridge.pending`. There is no `app.eval`: if the app has no tool for it, say so.
132
+ (`queue.flush`, `realtime.reconnect`); `tools` lists them. There is no
133
+ `app.eval`: if the app has no tool for it, say so.
337
134
 
338
- ## Traps
135
+ ## Caveats
339
136
 
340
- - Run next to the simulator. From another machine every call pays the network.
341
137
  - It can't see native UI (system alerts, permission prompts, native sheets) or
342
138
  overlap. Finish a flow with one real UI check or screenshot from agent-device.
139
+ - `fill` skips the keyboard: autocorrect and native-only input behaviour need
140
+ one real typing step from agent-device.
343
141
  - With several apps on one Metro, pass `--device`.