@avasapp/agent-bridge 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +19 -5
  2. package/dist/adapters/ably.cjs +79 -3
  3. package/dist/adapters/ably.d.cts +2 -1
  4. package/dist/adapters/async-storage.cjs +158 -0
  5. package/dist/adapters/async-storage.d.cts +14 -0
  6. package/dist/adapters/device.cjs +1110 -0
  7. package/dist/adapters/device.d.cts +49 -0
  8. package/dist/adapters/expo-router.cjs +112 -6
  9. package/dist/adapters/expo-router.d.cts +1 -1
  10. package/dist/adapters/jotai.cjs +266 -0
  11. package/dist/adapters/jotai.d.cts +19 -0
  12. package/dist/adapters/react-native-mmkv.cjs +80 -0
  13. package/dist/adapters/react-native-mmkv.d.cts +3 -1
  14. package/dist/adapters/react-native-webview.d.cts +1 -1
  15. package/dist/adapters/react-navigation.cjs +293 -0
  16. package/dist/adapters/react-navigation.d.cts +55 -0
  17. package/dist/adapters/redux.cjs +322 -0
  18. package/dist/adapters/redux.d.cts +28 -0
  19. package/dist/adapters/secure-store.cjs +169 -0
  20. package/dist/adapters/secure-store.d.cts +28 -0
  21. package/dist/adapters/socket-io.cjs +77 -3
  22. package/dist/adapters/socket-io.d.cts +2 -1
  23. package/dist/adapters/tanstack-query.d.cts +1 -1
  24. package/dist/adapters/zustand.cjs +193 -22
  25. package/dist/adapters/zustand.d.cts +11 -15
  26. package/dist/{chunk-3QIG7A5W.js → chunk-Y6QFU5ON.js} +233 -26
  27. package/dist/cli.js +1336 -255
  28. package/dist/client/index.cjs +233 -27
  29. package/dist/client/index.d.ts +67 -4
  30. package/dist/client/index.js +5 -3
  31. package/dist/expo/index.d.cts +1 -1
  32. package/dist/network/index.cjs +320 -73
  33. package/dist/network/index.d.cts +18 -3
  34. package/dist/noop/async-storage.cjs +26 -0
  35. package/dist/noop/device.cjs +26 -0
  36. package/dist/noop/jotai.cjs +26 -0
  37. package/dist/noop/react-navigation.cjs +26 -0
  38. package/dist/noop/realtime.cjs +6 -1
  39. package/dist/noop/redux.cjs +28 -0
  40. package/dist/noop/secure-store.cjs +26 -0
  41. package/dist/realtime/index.cjs +71 -4
  42. package/dist/realtime/index.d.cts +21 -2
  43. package/dist/runtime/index.cjs +785 -128
  44. package/dist/runtime/index.d.cts +2 -2
  45. package/dist/store-common-j0cHHvBr.d.cts +23 -0
  46. package/dist/{types-BmpykP1P.d.cts → types-qlwdcGjt.d.cts} +22 -0
  47. package/entries/async-storage.cjs +9 -0
  48. package/entries/device.cjs +9 -0
  49. package/entries/jotai.cjs +9 -0
  50. package/entries/react-navigation.cjs +9 -0
  51. package/entries/redux.cjs +9 -0
  52. package/entries/secure-store.cjs +9 -0
  53. package/package.json +32 -1
  54. package/skills/agent-bridge/SKILL.md +39 -8
  55. package/skills/agent-bridge/references/device.md +21 -0
  56. package/skills/agent-bridge/references/flows.md +13 -3
  57. package/skills/agent-bridge/references/network.md +19 -1
  58. package/skills/agent-bridge/references/realtime.md +1 -1
  59. package/skills/agent-bridge/references/restore.md +21 -3
  60. package/skills/agent-bridge/references/screen.md +8 -2
  61. package/skills/agent-bridge/references/state.md +21 -1
@@ -48,18 +48,23 @@ var signalMocksRemoved = (ids) => {
48
48
  // src/network/api.ts
49
49
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
50
50
  var ROUTE = /^(?:([A-Za-z]+)\s+)?(\/\S*)$/;
51
- function compileRoute(baseUrl, route) {
51
+ function compileRoute(baseUrl, route, { loose = false } = {}) {
52
52
  const parsed = ROUTE.exec(route.trim());
53
53
  if (!parsed)
54
54
  throw new Error(`Bad route "${route}": use "METHOD /path" or "/path"`);
55
- const [, method, path] = parsed;
55
+ const [, method, rawPath] = parsed;
56
+ const path = loose && rawPath.length > 1 ? rawPath.replace(/\/+$/, "") : rawPath;
56
57
  const names = [];
57
- const pattern = path.split(/(:[A-Za-z_][A-Za-z0-9_]*|\*)/).map((part) => {
58
+ const pattern = (path === "/" && loose ? "" : path).split(loose ? /(\/:[A-Za-z_][A-Za-z0-9_]*(?=\/|$)|\*)/ : /(:[A-Za-z_][A-Za-z0-9_]*|\*)/).map((part) => {
58
59
  if (part === "*") {
59
60
  names.push("*");
60
61
  return "([^?#]*)";
61
62
  }
62
- if (part.startsWith(":")) {
63
+ if (loose && part.startsWith("/:")) {
64
+ names.push(part.slice(2));
65
+ return "/([^/?#]+)";
66
+ }
67
+ if (!loose && part.startsWith(":")) {
63
68
  names.push(part.slice(1));
64
69
  return "([^/?#]+)";
65
70
  }
@@ -68,7 +73,7 @@ function compileRoute(baseUrl, route) {
68
73
  const base = baseUrl ? escape(baseUrl.replace(/\/+$/, "")) : "(?:[a-z][a-z0-9+.-]*://[^/?#]*)?";
69
74
  return {
70
75
  method: method?.toUpperCase(),
71
- regex: new RegExp(`^${base}${pattern}(?:[?#].*)?$`, "i"),
76
+ regex: new RegExp(`^${base}${pattern}${loose ? "/?" : ""}(?:[?#].*)?$`, "i"),
72
77
  names
73
78
  };
74
79
  }
@@ -147,6 +152,154 @@ function errorMessage(error) {
147
152
  return error instanceof Error ? error.message : String(error);
148
153
  }
149
154
 
155
+ // src/network/headers.ts
156
+ var SECRET = /auth|cookie|token|secret|session|password|passwd|signature|api-?key|credential|jwt/i;
157
+ var lower = (headers) => Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
158
+ function keepHeaders(headers) {
159
+ return headers && Object.keys(headers).length ? lower(headers) : void 0;
160
+ }
161
+ function redactHeaders(headers) {
162
+ const out = {};
163
+ for (const [name, value] of Object.entries(headers)) {
164
+ if (!SECRET.test(name)) {
165
+ out[name] = value;
166
+ continue;
167
+ }
168
+ const scheme = /^authorization$|^proxy-authorization$/i.test(name) ? /^[A-Za-z]+(?= \S)/.exec(value)?.[0] : void 0;
169
+ const keep = scheme ? scheme.length + 1 : Math.min(4, Math.floor(value.length / 4));
170
+ out[name] = `${value.slice(0, keep)}\u2026 (+${value.length - keep} chars)`;
171
+ }
172
+ return out;
173
+ }
174
+ function parseHeaderBlock(block) {
175
+ const out = {};
176
+ for (const line of (block ?? "").split(/\r?\n/)) {
177
+ const at = line.indexOf(":");
178
+ if (at <= 0) continue;
179
+ const name = line.slice(0, at).trim().toLowerCase();
180
+ const value = line.slice(at + 1).trim();
181
+ out[name] = name in out ? `${out[name]}, ${value}` : value;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // src/network/match.ts
187
+ var KEYS = ["url", "path", "method", "body", "graphql"];
188
+ var NEED = "A mock needs a match: a URL substring or { url?, path?, method?, body?, graphql? } with one of url, path, body or graphql";
189
+ var own = (o, key) => Object.prototype.hasOwnProperty.call(o, key);
190
+ function subset(spec, actual) {
191
+ if (spec === null || typeof spec !== "object") return spec === actual;
192
+ if (Array.isArray(spec))
193
+ return Array.isArray(actual) && actual.length === spec.length && spec.every((item, i) => subset(item, actual[i]));
194
+ if (actual === null || typeof actual !== "object" || Array.isArray(actual)) return false;
195
+ return Object.entries(spec).every(
196
+ ([key, value]) => own(actual, key) && subset(value, actual[key])
197
+ );
198
+ }
199
+ function operationName(probe) {
200
+ const json = probe.json();
201
+ if (json && typeof json === "object" && !Array.isArray(json)) {
202
+ const name = json.operationName;
203
+ if (typeof name === "string") return name;
204
+ }
205
+ return queryOf(probe.url).operationName;
206
+ }
207
+ function urlTest(url) {
208
+ if (typeof url === "string")
209
+ return { test: (u) => u.includes(url), shown: `URL containing ${JSON.stringify(url)}`, substring: url };
210
+ const isRegex = url instanceof RegExp;
211
+ if (!isRegex && !(url && typeof url === "object" && typeof url.regex === "string"))
212
+ throw new Error("url must be a string, or { regex, flags? }");
213
+ const regex = isRegex ? url : new RegExp(url.regex, url.flags);
214
+ return {
215
+ test: (u) => {
216
+ regex.lastIndex = 0;
217
+ return regex.test(u);
218
+ },
219
+ shown: `URL matching ${String(regex)}`
220
+ };
221
+ }
222
+ function compileMatch(match) {
223
+ const spec = typeof match === "string" ? { url: match } : match;
224
+ if (!spec || typeof spec !== "object" || Array.isArray(spec) || spec instanceof RegExp) throw new Error(NEED);
225
+ const unknown = Object.keys(spec).filter((k) => !KEYS.includes(k));
226
+ if (unknown.length)
227
+ throw new Error(`Unknown match key ${unknown.map((k) => `"${k}"`).join(", ")}; the keys are ${KEYS.join(", ")}`);
228
+ if (["url", "path", "body", "graphql"].every((k) => spec[k] === void 0)) throw new Error(NEED);
229
+ const parts = [];
230
+ const tests = [];
231
+ let method;
232
+ if (spec.method !== void 0) {
233
+ if (typeof spec.method !== "string") throw new Error('method must be a string such as "POST"');
234
+ method = spec.method.toUpperCase();
235
+ }
236
+ parts.push(method ?? "any method");
237
+ let substring;
238
+ if (spec.url !== void 0) {
239
+ const u = urlTest(spec.url);
240
+ substring = u.substring;
241
+ tests.push((_m, url) => u.test(url));
242
+ parts.push(u.shown);
243
+ }
244
+ if (spec.path !== void 0) {
245
+ const path = spec.path;
246
+ if (typeof path !== "string" || !path.startsWith("/"))
247
+ throw new Error('path is just the path, starting with "/", e.g. "/orders/:id"; use url to pin a host');
248
+ if (/[?#]/.test(path)) throw new Error("path has no query or hash; the query is ignored, use url to match it");
249
+ if (/\s/.test(path)) throw new Error("path has no spaces; write a space as %20");
250
+ const route = compileRoute("", path, { loose: true });
251
+ const decoded = (u) => {
252
+ try {
253
+ return decodeURI(u);
254
+ } catch {
255
+ return u;
256
+ }
257
+ };
258
+ tests.push((_m, url) => route.regex.test(url) || route.regex.test(decoded(url)));
259
+ parts.push(`path ${path} (whole path, any host)`);
260
+ }
261
+ const probeTests = [];
262
+ if (spec.body !== void 0) {
263
+ const body = spec.body;
264
+ const frozen = body && typeof body === "object" ? JSON.parse(JSON.stringify(body)) : void 0;
265
+ if (!frozen || typeof frozen !== "object" || !Array.isArray(frozen) && !Object.keys(frozen).length)
266
+ throw new Error("body must be a non-empty JSON object or an array to look for in the request body");
267
+ probeTests.push((p) => {
268
+ const json = p.json();
269
+ return json !== void 0 && subset(frozen, json);
270
+ });
271
+ parts.push(`JSON body containing ${truncateBody(JSON.stringify(frozen))}`);
272
+ }
273
+ if (spec.graphql !== void 0) {
274
+ const name = spec.graphql;
275
+ if (typeof name !== "string" || !name) throw new Error('graphql must be an operation name such as "GetUser"');
276
+ probeTests.push((p) => operationName(p) === name);
277
+ parts.push(`GraphQL operation ${JSON.stringify(name)}`);
278
+ }
279
+ return {
280
+ test: (m, u) => (!method || method === m) && tests.every((t) => t(m, u)),
281
+ needsBody: probeTests.length > 0,
282
+ testBody: (p) => {
283
+ try {
284
+ return probeTests.every((t) => t(p));
285
+ } catch {
286
+ return false;
287
+ }
288
+ },
289
+ matches: parts.join(", "),
290
+ key: JSON.stringify([
291
+ method,
292
+ spec.url instanceof RegExp ? String(spec.url) : spec.url,
293
+ spec.path,
294
+ spec.body,
295
+ spec.graphql
296
+ ]),
297
+ substring,
298
+ plain: substring !== void 0 && spec.path === void 0 && spec.body === void 0 && spec.graphql === void 0,
299
+ method
300
+ };
301
+ }
302
+
150
303
  // src/network/state.ts
151
304
  var LOG_SIZE = 100;
152
305
  var KEY2 = /* @__PURE__ */ Symbol.for("@avasapp/agent-bridge/network");
@@ -156,6 +309,7 @@ function networkState() {
156
309
  installed: false,
157
310
  log: [],
158
311
  nextLogId: 1,
312
+ logSize: LOG_SIZE,
159
313
  mocks: [],
160
314
  nextSeq: 1,
161
315
  insideFetch: 0,
@@ -251,21 +405,25 @@ function startEntry(state, fields) {
251
405
  pending: true
252
406
  };
253
407
  keepRequestBody(entry, fields.requestBody);
408
+ const requestHeaders = keepHeaders(fields.requestHeaders);
409
+ if (requestHeaders) fullOf(entry).requestHeaders = requestHeaders;
254
410
  state.log.push(entry);
255
411
  signalRequestStarted(entry.id);
256
- if (state.log.length > LOG_SIZE)
257
- state.log.splice(0, state.log.length - LOG_SIZE);
412
+ if (state.log.length > state.logSize)
413
+ state.log.splice(0, state.log.length - state.logSize);
258
414
  return entry;
259
415
  }
260
416
  function finishEntry(entry, fields) {
261
417
  entry.ms = Date.now() - entry.startedAt;
262
418
  delete entry.pending;
263
- const { responseBody, contentType, ...rest } = fields;
419
+ const { responseBody, contentType, responseHeaders, ...rest } = fields;
420
+ const kept = keepHeaders(responseHeaders);
421
+ if (kept) fullOf(entry).responseHeaders = kept;
264
422
  for (const [key, value] of Object.entries(rest))
265
423
  if (value !== void 0) entry[key] = value;
266
424
  if (responseBody !== void 0) setResponseBody(entry, responseBody, contentType);
267
425
  }
268
- function publicEntry(entry, full = false) {
426
+ function publicEntry(entry, full = false, headers) {
269
427
  const out = entry.pending ? { ...entry, ms: Date.now() - entry.startedAt } : { ...entry };
270
428
  if (full) {
271
429
  const { requestBody, responseBody } = FULL.get(entry) ?? {};
@@ -275,13 +433,34 @@ function publicEntry(entry, full = false) {
275
433
  const cutRequest = entry.requestBody !== void 0 && requestBody === void 0;
276
434
  if (cut || cutRequest) out.truncated = true;
277
435
  }
436
+ if (headers) {
437
+ const { requestHeaders, responseHeaders } = FULL.get(entry) ?? {};
438
+ const shown = (h) => h && (headers === "full" ? { ...h } : redactHeaders(h));
439
+ if (requestHeaders) out.requestHeaders = shown(requestHeaders);
440
+ if (responseHeaders) out.responseHeaders = shown(responseHeaders);
441
+ }
278
442
  return out;
279
443
  }
444
+ function setRequestBody(entry, text) {
445
+ if (entry.requestBody === void 0) keepRequestBody(entry, text);
446
+ }
447
+ function requestBodyForMatch(entry) {
448
+ const whole = FULL.get(entry)?.requestBody;
449
+ if (whole !== void 0) return whole;
450
+ const preview = entry.requestBody;
451
+ if (preview === void 0) return void 0;
452
+ try {
453
+ JSON.parse(preview);
454
+ return preview;
455
+ } catch {
456
+ return void 0;
457
+ }
458
+ }
280
459
  function fullResponse(entry) {
281
460
  const full = FULL.get(entry);
282
461
  return { state: full?.response ?? "none", body: full?.responseBody, contentType: full?.contentType };
283
462
  }
284
- function urlTest(url) {
463
+ function urlTest2(url) {
285
464
  if (typeof url === "string") return (u) => u.includes(url);
286
465
  const regex = url instanceof RegExp ? url : new RegExp(url.regex, url.flags);
287
466
  return (u) => {
@@ -289,19 +468,12 @@ function urlTest(url) {
289
468
  return regex.test(u);
290
469
  };
291
470
  }
292
- function compileMatch(match) {
293
- const spec = typeof match === "string" ? { url: match } : match;
294
- if (!spec || spec.url === void 0)
295
- throw new Error("A mock needs a match: a URL substring or { url, method? }");
296
- const testUrl = urlTest(spec.url);
297
- const method = spec.method?.toUpperCase();
298
- return (m, u) => (!method || method === m) && testUrl(u);
299
- }
300
471
  function addMock(state, source, match, response, options = {}) {
301
472
  if (!response || typeof response !== "object" && typeof response !== "function")
302
473
  throw new Error("A mock needs a response: { status?, json?, body?, headers? }, { offline: true } or a handler");
303
474
  if (options.priority !== void 0 && !Number.isFinite(options.priority))
304
475
  throw new Error("priority must be a finite number");
476
+ const compiled = compileMatch(match);
305
477
  const seq = state.nextSeq++;
306
478
  const id = options.id ?? `${source}-${seq}`;
307
479
  removeMocks(state, (m) => m.id === id);
@@ -315,7 +487,7 @@ function addMock(state, source, match, response, options = {}) {
315
487
  priority: options.priority ?? 0,
316
488
  hits: 0,
317
489
  seq,
318
- test: compileMatch(match)
490
+ ...(({ key, ...rest }) => ({ ...rest, matchKey: key }))(compiled)
319
491
  };
320
492
  state.mocks.push(mock2);
321
493
  return mock2;
@@ -326,28 +498,27 @@ function removeMocks(state, which) {
326
498
  return before - state.mocks.length;
327
499
  }
328
500
  var mockOrder = (a, b) => Number(b.source === "agent") - Number(a.source === "agent") || b.priority - a.priority || b.seq - a.seq;
329
- var substringMatch = (match) => {
330
- const spec = typeof match === "string" ? { url: match } : match;
331
- return typeof spec.url === "string" ? { url: spec.url, method: spec.method?.toUpperCase() } : null;
332
- };
333
501
  function shadowedMocks(state, added) {
334
- const next = substringMatch(added.match);
335
- if (!next || typeof added.response === "function") return [];
502
+ if (typeof added.response === "function") return [];
336
503
  return state.mocks.filter((old) => {
337
- if (old === added || old.source !== "agent" || mockOrder(added, old) >= 0)
504
+ if (old === added || old.source !== "agent" || mockOrder(added, old) >= 0 || added.times !== void 0)
338
505
  return false;
339
- const prev = substringMatch(old.match);
340
- return !!prev && prev.url.includes(next.url) && (!next.method || next.method === prev.method);
506
+ if (old.matchKey === added.matchKey) return true;
507
+ return added.plain && old.substring !== void 0 && old.substring.includes(added.substring) && (!added.method || added.method === old.method);
341
508
  });
342
509
  }
343
- function matchingMocks(state, method, url) {
344
- return state.mocks.filter((m) => m.test(method, url)).sort(mockOrder);
510
+ function matchingMocks(state, method, url, body) {
511
+ if (state.deviceOffline) return [];
512
+ const found = state.mocks.filter((m) => m.test(method, url));
513
+ const probe = body && found.some((m) => m.needsBody) ? mockRequest(method, url, {}, body.text) : null;
514
+ return found.filter((m) => !m.needsBody || !probe || m.testBody(probe)).sort(mockOrder);
345
515
  }
346
- async function answer(state, candidates, request) {
516
+ async function answer(state, candidates, request, bodyKnown = true) {
347
517
  for (const mock2 of candidates) {
518
+ if (mock2.needsBody && !bodyKnown && !mock2.testBody(await request())) continue;
348
519
  if (!state.mocks.includes(mock2)) continue;
349
520
  const response = typeof mock2.response === "function" ? await mock2.response(await request()) : mock2.response;
350
- if (!response) continue;
521
+ if (!response || !state.mocks.includes(mock2)) continue;
351
522
  mock2.hits += 1;
352
523
  if (mock2.times !== void 0 && mock2.hits >= mock2.times)
353
524
  removeMocks(state, (m) => m === mock2);
@@ -356,17 +527,21 @@ async function answer(state, candidates, request) {
356
527
  return null;
357
528
  }
358
529
  function mockRequest(method, url, headers, body) {
530
+ let parsed;
359
531
  return {
360
532
  method,
361
533
  url,
362
534
  headers,
363
535
  body,
364
536
  json: () => {
365
- try {
366
- return body === void 0 ? void 0 : JSON.parse(body);
367
- } catch {
368
- return void 0;
537
+ if (!parsed) {
538
+ try {
539
+ parsed = { value: body === void 0 ? void 0 : JSON.parse(body) };
540
+ } catch {
541
+ parsed = { value: void 0 };
542
+ }
369
543
  }
544
+ return parsed.value;
370
545
  }
371
546
  };
372
547
  }
@@ -387,7 +562,7 @@ function strictRule(options = {}) {
387
562
  if (!Array.isArray(allow))
388
563
  throw new Error("allow must be a list of URL substrings or regexes");
389
564
  return {
390
- allow: allow.map((rule) => urlTest(rule)),
565
+ allow: allow.map((rule) => urlTest2(rule)),
391
566
  allowShown: allow.map(
392
567
  (rule) => typeof rule === "string" ? rule : String(rule instanceof RegExp ? rule : new RegExp(rule.regex, rule.flags))
393
568
  ),
@@ -400,15 +575,19 @@ function strictRules(state) {
400
575
  return state.strictAgent ? [state.strictAgent, ...state.strictApp] : state.strictApp;
401
576
  }
402
577
  var BLOCKED_HEADER = "x-agent-bridge";
403
- function blockedResponse(state, method, url) {
578
+ function blockedResponse(state, method, url, body) {
579
+ if (state.deviceOffline) return { offline: true };
404
580
  const rules = strictRules(state);
405
581
  if (!rules.length || rules.some((r) => r.allow.some((test) => test(url))))
406
582
  return null;
407
- const key = `${method} ${url}`;
583
+ const operation = operationName(mockRequest(method, url, {}, body));
584
+ const key = operation ? `${method} ${url} (${operation})` : `${method} ${url}`;
585
+ const probe = mockRequest(method, url, {}, body);
586
+ const near = state.mocks.filter((m) => m.needsBody && m.test(method, url) && !m.testBody(probe)).map((m) => m.id);
408
587
  const seen = state.blocked.get(key);
409
588
  if (seen) seen.count += 1;
410
- else state.blocked.set(key, { method, url, count: 1 });
411
- const error = `agent-bridge strict network: no mock for ${key}`;
589
+ else state.blocked.set(key, { method, url, count: 1, ...operation && { operation } });
590
+ const error = `agent-bridge strict network: no mock for ${key}${near.length ? ` (${near.join(", ")} matched the URL but not the body)` : ""}`;
412
591
  console.error(`${error}. Mock it, or allow it in strict mode.`);
413
592
  if (rules[0].offline) return { offline: true };
414
593
  return {
@@ -479,11 +658,12 @@ function patchFetch(state) {
479
658
  const url = typeof input === "string" ? input : req.url ?? String(input);
480
659
  if (isSkipped(state, url)) return callOriginal(input, init);
481
660
  const method = String(init?.method ?? req.method ?? "GET").toUpperCase();
482
- const requestBody = bodyText(init?.body);
661
+ let requestBody = bodyText(init?.body);
483
662
  const entry = startEntry(state, {
484
663
  method,
485
664
  url,
486
- requestBody
665
+ requestBody,
666
+ requestHeaders: headersRecord(init?.headers ?? req.headers)
487
667
  });
488
668
  const real = () => {
489
669
  let promise;
@@ -495,7 +675,7 @@ function patchFetch(state) {
495
675
  }
496
676
  return promise.then(
497
677
  (res) => {
498
- finishEntry(entry, { status: res.status });
678
+ finishEntry(entry, { status: res.status, responseHeaders: headersRecord(res.headers) });
499
679
  const type = res.headers?.get?.("content-type") ?? void 0;
500
680
  if (isTextual(type) && typeof res.clone === "function") {
501
681
  markResponse(entry, "pending");
@@ -510,23 +690,27 @@ function patchFetch(state) {
510
690
  );
511
691
  };
512
692
  const passOn = () => {
513
- const blocked = blockedResponse(state, method, url);
693
+ const blocked = blockedResponse(state, method, url, requestBody);
514
694
  if (!blocked) return real();
515
- entry.blocked = true;
695
+ if (!state.deviceOffline) entry.blocked = true;
516
696
  if (isOffline(blocked)) {
517
- finishEntry(entry, { error: `${OFFLINE_MESSAGE} (strict network)` });
697
+ finishEntry(entry, { error: `${OFFLINE_MESSAGE} (${state.deviceOffline ? "device offline" : "strict network"})` });
518
698
  return Promise.reject(new TypeError(OFFLINE_MESSAGE));
519
699
  }
520
700
  const { status, text, headers } = responseParts(blocked);
521
- finishEntry(entry, { status, responseBody: text, contentType: contentTypeOf(headers) });
701
+ finishEntry(entry, { status, responseBody: text, contentType: contentTypeOf(headers), responseHeaders: headers });
522
702
  return Promise.resolve(mockedResponse(blocked));
523
703
  };
524
- const candidates = matchingMocks(state, method, url);
704
+ const bodyKnown = init?.body !== void 0 || !req.clone || method === "GET" || method === "HEAD" || !isTextual(headersRecord(init?.headers ?? req.headers)["content-type"]);
705
+ const candidates = matchingMocks(state, method, url, bodyKnown ? { text: requestBody } : void 0);
525
706
  if (!candidates.length) return passOn();
526
- const request = async () => {
707
+ let built;
708
+ const request = () => built ?? (built = (async () => {
527
709
  let body = requestBody;
528
- if (body === void 0 && init?.body === void 0 && req.clone) {
529
- body = await req.clone().text().catch(() => void 0);
710
+ if (body === void 0 && !bodyKnown) {
711
+ body = await Promise.resolve().then(() => req.clone().text()).catch(() => void 0);
712
+ setRequestBody(entry, body);
713
+ requestBody = body;
530
714
  }
531
715
  return mockRequest(
532
716
  method,
@@ -534,8 +718,8 @@ function patchFetch(state) {
534
718
  headersRecord(init?.headers ?? req.headers),
535
719
  body || void 0
536
720
  );
537
- };
538
- const answered = answer(state, candidates, request).catch((error) => {
721
+ })());
722
+ const answered = answer(state, candidates, request, bodyKnown).catch((error) => {
539
723
  entry.mocked = true;
540
724
  finishEntry(entry, { error: errorMessage(error) });
541
725
  throw error;
@@ -554,7 +738,7 @@ function patchFetch(state) {
554
738
  throw new TypeError(OFFLINE_MESSAGE);
555
739
  }
556
740
  const { status, text, headers } = responseParts(hit.response);
557
- finishEntry(entry, { status, responseBody: text, contentType: contentTypeOf(headers) });
741
+ finishEntry(entry, { status, responseBody: text, contentType: contentTypeOf(headers), responseHeaders: headers });
558
742
  return mockedResponse(hit.response);
559
743
  });
560
744
  };
@@ -586,7 +770,8 @@ function track(xhr, entry) {
586
770
  status: error ? void 0 : xhr.status,
587
771
  error,
588
772
  responseBody: text,
589
- contentType: xhr.getResponseHeader?.("content-type") ?? void 0
773
+ contentType: xhr.getResponseHeader?.("content-type") ?? void 0,
774
+ responseHeaders: error ? void 0 : parseHeaderBlock(xhr.getAllResponseHeaders?.())
590
775
  });
591
776
  if (!error && text === void 0) markResponse(entry, "none");
592
777
  });
@@ -615,7 +800,7 @@ function respond(xhr, mocked, url) {
615
800
  for (const [key, value] of Object.entries(values))
616
801
  Object.defineProperty(xhr, key, { value, configurable: true });
617
802
  };
618
- const lower = Object.fromEntries(
803
+ const lower2 = Object.fromEntries(
619
804
  Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v])
620
805
  );
621
806
  let response = text;
@@ -633,9 +818,9 @@ function respond(xhr, mocked, url) {
633
818
  responseURL: url,
634
819
  responseText: offline ? "" : text,
635
820
  response: offline ? "" : response,
636
- getAllResponseHeaders: () => offline ? "" : Object.entries(lower).map(([k, v]) => `${k}: ${v}\r
821
+ getAllResponseHeaders: () => offline ? "" : Object.entries(lower2).map(([k, v]) => `${k}: ${v}\r
637
822
  `).join(""),
638
- getResponseHeader: (name) => lower[name.toLowerCase()] ?? null
823
+ getResponseHeader: (name) => lower2[name.toLowerCase()] ?? null
639
824
  });
640
825
  fire(xhr, "readystatechange");
641
826
  fire(xhr, offline ? "error" : "load");
@@ -670,30 +855,36 @@ function patchXhr(state) {
670
855
  const meta = this[META];
671
856
  if (!meta || meta.internal || isSkipped(state, meta.url))
672
857
  return send.call(this, body);
858
+ meta.aborted = false;
673
859
  const { method, url } = meta;
674
860
  const text = bodyText(body);
675
861
  const entry = startEntry(state, {
676
862
  method,
677
863
  url,
678
- requestBody: text
864
+ requestBody: text,
865
+ requestHeaders: meta.headers
679
866
  });
680
867
  const passOn = () => {
681
- const blocked = blockedResponse(state, method, url);
868
+ if (meta.aborted) {
869
+ finishEntry(entry, { error: "aborted" });
870
+ return;
871
+ }
872
+ const blocked = blockedResponse(state, method, url, text);
682
873
  if (!blocked) {
683
874
  track(this, entry);
684
875
  return send.call(this, body);
685
876
  }
686
877
  const { status, text: out, headers } = responseParts(blocked);
687
- entry.blocked = true;
878
+ if (!state.deviceOffline) entry.blocked = true;
688
879
  finishEntry(
689
880
  entry,
690
- isOffline(blocked) ? { error: `${OFFLINE_MESSAGE} (strict network)` } : { status, responseBody: out, contentType: contentTypeOf(headers) }
881
+ isOffline(blocked) ? { error: `${OFFLINE_MESSAGE} (${state.deviceOffline ? "device offline" : "strict network"})` } : { status, responseBody: out, contentType: contentTypeOf(headers), responseHeaders: headers }
691
882
  );
692
883
  void Promise.resolve().then(() => {
693
884
  if (!meta.aborted) respond(this, blocked, url);
694
885
  });
695
886
  };
696
- const candidates = matchingMocks(state, method, url);
887
+ const candidates = matchingMocks(state, method, url, { text });
697
888
  if (!candidates.length) return passOn();
698
889
  const request = async () => mockRequest(method, url, meta.headers, text);
699
890
  answer(state, candidates, request).then(
@@ -710,7 +901,7 @@ function patchXhr(state) {
710
901
  const { status, text: out, headers } = responseParts(hit.response);
711
902
  finishEntry(
712
903
  entry,
713
- offline ? { error: `${OFFLINE_MESSAGE} (mocked offline)` } : { status, responseBody: out, contentType: contentTypeOf(headers) }
904
+ offline ? { error: `${OFFLINE_MESSAGE} (mocked offline)` } : { status, responseBody: out, contentType: contentTypeOf(headers), responseHeaders: headers }
714
905
  );
715
906
  respond(this, hit.response, url);
716
907
  },
@@ -799,6 +990,7 @@ var describeMock = (m, full) => ({
799
990
  id: m.id,
800
991
  source: m.source,
801
992
  match: describeMatch(m.match),
993
+ matches: m.matches,
802
994
  ...describeResponse(m.response, full),
803
995
  times: m.times,
804
996
  hits: m.hits,
@@ -809,6 +1001,11 @@ function networkTools(options = {}) {
809
1001
  installNetwork();
810
1002
  const state = networkState();
811
1003
  if (options.skip) state.skip = options.skip;
1004
+ if (options.logSize !== void 0) {
1005
+ if (!Number.isInteger(options.logSize) || options.logSize < 1)
1006
+ throw new Error("logSize must be a whole number of at least 1");
1007
+ state.logSize = options.logSize;
1008
+ }
812
1009
  const isAgent = (m) => m.source === "agent";
813
1010
  const logged = (id) => {
814
1011
  const entry = state.log.find((e) => e.id === id);
@@ -818,7 +1015,7 @@ function networkTools(options = {}) {
818
1015
  const addAgentMock = (match, response, { times, delayMs, priority }) => {
819
1016
  const added = addMock(state, "agent", match, response, { times, delayMs, priority });
820
1017
  const shadows = shadowedMocks(state, added).map((m) => m.id);
821
- return shadows.length ? { id: added.id, shadows } : { id: added.id };
1018
+ return shadows.length ? { id: added.id, matches: added.matches, shadows } : { id: added.id, matches: added.matches };
822
1019
  };
823
1020
  return {
824
1021
  "net.log": {
@@ -835,19 +1032,55 @@ function networkTools(options = {}) {
835
1032
  }
836
1033
  },
837
1034
  "net.entry": {
838
- description: "One logged request by id, with its request and response bodies whole (not cut at ~2 KB). Ids come from net.log.",
839
- run: (id) => publicEntry(logged(id), true)
1035
+ description: "One logged request by id, with its request and response bodies whole (not cut at ~2 KB) and its request and response headers (lower-case names). Authorization, Cookie, Set-Cookie, Proxy-Authorization and X-Api-Key values are cut to a short prefix; { full: true } as the second argument shows them whole. Ids come from net.log.",
1036
+ run: (id, options2 = {}) => publicEntry(logged(id), true, options2?.full ? "full" : "cut")
840
1037
  },
841
1038
  "net.mock": {
842
- description: 'Answer matching requests with a canned response until unmocked. match: "/path" or { url: string | { regex }, method? }. response: { status?, json?, body?, headers? } or { offline: true }. options: { times?, delayMs?, priority? }. The newest mock is tried first, so a later broad mock shadows an earlier specific one; give the specific one a higher priority (default 0, ties newest first; negative makes a fallback); agent mocks still come before the app mocks. The result lists `shadows` when the new mock will be tried before an agent mock it also matches.',
1039
+ description: 'Answer matching requests with a canned response until unmocked. match: "/path" (any URL containing it) or { url?: string | { regex }, path?: "/orders/:id" (the whole path on any host, query ignored; :name is one segment, * the rest), method?, body?: JSON subset the JSON request body must contain (objects by key, arrays by length and position), graphql?: "OperationName" (operationName in the JSON body or ?operationName=) }; every key given must match and one of url, path, body, graphql is required. The result has `matches`, which says in words what the mock matches. response: { status?, json?, body?, headers? } or { offline: true }. options: { times?, delayMs?, priority? }. The newest mock is tried first, so a later broad mock shadows an earlier specific one; give the specific one a higher priority (default 0, ties newest first; negative makes a fallback); agent mocks still come before the app mocks. The result lists `shadows` when the new mock will be tried before an agent mock it also matches.',
843
1040
  run: (match, response, mockOptions = {}) => {
844
1041
  if (typeof response !== "object" || response === null)
845
1042
  throw new Error("response must be an object, e.g. { status: 500 }");
846
1043
  return addAgentMock(match, response, mockOptions);
847
1044
  }
848
1045
  },
1046
+ "net.waitFor": {
1047
+ description: 'Wait until a request matching `match` (as net.mock: "/path" or { url?, path?, method?, body?, graphql? }) has finished, and return its entry like net.entry. Finished requests already in the log count, so pass { sinceId } (a log id from net.log) to ignore older ones. [match, { timeoutMs?: 5000, status?, sinceId? }]. A request that failed, was mocked or blocked still counts; status narrows it. Body and graphql checks need the request body: JSON up to 1 MB, and a Request object\'s body only when a mock read it. Times out with an error saying what it looked at.',
1048
+ run: (match, waitOptions = {}) => {
1049
+ const compiled = compileMatch(match);
1050
+ const { timeoutMs = 5e3, status, sinceId } = waitOptions ?? {};
1051
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 12e4)
1052
+ throw new Error("timeoutMs must be between 1 and 120000");
1053
+ const deadline = Date.now() + timeoutMs;
1054
+ let unreadable = 0;
1055
+ const find = () => {
1056
+ unreadable = 0;
1057
+ for (const e of state.log) {
1058
+ if (e.pending || fullResponse(e).state === "pending") continue;
1059
+ if (sinceId !== void 0 && e.id <= sinceId) continue;
1060
+ if (status !== void 0 && e.status !== status) continue;
1061
+ if (!compiled.test(e.method, e.url)) continue;
1062
+ if (!compiled.needsBody) return e;
1063
+ const body = requestBodyForMatch(e);
1064
+ if (body === void 0 && e.requestBody !== void 0) unreadable += 1;
1065
+ else if (compiled.testBody(mockRequest(e.method, e.url, {}, body))) return e;
1066
+ }
1067
+ return void 0;
1068
+ };
1069
+ return (async () => {
1070
+ for (; ; ) {
1071
+ const found = find();
1072
+ if (found) return publicEntry(found, true, "cut");
1073
+ if (Date.now() >= deadline) break;
1074
+ await new Promise((resolve) => setTimeout(resolve, 25));
1075
+ }
1076
+ throw new Error(
1077
+ `Timed out after ${timeoutMs} ms waiting for a finished request matching ${compiled.matches}${status !== void 0 ? ` with status ${status}` : ""}${unreadable ? ` (${unreadable} matched the URL but their body was not kept)` : ""}; ${state.log.length} requests are logged`
1078
+ );
1079
+ })();
1080
+ }
1081
+ },
849
1082
  "net.mockFromLog": {
850
- description: "Mock the same method and URL as a logged request (id from net.log) with its status and whole response body, optionally changed by patch: JSON objects merge deeply, other values replace. [id, patch?, options?] options as net.mock. Returns { id, match, response }.",
1083
+ description: "Mock the same method and URL as a logged request (id from net.log) with its status and whole response body, optionally changed by patch: JSON objects merge deeply, other values replace. [id, patch?, options?] options as net.mock. Returns { id, matches, match, response }; on a GraphQL request the mock only matches that operationName; `warning` appears when a POST body was not kept and the mock may answer other operations too.",
851
1084
  run: (id, patch, mockOptions = {}) => {
852
1085
  const entry = logged(id);
853
1086
  if (entry.status === void 0)
@@ -876,12 +1109,26 @@ function networkTools(options = {}) {
876
1109
  response.body = body;
877
1110
  if (contentType) response.headers = { "content-type": contentType };
878
1111
  }
879
- const match = { url: { regex: `^${escapeRegex(entry.url)}$` }, method: entry.method };
880
- return { ...addAgentMock(match, response, mockOptions), match: { url: entry.url, method: entry.method }, response };
1112
+ const sent = requestBodyForMatch(entry);
1113
+ let graphql = operationName(mockRequest(entry.method, entry.url, {}, sent));
1114
+ if (!graphql && sent === void 0 && entry.requestBody !== void 0)
1115
+ graphql = /"operationName":"((?:[^"\\]|\\.)*)"/.exec(entry.requestBody)?.[1];
1116
+ const unread = !graphql && !["GET", "HEAD"].includes(entry.method) && sent === void 0 ? "This request's body was not kept, so the mock matches every request to that URL, whatever its GraphQL operation. Check it, or unmock and use net.mock with graphql." : void 0;
1117
+ const match = {
1118
+ url: { regex: `^${escapeRegex(entry.url)}$` },
1119
+ method: entry.method,
1120
+ ...graphql && { graphql }
1121
+ };
1122
+ return {
1123
+ ...addAgentMock(match, response, mockOptions),
1124
+ match: { url: entry.url, method: entry.method, ...graphql && { graphql } },
1125
+ response,
1126
+ ...unread && { warning: unread }
1127
+ };
881
1128
  }
882
1129
  },
883
1130
  "net.mocks": {
884
- description: 'Active mocks, agent and app, in the order they are tried. Response json and body over ~2 KB are cut like net.log (json shown as its cut JSON text, "\u2026 (+N chars)"), with truncated: true; { full: true } returns them whole.',
1131
+ description: 'Active mocks, agent and app, in the order they are tried, each with `matches` in words. Response json and body over ~2 KB are cut like net.log (json shown as its cut JSON text, "\u2026 (+N chars)"), with truncated: true; { full: true } returns them whole.',
885
1132
  run: (options2 = {}) => [...state.mocks].sort(mockOrder).map((m) => describeMock(m, options2.full))
886
1133
  },
887
1134
  "net.unmock": {