@avasapp/agent-bridge 0.3.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 +24 -225
- 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 +38 -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
|
@@ -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
|

|
|
8
6
|
|
|
9
|
-
The same checks on [the example app](example)
|
|
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'
|
|
@@ -72,7 +48,14 @@ export function AgentBridge() {
|
|
|
72
48
|
transports: [expoTransport(), cdpTransport()],
|
|
73
49
|
tools: {
|
|
74
50
|
...queryTools(queryClient),
|
|
75
|
-
...storeTools(
|
|
51
|
+
...storeTools(
|
|
52
|
+
{ settings: useSettingsStore, auth: useAuthStore },
|
|
53
|
+
// Masks these paths in every store.* output; `store.set` and
|
|
54
|
+
// `store.call` never echo the whole store. An action's return value
|
|
55
|
+
// is matched by store path only if it is a piece of the state itself,
|
|
56
|
+
// not a copy; use a hook function for those.
|
|
57
|
+
{ redact: { auth: ['accessToken', 'refreshToken', 'user.phone'] } },
|
|
58
|
+
),
|
|
76
59
|
...mmkvTools({ storage }),
|
|
77
60
|
...routerTools(router, { navigation: useNavigationContainerRef() }),
|
|
78
61
|
...networkTools(),
|
|
@@ -84,225 +67,41 @@ export function AgentBridge() {
|
|
|
84
67
|
}
|
|
85
68
|
```
|
|
86
69
|
|
|
87
|
-
Every app also gets
|
|
88
|
-
|
|
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.
|
|
93
|
-
- `bridge.logs`, `bridge.ping`, `bridge.tools`.
|
|
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.
|
|
94
71
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
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.
|
|
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).
|
|
98
73
|
|
|
99
74
|
## From the agent
|
|
100
75
|
|
|
101
76
|
```sh
|
|
102
77
|
npx agent-bridge session start # hold one connection; call/tools/run reuse it
|
|
103
78
|
npx agent-bridge tools
|
|
104
|
-
npx agent-bridge scenarios # setups the app defines, e.g. signedIn
|
|
105
79
|
npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
|
|
106
80
|
npx agent-bridge call query.pin '[["features"], {"beta": false}]'
|
|
107
81
|
npx agent-bridge call screen.press '"add-plant"'
|
|
108
|
-
npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
|
|
109
82
|
npx agent-bridge call screen.waitFor '"Name is required"'
|
|
110
83
|
npx agent-bridge run flows/add-plant.mjs --strict # fail if the app logged an error
|
|
111
84
|
npx agent-bridge session stop # runs bridge.restore, then disconnects
|
|
112
85
|
```
|
|
113
86
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
```ts
|
|
117
|
-
import { connect } from '@avasapp/agent-bridge/client'
|
|
118
|
-
|
|
119
|
-
const app = await connect({ metro: 'localhost:8081' })
|
|
120
|
-
await app.call('router.navigate', '/add')
|
|
121
|
-
await app.call('screen.fill', 'plant-name', 'Fiddle leaf fig')
|
|
122
|
-
await app.call('screen.press', 'save-plant')
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
A flow is a module the CLI runs without a model in the loop:
|
|
126
|
-
|
|
127
|
-
```js
|
|
128
|
-
export const scenario = 'signedIn' // optional: see Scenarios
|
|
129
|
-
|
|
130
|
-
export default async ({ step }) => {
|
|
131
|
-
await step('flag: beta off', 'query.pin', ['features'], { beta: false })
|
|
132
|
-
await step('save empty form', 'screen.press', 'save-plant')
|
|
133
|
-
await step('error shown', 'screen.waitFor', 'Name is required')
|
|
134
|
-
await step('undo', 'bridge.restore')
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
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:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
import { connect, runFlow } from '@avasapp/agent-bridge/client'
|
|
142
|
-
import * as mainTabs from './flows/main-tabs.flow.mjs'
|
|
143
|
-
|
|
144
|
-
const app = await connect({ metro: 'localhost:8081' })
|
|
145
|
-
const { errors, restoreErrors } = await runFlow(app, mainTabs, { scenarios: [{ name: 'signedIn' }] })
|
|
146
|
-
app.close()
|
|
147
|
-
if (errors || restoreErrors.length) process.exit(1)
|
|
148
|
-
```
|
|
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.
|
|
149
88
|
|
|
150
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.
|
|
151
90
|
|
|
152
|
-
##
|
|
153
|
-
|
|
154
|
-
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.
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
import { type Scenarios, useAgentBridge } from '@avasapp/agent-bridge'
|
|
158
|
-
import { mockApi, strictNetwork } from '@avasapp/agent-bridge/network'
|
|
159
|
-
|
|
160
|
-
const scenarios: Scenarios = {
|
|
161
|
-
signedIn: {
|
|
162
|
-
description: 'Signed in locally; no request reaches a server.',
|
|
163
|
-
// JSON Schema: bad options fail before apply runs, and say why.
|
|
164
|
-
options: {
|
|
165
|
-
type: 'object',
|
|
166
|
-
properties: {
|
|
167
|
-
user: { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' } } },
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
apply: async ({ options, call, onUndo }) => {
|
|
171
|
-
const user = { ...defaultUser, ...options?.user }
|
|
172
|
-
// Guards first, so nothing leaves the app with the fake token.
|
|
173
|
-
onUndo(realtimeGate.close())
|
|
174
|
-
onUndo(strictNetwork({ allow: ['cdn.example.com'] }))
|
|
175
|
-
onUndo(mockApi(API_URL, {
|
|
176
|
-
'GET /me': { json: user },
|
|
177
|
-
'GET /orders/:id': ({ params }) => ({ json: orders[params.id] }),
|
|
178
|
-
}))
|
|
179
|
-
onUndo(await addNativeRewriteRule(tilesHost, localTiles)) // any app code
|
|
180
|
-
await call('store.set', 'auth', { token: 'local', user }) // store.restore undoes it
|
|
181
|
-
return { user }
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
useAgentBridge({ tools, scenarios })
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
- `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.
|
|
190
|
-
- `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.
|
|
191
|
-
- `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.
|
|
192
|
-
- Tools: `scenario.list`, `scenario.apply [name, options?]` (applying an active one again undoes it first) and `scenario.restore`. `npx agent-bridge scenarios` lists them.
|
|
193
|
-
- 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.
|
|
194
|
-
|
|
195
|
-
**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`.
|
|
196
|
-
|
|
197
|
-
**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.
|
|
198
|
-
|
|
199
|
-
**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.
|
|
200
|
-
|
|
201
|
-
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.
|
|
91
|
+
## Docs
|
|
202
92
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
| `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `strict` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
|
|
212
|
-
| `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
|
|
213
|
-
| `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
|
|
214
|
-
| `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
|
|
215
|
-
|
|
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`.
|
|
217
|
-
|
|
218
|
-
## Realtime
|
|
219
|
-
|
|
220
|
-
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.
|
|
221
|
-
|
|
222
|
-
With Ably or socket.io, pass the client where you create it, **before the app subscribes**. Listeners added earlier stay invisible.
|
|
223
|
-
|
|
224
|
-
```ts
|
|
225
|
-
import { ablyTools } from '@avasapp/agent-bridge/ably'
|
|
226
|
-
import { socketIoTools } from '@avasapp/agent-bridge/socket.io'
|
|
227
|
-
|
|
228
|
-
export const ably = new Ably.Realtime(options)
|
|
229
|
-
export const realtimeDevTools = ablyTools(ably) // or socketIoTools(socket)
|
|
230
|
-
|
|
231
|
-
// in AgentBridge: tools: { ...realtimeDevTools, ... }
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
With any other library, or your own subscribe layer, wrap each listener:
|
|
235
|
-
|
|
236
|
-
```ts
|
|
237
|
-
import { createRealtimeTap } from '@avasapp/agent-bridge/realtime'
|
|
238
|
-
|
|
239
|
-
export const realtimeTap = createRealtimeTap() // tools: { ...realtimeTap.tools }
|
|
240
|
-
|
|
241
|
-
export function subscribe(channel: string, onMessage: (message: Message) => void) {
|
|
242
|
-
const { listener, unsubscribe } = realtimeTap.wrap(channel, onMessage)
|
|
243
|
-
const off = client.subscribe(channel, listener)
|
|
244
|
-
return () => { off(); unsubscribe() }
|
|
245
|
-
}
|
|
246
|
-
```
|
|
247
|
-
|
|
248
|
-
```sh
|
|
249
|
-
npx agent-bridge call realtime.channels
|
|
250
|
-
npx agent-bridge call realtime.mute '"order-42"' # the real feed goes quiet
|
|
251
|
-
npx agent-bridge call realtime.emit '["order-42", {"name": "status", "data": {"status": "arrived"}}]'
|
|
252
|
-
npx agent-bridge call realtime.log '{"channel": "order-42"}'
|
|
253
|
-
npx agent-bridge call realtime.connection '"disconnected"' # null goes back
|
|
254
|
-
```
|
|
255
|
-
|
|
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.
|
|
257
|
-
- `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
|
|
258
|
-
- `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
|
-
- `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
|
|
260
|
-
- `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).
|
|
261
|
-
- socket.io `onAny` listeners don't get injected events.
|
|
262
|
-
|
|
263
|
-
### Writing an adapter
|
|
264
|
-
|
|
265
|
-
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:
|
|
266
|
-
|
|
267
|
-
```ts
|
|
268
|
-
import { createRealtimeTap, realtimeAdapter, tapListeners } from '@avasapp/agent-bridge/realtime'
|
|
269
|
-
|
|
270
|
-
export const pusherTools = realtimeAdapter((channel: Channel, options: {}) => {
|
|
271
|
-
const tap = createRealtimeTap()
|
|
272
|
-
tapListeners(tap, channel, {
|
|
273
|
-
add: 'bind',
|
|
274
|
-
remove: 'unbind',
|
|
275
|
-
channel: ([event]) => `${channel.name}:${event}`, // undefined leaves a call alone
|
|
276
|
-
})
|
|
277
|
-
return tap
|
|
278
|
-
})
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
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.
|
|
282
|
-
|
|
283
|
-
## Transports
|
|
284
|
-
|
|
285
|
-
| | Expo dev-tools socket | CDP |
|
|
286
|
-
| --- | --- | --- |
|
|
287
|
-
| Round trip, local | 0.5 ms | 1.3 ms |
|
|
288
|
-
| Emoji in payloads | yes | yes (escaped for you) |
|
|
289
|
-
| Works with | Expo CLI | any RN app on Metro |
|
|
290
|
-
|
|
291
|
-
`connect()` tries Expo first and falls back to CDP.
|
|
292
|
-
|
|
293
|
-
## Release builds
|
|
294
|
-
|
|
295
|
-
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:
|
|
296
|
-
|
|
297
|
-
```sh
|
|
298
|
-
npx agent-bridge assert-absent path/to/main.jsbundle
|
|
299
|
-
```
|
|
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)
|
|
300
101
|
|
|
301
|
-
##
|
|
102
|
+
## Contributing
|
|
302
103
|
|
|
303
|
-
|
|
304
|
-
- **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.
|
|
305
|
-
- **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).
|
|
306
105
|
|
|
307
106
|
## License
|
|
308
107
|
|
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;
|