@avasapp/agent-bridge 0.2.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.
Files changed (35) hide show
  1. package/README.md +220 -20
  2. package/dist/adapters/ably.cjs +22 -4
  3. package/dist/adapters/ably.d.cts +8 -1
  4. package/dist/adapters/expo-router.cjs +22 -1
  5. package/dist/adapters/expo-router.d.cts +5 -1
  6. package/dist/adapters/react-native-mmkv.cjs +1 -0
  7. package/dist/adapters/react-native-mmkv.d.cts +1 -1
  8. package/dist/adapters/react-native-webview.cjs +672 -0
  9. package/dist/adapters/react-native-webview.d.cts +79 -0
  10. package/dist/adapters/socket-io.cjs +3 -1
  11. package/dist/adapters/socket-io.d.cts +1 -1
  12. package/dist/adapters/tanstack-query.cjs +256 -17
  13. package/dist/adapters/tanstack-query.d.cts +1 -1
  14. package/dist/adapters/zustand.cjs +152 -12
  15. package/dist/adapters/zustand.d.cts +19 -3
  16. package/dist/{chunk-LBYZ5JHF.js → chunk-AJD3XTNT.js} +329 -46
  17. package/dist/cli.js +848 -98
  18. package/dist/client/index.cjs +311 -47
  19. package/dist/client/index.d.ts +64 -3
  20. package/dist/client/index.js +5 -3
  21. package/dist/expo/index.d.cts +1 -1
  22. package/dist/network/index.cjs +434 -66
  23. package/dist/network/index.d.cts +53 -3
  24. package/dist/noop/index.cjs +12 -0
  25. package/dist/noop/network.cjs +7 -1
  26. package/dist/noop/react-native-webview.cjs +31 -0
  27. package/dist/realtime/index.cjs +2 -1
  28. package/dist/realtime/index.d.cts +3 -1
  29. package/dist/runtime/index.cjs +1067 -111
  30. package/dist/runtime/index.d.cts +51 -5
  31. package/dist/types-BmpykP1P.d.cts +137 -0
  32. package/entries/react-native-webview.cjs +9 -0
  33. package/package.json +18 -6
  34. package/skills/agent-bridge/SKILL.md +226 -8
  35. package/dist/types-C6DUUHnB.d.cts +0 -76
package/README.md CHANGED
@@ -72,7 +72,14 @@ export function AgentBridge() {
72
72
  transports: [expoTransport(), cdpTransport()],
73
73
  tools: {
74
74
  ...queryTools(queryClient),
75
- ...storeTools({ settings: useSettingsStore, auth: useAuthStore }),
75
+ ...storeTools(
76
+ { settings: useSettingsStore, auth: useAuthStore },
77
+ // Masks these paths in every store.* output; `store.set` and
78
+ // `store.call` never echo the whole store. An action's return value
79
+ // is matched by store path only if it is a piece of the state itself,
80
+ // not a copy; use a hook function for those.
81
+ { redact: { auth: ['accessToken', 'refreshToken', 'user.phone'] } },
82
+ ),
76
83
  ...mmkvTools({ storage }),
77
84
  ...routerTools(router, { navigation: useNavigationContainerRef() }),
78
85
  ...networkTools(),
@@ -86,30 +93,113 @@ export function AgentBridge() {
86
93
 
87
94
  Every app also gets:
88
95
 
89
- - `screen.snapshot`: buttons, inputs, text and `testID` views on screen, with positions.
90
- - `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.
91
- - `screen.waitFor`, `screen.findText`: wait for, or check, text or a target on screen.
92
- - `bridge.restore`: undo what the agent changed, by running every `*.restore` tool.
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.
93
104
  - `bridge.logs`, `bridge.ping`, `bridge.tools`.
94
105
 
95
106
  Custom tools that change the screen can `await settle()` (from `@avasapp/agent-bridge`) so the next check sees the render.
96
107
 
97
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.
98
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.
141
+
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.
147
+
99
148
  ## From the agent
100
149
 
101
150
  ```sh
102
151
  npx agent-bridge session start # hold one connection; call/tools/run reuse it
103
152
  npx agent-bridge tools
153
+ npx agent-bridge scenarios # setups the app defines, e.g. signedIn
154
+ npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
104
155
  npx agent-bridge call query.pin '[["features"], {"beta": false}]'
105
156
  npx agent-bridge call screen.press '"add-plant"'
106
157
  npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
107
158
  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
108
160
  npx agent-bridge run flows/add-plant.mjs --strict # fail if the app logged an error
109
161
  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
110
163
  ```
111
164
 
112
- A session stops itself, restore included, after 15 minutes without calls (`--idle`), and reconnects if the app reloads.
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
+ ```
177
+
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).
113
203
 
114
204
  ```ts
115
205
  import { connect } from '@avasapp/agent-bridge/client'
@@ -120,9 +210,11 @@ await app.call('screen.fill', 'plant-name', 'Fiddle leaf fig')
120
210
  await app.call('screen.press', 'save-plant')
121
211
  ```
122
212
 
123
- A flow is a module the CLI runs without a model in the loop:
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:
124
214
 
125
215
  ```js
216
+ export const scenario = 'signedIn' // optional: see Scenarios
217
+
126
218
  export default async ({ step }) => {
127
219
  await step('flag: beta off', 'query.pin', ['features'], { beta: false })
128
220
  await step('save empty form', 'screen.press', 'save-plant')
@@ -131,8 +223,71 @@ export default async ({ step }) => {
131
223
  }
132
224
  ```
133
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
+ ```
237
+
134
238
  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.
135
239
 
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
+
136
291
  ## Adapters
137
292
 
138
293
  | Import | Tools | Needs |
@@ -140,13 +295,63 @@ No device tool is needed. Pair one (such as agent-device) with the bridge for wh
140
295
  | `@avasapp/agent-bridge/tanstack-query` | `query.list` `get` `set` `pin` `unpin` `unpinAll` `refetch` `invalidate` `restore` | your `QueryClient` |
141
296
  | `@avasapp/agent-bridge/zustand` | `store.list` `get` `set` `call` `restore` | your stores |
142
297
  | `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
143
- | `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
144
- | `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
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 |
145
301
  | `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
146
302
  | `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
147
303
  | `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
148
304
 
149
- A pin keeps seeded data in place through refetches until you unpin it. `net.mock('/inbox', { status: 500 })` or `{ offline: true }` fails a route; apps can fake a whole backend with `mockRequests` from the same import.
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).
150
355
 
151
356
  ## Realtime
152
357
 
@@ -186,7 +391,7 @@ npx agent-bridge call realtime.log '{"channel": "order-42"}'
186
391
  npx agent-bridge call realtime.connection '"disconnected"' # null goes back
187
392
  ```
188
393
 
189
- - `realtime.emit` runs the app's own handlers. Ably gets an Ably message (`{ id, name, data, timestamp }` plus what you pass), 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.
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.
190
395
  - `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
191
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.
192
397
  - `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
@@ -231,16 +436,11 @@ Every entry point is gated on `process.env.NODE_ENV`, like `react/index.js`: Met
231
436
  npx agent-bridge assert-absent path/to/main.jsbundle
232
437
  ```
233
438
 
234
- ## Traps we hit
439
+ ## What runs where
235
440
 
236
- - **Run the agent on the machine with the simulator.** Every call pays the network otherwise.
237
- - **Hidden tabs stay mounted.** `screen.findText` skips anything under an inactive `RNSScreen`.
238
- - **Expo checks the debugger's Origin** against the host Metro advertises and drops mismatches silently. The client reads it from the manifest.
239
- - **Expo Go on Android has no CDP `Runtime.evaluate`.** Use the Expo socket there (the default); CDP works in dev builds and in Expo Go on iOS.
240
- - **Expo's socket broadcasts to every app.** Calls are addressed to one device; pick it with `--device` when several are connected.
241
- - **Screen checks through the accessibility tree are slow** (hundreds of ms each). Check in-app, and keep one real UI check per flow.
242
- - **`screen.fill` skips the keyboard.** It runs the input's handlers, so validation and state are real, but autocorrect, native `maxLength` and uncontrolled inputs' native text are not.
243
- - **Keep your `QueryClient` in state** (`useState(() => new QueryClient())`). Created at module level, a Fast Refresh can leave the bridge holding a different client than the screen.
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).
244
444
 
245
445
  ## License
246
446
 
@@ -218,7 +218,7 @@ function createRealtimeTap(options = {}) {
218
218
  }
219
219
  },
220
220
  [`${ns}.emit`]: {
221
- description: "Deliver a fake message to every listener on a channel: [channel, message]. Runs the app's own handlers. Returns { id, delivered }.",
221
+ description: "Deliver a fake message to every listener on a channel: [channel, message]. Runs the app's own handlers. Returns { id, delivered }." + (options.emitUsage ? ` ${options.emitUsage}` : ""),
222
222
  run: (channel, ...values) => {
223
223
  const entries = channels.get(channel);
224
224
  if (!entries?.size)
@@ -254,6 +254,7 @@ function createRealtimeTap(options = {}) {
254
254
  run: (channel) => setMute(channel, null)
255
255
  },
256
256
  [`${ns}.restore`]: {
257
+ pending: () => mutes.size > 0 || faked !== null,
257
258
  description: "Undo the agent: unmute every channel and go back to the real connection state.",
258
259
  run: () => {
259
260
  const unmuted = [...mutes.keys()];
@@ -295,11 +296,27 @@ function accepts(filter, message) {
295
296
  }
296
297
  return true;
297
298
  }
299
+ var USAGE = 'Pass the message as { name, data }, e.g. ["chat", { "name": "typing", "data": { "user": "sam" } }]';
298
300
  function toMessage(values, { id }) {
299
301
  const [input] = values;
300
- if (!input || typeof input !== "object" || Array.isArray(input))
301
- throw new Error('Pass the message as { name, data }, e.g. ["chat", { "name": "typing", "data": { "user": "sam" } }]');
302
- return { id, timestamp: Date.now(), ...input };
302
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(USAGE);
303
+ const given = input;
304
+ if (given.name === void 0 && given.event !== void 0)
305
+ throw new Error(`Ably messages have "name", not "event". ${USAGE}`);
306
+ const { event: _event, ...fields } = given;
307
+ const message = {
308
+ id,
309
+ timestamp: Date.now(),
310
+ action: "message.create",
311
+ serial: id,
312
+ annotations: { summary: {} },
313
+ ...fields
314
+ };
315
+ const version = { ...message.version };
316
+ if (!version.serial && message.serial) version.serial = message.serial;
317
+ if (!version.timestamp && message.timestamp) version.timestamp = message.timestamp;
318
+ message.version = version;
319
+ return message;
303
320
  }
304
321
  var ablyTools = realtimeAdapter(
305
322
  (client, options) => {
@@ -308,6 +325,7 @@ var ablyTools = realtimeAdapter(
308
325
  namespace: options.namespace,
309
326
  describe: (m) => ({ event: m.name, data: m.data, id: m.id }),
310
327
  toMessage,
328
+ emitUsage: 'Ably: [channel, { name, data, clientId?, extras? }]. "name" is the event name (subscribe filters and message.name see it) and "data" the payload; there is no "event" field. Filled in like a real message: id, timestamp, action, version, annotations.',
311
329
  channelInfo: (name) => ({ state: channels.get(name)?.state }),
312
330
  // ably-js's connection is an event emitter; `emit` isn't in its public types.
313
331
  connection: fakeableConnection(client.connection, {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  /** The fields of an Ably message this adapter reads. */
4
4
  type AblyMessageLike = {
@@ -6,6 +6,13 @@ type AblyMessageLike = {
6
6
  name?: string;
7
7
  data?: unknown;
8
8
  clientId?: string;
9
+ connectionId?: string;
10
+ extras?: unknown;
11
+ timestamp?: number;
12
+ action?: string;
13
+ serial?: string;
14
+ version?: unknown;
15
+ annotations?: unknown;
9
16
  };
10
17
  /** The Ably realtime channel surface this adapter uses. */
11
18
  type AblyChannelLike = {
@@ -104,6 +104,13 @@ function settle(navigation, go) {
104
104
  }
105
105
  });
106
106
  }
107
+ function needDismiss(fn) {
108
+ if (!fn)
109
+ throw new Error(
110
+ "this router has no dismiss; update expo-router or close the modal with the app's own tool"
111
+ );
112
+ return fn;
113
+ }
107
114
  function routerTools(router, options = {}) {
108
115
  const { navigation } = options;
109
116
  const go = (fn, value) => navigation ? settle(navigation, fn).then(() => value) : (fn(), value);
@@ -121,12 +128,26 @@ function routerTools(router, options = {}) {
121
128
  run: (href) => go(() => router.replace(href), href)
122
129
  },
123
130
  "router.back": {
124
- description: "Go back if possible. Returns whether it could.",
131
+ description: "Pop the navigator once if it can. Returns whether it could. Ignores modals and sheets the app renders itself, so it can pop the screen under one: close those with the app's own tool, e.g. modal.close.",
125
132
  run: () => {
126
133
  const could = router.canGoBack();
127
134
  return could ? go(() => router.back(), true) : false;
128
135
  }
129
136
  },
137
+ "router.dismiss": {
138
+ description: "Close the top route-based modal (or `count` screens of the nearest stack). Returns whether it could. Does not close app-rendered modals or sheets.",
139
+ run: (count) => {
140
+ const dismiss = needDismiss(router.dismiss?.bind(router));
141
+ return router.canDismiss?.() === false ? false : go(() => dismiss(count), true);
142
+ }
143
+ },
144
+ "router.dismissAll": {
145
+ description: "Pop the nearest stack back to its first screen. Returns false if no stack has more than one screen. Does not close app-rendered modals or sheets.",
146
+ run: () => {
147
+ const dismissAll = needDismiss(router.dismissAll?.bind(router));
148
+ return router.canDismiss?.() === false ? false : go(() => dismissAll(), true);
149
+ }
150
+ },
130
151
  "router.current": {
131
152
  description: "The current route: pathname, href, params, segments, name, canGoBack.",
132
153
  run: () => {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  /** The imperative `router` from expo-router. Passed in, so this adds no dependency. */
4
4
  type RouterLike = {
@@ -7,6 +7,10 @@ type RouterLike = {
7
7
  replace(href: never): void;
8
8
  back(): void;
9
9
  canGoBack(): boolean;
10
+ /** Missing before expo-router 3; `router.dismiss` and `router.dismissAll` then throw. */
11
+ dismiss?(count?: number): void;
12
+ dismissAll?(): void;
13
+ canDismiss?(): boolean;
10
14
  };
11
15
  /** The root navigation container, from `useNavigationContainerRef()` in expo-router. */
12
16
  type NavigationLike = {
@@ -81,6 +81,7 @@ function mmkvTools(instances) {
81
81
  }
82
82
  },
83
83
  "mmkv.restore": {
84
+ pending: () => Object.values(instances).some((m) => originals.has(m)),
84
85
  description: "Undo the agent: put back keys changed with mmkv.set or mmkv.delete, deleting ones that did not exist. Returns how many.",
85
86
  run: () => {
86
87
  let restored = 0;
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-BmpykP1P.cjs';
2
2
 
3
3
  /** The MMKV surface this adapter uses. v3 has `delete`, v4 has `remove`. */
4
4
  type MMKVLike = {