@avasapp/agent-bridge 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,31 +2,9 @@
2
2
 
3
3
  Let coding agents drive a running React Native app directly. Seed data, flip flags, fake realtime messages, navigate and check the screen in **milliseconds**, without tapping through the app. It works alongside [agent-device](https://github.com/callstack/agent-device): agent-device opens the app and handles native UI, and agent-bridge handles everything inside the app.
4
4
 
5
- ## Demo
6
-
7
5
  ![The same checks with agent-device alone (22.4 s) and with agent-bridge (1.4 s): hide a tab, turn on dark mode, fill in a form, seed data, fake a live message and undo it all](example/media/demo.gif)
8
6
 
9
- The same checks on [the example app](example) in Expo Go, both at real speed, all on one Mac:
10
-
11
- - **agent-device alone** ([`demo-agent-device.mjs`](example/flows/demo-agent-device.mjs)) taps and types through the UI, edits the fake backend and reloads to change the flag and the data, and asks the realtime server to push a message.
12
- - **agent-device + agent-bridge** ([`demo.mjs`](example/flows/demo.mjs)) makes each change with one call, then undoes them all with `bridge.restore`.
13
-
14
- agent-device 0.21.15 won't press or fill most of this screen by selector on the iOS 27 simulator ([callstack/agent-device#2996](https://github.com/callstack/agent-device/issues/2996)), so the agent-device side looks up each element's frame and taps its centre. That adds one lookup per tap or fill.
15
-
16
- | Call | Round trip |
17
- | --- | --- |
18
- | `bridge.ping` | 1–4 ms |
19
- | `screen.findText`, `screen.waitFor` (already there) | 3–9 ms |
20
- | `screen.fill`, `screen.press` | 20–120 ms, render included |
21
- | `query.pin`, `store.call`, `router.navigate` | 11–55 ms, render included |
22
- | 19 steps, no pauses | 1.3–1.5 s wall, 0.9 s of it waiting on the fake backend |
23
-
24
- ```
25
- 19 steps with agent-bridge: 1.4 s wall
26
- same checks, agent-device alone: 22.4 s wall
27
- same flow from another machine: ~57 ms per call (network)
28
- same screen check via a11y tree: 450–970 ms
29
- ```
7
+ The same 19 checks on [the example app](example), at real speed: 22.4 s with agent-device alone, 1.4 s with agent-bridge. Most calls take a few milliseconds ([benchmarks](docs/benchmarks.md)).
30
8
 
31
9
  Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Dev builds only: release builds get empty stubs.
32
10
 
@@ -36,8 +14,6 @@ Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Dev
36
14
  bun add -d @avasapp/agent-bridge # or npm / yarn / pnpm
37
15
  ```
38
16
 
39
- ### Teach your agent
40
-
41
17
  The package ships an agent skill that tells your agent when and how to drive the app. Add it to Claude Code, Cursor, Codex and other agents:
42
18
 
43
19
  ```sh
@@ -53,7 +29,7 @@ In Claude Code you can install it as a plugin instead:
53
29
 
54
30
  ## In the app
55
31
 
56
- Mount the hook in a file that only runs in development. You choose every tool; adapters for common libraries are one import away.
32
+ Mount the hook in a file that only runs in development. You choose every tool; [adapters](docs/adapters.md) for common libraries are one import away.
57
33
 
58
34
  ```tsx
59
35
  import { cdpTransport, useAgentBridge } from '@avasapp/agent-bridge'
@@ -91,356 +67,41 @@ export function AgentBridge() {
91
67
  }
92
68
  ```
93
69
 
94
- Every app also gets:
95
-
96
- - `screen.snapshot`: buttons, inputs, text, and `testID` or labelled views on the focused screen, with positions and `checked` / `selected` / `expanded` state. Unfocused tabs and stack screens, content under an open modal, `display: none` and accessibility-hidden subtrees are left out (`hidden` counts them); `{all:true}` lists everything (a native-stack `formSheet` with an undimmed detent still hides the screen beneath it). Press, fill, `waitFor` and `findText` see the same focused screen.
97
- - `screen.fill`, `screen.press`: call an input's or button's own handlers, found by `testID`, label, placeholder or text, and return once React has rendered the result. A target is a string or `{testID, label, placeholder, text, at:[x,y], index}`; unknown keys are errors. `{at:[x,y]}` hits the smallest element covering that point (icon-only buttons), and `index` picks among several matches, in the target or as a trailing `{index}` argument. `screen.press(target, {force:true})` presses a disabled element on purpose, and `{scroll:true}` scrolls an off-screen target into view first.
98
- - `screen.scroll`, `screen.refresh`: scroll the nearest `ScrollView` / `FlatList` / `FlashList` to a target, to `{toEnd:true}` / `{toStart:true}`, or `{by: points}` (`{within: target}` names the scrollable), and pull to refresh by calling the `RefreshControl`'s `onRefresh`. Both return once React has rendered; follow a refresh with `screen.waitFor` for the data.
99
- - `screen.waitFor`, `screen.findText`: wait for, or check, text or a target on screen. Both match the same joined text the snapshot shows; `findText` also matches accessibility labels (`{labels:false}` for text only) and lists near misses when nothing matches.
100
- - `bridge.restore`: undo what the agent changed, by running every `*.restore` tool. With the network and TanStack Query adapters it also resets queries whose data came from a `net.mock` (`query.restore` returns `mockedCleared`; `net.unmock` and `net.restore` do it too), in any order. A query counts as mocked when a request its query function started synchronously was answered by an agent mock. A request started later (after an `await`, behind an async interceptor such as axios's async auth-token hook, or in a retry) can't be tied to a query, so a mock answering it marks every query fetching at that moment. Those get reset too, even if real, and one that is observed refetches once. Use synchronous interceptors to keep it exact. Mocks from app code or scenarios aren't tracked. Infinite queries stay marked until reset, since a real page fetch leaves the mocked pages.
101
- - `bridge.pending`: what `bridge.restore` would undo right now, per area. Stores show each changed key's snapshot against its current value (redacted, long values cut). Changes nothing.
102
- - `screen.capture`: screenshot as a PNG; returns `{path}` of a file on the device, or `{base64}` with `{base64:true}` when small. It works only if your app already has `react-native-view-shot` (the bridge never adds it). The package is loaded with a `require` inside `try/catch`, which Metro treats as optional, so apps without it bundle fine and get an error naming agent-device, `xcrun simctl io` and `adb exec-out screencap -p` as alternatives.
103
- - `app.reload`: reload the app's JS (Expo's `reloadAppAsync` when the app has Expo SDK 51+; otherwise `DevSettings.reload`). Pending restores are lost; a session waits for the new bridge before the next call.
104
- - `bridge.logs`, `bridge.ping`, `bridge.tools`.
105
-
106
- Custom tools that change the screen can `await settle()` (from `@avasapp/agent-bridge`) so the next check sees the render.
107
-
108
- Errors come back on their own: each reply carries what the app logged with `console.error`, threw or left unhandled since the previous reply, tagged with the call it happened during or after.
109
-
110
- ### Expose the hooks your agents keep needing
111
-
112
- Real apps have queues, timers, retry loops and caches that are not in a store or a query. Give the agent a tool for each one, next to the built-in ones. It is an ordinary function, JSON in and out, and it exists only in dev builds:
113
-
114
- ```tsx
115
- useAgentBridge({
116
- tools: {
117
- // Run the queued work now instead of waiting for its timer.
118
- 'queue.flush': () => uploadQueue.flush(),
119
- 'realtime.reconnect': () => realtime.reconnect(),
120
- },
121
- })
122
- ```
123
-
124
- When a tool changes something `bridge.restore` doesn't know about, register how to put it back with `onRestore(fn, label?)`. `bridge.restore` runs the undos once, newest first (`app.restore`), and `bridge.pending` lists their labels under `app` until then. A failing undo is reported and the rest still run.
125
-
126
- ```tsx
127
- import { onRestore } from '@avasapp/agent-bridge'
128
-
129
- tools: {
130
- // Swap a host handler for a fake; restore puts the real one back.
131
- 'host.mockMethod': (name: string, result: unknown) => {
132
- const original = host[name]
133
- host[name] = async () => result
134
- onRestore(() => { host[name] = original }, `host.${name}`)
135
- return { mocked: name }
136
- },
137
- }
138
- ```
139
-
140
- Call `onRestore` each time the tool changes something, so mocking twice restores both, in reverse. Register inside the tool's `run`: an `onRestore` called elsewhere (say at startup) counts as pending straight away and runs on the next restore, whether or not an agent touched anything. `bridge.restore` runs `*.restore` tools in area order, so app undos (`app.restore`) run before `net`, `query` and `store`, and newest first within `app`; `scenario.restore` always goes last. If an undo needs a store or mock still in place, register it from a scenario's `onUndo` instead. There is deliberately no `app.eval`: code the agent can run is a tool you wrote, and it can be undone.
70
+ Every app also gets `screen.snapshot`, `screen.press`, `screen.fill`, `screen.scroll`, `screen.waitFor`, `screen.findText`, `app.reload`, and `bridge.restore` to undo everything the agent changed. See [built-in tools and restore](docs/built-in-tools.md), including how to give agents your own hooks and register their undo.
141
71
 
142
- ### What restore will undo
143
-
144
- A store is snapshotted on the first `store.set` or `store.call` since the last restore. Changes made before that, such as tapping the UI, are not in the snapshot, so a value that was already wrong is what restore puts back. Check first with `bridge.pending`, and if the current value is the one you want, `store.commit <store>` drops the snapshot: restore leaves the store alone, and the next write snapshots again. `store.set <store> {}` snapshots without changing anything, to arm it before you drive the UI.
145
-
146
- `session stop --dry-run` prints what `bridge.pending` reports without restoring or stopping. `session stop --keep` warns when something is still pending, because a later `bridge.restore` will undo it.
72
+ Most flows need the app in a known state first, such as a user signed in locally with every request mocked. Define that once as a [scenario](docs/scenarios.md).
147
73
 
148
74
  ## From the agent
149
75
 
150
76
  ```sh
151
77
  npx agent-bridge session start # hold one connection; call/tools/run reuse it
152
78
  npx agent-bridge tools
153
- npx agent-bridge scenarios # setups the app defines, e.g. signedIn
154
79
  npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
155
80
  npx agent-bridge call query.pin '[["features"], {"beta": false}]'
156
81
  npx agent-bridge call screen.press '"add-plant"'
157
- npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
158
82
  npx agent-bridge call screen.waitFor '"Name is required"'
159
- npx agent-bridge call query.get '["feed"]' --out feed.json # big result to a file; prints size and shape
160
83
  npx agent-bridge run flows/add-plant.mjs --strict # fail if the app logged an error
161
84
  npx agent-bridge session stop # runs bridge.restore, then disconnects
162
- npx agent-bridge session stop --dry-run # lists what it would undo, keeps the session
163
- ```
164
-
165
- **Skip npx for agents.** `npx` loads npm's config on every call. If the project's `.npmrc` has keys npm doesn't know, each call prints `npm warn Unknown project config …` into the agent's output, and npx adds process startup. The installed bin does neither: run `./node_modules/.bin/agent-bridge …` (or `bunx agent-bridge …`) in place of `npx agent-bridge` in every command here.
166
-
167
- **Timing checks and long sequences.** Every CLI call is a new process. Measured on one Linux box (machine-dependent), that is roughly 90–100 ms with the bin and 280–320 ms through `npx` (about 55 and 250 ms with a session running), all of it startup rather than the bridge. For a timing check, or more than a handful of calls, write a flow file and use `agent-bridge run`: it pays startup once and prints how long each step took.
168
-
169
- ```js
170
- // flows/back-nav.mjs
171
- export default async ({ step }) => {
172
- await step('open detail', 'router.navigate', '/plants/1')
173
- await step('go back', 'router.back') // its time is printed in the step list
174
- await step('list shown', 'screen.waitFor', 'My plants')
175
- }
176
85
  ```
177
86
 
178
- ```sh
179
- ./node_modules/.bin/agent-bridge run flows/back-nav.mjs # one line per step: number, label, time
180
- ```
181
-
182
- `call` prints a result over 32 KB as a summary (`resultTooLarge`, size, shape, a hint) instead of flooding the terminal: pass `--out <file>` to write it to a file, or `--full` to print it. Flows and `connect()` always get the full value. To keep big values small at the source, `query.get` takes a last `{ pages: [from, to] }` (an infinite query's pages, `to` exclusive, with `totalPages`) or `{ path: "pages.0.items" }`, and `net.mocks` cuts response bodies over ~2 KB like `net.log` (`{ full: true }` returns them whole).
183
-
184
- **Big arguments.** The shell caps one argument at about 128 KB on Linux (`Argument list too long`). `call <tool> @args.json` reads the arguments from a file, and `@-` from stdin, with the inline rules: an array is the argument list, any other JSON value is one argument. With several words each is one argument and `@feed.json` is that file's JSON as it is: `call net.mock '"/feed"' @feed.json`. A bare word that starts with `@` is always a path; write a string like that as JSON (`'"@user"'`). A missing file or invalid JSON fails before connecting and names the path. `call --batch` lines take `@file` as their whole argument list (relative to the cwd; `@-` isn't allowed there, stdin carries the calls).
185
-
186
- ### Batch and REPL
187
-
188
- For a sequence of ad-hoc calls where a flow file is too much, `call --batch` reads one call per line from stdin (`tool args`, args as in `call`; blank lines and `#` lines are skipped; arguments that start like JSON but don't parse are reported as an error line rather than sent as a string) and prints one JSON line per call over a single connection:
189
-
190
- ```sh
191
- printf '%s\n' 'router.navigate /inbox' 'screen.waitFor "Inbox"' | agent-bridge call --batch
192
- # {"tool":"router.navigate","ok":true,"ms":1.8,"appMs":1,"value":{...}}
193
- # {"tool":"screen.waitFor","ok":false,"ms":2003.1,"error":"..."}
194
- ```
195
-
196
- `ms` is the client's round trip and `appMs` the time inside the app. `logs` and `notice` (what `call` prints on stderr) are added when present. It uses the running session under the same rules as `call`, or one direct connection. `--stop-on-error` stops at the first failed call; the exit code is 1 if any call failed. Results over 32 KB are summarised (`--full` prints them); `--out <dir>` (a new or empty directory) writes each call's full result to `<dir>/<n>-<tool>.json` and puts the file summary in `value`. Lines starting with `.` are the REPL's dot commands, run locally (`.tools`, `.time`, `.pending`, `.restore`, `.exit`), never sent to the app. Exiting early (`--stop-on-error`, or the reader closing stdout) doesn't wait for stdin.
197
-
198
- `agent-bridge repl` is the same loop with a prompt: history (`~/.agent-bridge/repl_history`, last 500 lines, saved as you type), tab completion of tool names and dot commands, pretty-printed values with their timings, and errors in red. Dot commands: `.help`, `.tools [prefix]`, `.time <call> [xN]` (N runs, min/median/max), `.pending`, `.restore`, `.exit`. Ctrl-C clears the line, or, during a call or `.time`, stops waiting for it (the app may still finish the call); Ctrl-D or `.exit` leaves. `.time` runs at most 1000 times. When stdin is not a terminal, `repl` behaves exactly like `call --batch`, so piping into it is safe; prompt and colours also need stdout to be a terminal. `repl` takes no `--out`.
199
-
200
- Measured against the fake Metro used in the tests (Node 24, the built CLI, 30 calls): 665 ms per call for separate `call`s, 54 ms per call with a session running; a batch of 30 through a session took 80 ms in total (about 2.7 ms per call, one process start included), and a direct batch adds about 0.3 ms per call after its single 660 ms start.
201
-
202
- A session stops itself, restore included, after 15 minutes without calls (`--idle`), and reconnects if the app reloads. After a reload it warns `app reloaded; N pending restores lost: store, query` (the areas that had something to undo) in the next call's output, failed or not, and in `session stop`, because the old runtime's undo state is gone. Every step of a reconnect is time-bounded (12 s per transport, Expo then CDP), so a Metro that stops answering can't wedge the session: calls fail with `app not connected; reconnecting` or `The app is gone (...)` instead of hanging, the daemon's health check (every 5 s) and the next call each try again until the idle timeout, and `session stop` and SIGTERM always finish (a stop still tries one reconnect so `bridge.restore` can run).
203
-
204
- ```ts
205
- import { connect } from '@avasapp/agent-bridge/client'
206
-
207
- const app = await connect({ metro: 'localhost:8081' })
208
- await app.call('router.navigate', '/add')
209
- await app.call('screen.fill', 'plant-name', 'Fiddle leaf fig')
210
- await app.call('screen.press', 'save-plant')
211
- ```
212
-
213
- A flow is a module the CLI runs without a model in the loop. `step(label, tool, ...args)` takes the arguments spread, or one array as the whole list, as `call` does:
214
-
215
- ```js
216
- export const scenario = 'signedIn' // optional: see Scenarios
217
-
218
- export default async ({ step }) => {
219
- await step('flag: beta off', 'query.pin', ['features'], { beta: false })
220
- await step('save empty form', 'screen.press', 'save-plant')
221
- await step('error shown', 'screen.waitFor', 'Name is required')
222
- await step('undo', 'bridge.restore')
223
- }
224
- ```
225
-
226
- To run flows from your own script or CI job, import them and pass them to `runFlow`. It applies declared scenarios, restores afterwards, and throws what the flow threw:
227
-
228
- ```ts
229
- import { connect, runFlow } from '@avasapp/agent-bridge/client'
230
- import * as mainTabs from './flows/main-tabs.flow.mjs'
231
-
232
- const app = await connect({ metro: 'localhost:8081' })
233
- const { errors, restoreErrors } = await runFlow(app, mainTabs, { scenarios: [{ name: 'signedIn' }] })
234
- app.close()
235
- if (errors || restoreErrors.length) process.exit(1)
236
- ```
87
+ Agents should run `./node_modules/.bin/agent-bridge` (or `bunx agent-bridge`) rather than `npx`, which is slower and can print npm warnings into their output. See [the CLI](docs/cli.md) for flows, batch mode, the REPL, sessions and the `connect()` client.
237
88
 
238
89
  No device tool is needed. Pair one (such as agent-device) with the bridge for what it can't reach: system alerts, permission prompts, the keyboard, screenshots, and one real tap per flow.
239
90
 
240
- ## Scenarios
241
-
242
- Most flows need the app in a known state first, above all a signed-in user. A scenario is a named setup the app defines once, for example a user who is signed in **locally**: a fake token, a fake user, and every request answered in the app, never by the real server.
243
-
244
- ```ts
245
- import { type Scenarios, useAgentBridge } from '@avasapp/agent-bridge'
246
- import { mockApi, strictNetwork } from '@avasapp/agent-bridge/network'
247
-
248
- const scenarios: Scenarios = {
249
- signedIn: {
250
- description: 'Signed in locally; no request reaches a server.',
251
- // JSON Schema: bad options fail before apply runs, and say why.
252
- options: {
253
- type: 'object',
254
- properties: {
255
- user: { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' } } },
256
- },
257
- },
258
- apply: async ({ options, call, onUndo }) => {
259
- const user = { ...defaultUser, ...options?.user }
260
- // Guards first, so nothing leaves the app with the fake token.
261
- onUndo(realtimeGate.close())
262
- onUndo(strictNetwork({ allow: ['cdn.example.com'] }))
263
- onUndo(mockApi(API_URL, {
264
- 'GET /me': { json: user },
265
- 'GET /orders/:id': ({ params }) => ({ json: orders[params.id] }),
266
- }))
267
- onUndo(await addNativeRewriteRule(tilesHost, localTiles)) // any app code
268
- await call('store.set', 'auth', { token: 'local', user }) // store.restore undoes it
269
- return { user }
270
- },
271
- },
272
- }
273
-
274
- useAgentBridge({ tools, scenarios })
275
- ```
276
-
277
- - `options` is a JSON Schema (2020-12, checked with [`@cfworker/json-schema`](https://github.com/cfworker/cfworker/tree/main/packages/json-schema), which needs no `eval` and runs on Hermes). `scenario.apply` checks the options before anything changes, and lists every problem at once, such as `options/user/email: String does not match format "email"`. An object schema that lists `properties` rejects other keys (`options: unknown option "usr". Known: user`) unless it sets `additionalProperties`. So a typo fails instead of being ignored. No options are checked as `{}`, so `required` ones are reported.
278
- - `apply` is ordinary app code. `call(tool, ...args)` runs a bridge tool, and that tool's own restorer undoes the change. `onUndo(fn)` covers everything else: app mocks, gates, native state such as a URL-rewrite rule.
279
- - `bridge.restore` runs every scenario's undo callbacks **last**, newest first, after the store, query and network restorers. The guards stay up until the app is back in its real state. If `apply` throws, the callbacks it had registered run straight away.
280
- - Tools: `scenario.list`, `scenario.apply [name, options?]` (applying an active one again undoes it first) and `scenario.restore`. `npx agent-bridge scenarios` lists them.
281
- - A flow declares what it needs with `export const scenario = 'signedIn'`, or `export const scenarios = ['signedIn', { name: 'cart', options: { items: 2 } }]`. `agent-bridge run` applies them before the flow and runs `bridge.restore` afterwards, even when the flow fails. The flow gets what each `apply` returned as `scenarios.signedIn`. A failed restorer makes the run exit non-zero. `run --scenario signedIn` adds one to any flow, with JSON options after `=` (`--scenario 'signedIn={"user":{"name":"Ada"}}'`); it replaces a declared one of the same name.
282
-
283
- **Strict network.** `strictNetwork({ allow?, status?, offline? })` from `@avasapp/agent-bridge/network` (or `net.strict` from the agent) fails every `fetch` or `XMLHttpRequest` that no mock answers. The request gets a 501 with `{ error: "agent-bridge strict network: no mock for GET https://…" }` and a `console.error`, so the agent sees it with the reply. With `offline: true` it fails like a network failure instead. `net.strict` lists the requests it blocked. `allow` lets hosts through (substrings or RegExps); Metro always gets through. It covers JS requests only: images, native SDKs (a map's tile fetches, say), and WebSockets go around it, so a scenario handles those with app code and `onUndo`.
284
-
285
- **Fixtures.** `mockApi(baseUrl, routes, options?)` answers `'METHOD /path'` routes, or `'/path'` for any method. `:name` matches one path segment and `*` the rest; the query string is ignored. A route's value is a response (`{ json }`, `{ status, body }`…) or a handler that gets the request with `params` and `query`. It returns a function that removes the routes.
286
-
287
- **Gates.** Some side effects must wait while a scenario runs, such as a realtime client connecting with the fake token and signing the user out when auth fails. `createGate(name)` from `@avasapp/agent-bridge` is a counted switch: `gate.close()` returns the function that reopens it (hand that to `onUndo`), `gate.closed` is what app code checks, and `gate.subscribe(fn)` hears it change. In a release build it's always open. `realtime.connection` isn't enough on its own: it hides the state from the app, but the client still connects.
288
-
289
- The example app's [`src/dev/scenarios.ts`](example/src/dev/scenarios.ts) is a complete local `signedIn`, and [`flows/checks/signed-in.mjs`](example/flows/checks/signed-in.mjs) declares it.
290
-
291
- ## Adapters
292
-
293
- | Import | Tools | Needs |
294
- | --- | --- | --- |
295
- | `@avasapp/agent-bridge/tanstack-query` | `query.list` `get` `set` `pin` `unpin` `unpinAll` `refetch` `invalidate` `restore` | your `QueryClient` |
296
- | `@avasapp/agent-bridge/zustand` | `store.list` `get` `set` `call` `restore` | your stores |
297
- | `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
298
- | `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `dismiss` `dismissAll` `current` | `router` and `useNavigationContainerRef()` from expo-router |
299
- | `@avasapp/agent-bridge/react-native-webview` | `webview.list` `snapshot` `press` `fill` `waitFor` `url` `reload` `messages` `message` `send` `receive` `restore` | `useWebViewTools` on each WebView, see [WebViews](#webviews) |
300
- | `@avasapp/agent-bridge/network` | `net.log` `entry` `mock` `mockFromLog` `mocks` `unmock` `strict` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
301
- | `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
302
- | `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
303
- | `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
304
-
305
- A pin keeps seeded data in place through refetches until you unpin it. `net.mock('/inbox', { status: 500 })` or `{ offline: true }` fails a route (the newest mock is tried first; `{ priority }` reorders mocks from the same source); `net.entry <id>` returns a logged request with whole bodies and `net.mockFromLog <id> [patch]` turns its response into a mock; apps can fake a whole backend with `mockRequests` or `mockApi` from the same import, and turn on strict mode with `strictNetwork`.
306
-
307
- **Modals.** `router.back` pops the navigator and knows nothing about modals or sheets your app renders itself, so it can pop the screen under an open one. `router.dismiss` and `router.dismissAll` close route-based modals. For app-rendered modals there is no reliable way to press hardware back from JS (iOS has no back button, and Android's `BackHandler` cannot be fired from outside), so register a tool with your own modal system and let the agent call it:
308
-
309
- ```ts
310
- // in AgentBridge: tools: { ...routerTools(router, { navigation }), 'modal.close': () => modalStore.getState().closeTop() }
311
- ```
312
-
313
- ## WebViews
314
-
315
- A page in a `react-native-webview` runs in its own JavaScript context, so `screen.*` sees one opaque element. Register the WebView and agents get the same verbs inside it, plus its `postMessage` traffic. Add `react-native-webview` (13 or later) as usual; the adapter needs nothing else and works on iOS and Android.
316
-
317
- ```tsx
318
- import { useWebViewTools, webviewTools } from '@avasapp/agent-bridge/react-native-webview'
319
-
320
- function Checkout() {
321
- const ref = useRef<WebView>(null)
322
- const webview = useWebViewTools(ref, {
323
- name: 'checkout',
324
- allowedOrigins: ['https://pay.example.com'], // optional
325
- redact: ['token', 'user.phone'], // optional, see below
326
- })
327
- return (
328
- <WebView
329
- ref={ref}
330
- source={{ uri: 'https://shop.example.com/cart' }}
331
- {...webview.props}
332
- onMessage={webview.wrap(onMessage)}
333
- />
334
- )
335
- }
336
-
337
- // in AgentBridge: tools: { ...webviewTools(), ... }
338
- ```
339
-
340
- `webview.props` holds `injectedJavaScriptBeforeContentLoaded` (the adapter's page script), `injectedJavaScriptBeforeContentLoadedForMainFrameOnly: true` and `onLoadStart`, `onLoadEnd` and `onNavigationStateChange`, which tell the adapter from native code which page it is talking to. Spread them **before** any prop of your own, and pass your own versions of those four as `useWebViewTools` options (`injectedJavaScriptBeforeContentLoaded` runs after the adapter's; the handlers are called after it): a prop set after the spread replaces the adapter's (setting `injectedJavaScriptBeforeContentLoadedForMainFrameOnly` to `false` after it puts the script into every iframe: don't), and calls then fail with `page script did not check in`. `wrap(onMessage)` gives the WebView its `onMessage`; the adapter's own messages never reach your handler. Each WebView needs its own `name`: a second live one with the same name is refused with a `console.error`. In a release build the hook returns empty props and `wrap` returns your handler unchanged.
341
-
342
- `screen.snapshot` shows the WebView where it sits, as `webview "checkout"`, so an agent knows to switch to `webview.*`.
343
-
344
- - `webview.list`, `webview.url`, `webview.reload`: registered WebViews (name, url, loaded, whether the current origin is allowed), the URL a WebView is on, and a reload that waits for the page to load again.
345
- - `webview.snapshot [name]`, `webview.press`, `webview.fill`, `webview.waitFor`: buttons, links, inputs, text and testID / `aria-label` elements, in the shape of `screen.snapshot`. Targets match in `screen.*` order: `data-testid` (also `data-test-id`, `data-cy`), `aria-label`, placeholder, visible text, then role; `{testID, label, placeholder, text, role, index}` works too. `press`, `fill` and `waitFor` take `{webview: name}` last when several are registered, and return after the page settles: the next animation frame, then 100 ms with no DOM mutations (iframes and shadow roots included), at most 2 s. `fill` uses the native value setter and dispatches `input` and `change`, so React and Vue inputs pick it up; password values are never echoed (`filled` is `[redacted]`). A `<select>` shows its `options` (value, label, selected) instead of listing each `<option>` as text, and `fill` picks by value, then by visible label; anything else is refused with the list of options. A `press` or `fill` that navigates returns `{navigated: true, url}`; a `waitFor` or `snapshot` in flight when the page navigates fails with `The page navigated to …` and is called again on the new page. Calls made while a new page loads wait (up to 10 s; `firstLoadTimeoutMs` in the hook options changes it) for its script to check in.
346
- - `webview.messages [name]`, `webview.message <id>`: the last 100 messages in both directions with timestamps: page → app (`onMessage`), and app → page (`postMessage`, `injectJavaScript`, `webview.send`). Bodies are cut at 2 KB (`truncated`, `size`); `webview.message` returns one whole. `redact` masks dotted paths in JSON bodies, or takes a hook `(webview, path, value) => value` like `storeTools`; text that isn't JSON (a script passed to `injectJavaScript`) is only reachable by the hook. Redaction is by key, not by value: a secret that appears inside a string value (a page echoing a token in a message, say) is logged as is. Use the hook form to scrub values, e.g. `(webview, path, value) => typeof value === 'string' ? value.replaceAll(TOKEN, '[redacted]') : value`.
347
- - `webview.send [name] <json>`: delivers a message to the page as if the app had sent it. `webview.receive [name] <json>`: runs your `onMessage` with a fake page message and waits for the render.
348
- - Page `console.error`, uncaught errors and unhandled rejections go to `bridge.logs` and the `! error during <tool>` stream, labelled `[webview checkout, page output]`, cut at 500 characters and on one line, since a page wrote them. `console.warn` is left out.
349
-
350
- **What can be read.** Only the origin the WebView first loaded, plus `allowedOrigins`, can be read or driven, and the adapter learns the origin from **native code** (`onLoadStart`, `onNavigationStateChange`, and the `url` of every `onMessage`), never from what a page says. Messages from the page script (checking in, replies, logs) are used only when the native URL is an allowed origin; from any other page they are dropped, and calls, `webview.send` and `webview.receive` are refused before anything is sent. When the page navigates elsewhere, `webview.list` shows `allowed: false`. Origins are compared as browsers write them (`https://x:443` is `https://x`, credentials in a URL don't count). A page with no origin of its own (`source={{ html }}`, `about:blank`, `data:`) has the opaque origin `null`, which is **never** allowed unless you list `'null'` in `allowedOrigins`. Open shadow roots are pierced. Same-origin iframes are read; a cross-origin one shows as `iframe (cross-origin, not reachable)`. Closed shadow roots and the `react-native-web` iframe are not supported. **Sub-frames.** react-native-webview reports the top-level URL, and on Android `window.ReactNativeWebView` is reachable from cross-origin iframes, so the origin alone can't tell an iframe's message from the page's. Every message from the adapter's script (check-in, reply, page log) therefore carries a random per-mount token, which lives in a closure of the script that runs only in the main frame (`injectedJavaScriptBeforeContentLoadedForMainFrameOnly`, forced to `true`; `injectJavaScript` also runs in the main frame only). A message without the token is dropped and never reaches `onMessage`. The script is not given to pages that aren't allowed a second time: after the WebView loads a page outside the allowed origins the token is replaced, so anything such a page learned is worthless (the hook renders the new props). The token is not defence against script running in the allowed page itself, which can read what the adapter reads by design.
351
-
352
- **Security.** Every call injects one fixed script with its arguments as a JSON string, so nothing an agent sends is evaluated as page code, and there is no `webview.eval`. Results return over `postMessage` with a per-call nonce, and the adapter swallows its own messages before `onMessage` sees them.
353
-
354
- **Restore.** `webview.restore` (part of `bridge.restore`) clears the message logs and drops calls still waiting for a reply. The hooks on the WebView's `postMessage` and `injectJavaScript` stay until it unmounts, so the log keeps filling. It does **not** undo `webview.press`, `webview.fill` or `webview.send`: they change the page and whatever your app did in response. Reload the page to reset it. To fake what the page asks the host for (pickers, biometric prompts, permission dialogs), swap your own handler table in a tool and register `onRestore`, as in [Expose the hooks your agents keep needing](#expose-the-hooks-your-agents-keep-needing).
355
-
356
- ## Realtime
91
+ ## Docs
357
92
 
358
- The realtime tools let an agent see realtime messages, send the app a fake one, and stop real ones from overwriting a state it set up. They work with any library.
359
-
360
- With Ably or socket.io, pass the client where you create it, **before the app subscribes**. Listeners added earlier stay invisible.
361
-
362
- ```ts
363
- import { ablyTools } from '@avasapp/agent-bridge/ably'
364
- import { socketIoTools } from '@avasapp/agent-bridge/socket.io'
365
-
366
- export const ably = new Ably.Realtime(options)
367
- export const realtimeDevTools = ablyTools(ably) // or socketIoTools(socket)
368
-
369
- // in AgentBridge: tools: { ...realtimeDevTools, ... }
370
- ```
371
-
372
- With any other library, or your own subscribe layer, wrap each listener:
373
-
374
- ```ts
375
- import { createRealtimeTap } from '@avasapp/agent-bridge/realtime'
376
-
377
- export const realtimeTap = createRealtimeTap() // tools: { ...realtimeTap.tools }
378
-
379
- export function subscribe(channel: string, onMessage: (message: Message) => void) {
380
- const { listener, unsubscribe } = realtimeTap.wrap(channel, onMessage)
381
- const off = client.subscribe(channel, listener)
382
- return () => { off(); unsubscribe() }
383
- }
384
- ```
385
-
386
- ```sh
387
- npx agent-bridge call realtime.channels
388
- npx agent-bridge call realtime.mute '"order-42"' # the real feed goes quiet
389
- npx agent-bridge call realtime.emit '["order-42", {"name": "status", "data": {"status": "arrived"}}]'
390
- npx agent-bridge call realtime.log '{"channel": "order-42"}'
391
- npx agent-bridge call realtime.connection '"disconnected"' # null goes back
392
- ```
393
-
394
- - `realtime.emit` runs the app's own handlers. Ably gets an Ably message shaped like one ably-js decodes (`{ id, name, data, timestamp, action, version, annotations }` plus what you pass, e.g. `clientId`, `connectionId`, `extras`; `name` is the event name, there is no `event` field), socket.io gets the arguments after the event name (`["chat", "a", "b"]` calls `listener("a", "b")`), and `createRealtimeTap` gets the value as is, or what its `toMessage` option builds.
395
- - `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
396
- - `realtime.connection` fakes a state through the client's own events (Ably's `connection.on` and `connection.state`, socket.io's `connect` / `disconnect`) and drops real messages until the state is `connected` again. Ably channel states don't follow. `socket.connected` keeps its real value: socket.io reads it itself, and faking it would hold real traffic back until the next reconnect.
397
- - `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
398
- - `createRealtimeTap` takes `describe` (what to log), `toMessage` (what `emit` delivers), `channelInfo` (extras for `realtime.channels`), `connection` (to add `realtime.connection`) and `namespace` (for a second tap).
399
- - socket.io `onAny` listeners don't get injected events.
400
-
401
- ### Writing an adapter
402
-
403
- To patch a client instead of wrapping each listener, name its add and remove methods and say which channel a call is for. `tapListeners` handles the bookkeeping and every remove form (all, by event, by listener). `realtimeAdapter` patches each client once. A Pusher-style `channel.bind(event, fn)` is this much:
404
-
405
- ```ts
406
- import { createRealtimeTap, realtimeAdapter, tapListeners } from '@avasapp/agent-bridge/realtime'
407
-
408
- export const pusherTools = realtimeAdapter((channel: Channel, options: {}) => {
409
- const tap = createRealtimeTap()
410
- tapListeners(tap, channel, {
411
- add: 'bind',
412
- remove: 'unbind',
413
- channel: ([event]) => `${channel.name}:${event}`, // undefined leaves a call alone
414
- })
415
- return tap
416
- })
417
- ```
418
-
419
- Pass `accepts` when a listener filters by event, and `message: 'args'` when the message is every argument (as in socket.io). `fakeableConnection(client, { property, events, states, announce })` adds `realtime.connection`: it fakes the state property and silences the client's own change events while a fake is on. Pass `fakeProperty: false` if the client reads that property itself to decide whether to deliver or send. [`src/adapters/`](src/adapters) has the Ably and socket.io adapters built this way.
420
-
421
- ## Transports
422
-
423
- | | Expo dev-tools socket | CDP |
424
- | --- | --- | --- |
425
- | Round trip, local | 0.5 ms | 1.3 ms |
426
- | Emoji in payloads | yes | yes (escaped for you) |
427
- | Works with | Expo CLI | any RN app on Metro |
428
-
429
- `connect()` tries Expo first and falls back to CDP.
430
-
431
- ## Release builds
432
-
433
- Every entry point is gated on `process.env.NODE_ENV`, like `react/index.js`: Metro inlines it and a release bundle gets empty stubs. Check a bundle in CI:
434
-
435
- ```sh
436
- npx agent-bridge assert-absent path/to/main.jsbundle
437
- ```
93
+ - [Built-in tools and restore](docs/built-in-tools.md)
94
+ - [The CLI, flows and the client](docs/cli.md)
95
+ - [Scenarios](docs/scenarios.md): signed-in state, strict network, fixtures, gates
96
+ - [Adapters](docs/adapters.md): TanStack Query, Zustand, MMKV, Expo Router, network
97
+ - [WebViews](docs/webviews.md)
98
+ - [Realtime](docs/realtime.md): Ably, socket.io, any other library
99
+ - [Security and release builds](docs/security.md)
100
+ - [Benchmarks](docs/benchmarks.md)
438
101
 
439
- ## What runs where
102
+ ## Contributing
440
103
 
441
- - **On your machine:** `agent-bridge run` and `runFlow` run the flow module you point them at, with your permissions, the same as `node flow.mjs`. Only run flows you'd run as scripts. In CI, don't run flows from untrusted forks with secrets in the environment, as with any test.
442
- - **In the app:** the bridge only calls tools and scenarios the app registered. Arguments and scenario options arrive as JSON data. Over CDP the client evaluates one fixed call with the message as a JSON string; the Expo transport sends plain JSON. Nothing the agent sends is evaluated as code.
443
- - **Who can reach it:** anyone who can reach Metro's debugger can already run any code in a dev build. The bridge adds no new way in, and release builds carry none of it (`assert-absent` checks).
104
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
444
105
 
445
106
  ## License
446
107
 
@@ -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 = () => ({});
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.1",
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",