@avasapp/agent-bridge 0.2.0 → 0.3.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
@@ -101,6 +101,8 @@ Errors come back on their own: each reply carries what the app logged with `cons
101
101
  ```sh
102
102
  npx agent-bridge session start # hold one connection; call/tools/run reuse it
103
103
  npx agent-bridge tools
104
+ npx agent-bridge scenarios # setups the app defines, e.g. signedIn
105
+ npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
104
106
  npx agent-bridge call query.pin '[["features"], {"beta": false}]'
105
107
  npx agent-bridge call screen.press '"add-plant"'
106
108
  npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
@@ -123,6 +125,8 @@ await app.call('screen.press', 'save-plant')
123
125
  A flow is a module the CLI runs without a model in the loop:
124
126
 
125
127
  ```js
128
+ export const scenario = 'signedIn' // optional: see Scenarios
129
+
126
130
  export default async ({ step }) => {
127
131
  await step('flag: beta off', 'query.pin', ['features'], { beta: false })
128
132
  await step('save empty form', 'screen.press', 'save-plant')
@@ -131,8 +135,71 @@ export default async ({ step }) => {
131
135
  }
132
136
  ```
133
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
+ ```
149
+
134
150
  No device tool is needed. Pair one (such as agent-device) with the bridge for what it can't reach: system alerts, permission prompts, the keyboard, screenshots, and one real tap per flow.
135
151
 
152
+ ## Scenarios
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.
202
+
136
203
  ## Adapters
137
204
 
138
205
  | Import | Tools | Needs |
@@ -141,12 +208,12 @@ No device tool is needed. Pair one (such as agent-device) with the bridge for wh
141
208
  | `@avasapp/agent-bridge/zustand` | `store.list` `get` `set` `call` `restore` | your stores |
142
209
  | `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
143
210
  | `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
144
- | `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
211
+ | `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `strict` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
145
212
  | `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
146
213
  | `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
147
214
  | `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
148
215
 
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.
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`.
150
217
 
151
218
  ## Realtime
152
219
 
@@ -231,16 +298,11 @@ Every entry point is gated on `process.env.NODE_ENV`, like `react/index.js`: Met
231
298
  npx agent-bridge assert-absent path/to/main.jsbundle
232
299
  ```
233
300
 
234
- ## Traps we hit
301
+ ## What runs where
235
302
 
236
- - **Run the agent on the machine with the simulator.** Every call pays the network otherwise.
237
- - **Hidden tabs stay mounted.** `screen.findText` skips anything under an inactive `RNSScreen`.
238
- - **Expo checks the debugger's Origin** against the host Metro advertises and drops mismatches silently. The client reads it from the manifest.
239
- - **Expo Go on Android has no CDP `Runtime.evaluate`.** Use the Expo socket there (the default); CDP works in dev builds and in Expo Go on iOS.
240
- - **Expo's socket broadcasts to every app.** Calls are addressed to one device; pick it with `--device` when several are connected.
241
- - **Screen checks through the accessibility tree are slow** (hundreds of ms each). Check in-app, and keep one real UI check per flow.
242
- - **`screen.fill` skips the keyboard.** It runs the input's handlers, so validation and state are real, but autocorrect, native `maxLength` and uncontrolled inputs' native text are not.
243
- - **Keep your `QueryClient` in state** (`useState(() => new QueryClient())`). Created at module level, a Fast Refresh can leave the bridge holding a different client than the screen.
303
+ - **On your machine:** `agent-bridge run` and `runFlow` run the flow module you point them at, with your permissions, the same as `node flow.mjs`. Only run flows you'd run as scripts. In CI, don't run flows from untrusted forks with secrets in the environment, as with any test.
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).
244
306
 
245
307
  ## License
246
308
 
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-6kKQsLC1.cjs';
2
2
 
3
3
  /** The fields of an Ably message this adapter reads. */
4
4
  type AblyMessageLike = {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-6kKQsLC1.cjs';
2
2
 
3
3
  /** The imperative `router` from expo-router. Passed in, so this adds no dependency. */
4
4
  type RouterLike = {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-6kKQsLC1.cjs';
2
2
 
3
3
  /** The MMKV surface this adapter uses. v3 has `delete`, v4 has `remove`. */
4
4
  type MMKVLike = {
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-6kKQsLC1.cjs';
2
2
 
3
3
  type Listener = (...args: any[]) => void;
4
4
  /** A socket.io client socket (`io(url)`), typed structurally. */
@@ -1,5 +1,5 @@
1
1
  import { QueryClient } from '@tanstack/query-core';
2
- import { T as Tools } from '../types-C6DUUHnB.cjs';
2
+ import { T as Tools } from '../types-6kKQsLC1.cjs';
3
3
 
4
4
  /**
5
5
  * Read and seed the TanStack Query cache. `query.pin` keeps a seed in place:
@@ -1,4 +1,4 @@
1
- import { T as Tools } from '../types-C6DUUHnB.cjs';
1
+ import { T as Tools } from '../types-6kKQsLC1.cjs';
2
2
 
3
3
  /** Anything with zustand's store shape. */
4
4
  type StoreLike = {
@@ -291,6 +291,119 @@ async function openConnection(options = {}) {
291
291
  }
292
292
  }
293
293
 
294
+ // src/client/log-lines.ts
295
+ function logLine(entry) {
296
+ const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
297
+ const first = entry.message.split("\n")[0] ?? "";
298
+ const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
299
+ return `! ${entry.level}${where}: ${text}`;
300
+ }
301
+
302
+ // src/client/flow.ts
303
+ function flowScenarios(flow) {
304
+ const declared = [
305
+ ...flow.scenario === void 0 ? [] : [flow.scenario],
306
+ ...flow.scenarios ?? []
307
+ ];
308
+ return declared.map((s) => {
309
+ if (typeof s === "string") return { name: s };
310
+ if (s && typeof s === "object" && typeof s.name === "string") return s;
311
+ throw new Error(
312
+ `A flow's scenario is a name or { name, options }, not ${JSON.stringify(s)}`
313
+ );
314
+ });
315
+ }
316
+ function parseScenarioFlag(value) {
317
+ const at = value.indexOf("=");
318
+ if (at === -1) return { name: value };
319
+ const name = value.slice(0, at);
320
+ const raw = value.slice(at + 1);
321
+ try {
322
+ return { name, options: JSON.parse(raw) };
323
+ } catch {
324
+ throw new Error(
325
+ `--scenario ${name}: the options after "=" must be JSON, got ${raw}`
326
+ );
327
+ }
328
+ }
329
+ function mergeScenarios(declared, extra) {
330
+ const names = new Set(extra.map((s) => s.name));
331
+ return [...declared.filter((s) => !names.has(s.name)), ...extra];
332
+ }
333
+ var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
334
+ async function runFlow(bridge, flow, options = {}) {
335
+ const print = options.print ?? console.log;
336
+ const needs = mergeScenarios(flowScenarios(flow), options.scenarios ?? []);
337
+ let n = 0;
338
+ let total = 0;
339
+ let errors = 0;
340
+ const t0 = performance.now();
341
+ const report = (logs) => {
342
+ errors += logs.length;
343
+ for (const e of logs) print(` ${logLine(e)}`);
344
+ };
345
+ const timed = async (tool, ...args) => {
346
+ try {
347
+ const result = await bridge.timed(tool, ...args);
348
+ report(result.logs);
349
+ return result;
350
+ } catch (error) {
351
+ report(failedLogs(error));
352
+ throw error;
353
+ }
354
+ };
355
+ const call = async (tool, ...args) => (await timed(tool, ...args)).value;
356
+ const step = async (label, tool, ...args) => {
357
+ const { value, ms, logs } = await bridge.timed(tool, ...args).catch((error) => {
358
+ report(failedLogs(error));
359
+ throw error;
360
+ });
361
+ total += ms;
362
+ print(
363
+ `${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
364
+ );
365
+ report(logs);
366
+ return value;
367
+ };
368
+ const restoreErrors = [];
369
+ const applied = {};
370
+ if (needs.length && !bridge.tools().some((t) => t.name === "scenario.apply"))
371
+ throw new Error(
372
+ `The flow needs scenario "${needs[0].name}", but the app defines none: pass \`scenarios\` to useAgentBridge.`
373
+ );
374
+ try {
375
+ for (const { name, options: options2 } of needs) {
376
+ const args = options2 === void 0 ? [name] : [name, options2];
377
+ applied[name] = await step(`scenario ${name}`, "scenario.apply", ...args);
378
+ }
379
+ await flow.default({
380
+ bridge: { ...bridge, timed, call },
381
+ call,
382
+ step,
383
+ scenarios: applied
384
+ });
385
+ } finally {
386
+ if (needs.length) {
387
+ try {
388
+ const result = await step("restore", "bridge.restore");
389
+ for (const [name, value] of Object.entries(result ?? {})) {
390
+ const error = value?.error;
391
+ if (error !== void 0) restoreErrors.push(`${name}: ${String(error)}`);
392
+ }
393
+ } catch (error) {
394
+ restoreErrors.push(
395
+ `bridge.restore: ${error instanceof Error ? error.message : String(error)}`
396
+ );
397
+ }
398
+ for (const e of restoreErrors) print(` ! restore failed: ${e}`);
399
+ }
400
+ }
401
+ print(
402
+ `${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
403
+ );
404
+ return { steps: n, errors, restoreErrors };
405
+ }
406
+
294
407
  // src/client/session/client.ts
295
408
  import { createConnection } from "net";
296
409
  import { createInterface } from "readline";
@@ -559,6 +672,10 @@ export {
559
672
  metroHost,
560
673
  RUNTIME_MARKER,
561
674
  openConnection,
675
+ logLine,
676
+ flowScenarios,
677
+ parseScenarioFlag,
678
+ runFlow,
562
679
  stateDir,
563
680
  sessionFiles,
564
681
  checkName,
package/dist/cli.js CHANGED
@@ -5,21 +5,25 @@ import {
5
5
  checkName,
6
6
  connect,
7
7
  connectSession,
8
+ flowScenarios,
8
9
  formatDuration,
9
10
  isAlive,
10
11
  listDevices,
11
12
  listSessions,
13
+ logLine,
12
14
  metroHost,
13
15
  openConnection,
14
16
  parseDuration,
17
+ parseScenarioFlag,
15
18
  pickSession,
16
19
  readSession,
17
20
  removeSessionFiles,
21
+ runFlow,
18
22
  sessionFiles,
19
23
  sessionRequest,
20
24
  stateDir,
21
25
  writePrivate
22
- } from "./chunk-LBYZ5JHF.js";
26
+ } from "./chunk-S2FYN2R5.js";
23
27
 
24
28
  // src/cli.ts
25
29
  import { readFile } from "fs/promises";
@@ -27,14 +31,6 @@ import { resolve } from "path";
27
31
  import { pathToFileURL } from "url";
28
32
  import { parseArgs } from "util";
29
33
 
30
- // src/client/log-lines.ts
31
- function logLine(entry) {
32
- const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
33
- const first = entry.message.split("\n")[0] ?? "";
34
- const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
35
- return `! ${entry.level}${where}: ${text}`;
36
- }
37
-
38
34
  // src/client/session/cli.ts
39
35
  import { spawn } from "child_process";
40
36
  import { existsSync, readFileSync, rmSync as rmSync2 } from "fs";
@@ -480,6 +476,9 @@ Usage
480
476
  agent-bridge tools Tools the app exposes
481
477
  agent-bridge call <tool> [args] Call a tool. args: a JSON array, or one JSON value
482
478
  agent-bridge run <flow.mjs|.ts> Run a flow: export default async ({ step, call }) => {}
479
+ export const scenario = 'signedIn' applies it first
480
+ and runs bridge.restore after, even on failure
481
+ agent-bridge scenarios Scenarios the app defines (scenario.apply applies one)
483
482
  agent-bridge assert-absent <files...> Fail if a release bundle contains the bridge
484
483
 
485
484
  Sessions: one connection for all of an agent's calls
@@ -494,6 +493,8 @@ Options
494
493
  --transport <name> auto (default), expo or cdp
495
494
  --timeout <ms> Per-call timeout (default 10000)
496
495
  --strict run: exit non-zero if the app logged an error
496
+ --scenario <name> run: also apply this scenario (repeatable). Options as
497
+ JSON after "=": --scenario 'signedIn={"user":{"name":"Ada"}}'
497
498
  --session <name> Use this session (env AGENT_BRIDGE_SESSION)
498
499
  --no-session Connect directly even if a session is running
499
500
  `;
@@ -517,6 +518,7 @@ async function main() {
517
518
  transport: { type: "string" },
518
519
  timeout: { type: "string" },
519
520
  strict: { type: "boolean" },
521
+ scenario: { type: "string", multiple: true },
520
522
  name: { type: "string" },
521
523
  idle: { type: "string" },
522
524
  keep: { type: "boolean" },
@@ -583,47 +585,34 @@ async function main() {
583
585
  const [file] = rest;
584
586
  if (!file) throw new Error("Usage: agent-bridge run <flow file>");
585
587
  const flow = await import(pathToFileURL(resolve(file)).href);
588
+ flowScenarios(flow);
589
+ const extra = (values.scenario ?? []).map(parseScenarioFlag);
586
590
  return withBridge(async (bridge) => {
587
- let n = 0;
588
- let total = 0;
589
- let errors = 0;
590
- const t0 = performance.now();
591
- const report = (logs) => {
592
- errors += logs.length;
593
- for (const e of logs) console.log(` ${logLine(e)}`);
594
- };
595
- const timed = async (tool, ...args) => {
596
- try {
597
- const result = await bridge.timed(tool, ...args);
598
- report(result.logs);
599
- return result;
600
- } catch (error) {
601
- report(failedLogs(error));
602
- throw error;
603
- }
604
- };
605
- const call = async (tool, ...args) => (await timed(tool, ...args)).value;
606
- const step = async (label, tool, ...args) => {
607
- const { value, ms, logs } = await bridge.timed(tool, ...args).catch(
608
- (error) => {
609
- report(failedLogs(error));
610
- throw error;
611
- }
591
+ const { errors, restoreErrors } = await runFlow(bridge, flow, {
592
+ scenarios: extra
593
+ });
594
+ if (values.strict && errors || restoreErrors.length)
595
+ process.exitCode = 1;
596
+ });
597
+ }
598
+ case "scenarios":
599
+ return withBridge(async (bridge) => {
600
+ if (!bridge.tools().some((t) => t.name === "scenario.list")) {
601
+ console.log(
602
+ `${bridge.device.name} defines no scenarios (pass \`scenarios\` to useAgentBridge).`
612
603
  );
613
- total += ms;
604
+ return;
605
+ }
606
+ const list2 = await bridge.call("scenario.list");
607
+ console.log(`${bridge.device.name} via ${bridge.transport}`);
608
+ for (const s of list2) {
614
609
  console.log(
615
- `${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
610
+ ` ${s.name.padEnd(20)} ${s.active ? "(active) " : ""}${s.description ?? ""}`
616
611
  );
617
- report(logs);
618
- return value;
619
- };
620
- await flow.default({ bridge: { ...bridge, timed, call }, call, step });
621
- console.log(
622
- `${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
623
- );
624
- if (values.strict && errors) process.exitCode = 1;
612
+ if (s.options)
613
+ console.log(` ${"".padEnd(20)} options: ${JSON.stringify(s.options)}`);
614
+ }
625
615
  });
626
- }
627
616
  case "session":
628
617
  return sessionCommand(rest[0], values);
629
618
  case DAEMON_COMMAND:
@@ -34,7 +34,8 @@ __export(client_exports, {
34
34
  connect: () => connect,
35
35
  connectSession: () => connectSession,
36
36
  listDevices: () => listDevices,
37
- listSessions: () => listSessions
37
+ listSessions: () => listSessions,
38
+ runFlow: () => runFlow
38
39
  });
39
40
  module.exports = __toCommonJS(client_exports);
40
41
 
@@ -330,6 +331,106 @@ async function openConnection(options = {}) {
330
331
  }
331
332
  }
332
333
 
334
+ // src/client/log-lines.ts
335
+ function logLine(entry) {
336
+ const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
337
+ const first = entry.message.split("\n")[0] ?? "";
338
+ const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
339
+ return `! ${entry.level}${where}: ${text}`;
340
+ }
341
+
342
+ // src/client/flow.ts
343
+ function flowScenarios(flow) {
344
+ const declared = [
345
+ ...flow.scenario === void 0 ? [] : [flow.scenario],
346
+ ...flow.scenarios ?? []
347
+ ];
348
+ return declared.map((s) => {
349
+ if (typeof s === "string") return { name: s };
350
+ if (s && typeof s === "object" && typeof s.name === "string") return s;
351
+ throw new Error(
352
+ `A flow's scenario is a name or { name, options }, not ${JSON.stringify(s)}`
353
+ );
354
+ });
355
+ }
356
+ function mergeScenarios(declared, extra) {
357
+ const names = new Set(extra.map((s) => s.name));
358
+ return [...declared.filter((s) => !names.has(s.name)), ...extra];
359
+ }
360
+ var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
361
+ async function runFlow(bridge, flow, options = {}) {
362
+ const print = options.print ?? console.log;
363
+ const needs = mergeScenarios(flowScenarios(flow), options.scenarios ?? []);
364
+ let n = 0;
365
+ let total = 0;
366
+ let errors = 0;
367
+ const t0 = performance.now();
368
+ const report = (logs) => {
369
+ errors += logs.length;
370
+ for (const e of logs) print(` ${logLine(e)}`);
371
+ };
372
+ const timed = async (tool, ...args) => {
373
+ try {
374
+ const result = await bridge.timed(tool, ...args);
375
+ report(result.logs);
376
+ return result;
377
+ } catch (error) {
378
+ report(failedLogs(error));
379
+ throw error;
380
+ }
381
+ };
382
+ const call = async (tool, ...args) => (await timed(tool, ...args)).value;
383
+ const step = async (label, tool, ...args) => {
384
+ const { value, ms, logs } = await bridge.timed(tool, ...args).catch((error) => {
385
+ report(failedLogs(error));
386
+ throw error;
387
+ });
388
+ total += ms;
389
+ print(
390
+ `${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
391
+ );
392
+ report(logs);
393
+ return value;
394
+ };
395
+ const restoreErrors = [];
396
+ const applied = {};
397
+ if (needs.length && !bridge.tools().some((t) => t.name === "scenario.apply"))
398
+ throw new Error(
399
+ `The flow needs scenario "${needs[0].name}", but the app defines none: pass \`scenarios\` to useAgentBridge.`
400
+ );
401
+ try {
402
+ for (const { name, options: options2 } of needs) {
403
+ const args = options2 === void 0 ? [name] : [name, options2];
404
+ applied[name] = await step(`scenario ${name}`, "scenario.apply", ...args);
405
+ }
406
+ await flow.default({
407
+ bridge: { ...bridge, timed, call },
408
+ call,
409
+ step,
410
+ scenarios: applied
411
+ });
412
+ } finally {
413
+ if (needs.length) {
414
+ try {
415
+ const result = await step("restore", "bridge.restore");
416
+ for (const [name, value] of Object.entries(result ?? {})) {
417
+ const error = value?.error;
418
+ if (error !== void 0) restoreErrors.push(`${name}: ${String(error)}`);
419
+ }
420
+ } catch (error) {
421
+ restoreErrors.push(
422
+ `bridge.restore: ${error instanceof Error ? error.message : String(error)}`
423
+ );
424
+ }
425
+ for (const e of restoreErrors) print(` ! restore failed: ${e}`);
426
+ }
427
+ }
428
+ print(
429
+ `${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
430
+ );
431
+ return { steps: n, errors, restoreErrors };
432
+ }
433
+
333
434
  // src/client/session/client.ts
334
435
  var import_node_net = require("net");
335
436
  var import_node_readline = require("readline");
@@ -567,5 +668,6 @@ async function listDevices(options = {}) {
567
668
  connect,
568
669
  connectSession,
569
670
  listDevices,
570
- listSessions
671
+ listSessions,
672
+ runFlow
571
673
  });
@@ -25,6 +25,49 @@ type DeviceInfo = {
25
25
 
26
26
  type TransportName = 'expo' | 'cdp';
27
27
 
28
+ /** A scenario a flow needs: a name, or a name with options. */
29
+ type FlowScenario = string | {
30
+ name: string;
31
+ options?: unknown;
32
+ };
33
+ type FlowApi = {
34
+ bridge: AgentBridge;
35
+ call: AgentBridge['call'];
36
+ step: (label: string, tool: string, ...args: unknown[]) => Promise<unknown>;
37
+ /** What each declared scenario's apply returned, by name. */
38
+ scenarios: Record<string, unknown>;
39
+ };
40
+ /** A flow file: `export default async ({ step, call }) => {}`, plus what it needs. */
41
+ type FlowModule = {
42
+ default: (api: FlowApi) => Promise<void>;
43
+ /** Applied before the flow and undone after it, even when it fails. */
44
+ scenario?: FlowScenario;
45
+ scenarios?: FlowScenario[];
46
+ };
47
+ type FlowResult = {
48
+ steps: number;
49
+ /** Errors the app logged during the flow. */
50
+ errors: number;
51
+ /** Restorers that failed in the closing bridge.restore, with why. */
52
+ restoreErrors: string[];
53
+ };
54
+ type RunFlowOptions = {
55
+ /** Where step lines go. Default: console.log. */
56
+ print?: (line: string) => void;
57
+ /** More scenarios to apply, e.g. from `--scenario`; one of the same name replaces the flow's. */
58
+ scenarios?: Array<{
59
+ name: string;
60
+ options?: unknown;
61
+ }>;
62
+ };
63
+ /**
64
+ * Runs a flow module you imported: applies its scenarios, runs it, and when
65
+ * there were any, undoes everything with bridge.restore afterwards, even
66
+ * when it fails. Throws what the flow threw; otherwise returns how it went.
67
+ * It runs only the code you hand it, in this process.
68
+ */
69
+ declare function runFlow(bridge: AgentBridge, flow: FlowModule, options?: RunFlowOptions): Promise<FlowResult>;
70
+
28
71
  /** What a running session writes next to its socket. Readable only by the user. */
29
72
  type SessionState = {
30
73
  name: string;
@@ -109,4 +152,4 @@ type ListedDevice = {
109
152
  /** Apps with the bridge on Expo's socket, plus every React Native page Metro knows. */
110
153
  declare function listDevices(options?: Pick<ConnectOptions, 'metro'>): Promise<ListedDevice[]>;
111
154
 
112
- export { type AgentBridge, AgentBridgeCallError, type ConnectOptions, type DeviceInfo, type ListedDevice, type LogEntry, type SessionConnectOptions, type SessionState, type Timed, type ToolInfo, type TransportName, connect, connectSession, listDevices, listSessions };
155
+ export { type AgentBridge, AgentBridgeCallError, type ConnectOptions, type DeviceInfo, type FlowApi, type FlowModule, type FlowResult, type FlowScenario, type ListedDevice, type LogEntry, type RunFlowOptions, type SessionConnectOptions, type SessionState, type Timed, type ToolInfo, type TransportName, connect, connectSession, listDevices, listSessions, runFlow };
@@ -3,12 +3,14 @@ import {
3
3
  connect,
4
4
  connectSession,
5
5
  listDevices,
6
- listSessions
7
- } from "../chunk-LBYZ5JHF.js";
6
+ listSessions,
7
+ runFlow
8
+ } from "../chunk-S2FYN2R5.js";
8
9
  export {
9
10
  AgentBridgeCallError,
10
11
  connect,
11
12
  connectSession,
12
13
  listDevices,
13
- listSessions
14
+ listSessions,
15
+ runFlow
14
16
  };
@@ -1,4 +1,4 @@
1
- import { a as Transport } from '../types-C6DUUHnB.cjs';
1
+ import { a as Transport } from '../types-6kKQsLC1.cjs';
2
2
 
3
3
  /**
4
4
  * Plain JSON over Expo's dev-tools plugin socket: no string evaluation, emoji