@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,304 @@
1
+ "use strict";
2
+
3
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
4
+ import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View, useWindowDimensions } from "react-native";
5
+ import { FloatingTool, night, openFloatingTool, persistentStorage, registerPersistentFloatingTool } 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
+ /** A live source keeps the desktop strip current after its launching panel closes. */
9
+ export function createRemoteConditionsSource() {
10
+ let value;
11
+ const listeners = new Set();
12
+ return {
13
+ getSnapshot: () => value,
14
+ subscribe: listener => {
15
+ listeners.add(listener);
16
+ return () => {
17
+ listeners.delete(listener);
18
+ };
19
+ },
20
+ update(next) {
21
+ value = next;
22
+ listeners.forEach(listener => listener());
23
+ }
24
+ };
25
+ }
26
+ const noSubscribe = () => () => {};
27
+ const noRemote = () => undefined;
28
+ const options = [{
29
+ id: "normal",
30
+ label: "No throttling",
31
+ detail: "No added delay"
32
+ }, {
33
+ id: "slow",
34
+ label: "Slow",
35
+ detail: "+500 ms before sending"
36
+ }, {
37
+ id: "verySlow",
38
+ label: "Very slow",
39
+ detail: "+2 seconds before sending"
40
+ }, {
41
+ id: "offline",
42
+ label: "Offline",
43
+ detail: "New requests fail"
44
+ }];
45
+ const localRegistration = {
46
+ id: "network-throttling",
47
+ render: context => /*#__PURE__*/_jsx(NetworkFloatingTool, {
48
+ ...context
49
+ })
50
+ };
51
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof navigator !== "undefined" && navigator.product === "ReactNative") {
52
+ registerPersistentFloatingTool(localRegistration, persistentStorage);
53
+ }
54
+ export function openNetworkFloatingTool(source) {
55
+ openFloatingTool({
56
+ id: "network-throttling",
57
+ render: context => /*#__PURE__*/_jsx(NetworkFloatingTool, {
58
+ ...context,
59
+ source: source
60
+ })
61
+ });
62
+ }
63
+ export function NetworkFloatingTool({
64
+ id,
65
+ revealToken,
66
+ dismiss,
67
+ source
68
+ }) {
69
+ const local = useSyncExternalStore(subscribeNetworkConditions, getNetworkCondition, getNetworkCondition);
70
+ const localAvailable = useSyncExternalStore(subscribeNetworkConditions, conditionsCanApply, conditionsCanApply);
71
+ const remote = useSyncExternalStore(source?.subscribe ?? noSubscribe, source?.getSnapshot ?? noRemote, noRemote);
72
+ const profile = source ? remote?.profile ?? null : local;
73
+ const available = source ? remote?.available === true : localAvailable;
74
+ const [pending, setPending] = useState(false);
75
+ const [error, setError] = useState();
76
+ const busy = useRef(false);
77
+ const operation = useRef(0);
78
+ const mounted = useRef(true);
79
+ const {
80
+ width
81
+ } = useWindowDimensions();
82
+ useEffect(() => {
83
+ mounted.current = true;
84
+ return () => {
85
+ mounted.current = false;
86
+ };
87
+ }, []);
88
+ useEffect(() => {
89
+ operation.current += 1;
90
+ busy.current = false;
91
+ setPending(false);
92
+ setError(undefined);
93
+ }, [source, remote?.targetId]);
94
+ const active = profile !== null && profile !== "normal";
95
+ const selected = profile ?? "normal";
96
+ const choice = options.find(option => option.id === selected);
97
+ const next = options[(options.findIndex(option => option.id === selected) + 1) % options.length];
98
+ const bars = !available || selected === "offline" ? 0 : selected === "verySlow" ? 1 : selected === "slow" ? 2 : 4;
99
+ const detail = pending ? "Applying…" : !available ? "Unavailable" : selected === "normal" ? "0 ms" : selected === "slow" ? "+500 ms" : selected === "verySlow" ? "+2000 ms" : "Blocked";
100
+ async function apply(next, close = false) {
101
+ if (busy.current) return;
102
+ const currentOperation = ++operation.current;
103
+ busy.current = true;
104
+ setPending(true);
105
+ setError(undefined);
106
+ const startingRemote = source?.getSnapshot();
107
+ try {
108
+ if (source) {
109
+ if (!startingRemote?.available) throw new Error("Device unavailable. Open Network again to reconnect these controls.");
110
+ await startingRemote.onChange(next);
111
+ } else setNetworkCondition(next);
112
+ if (mounted.current && operation.current === currentOperation) {
113
+ if (close) dismiss();
114
+ }
115
+ } catch (failure) {
116
+ if (mounted.current && operation.current === currentOperation) setError(failure instanceof Error ? failure.message : String(failure));
117
+ } finally {
118
+ if (operation.current === currentOperation) {
119
+ busy.current = false;
120
+ if (mounted.current) setPending(false);
121
+ }
122
+ }
123
+ }
124
+ const accessory = error ? /*#__PURE__*/_jsx(View, {
125
+ style: styles.menu,
126
+ children: /*#__PURE__*/_jsx(Text, {
127
+ accessibilityRole: "alert",
128
+ style: styles.error,
129
+ children: error
130
+ })
131
+ }) : undefined;
132
+ return /*#__PURE__*/_jsx(FloatingTool, {
133
+ id: id,
134
+ revealToken: revealToken,
135
+ variant: "strip",
136
+ dragFromSurface: true,
137
+ accessibilityLabel: `Move or hide network throttling. ${active ? "Active" : "Off"}`,
138
+ accessory: accessory,
139
+ children: /*#__PURE__*/_jsxs(View, {
140
+ style: [styles.row, {
141
+ width: Math.max(220, Math.min(292, width - 64))
142
+ }],
143
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
144
+ testID: "throttling-cycle",
145
+ accessibilityRole: "button",
146
+ accessibilityLabel: `Network: ${choice.label}. Switch to ${next.label}`,
147
+ accessibilityState: {
148
+ disabled: pending || !available
149
+ },
150
+ disabled: pending || !available,
151
+ onPress: () => void apply(next.id),
152
+ style: styles.signalButton,
153
+ children: pending ? /*#__PURE__*/_jsx(ActivityIndicator, {
154
+ size: "small",
155
+ color: night.color.accent
156
+ }) : /*#__PURE__*/_jsxs(View, {
157
+ testID: `throttling-signal-${selected}`,
158
+ style: styles.signal,
159
+ children: [[7, 12, 18, 24].map((height, index) => /*#__PURE__*/_jsx(View, {
160
+ style: {
161
+ width: 4,
162
+ height,
163
+ borderRadius: 2,
164
+ backgroundColor: index < bars ? selected === "offline" ? night.color.danger : night.color.accent : night.color.fillTertiary
165
+ }
166
+ }, height)), selected === "offline" && /*#__PURE__*/_jsx(View, {
167
+ style: styles.slash
168
+ })]
169
+ })
170
+ }), /*#__PURE__*/_jsx(View, {
171
+ style: styles.profile,
172
+ children: /*#__PURE__*/_jsx(Text, {
173
+ testID: "throttling-profile",
174
+ accessibilityLabel: `Connection profile: ${choice.label}`,
175
+ accessibilityLiveRegion: "polite",
176
+ numberOfLines: 1,
177
+ style: styles.profileLabel,
178
+ children: choice.label
179
+ })
180
+ }), /*#__PURE__*/_jsx(Text, {
181
+ testID: "throttling-status",
182
+ numberOfLines: 1,
183
+ accessibilityLiveRegion: "polite",
184
+ style: styles.status,
185
+ children: detail
186
+ }), /*#__PURE__*/_jsx(View, {
187
+ style: styles.separator
188
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
189
+ testID: "throttling-close",
190
+ accessibilityRole: "button",
191
+ accessibilityLabel: "Close and turn off throttling",
192
+ disabled: pending,
193
+ accessibilityState: {
194
+ disabled: pending
195
+ },
196
+ onPress: () => {
197
+ if (profile === "normal") dismiss();else void apply("normal", true);
198
+ },
199
+ style: [styles.action, pending && styles.disabled],
200
+ children: /*#__PURE__*/_jsx(Text, {
201
+ style: styles.actionText,
202
+ children: "Close"
203
+ })
204
+ })]
205
+ })
206
+ });
207
+ }
208
+ const styles = StyleSheet.create({
209
+ row: {
210
+ flexDirection: "row",
211
+ alignItems: "center",
212
+ gap: 6,
213
+ paddingVertical: 4,
214
+ minHeight: 48
215
+ },
216
+ signalButton: {
217
+ width: 36,
218
+ minHeight: 44,
219
+ alignItems: "center",
220
+ justifyContent: "center"
221
+ },
222
+ signal: {
223
+ flexDirection: "row",
224
+ alignItems: "flex-end",
225
+ gap: 3,
226
+ height: 24
227
+ },
228
+ slash: {
229
+ position: "absolute",
230
+ width: 30,
231
+ height: 2,
232
+ backgroundColor: night.color.danger,
233
+ top: 11,
234
+ left: -2,
235
+ transform: [{
236
+ rotate: "-45deg"
237
+ }]
238
+ },
239
+ separator: {
240
+ height: 20,
241
+ width: 1,
242
+ backgroundColor: night.color.separator,
243
+ marginHorizontal: 2
244
+ },
245
+ status: {
246
+ color: night.color.textSecondary,
247
+ fontSize: 10,
248
+ lineHeight: 15,
249
+ flexShrink: 1
250
+ },
251
+ profile: {
252
+ flex: 1,
253
+ flexDirection: "row",
254
+ alignItems: "center",
255
+ gap: 6,
256
+ minHeight: 44
257
+ },
258
+ profileLabel: {
259
+ color: night.color.text,
260
+ fontSize: 13,
261
+ fontWeight: "500",
262
+ flexShrink: 1
263
+ },
264
+ action: {
265
+ minHeight: 36,
266
+ minWidth: 52,
267
+ borderRadius: night.radius.input,
268
+ borderWidth: 1,
269
+ borderColor: night.color.accentBorderStrong,
270
+ alignItems: "center",
271
+ justifyContent: "center",
272
+ paddingHorizontal: 10
273
+ },
274
+ actionText: {
275
+ color: night.color.accent,
276
+ fontSize: 12,
277
+ fontWeight: "600"
278
+ },
279
+ disabled: {
280
+ opacity: 0.4
281
+ },
282
+ menu: {
283
+ marginVertical: 8,
284
+ padding: 6,
285
+ borderRadius: night.radius.card,
286
+ borderWidth: 1,
287
+ borderColor: night.color.separator,
288
+ backgroundColor: night.color.surface,
289
+ shadowColor: "#000",
290
+ shadowOffset: {
291
+ width: 0,
292
+ height: 6
293
+ },
294
+ shadowOpacity: 0.25,
295
+ shadowRadius: 16,
296
+ elevation: 8
297
+ },
298
+ error: {
299
+ color: night.color.danger,
300
+ fontSize: 12,
301
+ lineHeight: 18,
302
+ padding: 10
303
+ }
304
+ });
@@ -0,0 +1,218 @@
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 { NetworkFloatingTool, createRemoteConditionsSource } from "./NetworkFloatingTool";
8
+ const state = vi.hoisted(() => ({
9
+ profile: "normal",
10
+ available: true,
11
+ listeners: new Set(),
12
+ set: vi.fn()
13
+ }));
14
+ vi.mock("./store", () => ({
15
+ getNetworkCondition: () => state.profile,
16
+ conditionsCanApply: () => state.available,
17
+ subscribeNetworkConditions: listener => {
18
+ state.listeners.add(listener);
19
+ return () => state.listeners.delete(listener);
20
+ },
21
+ setNetworkCondition: profile => {
22
+ state.set(profile);
23
+ state.profile = profile;
24
+ state.listeners.forEach(listener => listener());
25
+ }
26
+ }));
27
+ vi.mock("@buoy-gg/shared-ui", async () => ({
28
+ night: (await vi.importActual("../../../../shared/src/ui/night/nightTheme")).night,
29
+ ChevronDown: () => null,
30
+ Check: () => null,
31
+ openFloatingTool: vi.fn(),
32
+ FloatingTool: ({
33
+ children,
34
+ accessory
35
+ }) => /*#__PURE__*/createElement("div", null, children, accessory)
36
+ }));
37
+ vi.mock("react-native", () => ({
38
+ StyleSheet: {
39
+ create: value => value
40
+ },
41
+ useWindowDimensions: () => ({
42
+ width: 390,
43
+ height: 844
44
+ }),
45
+ View: ({
46
+ children,
47
+ testID
48
+ }) => /*#__PURE__*/createElement("div", {
49
+ "data-testid": testID
50
+ }, children),
51
+ Text: ({
52
+ children,
53
+ testID
54
+ }) => /*#__PURE__*/createElement("span", {
55
+ "data-testid": testID
56
+ }, children),
57
+ ActivityIndicator: () => null,
58
+ TouchableOpacity: ({
59
+ children,
60
+ onPress,
61
+ disabled,
62
+ testID,
63
+ accessibilityLabel
64
+ }) => /*#__PURE__*/createElement("button", {
65
+ onClick: onPress,
66
+ disabled,
67
+ "data-testid": testID,
68
+ "aria-label": accessibilityLabel
69
+ }, children)
70
+ }));
71
+ let root;
72
+ let container;
73
+ const dismiss = vi.fn();
74
+ beforeEach(() => {
75
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
76
+ state.profile = "normal";
77
+ state.available = true;
78
+ state.set.mockClear();
79
+ dismiss.mockClear();
80
+ container = document.createElement("div");
81
+ root = createRoot(container);
82
+ });
83
+ afterEach(() => act(() => root.unmount()));
84
+ const button = id => container.querySelector(`[data-testid="${id}"]`);
85
+ const tap = async id => act(async () => button(id).click());
86
+ const status = () => container.querySelector('[data-testid="throttling-status"]')?.textContent;
87
+ function render(source) {
88
+ act(() => root.render(/*#__PURE__*/createElement(NetworkFloatingTool, {
89
+ id: "network-throttling",
90
+ revealToken: 1,
91
+ dismiss,
92
+ source
93
+ })));
94
+ }
95
+ async function choose(value) {
96
+ const order = ["normal", "slow", "verySlow", "offline"];
97
+ const count = (order.indexOf(value) - order.indexOf(state.profile) + order.length) % order.length;
98
+ for (let i = 0; i < count; i++) await tap("throttling-cycle");
99
+ }
100
+ it("cycles and applies every profile immediately, including wrapping to normal", async () => {
101
+ render();
102
+ for (const [profile, label] of [["slow", "+500 ms"], ["verySlow", "+2000 ms"], ["offline", "Blocked"], ["normal", "0 ms"]]) {
103
+ await tap("throttling-cycle");
104
+ expect(state.profile).toBe(profile);
105
+ expect(container.querySelector(`[data-testid="throttling-signal-${profile}"]`)).not.toBeNull();
106
+ expect(status()).toBe(label);
107
+ }
108
+ expect(state.set).toHaveBeenCalledTimes(4);
109
+ });
110
+ it("keeps the profile label informational when tapped", async () => {
111
+ render();
112
+ await tap("throttling-profile");
113
+ expect(state.set).not.toHaveBeenCalled();
114
+ expect(state.profile).toBe("normal");
115
+ expect(button("throttling-profile").closest("button")).toBeNull();
116
+ });
117
+ it("changes active profiles and stops before dismissal", async () => {
118
+ state.profile = "slow";
119
+ render();
120
+ await choose("offline");
121
+ expect(state.profile).toBe("offline");
122
+ await tap("throttling-close");
123
+ expect(state.profile).toBe("normal");
124
+ expect(dismiss).toHaveBeenCalledOnce();
125
+ });
126
+ it("reflects external changes and disables cycling when account admission is revoked", () => {
127
+ render();
128
+ act(() => {
129
+ state.profile = "offline";
130
+ state.listeners.forEach(listener => listener());
131
+ });
132
+ expect(status()).toBe("Blocked");
133
+ act(() => {
134
+ state.profile = "normal";
135
+ state.available = false;
136
+ state.listeners.forEach(listener => listener());
137
+ });
138
+ expect(status()).toBe("Unavailable");
139
+ expect(button("throttling-cycle").disabled).toBe(true);
140
+ });
141
+ it("keeps remote failures visible and does not claim a profile was applied", async () => {
142
+ const source = createRemoteConditionsSource();
143
+ const onChange = vi.fn().mockRejectedValue(new Error("Connection lost"));
144
+ source.update({
145
+ profile: "normal",
146
+ available: true,
147
+ onChange
148
+ });
149
+ render(source);
150
+ await choose("slow");
151
+ expect(container.textContent).toContain("Connection lost");
152
+ expect(status()).toBe("0 ms");
153
+ expect(state.set).not.toHaveBeenCalled();
154
+ });
155
+ it("prevents duplicate remote operations and follows confirmed snapshots", async () => {
156
+ const source = createRemoteConditionsSource();
157
+ let resolve;
158
+ const onChange = vi.fn(() => new Promise(done => {
159
+ resolve = done;
160
+ }));
161
+ source.update({
162
+ profile: "normal",
163
+ available: true,
164
+ onChange
165
+ });
166
+ render(source);
167
+ await tap("throttling-cycle");
168
+ await tap("throttling-cycle");
169
+ expect(onChange).toHaveBeenCalledOnce();
170
+ expect(status()).toBe("Applying…");
171
+ await act(async () => resolve());
172
+ expect(status()).toBe("0 ms");
173
+ act(() => source.update({
174
+ profile: "slow",
175
+ available: true,
176
+ onChange
177
+ }));
178
+ expect(status()).toBe("+500 ms");
179
+ act(() => source.update(undefined));
180
+ expect(status()).toBe("Unavailable");
181
+ });
182
+ it("ignores a previous device's completion after switching devices", async () => {
183
+ const source = createRemoteConditionsSource();
184
+ let reject;
185
+ source.update({
186
+ targetId: "first",
187
+ profile: "normal",
188
+ available: true,
189
+ onChange: () => new Promise((_resolve, fail) => {
190
+ reject = fail;
191
+ })
192
+ });
193
+ render(source);
194
+ await choose("slow");
195
+ act(() => source.update({
196
+ targetId: "second",
197
+ profile: "normal",
198
+ available: true,
199
+ onChange: vi.fn()
200
+ }));
201
+ await act(async () => reject(new Error("Old device failed")));
202
+ expect(container.textContent).not.toContain("Old device failed");
203
+ expect(status()).toBe("0 ms");
204
+ expect(button("throttling-profile").textContent).toBe("No throttling");
205
+ });
206
+ it("keeps the strip open when stopping a remote device fails", async () => {
207
+ const source = createRemoteConditionsSource();
208
+ source.update({
209
+ profile: "offline",
210
+ available: true,
211
+ onChange: vi.fn().mockRejectedValue(new Error("Could not stop"))
212
+ });
213
+ render(source);
214
+ await tap("throttling-close");
215
+ expect(dismiss).not.toHaveBeenCalled();
216
+ expect(status()).toBe("Blocked");
217
+ expect(container.textContent).toContain("Could not stop");
218
+ });
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+
3
+ import { hasAccountAccess, subscribeAccountAccess } from "@buoy-gg/shared-ui";
4
+ import { addNetworkListener, startNetworkListener, getNetworkListenerCount, stopNetworkListener } from "../utils/networkListener";
5
+ export const NETWORK_CONDITIONS = {
6
+ normal: {
7
+ label: "Normal",
8
+ delayMs: 0
9
+ },
10
+ offline: {
11
+ label: "Offline (requests)",
12
+ delayMs: 0
13
+ },
14
+ slow: {
15
+ label: "Slow (+500 ms)",
16
+ delayMs: 500
17
+ },
18
+ verySlow: {
19
+ label: "Very slow (+2000 ms)",
20
+ delayMs: 2000
21
+ }
22
+ };
23
+ export const networkConditionsRuntimeId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
24
+ let profile = "normal";
25
+ let keepAlive;
26
+ const listeners = new Set();
27
+ export const conditionsCanApply = () => typeof __DEV__ !== "undefined" && __DEV__ && hasAccountAccess();
28
+ export const getNetworkCondition = () => profile;
29
+ export const subscribeNetworkConditions = listener => {
30
+ listeners.add(listener);
31
+ return () => {
32
+ listeners.delete(listener);
33
+ };
34
+ };
35
+ export function setNetworkCondition(value) {
36
+ if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(NETWORK_CONDITIONS, value)) {
37
+ throw new Error("Choose normal, offline, slow, or verySlow.");
38
+ }
39
+ if (value !== "normal" && !conditionsCanApply()) {
40
+ throw new Error("Network conditions require an admitted account and a development build.");
41
+ }
42
+ if (value === profile) return profile;
43
+ if (value !== "normal" && !keepAlive) {
44
+ startNetworkListener();
45
+ keepAlive = addNetworkListener(() => {});
46
+ } else if (value === "normal") {
47
+ keepAlive?.();
48
+ keepAlive = undefined;
49
+ if (getNetworkListenerCount() === 0) stopNetworkListener();
50
+ }
51
+ profile = value;
52
+ listeners.forEach(listener => listener());
53
+ return profile;
54
+ }
55
+ export function snapshotNetworkCondition(url) {
56
+ if (profile === "normal" || !conditionsCanApply() || !/^https?:\/\//i.test(url)) return;
57
+ return Object.freeze({
58
+ profile,
59
+ delayMs: NETWORK_CONDITIONS[profile].delayMs
60
+ });
61
+ }
62
+ subscribeAccountAccess(() => {
63
+ if (!hasAccountAccess()) setNetworkCondition("normal");
64
+ listeners.forEach(listener => listener());
65
+ });
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+
3
+ export function throwIfAborted(signal) {
4
+ if (!signal?.aborted) return;
5
+ if (signal.reason !== undefined) throw signal.reason;
6
+ const error = new Error("The request was aborted.");
7
+ error.name = "AbortError";
8
+ throw error;
9
+ }
10
+
11
+ /** Wait before dispatch, releasing the timer and listener on cancellation. */
12
+ export function waitBeforeDispatch(ms, signal) {
13
+ return new Promise((resolve, reject) => {
14
+ try {
15
+ throwIfAborted(signal);
16
+ } catch (error) {
17
+ reject(error);
18
+ return;
19
+ }
20
+ const cleanup = () => signal?.removeEventListener("abort", abort);
21
+ const abort = () => {
22
+ clearTimeout(timer);
23
+ cleanup();
24
+ try {
25
+ throwIfAborted(signal);
26
+ } catch (error) {
27
+ reject(error);
28
+ }
29
+ };
30
+ const timer = setTimeout(() => {
31
+ cleanup();
32
+ resolve();
33
+ }, ms);
34
+ signal?.addEventListener("abort", abort, {
35
+ once: true
36
+ });
37
+ });
38
+ }