@avasapp/agent-bridge 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -11
- package/dist/adapters/ably.cjs +22 -4
- package/dist/adapters/ably.d.cts +8 -1
- package/dist/adapters/expo-router.cjs +22 -1
- package/dist/adapters/expo-router.d.cts +5 -1
- package/dist/adapters/react-native-mmkv.cjs +1 -0
- package/dist/adapters/react-native-mmkv.d.cts +1 -1
- package/dist/adapters/react-native-webview.cjs +672 -0
- package/dist/adapters/react-native-webview.d.cts +79 -0
- package/dist/adapters/socket-io.cjs +3 -1
- package/dist/adapters/socket-io.d.cts +1 -1
- package/dist/adapters/tanstack-query.cjs +256 -17
- package/dist/adapters/tanstack-query.d.cts +1 -1
- package/dist/adapters/zustand.cjs +152 -12
- package/dist/adapters/zustand.d.cts +19 -3
- package/dist/{chunk-S2FYN2R5.js → chunk-AJD3XTNT.js} +223 -57
- package/dist/cli.js +817 -56
- package/dist/client/index.cjs +218 -56
- package/dist/client/index.d.ts +20 -2
- package/dist/client/index.js +1 -1
- package/dist/expo/index.d.cts +1 -1
- package/dist/network/index.cjs +254 -51
- package/dist/network/index.d.cts +9 -2
- package/dist/noop/index.cjs +3 -0
- package/dist/noop/react-native-webview.cjs +31 -0
- package/dist/realtime/index.cjs +2 -1
- package/dist/realtime/index.d.cts +3 -1
- package/dist/runtime/index.cjs +860 -100
- package/dist/runtime/index.d.cts +31 -5
- package/dist/{types-6kKQsLC1.d.cts → types-BmpykP1P.d.cts} +23 -1
- package/entries/react-native-webview.cjs +9 -0
- package/package.json +17 -6
- package/skills/agent-bridge/SKILL.md +196 -5
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(
|
|
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,16 +93,58 @@ 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.
|
|
92
|
-
- `
|
|
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
|
|
@@ -107,11 +156,50 @@ npx agent-bridge call query.pin '[["features"], {"beta": false}]'
|
|
|
107
156
|
npx agent-bridge call screen.press '"add-plant"'
|
|
108
157
|
npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
|
|
109
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
|
|
110
160
|
npx agent-bridge run flows/add-plant.mjs --strict # fail if the app logged an error
|
|
111
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
|
|
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
|
+
}
|
|
112
176
|
```
|
|
113
177
|
|
|
114
|
-
|
|
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).
|
|
115
203
|
|
|
116
204
|
```ts
|
|
117
205
|
import { connect } from '@avasapp/agent-bridge/client'
|
|
@@ -122,7 +210,7 @@ await app.call('screen.fill', 'plant-name', 'Fiddle leaf fig')
|
|
|
122
210
|
await app.call('screen.press', 'save-plant')
|
|
123
211
|
```
|
|
124
212
|
|
|
125
|
-
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:
|
|
126
214
|
|
|
127
215
|
```js
|
|
128
216
|
export const scenario = 'signedIn' // optional: see Scenarios
|
|
@@ -207,13 +295,63 @@ The example app's [`src/dev/scenarios.ts`](example/src/dev/scenarios.ts) is a co
|
|
|
207
295
|
| `@avasapp/agent-bridge/tanstack-query` | `query.list` `get` `set` `pin` `unpin` `unpinAll` `refetch` `invalidate` `restore` | your `QueryClient` |
|
|
208
296
|
| `@avasapp/agent-bridge/zustand` | `store.list` `get` `set` `call` `restore` | your stores |
|
|
209
297
|
| `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
|
|
210
|
-
| `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
|
|
211
|
-
| `@avasapp/agent-bridge/
|
|
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 |
|
|
212
301
|
| `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
|
|
213
302
|
| `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
|
|
214
303
|
| `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
|
|
215
304
|
|
|
216
|
-
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` or `mockApi` from the same import, and turn on strict mode with `strictNetwork`.
|
|
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).
|
|
217
355
|
|
|
218
356
|
## Realtime
|
|
219
357
|
|
|
@@ -253,7 +391,7 @@ npx agent-bridge call realtime.log '{"channel": "order-42"}'
|
|
|
253
391
|
npx agent-bridge call realtime.connection '"disconnected"' # null goes back
|
|
254
392
|
```
|
|
255
393
|
|
|
256
|
-
- `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.
|
|
257
395
|
- `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
|
|
258
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.
|
|
259
397
|
- `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
|
package/dist/adapters/ably.cjs
CHANGED
|
@@ -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
|
-
|
|
302
|
-
|
|
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, {
|
package/dist/adapters/ably.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { T as Tools } from '../types-
|
|
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: "
|
|
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-
|
|
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;
|