@avasapp/agent-bridge 0.1.0 → 0.2.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 CHANGED
@@ -1,25 +1,29 @@
1
1
  # @avasapp/agent-bridge
2
2
 
3
- Let coding agents drive a running React Native app directly. Seed data, flip flags, navigate and check the screen in **milliseconds**, without tapping through the app.
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
5
  ## Demo
6
6
 
7
- ![An agent hides a tab, turns on dark mode, fills in a form, seeds data, checks the screen and undoes it all](example/media/demo.gif)
7
+ ![The same checks with agent-device alone (22.4 s) and with agent-bridge (1.4 s): hide a tab, turn on dark mode, fill in a form, seed data, fake a live message and undo it all](example/media/demo.gif)
8
8
 
9
- <!-- TODO: upload example/media/demo-x.mp4 in GitHub's web editor and put the URL it gives here. GitHub only plays an mp4 uploaded that way. -->
9
+ The same checks on [the example app](example) in Expo Go, both at real speed, all on one Mac:
10
10
 
11
- [`example/flows/demo.mjs`](example/flows/demo.mjs) driving [the example app](example) in Expo Go, all on one Mac:
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.
12
15
 
13
16
  | Call | Round trip |
14
17
  | --- | --- |
15
18
  | `bridge.ping` | 1–4 ms |
16
19
  | `screen.findText`, `screen.waitFor` (already there) | 3–9 ms |
17
- | `screen.fill`, `screen.press` | 20–65 ms, render included |
18
- | `query.pin`, `store.call`, `router.navigate` | 11–44 ms, render included |
19
- | 14 steps, no pauses | 0.8 s wall, 0.4 s of it waiting on a save |
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 |
20
23
 
21
24
  ```
22
- 14 steps, all local on one Mac: 0.8 s wall with no pauses
25
+ 19 steps with agent-bridge: 1.4 s wall
26
+ same checks, agent-device alone: 22.4 s wall
23
27
  same flow from another machine: ~57 ms per call (network)
24
28
  same screen check via a11y tree: 450–970 ms
25
29
  ```
@@ -32,6 +36,21 @@ Works on Expo (dev-tools socket) and on bare React Native (CDP over Metro). Dev
32
36
  bun add -d @avasapp/agent-bridge # or npm / yarn / pnpm
33
37
  ```
34
38
 
39
+ ### Teach your agent
40
+
41
+ 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
+
43
+ ```sh
44
+ npx skills add avas-app/agent-bridge
45
+ ```
46
+
47
+ In Claude Code you can install it as a plugin instead:
48
+
49
+ ```
50
+ /plugin marketplace add avas-app/agent-bridge
51
+ /plugin install agent-bridge@agent-bridge
52
+ ```
53
+
35
54
  ## In the app
36
55
 
37
56
  Mount the hook in a file that only runs in development. You choose every tool; adapters for common libraries are one import away.
@@ -123,9 +142,77 @@ No device tool is needed. Pair one (such as agent-device) with the bridge for wh
123
142
  | `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
124
143
  | `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
125
144
  | `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
145
+ | `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
146
+ | `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
147
+ | `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
126
148
 
127
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.
128
150
 
151
+ ## Realtime
152
+
153
+ 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.
154
+
155
+ With Ably or socket.io, pass the client where you create it, **before the app subscribes**. Listeners added earlier stay invisible.
156
+
157
+ ```ts
158
+ import { ablyTools } from '@avasapp/agent-bridge/ably'
159
+ import { socketIoTools } from '@avasapp/agent-bridge/socket.io'
160
+
161
+ export const ably = new Ably.Realtime(options)
162
+ export const realtimeDevTools = ablyTools(ably) // or socketIoTools(socket)
163
+
164
+ // in AgentBridge: tools: { ...realtimeDevTools, ... }
165
+ ```
166
+
167
+ With any other library, or your own subscribe layer, wrap each listener:
168
+
169
+ ```ts
170
+ import { createRealtimeTap } from '@avasapp/agent-bridge/realtime'
171
+
172
+ export const realtimeTap = createRealtimeTap() // tools: { ...realtimeTap.tools }
173
+
174
+ export function subscribe(channel: string, onMessage: (message: Message) => void) {
175
+ const { listener, unsubscribe } = realtimeTap.wrap(channel, onMessage)
176
+ const off = client.subscribe(channel, listener)
177
+ return () => { off(); unsubscribe() }
178
+ }
179
+ ```
180
+
181
+ ```sh
182
+ npx agent-bridge call realtime.channels
183
+ npx agent-bridge call realtime.mute '"order-42"' # the real feed goes quiet
184
+ npx agent-bridge call realtime.emit '["order-42", {"name": "status", "data": {"status": "arrived"}}]'
185
+ npx agent-bridge call realtime.log '{"channel": "order-42"}'
186
+ npx agent-bridge call realtime.connection '"disconnected"' # null goes back
187
+ ```
188
+
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.
190
+ - `realtime.mute` drops real messages; injected ones still get through unless you pass `{ "dropInjected": true }`. `"*"` mutes every channel.
191
+ - `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
+ - `realtime.log` keeps the last 50 messages, and logs a message once however many listeners get it.
193
+ - `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).
194
+ - socket.io `onAny` listeners don't get injected events.
195
+
196
+ ### Writing an adapter
197
+
198
+ 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:
199
+
200
+ ```ts
201
+ import { createRealtimeTap, realtimeAdapter, tapListeners } from '@avasapp/agent-bridge/realtime'
202
+
203
+ export const pusherTools = realtimeAdapter((channel: Channel, options: {}) => {
204
+ const tap = createRealtimeTap()
205
+ tapListeners(tap, channel, {
206
+ add: 'bind',
207
+ remove: 'unbind',
208
+ channel: ([event]) => `${channel.name}:${event}`, // undefined leaves a call alone
209
+ })
210
+ return tap
211
+ })
212
+ ```
213
+
214
+ 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.
215
+
129
216
  ## Transports
130
217
 
131
218
  | | Expo dev-tools socket | CDP |
@@ -149,6 +236,7 @@ npx agent-bridge assert-absent path/to/main.jsbundle
149
236
  - **Run the agent on the machine with the simulator.** Every call pays the network otherwise.
150
237
  - **Hidden tabs stay mounted.** `screen.findText` skips anything under an inactive `RNSScreen`.
151
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.
152
240
  - **Expo's socket broadcasts to every app.** Calls are addressed to one device; pick it with `--device` when several are connected.
153
241
  - **Screen checks through the accessibility tree are slow** (hundreds of ms each). Check in-app, and keep one real UI check per flow.
154
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.
@@ -0,0 +1,344 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/adapters/ably.ts
21
+ var ably_exports = {};
22
+ __export(ably_exports, {
23
+ ablyTools: () => ablyTools
24
+ });
25
+ module.exports = __toCommonJS(ably_exports);
26
+
27
+ // src/realtime/patch.ts
28
+ var list = (names) => Array.isArray(names) ? names : [names];
29
+ var sameKey = (a, b) => JSON.stringify(a) === JSON.stringify(b);
30
+ function split(args) {
31
+ const last = args[args.length - 1];
32
+ return typeof last === "function" ? { key: args.slice(0, -1), listener: last } : { key: args };
33
+ }
34
+ function tapListeners(tap, target, options) {
35
+ const methods = target;
36
+ const tracked = [];
37
+ const spread = options.message === "args";
38
+ for (const name of list(options.add)) {
39
+ const add = methods[name];
40
+ if (typeof add !== "function") continue;
41
+ methods[name] = function(...args) {
42
+ const { key, listener: original } = split(args);
43
+ const channel = original && options.channel(key);
44
+ if (!original || channel === void 0) return add.apply(this, args);
45
+ const accepts2 = options.accepts;
46
+ const { listener, unsubscribe } = tap.wrap(
47
+ channel,
48
+ (message, ...rest) => spread ? original.apply(target, message) : original.call(target, message, ...rest),
49
+ accepts2 ? { accepts: (m) => accepts2(key, m) } : {}
50
+ );
51
+ const wrapped = spread ? (...received) => listener(received) : (message, ...rest) => listener(message, ...rest);
52
+ tracked.push({ key, original, wrapped, unsubscribe });
53
+ return add.apply(this, [...key, wrapped]);
54
+ };
55
+ }
56
+ for (const name of list(options.remove)) {
57
+ const remove = methods[name];
58
+ if (typeof remove !== "function") continue;
59
+ methods[name] = function(...args) {
60
+ const { key, listener } = split(args);
61
+ const hits = tracked.filter(
62
+ (t) => (!listener || t.original === listener || t.original.fn === listener) && (!key.length || sameKey(t.key, key))
63
+ );
64
+ for (const hit of hits) {
65
+ tracked.splice(tracked.indexOf(hit), 1);
66
+ hit.unsubscribe();
67
+ }
68
+ if (!listener || !hits.length) return remove.apply(this, args);
69
+ let result;
70
+ for (const hit of hits) result = remove.apply(this, [...hit.key, hit.wrapped]);
71
+ return result;
72
+ };
73
+ }
74
+ }
75
+ function fakeableConnection(target, options) {
76
+ const methods = target;
77
+ const events = methods?.[options.events];
78
+ if (!methods || typeof events !== "function") return void 0;
79
+ const read = options.read ?? String;
80
+ const write = options.write ?? ((state) => state);
81
+ const values = methods;
82
+ let fake = null;
83
+ let real = values[options.property];
84
+ const readReal = () => read(options.fakeProperty === false ? values[options.property] : real);
85
+ if (options.fakeProperty !== false)
86
+ Object.defineProperty(target, options.property, {
87
+ configurable: true,
88
+ enumerable: true,
89
+ get: () => fake === null ? real : write(fake),
90
+ set: (value) => {
91
+ real = value;
92
+ }
93
+ });
94
+ methods[options.events] = function(...args) {
95
+ return fake === null ? events.apply(this, args) : void 0;
96
+ };
97
+ const emit = (...args) => void events.apply(target, args);
98
+ return {
99
+ state: readReal,
100
+ states: options.states,
101
+ live: options.live,
102
+ fake: (state, previous) => {
103
+ fake = state;
104
+ const current = state ?? readReal();
105
+ if (current !== previous)
106
+ options.announce(emit, { current, previous, faked: state !== null });
107
+ }
108
+ };
109
+ }
110
+ function realtimeAdapter(install) {
111
+ const taps = /* @__PURE__ */ new WeakMap();
112
+ return (client, options = {}) => {
113
+ let tools = taps.get(client);
114
+ if (!tools) taps.set(client, tools = install(client, options).tools);
115
+ return tools;
116
+ };
117
+ }
118
+
119
+ // src/realtime/tap.ts
120
+ var LOG_SIZE = 50;
121
+ var ALL = "*";
122
+ var defaultDescribe = (message) => {
123
+ const id = message?.id;
124
+ return typeof id === "string" ? { data: message, id } : { data: message };
125
+ };
126
+ var sameMessage = (a, b) => a === b || Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]);
127
+ function createRealtimeTap(options = {}) {
128
+ const ns = options.namespace ?? "realtime";
129
+ const describe = options.describe ?? defaultDescribe;
130
+ const toMessage2 = options.toMessage ?? ((values) => values[0]);
131
+ const connection = options.connection;
132
+ const live = connection?.live ?? "connected";
133
+ const channels = /* @__PURE__ */ new Map();
134
+ const mutes = /* @__PURE__ */ new Map();
135
+ const log = [];
136
+ const last = /* @__PURE__ */ new Map();
137
+ let injectedCount = 0;
138
+ let faked = null;
139
+ const muteFor = (channel) => mutes.get(channel) ?? mutes.get(ALL);
140
+ const liveChannels = () => [...channels.keys()].join(", ") || "none";
141
+ function record(channel, message, injected, dropped) {
142
+ log.push({
143
+ at: (/* @__PURE__ */ new Date()).toISOString(),
144
+ channel,
145
+ ...describe(message),
146
+ injected,
147
+ ...dropped && { dropped }
148
+ });
149
+ if (log.length > LOG_SIZE) log.splice(0, log.length - LOG_SIZE);
150
+ }
151
+ function receive(channel, entry, message, rest) {
152
+ const previous = last.get(channel);
153
+ const dropped = muteFor(channel) ? "muted" : faked !== null && faked !== live ? "connection" : void 0;
154
+ if (previous && !previous.seen.has(entry) && sameMessage(previous.message, message)) {
155
+ previous.seen.add(entry);
156
+ } else {
157
+ last.set(channel, { message, seen: /* @__PURE__ */ new Set([entry]) });
158
+ record(channel, message, false, dropped);
159
+ }
160
+ if (!dropped) entry.deliver(message, ...rest);
161
+ }
162
+ const wrap = (channel, listener, wrapOptions = {}) => {
163
+ const entry = {
164
+ deliver: listener,
165
+ accepts: wrapOptions.accepts
166
+ };
167
+ let entries = channels.get(channel);
168
+ if (!entries) channels.set(channel, entries = /* @__PURE__ */ new Set());
169
+ entries.add(entry);
170
+ return {
171
+ listener: (message, ...rest) => receive(channel, entry, message, rest),
172
+ unsubscribe: () => {
173
+ const current = channels.get(channel);
174
+ if (!current?.delete(entry) || current.size) return;
175
+ channels.delete(channel);
176
+ last.delete(channel);
177
+ }
178
+ };
179
+ };
180
+ function setMute(channel, mute) {
181
+ if (typeof channel !== "string" || !channel)
182
+ throw new Error(`Pass a channel name, or "${ALL}" for every channel`);
183
+ if (mute) mutes.set(channel, mute);
184
+ else if (channel === ALL) mutes.clear();
185
+ else mutes.delete(channel);
186
+ return { muted: [...mutes.keys()] };
187
+ }
188
+ function setConnection(conn, state) {
189
+ const shown = faked ?? conn.state();
190
+ if (state !== null && typeof state !== "string")
191
+ throw new Error('Pass a state such as "disconnected", or null for the real one');
192
+ if (state !== null && conn.states && !conn.states.includes(state))
193
+ throw new Error(
194
+ `Unknown state "${state}". Known: ${conn.states.join(", ")}`
195
+ );
196
+ if (state === faked) return;
197
+ faked = state;
198
+ conn.fake(state, shown);
199
+ }
200
+ const tools = {
201
+ [`${ns}.channels`]: {
202
+ description: "Channels the app listens on: listener count, muted, and what the library says about each.",
203
+ run: () => [...channels].map(([name, entries]) => ({
204
+ name,
205
+ listeners: entries.size,
206
+ muted: Boolean(muteFor(name)),
207
+ ...options.channelInfo?.(name)
208
+ }))
209
+ },
210
+ [`${ns}.log`]: {
211
+ description: `Recent messages, real and injected, newest first (last ${LOG_SIZE}). Filter: { channel?, event?, limit? }; clear: true empties the log after reading.`,
212
+ run: (filter = {}) => {
213
+ const entries = log.filter(
214
+ (e) => (!filter.channel || e.channel === filter.channel) && (!filter.event || e.event === filter.event)
215
+ ).reverse().slice(0, filter.limit ?? 20);
216
+ if (filter.clear) log.length = 0;
217
+ return entries;
218
+ }
219
+ },
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 }.",
222
+ run: (channel, ...values) => {
223
+ const entries = channels.get(channel);
224
+ if (!entries?.size)
225
+ throw new Error(
226
+ `Nothing listens on "${channel}". Live channels: ${liveChannels()}`
227
+ );
228
+ injectedCount += 1;
229
+ const message = toMessage2(values, {
230
+ channel,
231
+ id: `agent-bridge-${injectedCount}`
232
+ });
233
+ const id = describe(message).id;
234
+ if (muteFor(channel)?.dropInjected) {
235
+ record(channel, message, true, "muted");
236
+ return { id, delivered: 0 };
237
+ }
238
+ record(channel, message, true);
239
+ let delivered = 0;
240
+ for (const entry of [...entries]) {
241
+ if (entry.accepts && !entry.accepts(message)) continue;
242
+ entry.deliver(message);
243
+ delivered += 1;
244
+ }
245
+ return { id, delivered };
246
+ }
247
+ },
248
+ [`${ns}.mute`]: {
249
+ description: `Drop real messages on a channel ("${ALL}" for all) until unmuted. Injected messages still get through unless { dropInjected: true }.`,
250
+ run: (channel, muteOptions = {}) => setMute(channel, { dropInjected: Boolean(muteOptions.dropInjected) })
251
+ },
252
+ [`${ns}.unmute`]: {
253
+ description: `Stop dropping messages on a channel ("${ALL}" for all).`,
254
+ run: (channel) => setMute(channel, null)
255
+ },
256
+ [`${ns}.restore`]: {
257
+ description: "Undo the agent: unmute every channel and go back to the real connection state.",
258
+ run: () => {
259
+ const unmuted = [...mutes.keys()];
260
+ mutes.clear();
261
+ const connectionFaked = faked !== null;
262
+ if (connection && connectionFaked) setConnection(connection, null);
263
+ return { unmuted, connectionFaked };
264
+ }
265
+ }
266
+ };
267
+ if (connection)
268
+ tools[`${ns}.connection`] = {
269
+ description: `The connection state, or fake one: [state] shows the app that state${connection.states ? ` (${connection.states.join(", ")})` : ""} and drops real messages unless it is "${live}"; [null] goes back to the real one.`,
270
+ run: (...args) => {
271
+ if (args.length) setConnection(connection, args[0] ?? null);
272
+ return { state: faked ?? connection.state(), real: connection.state(), faked: faked !== null };
273
+ }
274
+ };
275
+ return { wrap, tools };
276
+ }
277
+
278
+ // src/adapters/ably.ts
279
+ var CONNECTION_STATES = [
280
+ "initialized",
281
+ "connecting",
282
+ "connected",
283
+ "disconnected",
284
+ "suspended",
285
+ "closing",
286
+ "closed",
287
+ "failed"
288
+ ];
289
+ function accepts(filter, message) {
290
+ if (typeof filter === "string") return message.name === filter;
291
+ if (Array.isArray(filter)) return filter.includes(message.name);
292
+ if (filter && typeof filter === "object") {
293
+ const { name, clientId } = filter;
294
+ return (name === void 0 || name === message.name) && (clientId === void 0 || clientId === message.clientId);
295
+ }
296
+ return true;
297
+ }
298
+ function toMessage(values, { id }) {
299
+ 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 };
303
+ }
304
+ var ablyTools = realtimeAdapter(
305
+ (client, options) => {
306
+ const channels = /* @__PURE__ */ new Map();
307
+ const tap = createRealtimeTap({
308
+ namespace: options.namespace,
309
+ describe: (m) => ({ event: m.name, data: m.data, id: m.id }),
310
+ toMessage,
311
+ channelInfo: (name) => ({ state: channels.get(name)?.state }),
312
+ // ably-js's connection is an event emitter; `emit` isn't in its public types.
313
+ connection: fakeableConnection(client.connection, {
314
+ property: "state",
315
+ events: "emit",
316
+ states: CONNECTION_STATES,
317
+ announce: (emit, { current, previous, faked }) => emit(current, {
318
+ previous,
319
+ current,
320
+ event: current,
321
+ ...faked && { reason: { message: "Faked by agent-bridge" } }
322
+ })
323
+ })
324
+ });
325
+ const patch = (channel) => {
326
+ if (!channel || channels.get(channel.name) === channel) return channel;
327
+ channels.set(channel.name, channel);
328
+ tapListeners(tap, channel, {
329
+ add: "subscribe",
330
+ remove: "unsubscribe",
331
+ channel: () => channel.name,
332
+ accepts: ([filter], message) => accepts(filter, message)
333
+ });
334
+ return channel;
335
+ };
336
+ const registry = client.channels;
337
+ const get = registry.get;
338
+ registry.get = function(...args) {
339
+ return patch(get.apply(this, args));
340
+ };
341
+ for (const channel of Object.values(registry.all ?? {})) patch(channel);
342
+ return tap;
343
+ }
344
+ );
@@ -0,0 +1,37 @@
1
+ import { T as Tools } from '../types-C6DUUHnB.cjs';
2
+
3
+ /** The fields of an Ably message this adapter reads. */
4
+ type AblyMessageLike = {
5
+ id?: string;
6
+ name?: string;
7
+ data?: unknown;
8
+ clientId?: string;
9
+ };
10
+ /** The Ably realtime channel surface this adapter uses. */
11
+ type AblyChannelLike = {
12
+ name: string;
13
+ state?: string;
14
+ subscribe(...args: never[]): unknown;
15
+ unsubscribe(...args: never[]): unknown;
16
+ };
17
+ /** An Ably realtime client (`new Ably.Realtime(...)`), typed structurally. */
18
+ type AblyRealtimeLike = {
19
+ channels: {
20
+ get(name: string, ...rest: never[]): AblyChannelLike;
21
+ };
22
+ connection?: {
23
+ state: string;
24
+ };
25
+ };
26
+ type AblyToolsOptions = {
27
+ /** Tool name prefix. Defaults to "realtime". */
28
+ namespace?: string;
29
+ };
30
+ /**
31
+ * See, fake and drop Ably messages: `realtime.*`. Patches the client, so call
32
+ * it where you create the client, before the app subscribes; listeners added
33
+ * earlier stay invisible. Calling it again with the same client is free.
34
+ */
35
+ declare const ablyTools: (client: AblyRealtimeLike, options?: AblyToolsOptions | undefined) => Tools;
36
+
37
+ export { type AblyChannelLike, type AblyMessageLike, type AblyRealtimeLike, type AblyToolsOptions, ablyTools };