@buoy-gg/network 7.0.35 → 7.0.38

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 (91) hide show
  1. package/README.md +45 -12
  2. package/lib/commonjs/index.js +26 -1
  3. package/lib/commonjs/index.web.js +26 -1
  4. package/lib/commonjs/network/components/NetworkEventDetailView.js +6 -1
  5. package/lib/commonjs/network/components/NetworkHeaderMenu.js +4 -0
  6. package/lib/commonjs/network/components/NetworkModal.js +24 -8
  7. package/lib/commonjs/network/conditions/NetworkConditionsControl.js +235 -0
  8. package/lib/commonjs/network/conditions/NetworkConditionsControl.test.js +136 -0
  9. package/lib/commonjs/network/conditions/NetworkConditionsIcon.js +33 -0
  10. package/lib/commonjs/network/conditions/NetworkFloatingTool.js +310 -0
  11. package/lib/commonjs/network/conditions/NetworkFloatingTool.test.js +219 -0
  12. package/lib/commonjs/network/conditions/store.js +75 -0
  13. package/lib/commonjs/network/conditions/wait.js +43 -0
  14. package/lib/commonjs/network/conditions/xhr.js +110 -0
  15. package/lib/commonjs/network/hooks/useNetworkEvents.js +1 -1
  16. package/lib/commonjs/network/overrides/applyToXhr.js +10 -8
  17. package/lib/commonjs/network/overrides/engine.js +2 -1
  18. package/lib/commonjs/network/sync/networkSyncAdapter.js +224 -3
  19. package/lib/commonjs/network/utils/generateNetworkCopyText.js +2 -0
  20. package/lib/commonjs/network/utils/networkEventStore.js +5 -77
  21. package/lib/commonjs/network/utils/networkListener.js +245 -46
  22. package/lib/commonjs/network/utils/networkSavedStore.js +2 -2
  23. package/lib/commonjs/network/utils/selectSavedEvents.js +2 -2
  24. package/lib/commonjs/preset.js +2 -1
  25. package/lib/module/index.js +2 -1
  26. package/lib/module/index.web.js +2 -1
  27. package/lib/module/network/components/NetworkEventDetailView.js +6 -1
  28. package/lib/module/network/components/NetworkHeaderMenu.js +4 -0
  29. package/lib/module/network/components/NetworkModal.js +25 -9
  30. package/lib/module/network/conditions/NetworkConditionsControl.js +231 -0
  31. package/lib/module/network/conditions/NetworkConditionsControl.test.js +135 -0
  32. package/lib/module/network/conditions/NetworkConditionsIcon.js +29 -0
  33. package/lib/module/network/conditions/NetworkFloatingTool.js +304 -0
  34. package/lib/module/network/conditions/NetworkFloatingTool.test.js +218 -0
  35. package/lib/module/network/conditions/store.js +65 -0
  36. package/lib/module/network/conditions/wait.js +38 -0
  37. package/lib/module/network/conditions/xhr.js +106 -0
  38. package/lib/module/network/hooks/useNetworkEvents.js +1 -1
  39. package/lib/module/network/overrides/applyToXhr.js +9 -8
  40. package/lib/module/network/overrides/engine.js +2 -1
  41. package/lib/module/network/sync/networkSyncAdapter.js +225 -3
  42. package/lib/module/network/utils/generateNetworkCopyText.js +2 -0
  43. package/lib/module/network/utils/networkEventStore.js +6 -77
  44. package/lib/module/network/utils/networkListener.js +246 -47
  45. package/lib/module/network/utils/networkSavedStore.js +2 -2
  46. package/lib/module/network/utils/selectSavedEvents.js +2 -2
  47. package/lib/module/preset.js +3 -2
  48. package/lib/typescript/index.d.ts +2 -0
  49. package/lib/typescript/index.d.ts.map +1 -1
  50. package/lib/typescript/index.web.d.ts +2 -0
  51. package/lib/typescript/index.web.d.ts.map +1 -1
  52. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  53. package/lib/typescript/network/components/NetworkHeaderMenu.d.ts +2 -1
  54. package/lib/typescript/network/components/NetworkHeaderMenu.d.ts.map +1 -1
  55. package/lib/typescript/network/components/NetworkModal.d.ts +3 -0
  56. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  57. package/lib/typescript/network/conditions/NetworkConditionsControl.d.ts +12 -0
  58. package/lib/typescript/network/conditions/NetworkConditionsControl.d.ts.map +1 -0
  59. package/lib/typescript/network/conditions/NetworkConditionsControl.test.d.ts +2 -0
  60. package/lib/typescript/network/conditions/NetworkConditionsControl.test.d.ts.map +1 -0
  61. package/lib/typescript/network/conditions/NetworkConditionsIcon.d.ts +4 -0
  62. package/lib/typescript/network/conditions/NetworkConditionsIcon.d.ts.map +1 -0
  63. package/lib/typescript/network/conditions/NetworkFloatingTool.d.ts +15 -0
  64. package/lib/typescript/network/conditions/NetworkFloatingTool.d.ts.map +1 -0
  65. package/lib/typescript/network/conditions/NetworkFloatingTool.test.d.ts +2 -0
  66. package/lib/typescript/network/conditions/NetworkFloatingTool.test.d.ts.map +1 -0
  67. package/lib/typescript/network/conditions/store.d.ts +30 -0
  68. package/lib/typescript/network/conditions/store.d.ts.map +1 -0
  69. package/lib/typescript/network/conditions/wait.d.ts +4 -0
  70. package/lib/typescript/network/conditions/wait.d.ts.map +1 -0
  71. package/lib/typescript/network/conditions/xhr.d.ts +5 -0
  72. package/lib/typescript/network/conditions/xhr.d.ts.map +1 -0
  73. package/lib/typescript/network/hooks/useNetworkEvents.d.ts +1 -1
  74. package/lib/typescript/network/overrides/applyToXhr.d.ts +2 -0
  75. package/lib/typescript/network/overrides/applyToXhr.d.ts.map +1 -1
  76. package/lib/typescript/network/overrides/engine.d.ts +0 -10
  77. package/lib/typescript/network/overrides/engine.d.ts.map +1 -1
  78. package/lib/typescript/network/sync/networkSyncAdapter.d.ts +29 -0
  79. package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
  80. package/lib/typescript/network/types/index.d.ts +1 -0
  81. package/lib/typescript/network/types/index.d.ts.map +1 -1
  82. package/lib/typescript/network/utils/generateNetworkCopyText.d.ts.map +1 -1
  83. package/lib/typescript/network/utils/networkEventStore.d.ts +0 -15
  84. package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
  85. package/lib/typescript/network/utils/networkListener.d.ts +13 -28
  86. package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
  87. package/lib/typescript/network/utils/networkSavedStore.d.ts +2 -2
  88. package/lib/typescript/network/utils/selectSavedEvents.d.ts +2 -2
  89. package/lib/typescript/preset.d.ts +0 -16
  90. package/lib/typescript/preset.d.ts.map +1 -1
  91. package/package.json +2 -2
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.networkConditionsRuntimeId = exports.getNetworkCondition = exports.conditionsCanApply = exports.NETWORK_CONDITIONS = void 0;
7
+ exports.setNetworkCondition = setNetworkCondition;
8
+ exports.snapshotNetworkCondition = snapshotNetworkCondition;
9
+ exports.subscribeNetworkConditions = void 0;
10
+ var _sharedUi = require("@buoy-gg/shared-ui");
11
+ var _networkListener = require("../utils/networkListener");
12
+ const NETWORK_CONDITIONS = exports.NETWORK_CONDITIONS = {
13
+ normal: {
14
+ label: "Normal",
15
+ delayMs: 0
16
+ },
17
+ offline: {
18
+ label: "Offline (requests)",
19
+ delayMs: 0
20
+ },
21
+ slow: {
22
+ label: "Slow (+500 ms)",
23
+ delayMs: 500
24
+ },
25
+ verySlow: {
26
+ label: "Very slow (+2000 ms)",
27
+ delayMs: 2000
28
+ }
29
+ };
30
+ const networkConditionsRuntimeId = exports.networkConditionsRuntimeId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
31
+ let profile = "normal";
32
+ let keepAlive;
33
+ const listeners = new Set();
34
+ const conditionsCanApply = () => typeof __DEV__ !== "undefined" && __DEV__ && (0, _sharedUi.hasAccountAccess)();
35
+ exports.conditionsCanApply = conditionsCanApply;
36
+ const getNetworkCondition = () => profile;
37
+ exports.getNetworkCondition = getNetworkCondition;
38
+ const subscribeNetworkConditions = listener => {
39
+ listeners.add(listener);
40
+ return () => {
41
+ listeners.delete(listener);
42
+ };
43
+ };
44
+ exports.subscribeNetworkConditions = subscribeNetworkConditions;
45
+ function setNetworkCondition(value) {
46
+ if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(NETWORK_CONDITIONS, value)) {
47
+ throw new Error("Choose normal, offline, slow, or verySlow.");
48
+ }
49
+ if (value !== "normal" && !conditionsCanApply()) {
50
+ throw new Error("Network conditions require an admitted account and a development build.");
51
+ }
52
+ if (value === profile) return profile;
53
+ if (value !== "normal" && !keepAlive) {
54
+ (0, _networkListener.startNetworkListener)();
55
+ keepAlive = (0, _networkListener.addNetworkListener)(() => {});
56
+ } else if (value === "normal") {
57
+ keepAlive?.();
58
+ keepAlive = undefined;
59
+ if ((0, _networkListener.getNetworkListenerCount)() === 0) (0, _networkListener.stopNetworkListener)();
60
+ }
61
+ profile = value;
62
+ listeners.forEach(listener => listener());
63
+ return profile;
64
+ }
65
+ function snapshotNetworkCondition(url) {
66
+ if (profile === "normal" || !conditionsCanApply() || !/^https?:\/\//i.test(url)) return;
67
+ return Object.freeze({
68
+ profile,
69
+ delayMs: NETWORK_CONDITIONS[profile].delayMs
70
+ });
71
+ }
72
+ (0, _sharedUi.subscribeAccountAccess)(() => {
73
+ if (!(0, _sharedUi.hasAccountAccess)()) setNetworkCondition("normal");
74
+ listeners.forEach(listener => listener());
75
+ });
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.throwIfAborted = throwIfAborted;
7
+ exports.waitBeforeDispatch = waitBeforeDispatch;
8
+ function throwIfAborted(signal) {
9
+ if (!signal?.aborted) return;
10
+ if (signal.reason !== undefined) throw signal.reason;
11
+ const error = new Error("The request was aborted.");
12
+ error.name = "AbortError";
13
+ throw error;
14
+ }
15
+
16
+ /** Wait before dispatch, releasing the timer and listener on cancellation. */
17
+ function waitBeforeDispatch(ms, signal) {
18
+ return new Promise((resolve, reject) => {
19
+ try {
20
+ throwIfAborted(signal);
21
+ } catch (error) {
22
+ reject(error);
23
+ return;
24
+ }
25
+ const cleanup = () => signal?.removeEventListener("abort", abort);
26
+ const abort = () => {
27
+ clearTimeout(timer);
28
+ cleanup();
29
+ try {
30
+ throwIfAborted(signal);
31
+ } catch (error) {
32
+ reject(error);
33
+ }
34
+ };
35
+ const timer = setTimeout(() => {
36
+ cleanup();
37
+ resolve();
38
+ }, ms);
39
+ signal?.addEventListener("abort", abort, {
40
+ once: true
41
+ });
42
+ });
43
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.runConditionXhr = runConditionXhr;
7
+ var _applyToXhr = require("../overrides/applyToXhr");
8
+ /** Active simulation uses a send-to-completion deadline, including the wait. */
9
+ function runConditionXhr(target, url, condition, outcome, dispatch) {
10
+ if (!(0, _applyToXhr.canSynthesize)(target)) throw new Error("Network conditions require React Native XMLHttpRequest.");
11
+ const xhr = target;
12
+ if (xhr.readyState !== 1 || xhr._sent) throw new Error("Request is not open or has already been sent.");
13
+ const started = Date.now();
14
+ let timeout = xhr.timeout;
15
+ let settled = false;
16
+ let dispatching = false;
17
+ let deadline;
18
+ let pending;
19
+ const originalAbort = xhr.abort;
20
+ const originalOpen = xhr.open;
21
+ const abortDescriptor = Object.getOwnPropertyDescriptor(xhr, "abort");
22
+ const openDescriptor = Object.getOwnPropertyDescriptor(xhr, "open");
23
+ const timeoutDescriptor = Object.getOwnPropertyDescriptor(xhr, "timeout");
24
+ const restore = (key, descriptor) => {
25
+ if (descriptor) Object.defineProperty(xhr, key, descriptor);else delete xhr[key];
26
+ };
27
+ const cleanup = () => {
28
+ if (settled) return;
29
+ settled = true;
30
+ clearTimeout(pending);
31
+ clearTimeout(deadline);
32
+ xhr.removeEventListener("loadend", cleanup);
33
+ restore("abort", abortDescriptor);
34
+ restore("open", openDescriptor);
35
+ restore("timeout", timeoutDescriptor);
36
+ xhr.timeout = timeout;
37
+ };
38
+ const fail = timedOut => {
39
+ if (settled) return;
40
+ // DONE suppresses abort events while the native transport is cancelled.
41
+ Object.assign(xhr, {
42
+ readyState: 4
43
+ });
44
+ originalAbort.call(xhr);
45
+ xhr._aborted = false;
46
+ xhr._hasError = true;
47
+ xhr._timedOut = timedOut;
48
+ xhr._response = timedOut ? "Request timed out (Buoy conditions)" : "Network request failed (Buoy Offline)";
49
+ xhr.setReadyState(4);
50
+ cleanup();
51
+ };
52
+ const expired = () => timeout > 0 && Date.now() - started >= timeout;
53
+ const scheduleDeadline = () => {
54
+ clearTimeout(deadline);
55
+ if (timeout > 0) deadline = setTimeout(() => fail(true), Math.max(0, timeout - (Date.now() - started)));
56
+ };
57
+ Object.defineProperty(xhr, "timeout", {
58
+ configurable: true,
59
+ get: () => dispatching ? 0 : timeout,
60
+ set: value => {
61
+ timeout = Math.max(0, Number(value) || 0);
62
+ scheduleDeadline();
63
+ }
64
+ });
65
+ Object.defineProperty(xhr, "abort", {
66
+ configurable: true,
67
+ value: () => {
68
+ if (!settled) {
69
+ originalAbort.call(xhr);
70
+ cleanup();
71
+ }
72
+ }
73
+ });
74
+ Object.defineProperty(xhr, "open", {
75
+ configurable: true,
76
+ value: (...args) => {
77
+ const result = Reflect.apply(originalOpen, xhr, args);
78
+ cleanup();
79
+ return result;
80
+ }
81
+ });
82
+ xhr._sent = true;
83
+ xhr._requestId = null;
84
+ xhr.addEventListener("loadend", cleanup);
85
+ scheduleDeadline();
86
+ pending = setTimeout(() => {
87
+ if (settled) return;
88
+ if (expired()) {
89
+ fail(true);
90
+ return;
91
+ }
92
+ if (condition.profile === "offline") {
93
+ fail(false);
94
+ return;
95
+ }
96
+ if (outcome && outcome.kind !== "delay") {
97
+ (0, _applyToXhr.completeNativeOverride)(xhr, url, outcome);
98
+ return;
99
+ }
100
+ xhr._sent = false;
101
+ dispatching = true;
102
+ try {
103
+ dispatch();
104
+ } catch {
105
+ fail(false);
106
+ } finally {
107
+ dispatching = false;
108
+ }
109
+ }, condition.delayMs + (outcome?.delayMs ?? 0));
110
+ }
@@ -18,7 +18,7 @@ var _formatGraphQLVariables = require("../utils/formatGraphQLVariables");
18
18
 
19
19
  /**
20
20
  * @deprecated Read the live limit with `useLimit("network.requests")` — this
21
- * only reports the FREE tier's value and is blind to the anonymous tier. Kept
21
+ * only reports the FREE tier's value and ignores the Weekend Pass. Kept
22
22
  * because it is part of the package's public exports.
23
23
  */
24
24
  const FREE_TIER_REQUEST_LIMIT = exports.FREE_TIER_REQUEST_LIMIT = _sharedUi.TIER_LIMITS["network.requests"].free;
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.canSynthesize = canSynthesize;
7
7
  exports.canSynthesizeStandard = canSynthesizeStandard;
8
+ exports.completeNativeOverride = completeNativeOverride;
8
9
  exports.runOverride = runOverride;
9
10
  /**
10
11
  * Perform an override outcome against an XMLHttpRequest — React Native's OR a
@@ -406,16 +407,12 @@ function runOverride(xhr, url, outcome, passThrough) {
406
407
  // had already aborted. Claiming it means abort() dispatches DONE + 'abort'
407
408
  // and whatwg-fetch rejects with AbortError, as it should.
408
409
  internals._sent = true;
409
- if (outcome.kind !== "delay") {
410
- // Buoy's own `open()` swizzle overwrites RN's private `_requestId` with a
411
- // string id. RN normally clobbers it back with the native numeric id inside
412
- // `send()` — which we're skipping — so it would survive here, and
413
- // `abort()` would hand that string to `RCTNetworking.abortRequest()`.
414
- // Nulling it makes abort a clean no-op on the native side.
415
- internals._requestId = null;
416
- }
410
+
411
+ // There is no native request to cancel until a delayed send is dispatched.
412
+ internals._requestId = null;
417
413
  let timer = null;
418
414
  const onAbort = () => {
415
+ xhr.removeEventListener("abort", onAbort);
419
416
  if (timer !== null) {
420
417
  clearTimeout(timer);
421
418
  timer = null;
@@ -448,4 +445,9 @@ function runOverride(xhr, url, outcome, passThrough) {
448
445
  // 'load' before they exist.
449
446
  timer = setTimeout(fire, outcome.delayMs);
450
447
  return true;
448
+ }
449
+
450
+ /** Complete an already scheduled native outcome without adding another timer. */
451
+ function completeNativeOverride(xhr, url, outcome) {
452
+ if (outcome.kind === "fail") fail(xhr, outcome);else if (outcome.kind === "respond") respond(xhr, url, outcome);
451
453
  }
@@ -8,6 +8,7 @@ exports.inspectEngine = inspectEngine;
8
8
  exports.overrideForRequest = overrideForRequest;
9
9
  exports.overridesCanApply = overridesCanApply;
10
10
  exports.setOverrideHooks = setOverrideHooks;
11
+ var _sharedUi = require("@buoy-gg/shared-ui");
11
12
  var _resolveOverride = require("./resolveOverride");
12
13
  /**
13
14
  * The seam between the interceptor and the rule store.
@@ -42,7 +43,7 @@ function setOverrideHooks(next) {
42
43
  function overridesCanApply() {
43
44
  // `__DEV__` is undefined outside React Native (unit tests, the desktop
44
45
  // bundle); only an explicit `false` — a real release build — disables it.
45
- return !(typeof __DEV__ !== "undefined" && !__DEV__);
46
+ return (0, _sharedUi.hasAccountAccess)() && !(typeof __DEV__ !== "undefined" && !__DEV__);
46
47
  }
47
48
 
48
49
  /** Why overrides are inert, for a caller that has to explain it to a person. */
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.networkSyncAdapter = exports.SNAPSHOT_HEADER_VALUE_LIMIT = exports.SNAPSHOT_EVENTS_BUDGET = exports.SNAPSHOT_BODY_INLINE_LIMIT = void 0;
7
+ var _store = require("../conditions/store");
7
8
  var _networkEventStore = require("../utils/networkEventStore");
8
9
  var _networkSavedStore = require("../utils/networkSavedStore");
9
10
  var _overrideRulesStore = require("../overrides/overrideRulesStore");
@@ -311,13 +312,116 @@ base) {
311
312
  headers: rule.headers && typeof rule.headers === "object" ? rule.headers : base?.headers,
312
313
  // Objects are accepted and serialized so a caller can send JSON directly
313
314
  // instead of pre-stringifying it, which is the mistake everyone makes once.
314
- body: typeof rule.body === "string" ? rule.body : rule.body != null ? JSON.stringify(rule.body, null, 2) : base?.body,
315
+ body: patchedBody(rule, base),
315
316
  failKind: rule.failKind === "timeout" ? "timeout" : undefined,
316
317
  delayMs: typeof rule.delayMs === "number" ? rule.delayMs : undefined,
317
318
  times: typeof rule.times === "number" ? rule.times : null,
318
319
  alternate: rule.alternate === true
319
320
  };
320
321
  }
322
+ const isPlainObject = v => !!v && typeof v === "object" && !Array.isArray(v);
323
+ /**
324
+ * Objects merge key by key; arrays and primitives are replaced. Collects two
325
+ * dangers instead of writing them: `shrunk` (a non-empty array shorter than
326
+ * the one it replaces) and `breaks` (an array/object field overwritten by null
327
+ * or a primitive — the shape the app renders, changed out from under it). The
328
+ * handler refuses either unless forced.
329
+ *
330
+ * This stays local while the typed-edit RULE is shared: it is the merge itself,
331
+ * not the check, and it reports network-specific dangers the store surfaces do
332
+ * not have. `shapeKind` comes from the shared module so both agree on what
333
+ * counts as an array.
334
+ */
335
+ function deepMerge(base, patch, path = "", issues = {
336
+ shrunk: [],
337
+ breaks: []
338
+ }) {
339
+ if (Array.isArray(base) && Array.isArray(patch)) {
340
+ if (patch.length > 0 && patch.length < base.length) {
341
+ issues.shrunk.push({
342
+ path: path || "(root)",
343
+ have: base.length,
344
+ sent: patch.length
345
+ });
346
+ }
347
+ return patch;
348
+ }
349
+ if (!isPlainObject(base) || !isPlainObject(patch)) return patch;
350
+ const out = {
351
+ ...base
352
+ };
353
+ for (const [k, v] of Object.entries(patch)) {
354
+ const p = path ? `${path}.${k}` : k;
355
+ if (k in base) {
356
+ const from = (0, _sharedUi.shapeKind)(base[k]);
357
+ const to = (0, _sharedUi.shapeKind)(v);
358
+ if ((from === "array" || from === "object") && from !== to) {
359
+ issues.breaks.push({
360
+ path: p,
361
+ from,
362
+ to
363
+ });
364
+ }
365
+ out[k] = deepMerge(base[k], v, p, issues);
366
+ } else {
367
+ out[k] = v;
368
+ }
369
+ }
370
+ return out;
371
+ }
372
+
373
+ /** Typed-edit violations a bodyPatch would cause against the seed body, or []. */
374
+ function patchViolations(rule, seed) {
375
+ if (rule.body != null || !isPlainObject(rule.bodyPatch) || typeof seed !== "string") return [];
376
+ try {
377
+ return (0, _sharedUi.typedEditViolations)(JSON.parse(seed), rule.bodyPatch, {
378
+ noun: "response body"
379
+ });
380
+ } catch {
381
+ return [];
382
+ }
383
+ }
384
+
385
+ /**
386
+ * The rule's body: explicit `body` wins, else `bodyPatch` deep-merged into the
387
+ * seeded body (from `fromRequestId`, or the rule being replaced), else the seed.
388
+ *
389
+ * `bodyPatch` exists for the same reason `setQueryData` has `merge`: a remote
390
+ * caller reads the real response through a wire that truncates big payloads,
391
+ * edits one field, and pastes the torn object back as `body`. The app then
392
+ * renders a response missing half its fields. With a patch the caller sends
393
+ * `{ stats: [...] }` and the other 30KB stay exactly as the server sent them.
394
+ */
395
+ function patchedBody(rule, base) {
396
+ if (typeof rule.body === "string") return rule.body;
397
+ if (rule.body != null) return JSON.stringify(rule.body, null, 2);
398
+ const seed = base?.body ?? (typeof rule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rule.id)?.body : undefined);
399
+ if (isPlainObject(rule.bodyPatch) && typeof seed === "string") {
400
+ try {
401
+ return JSON.stringify(deepMerge(JSON.parse(seed), rule.bodyPatch), null, 2);
402
+ } catch {
403
+ return seed;
404
+ }
405
+ }
406
+ return seed;
407
+ }
408
+
409
+ /**
410
+ * Replacing a rule by id inherits every field the caller did not send, so a
411
+ * remote driver can flip one thing — `{ id, bodyPatch }`, `{ id, status: 500 }`
412
+ * — without re-sending the urlPattern it never had to know.
413
+ */
414
+ function existingRule(params) {
415
+ if (!params || typeof params !== "object") return null;
416
+ const raw = params.rule ?? params;
417
+ if (!raw || typeof raw !== "object") return null;
418
+ const id = raw.id;
419
+ if (typeof id !== "string") return null;
420
+ const existing = _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === id);
421
+ return existing ? {
422
+ ...existing
423
+ } : null;
424
+ }
321
425
 
322
426
  /**
323
427
  * The body a remote surface never received, when it says so.
@@ -373,10 +477,13 @@ function baseFromRequest(params) {
373
477
  * `NetworkPanel`/`NetworkInline` accept either shape.
374
478
  */
375
479
  const networkSyncAdapter = exports.networkSyncAdapter = {
480
+ dispose: () => {
481
+ _overrideRulesStore.overrideRulesStore.setEnabled(false);
482
+ },
376
483
  // v5: honest request/responseSize + explicit request/responseBodyOmitted
377
484
  // flags, and a total snapshot budget so the list degrades instead of the
378
485
  // whole panel being dropped at the emit layer.
379
- version: 5,
486
+ version: 6,
380
487
  getSnapshot: () => {
381
488
  // Piggyback the interception health check on the watch loop: while a
382
489
  // dashboard/MCP watch is open this runs continuously, so a clobbered
@@ -385,6 +492,12 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
385
492
  // reads when healthy.
386
493
  _networkEventStore.networkEventStore.ensureInterception();
387
494
  return {
495
+ conditions: {
496
+ version: 1,
497
+ runtimeId: _store.networkConditionsRuntimeId,
498
+ profile: (0, _store.getNetworkCondition)(),
499
+ available: (0, _store.conditionsCanApply)()
500
+ },
388
501
  events: fitEventsToBudget(_networkEventStore.networkEventStore.getEvents()),
389
502
  /**
390
503
  * Response-override rules live on the DEVICE — that's the only place they
@@ -405,17 +518,32 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
405
518
  // Pinning with no traffic in flight still has to reach the dashboard, so
406
519
  // both stores drive the snapshot.
407
520
  const unsubscribeEvents = _networkEventStore.networkEventStore.subscribeToEvents(onChange);
521
+ const unsubscribeConditions = (0, _store.subscribeNetworkConditions)(onChange);
408
522
  const unsubscribeSaved = _networkSavedStore.networkSavedStore.subscribe(onChange);
409
523
  // Same reasoning for rules: editing one with no traffic in flight must
410
524
  // still reach the dashboard. Subscribing also hydrates the rule list.
411
525
  const unsubscribeOverrides = _overrideRulesStore.overrideRulesStore.subscribe(onChange);
412
526
  return () => {
527
+ unsubscribeConditions();
413
528
  unsubscribeEvents();
414
529
  unsubscribeSaved();
415
530
  unsubscribeOverrides();
416
531
  };
417
532
  },
418
533
  actions: {
534
+ getNetworkConditions: () => ({
535
+ runtimeId: _store.networkConditionsRuntimeId,
536
+ profile: (0, _store.getNetworkCondition)(),
537
+ available: (0, _store.conditionsCanApply)()
538
+ }),
539
+ setNetworkConditions: params => {
540
+ const profile = params && typeof params === "object" ? params.profile : undefined;
541
+ return {
542
+ runtimeId: _store.networkConditionsRuntimeId,
543
+ profile: (0, _store.setNetworkCondition)(profile),
544
+ available: (0, _store.conditionsCanApply)()
545
+ };
546
+ },
419
547
  clearEvents: () => {
420
548
  _networkEventStore.networkEventStore.clearEvents();
421
549
  },
@@ -511,7 +639,89 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
511
639
  },
512
640
  /** Create a rule, or replace one by id. */
513
641
  upsertOverrideRule: params => {
514
- const base = baseFromRequest(params) ?? preservedBody(params);
642
+ const base = baseFromRequest(params) ?? preservedBody(params) ?? existingRule(params);
643
+ const rawRule = params?.rule ?? params;
644
+ // `bodyPath` + `bodyValue` are sugar over `bodyPatch`, resolved against the
645
+ // real response before anything else looks at the rule — so the keyed
646
+ // expansion, the typed-edit guard and the merge below all run on an
647
+ // ordinary patch and gain no special cases. This is the same form the
648
+ // state tools take; it exists here because a response body is the surface
649
+ // where the caller is LEAST likely to have seen the shape (it read the
650
+ // body over a wire that truncates at 24,000 characters), and therefore
651
+ // most likely to get the nesting wrong.
652
+ if (rawRule && typeof rawRule.bodyPath === "string" && rawRule.body == null) {
653
+ const seed = base?.body ?? (typeof rawRule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
654
+ if (typeof seed !== "string") {
655
+ return {
656
+ ok: false,
657
+ error: "bodyPath needs something to patch: send fromRequestId (the captured request whose real response it addresses) or the id of an existing rule."
658
+ };
659
+ }
660
+ if (rawRule.bodyValue === undefined) {
661
+ return {
662
+ ok: false,
663
+ error: `Refused: sent \`bodyPath\` without \`bodyValue\`. Say what to set "${rawRule.bodyPath}" to.`
664
+ };
665
+ }
666
+ let parsed;
667
+ try {
668
+ parsed = JSON.parse(seed);
669
+ } catch {
670
+ return {
671
+ ok: false,
672
+ error: "The captured response body is not JSON, so it cannot be addressed by path. Send a whole `body` instead."
673
+ };
674
+ }
675
+ const built = (0, _sharedUi.patchFromPath)(parsed, rawRule.bodyPath, rawRule.bodyValue);
676
+ if (!built.ok) return {
677
+ ok: false,
678
+ error: `Refused: ${built.error}`
679
+ };
680
+ rawRule.bodyPatch = built.patch;
681
+ delete rawRule.bodyPath;
682
+ delete rawRule.bodyValue;
683
+ }
684
+ if (rawRule && isPlainObject(rawRule.bodyPatch) && rawRule.body == null) {
685
+ const seed = base?.body ?? (typeof rawRule.id === "string" ? _overrideRulesStore.overrideRulesStore.getState().rules.find(r => r.id === rawRule.id)?.body : undefined);
686
+ if (typeof seed !== "string") {
687
+ return {
688
+ ok: false,
689
+ error: "bodyPatch needs something to patch: send fromRequestId (the captured request whose real response the patch is merged into) or the id of an existing rule."
690
+ };
691
+ }
692
+ if (rawRule.force !== true) {
693
+ // Address list items in the real response by their own id rather than
694
+ // resending the array: {results:{pikachu:{name:"test123"}}}. Expanded
695
+ // against the seed BEFORE the guard and before the merge, so the
696
+ // typed-edit rule still holds over the result. This is the only form
697
+ // that is correct when the caller read the response through a wire
698
+ // that truncated it — see packages/shared/src/sync/keyedArrays.ts.
699
+ let keyedError;
700
+ try {
701
+ const expanded = (0, _sharedUi.expandKeyedArrays)(JSON.parse(seed), rawRule.bodyPatch);
702
+ keyedError = expanded.error;
703
+ if (expanded.notes.length) rawRule.bodyPatch = expanded.patch;
704
+ } catch {
705
+ /* a non-JSON seed has no lists to address; leave the patch alone */
706
+ }
707
+ // See keyedArrays.ts: an added item whose id disagrees with the key
708
+ // it was sent under is ambiguous, and only this layer can say so.
709
+ if (keyedError) {
710
+ return {
711
+ ok: false,
712
+ error: `Refused: ${keyedError}`
713
+ };
714
+ }
715
+ const violations = patchViolations(rawRule, seed);
716
+ if (violations.length > 0) {
717
+ return {
718
+ ok: false,
719
+ error: (0, _sharedUi.refusalText)(violations, "response body", (0, _sharedUi.suggestedPatch)(rawRule.bodyPatch, violations)),
720
+ violations
721
+ };
722
+ }
723
+ }
724
+ }
515
725
  const draft = readRuleDraft(params, base);
516
726
  if (!draft) {
517
727
  const wanted = params?.fromRequestId;
@@ -541,8 +751,19 @@ const networkSyncAdapter = exports.networkSyncAdapter = {
541
751
  enabled: rule.enabled,
542
752
  kind: rule.kind,
543
753
  status: rule.status,
754
+ times: rule.times,
544
755
  bodySize: rule.body ? rule.body.length : 0
545
756
  },
757
+ // A rule with no budget overrides EVERY matching request until someone
758
+ // deletes it, and that is rarely what a one-off ask meant. Measured on
759
+ // `api-sim/next-call-fails-once` ("just the next one, then let it work
760
+ // again"): ten of fourteen attempts left the budget off and got a rule
761
+ // that never expires. Saying so is not a nag — it is the difference
762
+ // between a caller that adds `times` on its next turn and one that walks
763
+ // away leaving the app permanently broken.
764
+ ...(rule.times === undefined || rule.times === null ? {
765
+ lingers: `This rule has no \`times\` budget, so it overrides EVERY matching request until it is disabled or deleted (id \`${rule.id}\`). If you meant only the next one, set times:1.`
766
+ } : {}),
546
767
  ...inactiveStamp()
547
768
  };
548
769
  },
@@ -81,6 +81,7 @@ function generateNetworkCopyText(events, copySettings) {
81
81
  obj.statusText = event.statusText;
82
82
  }
83
83
  if (copySettings.includeDuration) obj.duration = event.duration;
84
+ if (event.condition) obj.condition = event.condition;
84
85
  if (copySettings.includeTimestamp) obj.timestamp = event.timestamp;
85
86
  if (copySettings.includeClient) obj.requestClient = event.requestClient;
86
87
  if (copySettings.includeSizes) {
@@ -119,6 +120,7 @@ function generateNetworkCopyText(events, copySettings) {
119
120
  if (copySettings.includeClient && event.requestClient) {
120
121
  md += `**Client:** ${event.requestClient}\n`;
121
122
  }
123
+ if (event.condition) md += `Conditions: ${event.condition.profile} (+${event.condition.delayMs} ms)\n`;
122
124
  if (copySettings.includeDuration && event.duration) {
123
125
  md += `**Duration:** ${event.duration}ms\n`;
124
126
  }