@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
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.networkToolPreset = exports.createNetworkTool = void 0;
7
+ var _NetworkConditionsIcon = require("./network/conditions/NetworkConditionsIcon");
7
8
  var _sharedUi = require("@buoy-gg/shared-ui");
8
9
  var _NetworkModal = require("./network/components/NetworkModal");
9
10
  var _overrideRulesStore = require("./network/overrides/overrideRulesStore");
@@ -72,7 +73,7 @@ const networkToolPreset = exports.networkToolPreset = (0, _sharedUi.defineToolPr
72
73
  slot: "both",
73
74
  icon: ({
74
75
  size
75
- }) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.NetworkIcon, {
76
+ }) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_NetworkConditionsIcon.NetworkConditionsIcon, {
76
77
  size: size
77
78
  }),
78
79
  component: _NetworkModal.NetworkModal,
@@ -99,4 +99,5 @@ export { networkEventStore } from "./network/utils/networkEventStore";
99
99
  export { networkSavedStore, flagsForEventId, FREE_TIER_SAVED_LIMIT, MAX_PINNED, MAX_SAVED } from "./network/utils/networkSavedStore";
100
100
  export { generateCurl } from "./network/utils/generateCurl";
101
101
  /** @internal */
102
- export { networkListener, addNetworkListener, removeAllNetworkListeners } from "./network/utils/networkListener";
102
+ export { networkListener, addNetworkListener, removeAllNetworkListeners } from "./network/utils/networkListener";
103
+ export { NETWORK_CONDITIONS, getNetworkCondition, setNetworkCondition, subscribeNetworkConditions } from "./network/conditions/store";
@@ -78,4 +78,5 @@ export { ruleMatches } from "./network/overrides/matchRule";
78
78
  export { filterNetworkEvents, applyIgnoredPatterns } from "./network/utils/filterNetworkEvents";
79
79
  export { splitPinned } from "./network/utils/withPinnedFirst";
80
80
  export { extractOperationName } from "./network/utils/extractOperationName";
81
- export { generateCurl } from "./network/utils/generateCurl";
81
+ export { generateCurl } from "./network/utils/generateCurl";
82
+ export { NETWORK_CONDITIONS, getNetworkCondition, setNetworkCondition, subscribeNetworkConditions } from "./network/conditions/store";
@@ -446,7 +446,12 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
446
446
  style: styles.pendingBadgeText,
447
447
  children: "Pending"
448
448
  })]
449
- }) : null, event.duration ? /*#__PURE__*/_jsxs(View, {
449
+ }) : null, event.condition && /*#__PURE__*/_jsxs(Text, {
450
+ style: {
451
+ color: "#FFCC66"
452
+ },
453
+ children: ["Conditions: ", event.condition.profile, " (+", event.condition.delayMs, " ms)"]
454
+ }), event.duration ? /*#__PURE__*/_jsxs(View, {
450
455
  style: styles.httpDuration,
451
456
  children: [/*#__PURE__*/_jsx(Clock, {
452
457
  size: 10,
@@ -76,11 +76,15 @@ export function NetworkHeaderMenuItem({
76
76
  icon,
77
77
  label,
78
78
  detail,
79
+ testID,
79
80
  onPress
80
81
  }) {
81
82
  return /*#__PURE__*/_jsxs(TouchableOpacity, {
82
83
  onPress: onPress,
83
84
  "sentry-label": "ignore header menu item",
85
+ testID: testID,
86
+ accessibilityRole: "button",
87
+ accessibilityLabel: label,
84
88
  style: styles.item,
85
89
  children: [/*#__PURE__*/_jsx(View, {
86
90
  style: styles.itemIcon,
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
 
3
+ import { createRemoteConditionsSource, openNetworkFloatingTool } from "../conditions/NetworkFloatingTool";
3
4
  import { useState, useRef, useEffect, useCallback } from "react";
4
5
  import { View, StyleSheet, TouchableOpacity } from "react-native";
5
- import { Search, Filter, Bookmark, JsModal, ModalHeader, devToolsStorageKeys, macOSColors, night, persistentStorage, NightSegmented, useFeatureGate, UpgradeModal, PowerToggleButton, useIgnoredPatterns, windowDragProps } from "@buoy-gg/shared-ui";
6
+ import { Search, Wifi, Filter, Bookmark, JsModal, ModalHeader, devToolsStorageKeys, macOSColors, night, persistentStorage, NightSegmented, useFeatureGate, UpgradeModal, PowerToggleButton, useIgnoredPatterns, windowDragProps } from "@buoy-gg/shared-ui";
6
7
  import { NetworkFilterViewV3 } from "./NetworkFilterViewV3";
7
8
  import { TickProvider } from "../hooks/useTickEveryMinute";
8
9
  import { NetworkEventDetailView } from "./NetworkEventDetailView";
@@ -178,7 +179,8 @@ function NetworkModalInner({
178
179
  enableSharedModalDimensions = false,
179
180
  inline = false,
180
181
  paused: pausedProp,
181
- onPausedChange
182
+ onPausedChange,
183
+ remoteConditions
182
184
  }) {
183
185
  const {
184
186
  isPro
@@ -208,6 +210,12 @@ function NetworkModalInner({
208
210
  const [showSavedView, setShowSavedView] = useState(false);
209
211
  const [showOverridesView, setShowOverridesView] = useState(false);
210
212
  const [showHeaderMenu, setShowHeaderMenu] = useState(false);
213
+ const [minimizeRequest, setMinimizeRequest] = useState(0);
214
+ const [remoteConditionsSource] = useState(createRemoteConditionsSource);
215
+ useEffect(() => {
216
+ remoteConditionsSource.update(remoteConditions);
217
+ }, [remoteConditionsSource, remoteConditions]);
218
+ useEffect(() => () => remoteConditionsSource.update(undefined), [remoteConditionsSource]);
211
219
  /**
212
220
  * Overrides are a DEVICE capability — a rule runs inside the app's own
213
221
  * `fetch`/`XMLHttpRequest`, which the dashboard doesn't have.
@@ -488,7 +496,7 @@ function NetworkModalInner({
488
496
  }) : null, /*#__PURE__*/_jsx(PowerToggleButton, {
489
497
  isEnabled: !paused,
490
498
  onToggle: () => setPaused(prev => !prev),
491
- accessibilityLabel: "Toggle network interception",
499
+ accessibilityLabel: "Pause network list",
492
500
  testID: "buoy-network-power"
493
501
  }), /*#__PURE__*/_jsx(NetworkClearButton, {
494
502
  paused: paused
@@ -586,6 +594,18 @@ function NetworkModalInner({
586
594
  }), showHeaderMenu ? /*#__PURE__*/_jsxs(NetworkHeaderMenu, {
587
595
  onClose: () => setShowHeaderMenu(false),
588
596
  children: [/*#__PURE__*/_jsx(NetworkHeaderMenuItem, {
597
+ icon: /*#__PURE__*/_jsx(Wifi, {
598
+ size: 14,
599
+ color: night.color.textSecondary
600
+ }),
601
+ label: "Network throttling",
602
+ testID: "buoy-network-menu-throttling",
603
+ onPress: () => {
604
+ setShowHeaderMenu(false);
605
+ openNetworkFloatingTool(remoteConditions ? remoteConditionsSource : undefined);
606
+ if (!inline) setMinimizeRequest(value => value + 1);
607
+ }
608
+ }), /*#__PURE__*/_jsx(NetworkHeaderMenuItem, {
589
609
  icon: /*#__PURE__*/_jsx(Filter, {
590
610
  size: 14,
591
611
  color: hasActiveFilter ? night.color.accent : night.color.textSecondary
@@ -624,12 +644,7 @@ function NetworkModalInner({
624
644
  })]
625
645
  }) : null, /*#__PURE__*/_jsx(UpgradeModal, {
626
646
  visible: showUpgradeModal,
627
- onClose: () => setShowUpgradeModal(false)
628
- // "saved" is a count limit a free key can raise; "overrides" is one it
629
- // can't (1 on both tiers) and "export" is Pro-only — freeKeyRaises
630
- // sorts those out, so the mapping here can stay literal.
631
- ,
632
- limitKey: upgradeReason === "saved" ? "network.saved" : upgradeReason === "overrides" ? "network.overrideRules" : undefined,
647
+ onClose: () => setShowUpgradeModal(false),
633
648
  featureName: upgradeReason === "saved" ? "Saved Requests" : upgradeReason === "overrides" ? "Response Overrides" : "Network Export",
634
649
  description: upgradeReason === "saved" ? "Keep the requests that matter. Saved requests survive Clear, app restarts, and the 500-request history cap." : upgradeReason === "overrides" ? "Force any endpoint to fail, stall or return something else — no backend changes, no code changes. Free keeps one active rule." : "Export network requests to share with your team, debug APIs, or feed into AI tools.",
635
650
  benefits: upgradeReason === "overrides" ? [`Run ${MAX_OVERRIDE_RULES} rules at once (free keeps ${FREE_TIER_OVERRIDE_LIMIT})`, "Force a 500, a 401 or an empty list in one tap", "Simulate offline, timeouts and slow connections", "Rules survive an app reload, so you can test the boot path"] : upgradeReason === "saved" ? [`Save up to ${MAX_SAVED} requests (free keeps ${FREE_TIER_SAVED_LIMIT})`, "Failed requests stay put through a Clear", "Snapshots survive an app restart", "Export saved requests to teammates or AI assistants"] : ["Export to JSON, Markdown, or plain text", "Customizable copy presets (URLs only, LLM-ready, full data)", "Share request details with teammates", "Feed network data directly to AI assistants"]
@@ -652,6 +667,7 @@ function NetworkModalInner({
652
667
  visible: visible,
653
668
  onClose: onClose,
654
669
  onMinimize: onMinimize,
670
+ minimizeRequest: minimizeRequest,
655
671
  persistenceKey: persistenceKey,
656
672
  variant: "night",
657
673
  header: {
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native";
5
+ import { ChevronDown, Check, night } from "@buoy-gg/shared-ui";
6
+ import { conditionsCanApply, getNetworkCondition, setNetworkCondition, subscribeNetworkConditions } from "./store";
7
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ const options = [{
9
+ profile: "normal",
10
+ label: "No throttling"
11
+ }, {
12
+ profile: "slow",
13
+ label: "Slow"
14
+ }, {
15
+ profile: "verySlow",
16
+ label: "Very slow"
17
+ }, {
18
+ profile: "offline",
19
+ label: "Offline"
20
+ }];
21
+ const summaries = {
22
+ normal: "No added delay",
23
+ slow: "+500 ms before each new request",
24
+ verySlow: "+2 seconds before each new request",
25
+ offline: "New requests fail before sending"
26
+ };
27
+ export function NetworkConditionsControl({
28
+ remote
29
+ }) {
30
+ const local = useSyncExternalStore(subscribeNetworkConditions, getNetworkCondition, getNetworkCondition);
31
+ const localAvailable = useSyncExternalStore(subscribeNetworkConditions, conditionsCanApply, conditionsCanApply);
32
+ const [expanded, setExpanded] = useState(false);
33
+ const [error, setError] = useState();
34
+ const [pending, setPending] = useState(false);
35
+ const busy = useRef(false);
36
+ const mounted = useRef(true);
37
+ useEffect(() => {
38
+ mounted.current = true;
39
+ return () => {
40
+ mounted.current = false;
41
+ };
42
+ }, []);
43
+ const profile = remote ? remote.profile : local;
44
+ const available = remote ? remote.available : localAvailable;
45
+ const active = profile !== null && profile !== "normal";
46
+ const tint = profile === "offline" ? night.color.danger : active ? night.color.warning : night.color.accent;
47
+ async function select(value) {
48
+ if (!available || busy.current) return;
49
+ if (value === profile) {
50
+ setExpanded(false);
51
+ return;
52
+ }
53
+ busy.current = true;
54
+ setPending(true);
55
+ setError(undefined);
56
+ try {
57
+ if (remote) await remote.onChange(value);else setNetworkCondition(value);
58
+ if (mounted.current) setExpanded(false);
59
+ } catch (failure) {
60
+ if (mounted.current) setError(failure instanceof Error ? failure.message : String(failure));
61
+ } finally {
62
+ busy.current = false;
63
+ if (mounted.current) setPending(false);
64
+ }
65
+ }
66
+ const label = options.find(option => option.profile === profile)?.label ?? "Unavailable";
67
+ return /*#__PURE__*/_jsxs(View, {
68
+ style: styles.container,
69
+ testID: "buoy-network-throttling-screen",
70
+ children: [/*#__PURE__*/_jsx(Text, {
71
+ style: styles.heading,
72
+ children: "Connection profile"
73
+ }), /*#__PURE__*/_jsxs(TouchableOpacity, {
74
+ testID: "buoy-network-conditions",
75
+ accessibilityRole: "button",
76
+ accessibilityLabel: `Network throttling: ${label}`,
77
+ accessibilityState: {
78
+ expanded,
79
+ disabled: !available || pending
80
+ },
81
+ disabled: !available || pending,
82
+ onPress: () => setExpanded(value => !value),
83
+ style: [styles.dropdown, expanded && styles.dropdownOpen, !available && styles.disabled],
84
+ children: [/*#__PURE__*/_jsx(Text, {
85
+ style: [styles.value, active && {
86
+ color: tint
87
+ }],
88
+ children: label
89
+ }), pending ? /*#__PURE__*/_jsx(ActivityIndicator, {
90
+ size: "small",
91
+ color: night.color.textSecondary
92
+ }) : /*#__PURE__*/_jsx(ChevronDown, {
93
+ size: 16,
94
+ color: night.color.textSecondary
95
+ })]
96
+ }), expanded && /*#__PURE__*/_jsx(View, {
97
+ accessibilityRole: "radiogroup",
98
+ accessibilityLabel: "Connection profile",
99
+ style: styles.options,
100
+ children: options.map(option => {
101
+ const selected = profile === option.profile;
102
+ return /*#__PURE__*/_jsxs(TouchableOpacity, {
103
+ testID: `buoy-network-condition-${option.profile}`,
104
+ accessibilityRole: "radio",
105
+ accessibilityLabel: option.label,
106
+ accessibilityState: {
107
+ checked: selected,
108
+ disabled: !available || pending
109
+ },
110
+ disabled: !available || pending,
111
+ onPress: () => void select(option.profile),
112
+ style: [styles.option, selected && styles.selected],
113
+ children: [/*#__PURE__*/_jsxs(View, {
114
+ style: styles.optionBody,
115
+ children: [/*#__PURE__*/_jsx(Text, {
116
+ style: [styles.optionTitle, selected && {
117
+ color: tint
118
+ }],
119
+ children: option.label
120
+ }), /*#__PURE__*/_jsx(Text, {
121
+ style: styles.caption,
122
+ children: summaries[option.profile]
123
+ })]
124
+ }), selected && /*#__PURE__*/_jsx(Check, {
125
+ size: 16,
126
+ color: tint
127
+ })]
128
+ }, option.profile);
129
+ })
130
+ }), /*#__PURE__*/_jsx(Text, {
131
+ style: styles.caption,
132
+ accessibilityLiveRegion: "polite",
133
+ children: pending ? "Applying to device…" : !available ? remote ? "Conditions unavailable on this device" : "Requires a signed-in account and a development build" : profile ? summaries[profile] : "Waiting for device status"
134
+ }), !!error && /*#__PURE__*/_jsx(Text, {
135
+ accessibilityRole: "alert",
136
+ style: styles.error,
137
+ children: error
138
+ }), /*#__PURE__*/_jsxs(View, {
139
+ style: styles.help,
140
+ children: [/*#__PURE__*/_jsx(Text, {
141
+ style: styles.heading,
142
+ children: "How it works"
143
+ }), /*#__PURE__*/_jsx(Text, {
144
+ style: styles.caption,
145
+ children: "Affects new HTTP requests through global fetch and XHR. Slow adds a wait before sending; download speed stays unchanged."
146
+ }), /*#__PURE__*/_jsx(Text, {
147
+ style: styles.caption,
148
+ children: "Stays active when you close this tool or pause capture. Choose No throttling to stop. Reloading the app resets it."
149
+ })]
150
+ })]
151
+ });
152
+ }
153
+ const styles = StyleSheet.create({
154
+ container: {
155
+ padding: night.gutter,
156
+ gap: 10
157
+ },
158
+ heading: {
159
+ fontSize: night.font.caption,
160
+ fontWeight: "600",
161
+ color: night.color.textSecondary
162
+ },
163
+ dropdown: {
164
+ flexDirection: "row",
165
+ alignItems: "center",
166
+ justifyContent: "space-between",
167
+ gap: 12,
168
+ minHeight: 44,
169
+ paddingHorizontal: 12,
170
+ paddingVertical: 10,
171
+ borderRadius: night.radius.segment,
172
+ borderWidth: 1,
173
+ borderColor: night.color.separator,
174
+ backgroundColor: night.color.surfaceElevated
175
+ },
176
+ dropdownOpen: {
177
+ borderColor: night.color.accentBorderStrong
178
+ },
179
+ value: {
180
+ flex: 1,
181
+ fontSize: night.font.body,
182
+ color: night.color.text
183
+ },
184
+ options: {
185
+ padding: 4,
186
+ borderRadius: night.radius.row,
187
+ borderWidth: 1,
188
+ borderColor: night.color.border,
189
+ backgroundColor: night.color.surface
190
+ },
191
+ option: {
192
+ flexDirection: "row",
193
+ alignItems: "center",
194
+ gap: 12,
195
+ minHeight: 54,
196
+ padding: 10,
197
+ borderRadius: night.radius.segment
198
+ },
199
+ optionBody: {
200
+ flex: 1,
201
+ gap: 4
202
+ },
203
+ optionTitle: {
204
+ fontSize: night.font.caption,
205
+ fontWeight: "600",
206
+ color: night.color.text
207
+ },
208
+ selected: {
209
+ backgroundColor: night.color.surfaceElevated
210
+ },
211
+ disabled: {
212
+ opacity: 0.45
213
+ },
214
+ caption: {
215
+ fontSize: night.font.caption,
216
+ lineHeight: 19,
217
+ color: night.color.textSecondary
218
+ },
219
+ help: {
220
+ marginTop: 14,
221
+ paddingTop: 16,
222
+ gap: 10,
223
+ borderTopWidth: 1,
224
+ borderTopColor: night.color.separator
225
+ },
226
+ error: {
227
+ fontSize: night.font.caption,
228
+ lineHeight: 18,
229
+ color: night.color.danger
230
+ }
231
+ });
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+
3
+ // @vitest-environment jsdom
4
+ import { act, createElement } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
7
+ import { NetworkConditionsControl } from "./NetworkConditionsControl";
8
+ vi.mock("@buoy-gg/shared-ui", async () => ({
9
+ ChevronDown: () => null,
10
+ Check: () => null,
11
+ night: (await vi.importActual("../../../../shared/src/ui/night/nightTheme")).night
12
+ }));
13
+ vi.mock("react-native", () => ({
14
+ StyleSheet: {
15
+ create: value => value
16
+ },
17
+ View: ({
18
+ children
19
+ }) => /*#__PURE__*/createElement("div", null, children),
20
+ Text: ({
21
+ children
22
+ }) => /*#__PURE__*/createElement("span", null, children),
23
+ ActivityIndicator: () => /*#__PURE__*/createElement("span", null, "pending"),
24
+ TouchableOpacity: ({
25
+ children,
26
+ onPress,
27
+ disabled,
28
+ accessibilityLabel,
29
+ accessibilityState,
30
+ testID
31
+ }) => /*#__PURE__*/createElement("button", {
32
+ onClick: onPress,
33
+ disabled,
34
+ "aria-label": accessibilityLabel,
35
+ "aria-checked": accessibilityState?.checked,
36
+ "data-testid": testID
37
+ }, children)
38
+ }));
39
+ vi.mock("./store", () => ({
40
+ NETWORK_CONDITIONS: {
41
+ normal: {
42
+ label: "Normal"
43
+ },
44
+ slow: {
45
+ label: "Slow"
46
+ },
47
+ verySlow: {
48
+ label: "Very slow"
49
+ },
50
+ offline: {
51
+ label: "Offline"
52
+ }
53
+ },
54
+ getNetworkCondition: () => "normal",
55
+ conditionsCanApply: () => true,
56
+ subscribeNetworkConditions: () => () => {},
57
+ setNetworkCondition: vi.fn()
58
+ }));
59
+ let root;
60
+ let container;
61
+ beforeEach(() => {
62
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
63
+ container = document.createElement("div");
64
+ root = createRoot(container);
65
+ });
66
+ afterEach(() => act(() => root.unmount()));
67
+ const button = profile => container.querySelector(`[data-testid="buoy-network-condition-${profile}"]`);
68
+ function render(remote) {
69
+ act(() => root.render(/*#__PURE__*/createElement(NetworkConditionsControl, {
70
+ remote
71
+ })));
72
+ }
73
+ const dropdown = () => container.querySelector('[data-testid="buoy-network-conditions"]');
74
+ const open = () => act(() => dropdown().click());
75
+ it("defaults to a closed No throttling dropdown and closes when the current option is chosen", () => {
76
+ const onChange = vi.fn();
77
+ render({
78
+ profile: "normal",
79
+ available: true,
80
+ onChange
81
+ });
82
+ expect(dropdown().textContent).toBe("No throttling");
83
+ expect(button("normal")).toBeNull();
84
+ open();
85
+ expect(["normal", "slow", "verySlow", "offline"].every(value => !!button(value))).toBe(true);
86
+ act(() => button("normal").click());
87
+ expect(button("normal")).toBeNull();
88
+ expect(onChange).not.toHaveBeenCalled();
89
+ });
90
+ it("blocks duplicate remote changes and waits for the device profile before selecting it", async () => {
91
+ let finish;
92
+ const onChange = vi.fn(() => new Promise(resolve => {
93
+ finish = resolve;
94
+ }));
95
+ render({
96
+ profile: "normal",
97
+ available: true,
98
+ onChange
99
+ });
100
+ open();
101
+ act(() => button("slow").click());
102
+ expect(button("offline").disabled).toBe(true);
103
+ expect(button("normal").getAttribute("aria-checked")).toBe("true");
104
+ act(() => button("offline").click());
105
+ expect(onChange).toHaveBeenCalledExactlyOnceWith("slow");
106
+ await act(async () => finish());
107
+ render({
108
+ profile: "slow",
109
+ available: true,
110
+ onChange
111
+ });
112
+ expect(dropdown().textContent).toBe("Slow");
113
+ expect(button("slow")).toBeNull();
114
+ open();
115
+ expect(button("slow").getAttribute("aria-checked")).toBe("true");
116
+ });
117
+ it("reports a remote error, permits retry, and disables unavailable devices", async () => {
118
+ const onChange = vi.fn().mockRejectedValue(new Error("Device disconnected"));
119
+ render({
120
+ profile: "normal",
121
+ available: true,
122
+ onChange
123
+ });
124
+ open();
125
+ await act(async () => button("offline").click());
126
+ expect(container.textContent).toContain("Device disconnected");
127
+ expect(button("offline").disabled).toBe(false);
128
+ render({
129
+ profile: null,
130
+ available: false,
131
+ onChange
132
+ });
133
+ expect(button("normal").disabled).toBe(true);
134
+ expect(container.textContent).toContain("Conditions unavailable on this device");
135
+ });
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+ import { Text, View } from "react-native";
5
+ import { NetworkIcon } from "@buoy-gg/shared-ui";
6
+ import { getNetworkCondition, subscribeNetworkConditions, NETWORK_CONDITIONS } from "./store";
7
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
+ export function NetworkConditionsIcon({
9
+ size
10
+ }) {
11
+ const profile = useSyncExternalStore(subscribeNetworkConditions, getNetworkCondition, getNetworkCondition);
12
+ return /*#__PURE__*/_jsxs(View, {
13
+ accessibilityLabel: profile === "normal" ? "Network" : `Network: ${NETWORK_CONDITIONS[profile].label}`,
14
+ children: [/*#__PURE__*/_jsx(NetworkIcon, {
15
+ size: size
16
+ }), profile !== "normal" && /*#__PURE__*/_jsx(Text, {
17
+ testID: "buoy-network-condition-indicator",
18
+ style: {
19
+ position: "absolute",
20
+ top: -6,
21
+ right: -8,
22
+ color: "#FFCC66",
23
+ fontSize: 10,
24
+ fontWeight: "700"
25
+ },
26
+ children: profile === "offline" ? "OFF" : `+${NETWORK_CONDITIONS[profile].delayMs}`
27
+ })]
28
+ });
29
+ }