@omercnet/paseo-shared-browser 1.2.1 → 1.2.2-next.237.1

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 (170) hide show
  1. package/README.md +312 -25
  2. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  3. package/STREAMING_DESIGN.md +191 -0
  4. package/client/browser-auto-capture-density.test.ts +102 -0
  5. package/client/browser-auto-capture-density.ts +30 -0
  6. package/client/browser-canvas-input.test.ts +198 -0
  7. package/client/browser-canvas-input.ts +202 -0
  8. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  9. package/client/browser-canvas-keyboard-web.ts +230 -0
  10. package/client/browser-canvas-keyboard.test.ts +123 -0
  11. package/client/browser-canvas-keyboard.ts +126 -0
  12. package/client/browser-canvas-layout.test.ts +109 -0
  13. package/client/browser-canvas-layout.ts +51 -0
  14. package/client/browser-canvas-viewport-web.test.ts +77 -0
  15. package/client/browser-canvas-viewport-web.ts +63 -0
  16. package/client/browser-canvas-viewport.tsx +152 -0
  17. package/client/browser-capture-cadence.test.ts +9 -0
  18. package/client/browser-capture-cadence.ts +6 -0
  19. package/client/browser-capture-density-controls.tsx +75 -0
  20. package/client/browser-chrome.tsx +234 -0
  21. package/client/browser-client-presence.ts +184 -0
  22. package/client/browser-compose-controls.test.tsx +275 -0
  23. package/client/browser-compose-controls.tsx +174 -0
  24. package/client/browser-control-button.tsx +109 -0
  25. package/client/browser-control-dialog.tsx +71 -0
  26. package/client/browser-control-request.test.ts +27 -0
  27. package/client/browser-control-request.ts +57 -0
  28. package/client/browser-control-tooltip-web.ts +10 -0
  29. package/client/browser-dialog-body.tsx +22 -0
  30. package/client/browser-emulation-mode.test.ts +75 -0
  31. package/client/browser-emulation-mode.ts +98 -0
  32. package/client/browser-fill-viewport.test.ts +45 -0
  33. package/client/browser-fill-viewport.ts +28 -0
  34. package/client/browser-frame-buffer.test.ts +157 -0
  35. package/client/browser-frame-buffer.ts +148 -0
  36. package/client/browser-frame-image.tsx +62 -0
  37. package/client/browser-image-load.test.ts +23 -0
  38. package/client/browser-image-load.ts +26 -0
  39. package/client/browser-input-queue.test.ts +1201 -0
  40. package/client/browser-input-queue.ts +505 -0
  41. package/client/browser-menu-placement.test.ts +90 -0
  42. package/client/browser-menu-placement.ts +78 -0
  43. package/client/browser-panel-styles.ts +384 -0
  44. package/client/browser-quality-controls.test.ts +26 -0
  45. package/client/browser-quality-controls.tsx +139 -0
  46. package/client/browser-resolution-picker.tsx +189 -0
  47. package/client/browser-tabs.tsx +117 -0
  48. package/client/browser-toolbar-menu-web.test.ts +185 -0
  49. package/client/browser-toolbar-menu-web.ts +159 -0
  50. package/client/browser-toolbar-menu.tsx +255 -0
  51. package/client/browser-toolbar-submenu.tsx +93 -0
  52. package/client/browser-video-bytes.test.ts +44 -0
  53. package/client/browser-video-decoder.test.ts +473 -0
  54. package/client/browser-video-decoder.ts +311 -0
  55. package/client/browser-video-surface.tsx +21 -0
  56. package/client/browser-video-visibility.test.ts +45 -0
  57. package/client/browser-viewer-recovery.test.ts +111 -0
  58. package/client/browser-viewer-recovery.ts +78 -0
  59. package/client/browser.tsx +1642 -1569
  60. package/client/favorite-star.tsx +23 -0
  61. package/client/frame-lifecycle.ts +62 -59
  62. package/client/use-browser-auto-capture-density.test.ts +104 -0
  63. package/client/use-browser-auto-capture-density.ts +43 -0
  64. package/client/use-browser-canvas-input.ts +370 -0
  65. package/client/use-browser-capture-density.ts +87 -0
  66. package/client/use-browser-emulation-mode.ts +56 -0
  67. package/client/use-browser-fill-viewport.test.ts +108 -0
  68. package/client/use-browser-fill-viewport.ts +62 -0
  69. package/client/use-browser-frame-buffer.ts +66 -0
  70. package/client/use-browser-image-capture.ts +139 -0
  71. package/client/use-browser-scoped-mutation.test.ts +118 -0
  72. package/client/use-browser-scoped-mutation.ts +59 -0
  73. package/client/use-browser-video.test.ts +991 -0
  74. package/client/use-browser-video.ts +474 -0
  75. package/client/use-browser-viewer-recovery.ts +26 -0
  76. package/client/use-resolution-favorites.ts +90 -0
  77. package/client/web.test.ts +125 -0
  78. package/client/web.ts +405 -0
  79. package/index.client.tsx +0 -10
  80. package/index.server.ts +32 -0
  81. package/npm-shrinkwrap.json +1834 -0
  82. package/package.json +13 -8
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-dependencies.mjs +1 -1
  85. package/scripts/prepare-runtime.mjs +1 -1
  86. package/server/agent-browser-runtime.ts +1598 -314
  87. package/server/browser-cursor.test.ts +64 -0
  88. package/server/browser-cursor.ts +69 -0
  89. package/server/browser-frame-context.ts +22 -0
  90. package/server/browser-frame-epoch.test.ts +95 -0
  91. package/server/browser-frame-rejection.ts +17 -0
  92. package/server/browser-gesture.ts +120 -0
  93. package/server/browser-operation-diagnostics.test.ts +79 -0
  94. package/server/browser-operation-diagnostics.ts +103 -0
  95. package/server/browser-policy.ts +1313 -151
  96. package/server/browser-video-input-authority.test.ts +208 -0
  97. package/server/browser-video-policy.test.ts +564 -0
  98. package/server/browser-video-socket.test.ts +368 -0
  99. package/server/browser.ts +151 -16
  100. package/server/capture-transport-policy.test.ts +105 -0
  101. package/server/capture-transport-policy.ts +154 -0
  102. package/server/capture-transport-runtime.test.ts +213 -0
  103. package/server/daemon-launch-chain.test.ts +70 -0
  104. package/server/daemon-shutdown.test.ts +289 -0
  105. package/server/discrete-input-freshness.test.ts +364 -0
  106. package/server/discrete-input-native-lifetime.test.ts +150 -0
  107. package/server/display-loss-recovery.test.ts +294 -0
  108. package/server/emulation-restoration.test.ts +262 -0
  109. package/server/input-cleanup.ts +23 -0
  110. package/server/input-generation.test.ts +31 -0
  111. package/server/input-generation.ts +36 -0
  112. package/server/jpeg-capture-demand.test.ts +76 -0
  113. package/server/jpeg-capture-demand.ts +98 -0
  114. package/server/jpeg-frame.ts +29 -0
  115. package/server/keyboard-input.test.ts +61 -0
  116. package/server/keyboard-input.ts +104 -0
  117. package/server/live-input-policy.test.ts +958 -0
  118. package/server/live-input-runtime.test.ts +732 -0
  119. package/server/live-input-supervisor.test.ts +147 -0
  120. package/server/mcp-entry.ts +44 -2
  121. package/server/native-video-backpressure.test.ts +228 -0
  122. package/server/native-video-buffer.test.ts +141 -0
  123. package/server/native-video-buffer.ts +114 -0
  124. package/server/native-video-capture.test.ts +599 -0
  125. package/server/native-video-capture.ts +497 -0
  126. package/server/native-video-extension-source.test.ts +201 -0
  127. package/server/native-video-extension-source.ts +364 -0
  128. package/server/native-video-extension.test.ts +41 -0
  129. package/server/native-video-extension.ts +83 -0
  130. package/server/native-video-packet.ts +9 -0
  131. package/server/native-video-settings.test.ts +245 -0
  132. package/server/navigation-budget.ts +8 -0
  133. package/server/navigation-commit.ts +125 -0
  134. package/server/navigation-policy.test.ts +153 -0
  135. package/server/process-identity.ts +58 -0
  136. package/server/reattach-held-input.test.ts +143 -0
  137. package/server/runtime-frame-reuse.test.ts +295 -0
  138. package/server/runtime-jpeg-demand.test.ts +200 -0
  139. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  140. package/server/runtime-jpeg-source-order.test.ts +201 -0
  141. package/server/runtime-launch-environment.test.ts +111 -0
  142. package/server/runtime-navigation.test.ts +327 -0
  143. package/server/runtime-owner-display.test.ts +152 -0
  144. package/server/runtime-owner.ts +305 -31
  145. package/server/runtime-page-state.test.ts +249 -0
  146. package/server/runtime-protocol.ts +31 -1
  147. package/server/runtime-target-retention.test.ts +181 -0
  148. package/server/runtime-video-opt-in.test.ts +82 -0
  149. package/server/runtime-video.test.ts +444 -0
  150. package/server/screencast-source-time.ts +46 -0
  151. package/server/stale-wheel-admission.test.ts +136 -0
  152. package/server/supervisor-client.ts +28 -4
  153. package/server/supervisor-heartbeat.test.ts +80 -0
  154. package/server/supervisor.ts +553 -55
  155. package/server/video-source-recovery.test.ts +20 -0
  156. package/server/video-source-recovery.ts +64 -0
  157. package/server/viewer-capture-lifetime.ts +51 -0
  158. package/server/virtual-display.test.ts +184 -0
  159. package/server/virtual-display.ts +236 -0
  160. package/shared/browser-display-preferences.ts +46 -0
  161. package/shared/browser-video.ts +80 -0
  162. package/shared/browser.ts +286 -65
  163. package/shared/capture-density.ts +16 -0
  164. package/shared/capture-settings.ts +17 -0
  165. package/shared/device-presets.ts +90 -0
  166. package/shared/resolution-menu.test.ts +120 -0
  167. package/shared/resolution-menu.ts +72 -0
  168. package/shared/video-settings.test.ts +38 -0
  169. package/shared/video-settings.ts +34 -0
  170. package/shared/viewport-limits.ts +5 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * One anchored menu surface for workspace-panel toolbar controls. The public SDK
3
+ * has no inline menu, so use its theme and public RN primitives without importing
4
+ * host internals. Placement stays inside the pane, including compact clients.
5
+ */
6
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
7
+ import { Icon } from "@getpaseo/plugin/client/react-native";
8
+ import {
9
+ type ReactNode,
10
+ type RefObject,
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useRef,
14
+ useState,
15
+ } from "react";
16
+ import { BackHandler, Platform, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
17
+ import { browserMenuPlacement, type MenuRect } from "./browser-menu-placement";
18
+ import { bindBrowserToolbarMenuWeb, focusBrowserMenuTrigger } from "./browser-toolbar-menu-web";
19
+ import { type BrowserSubmenu, BrowserToolbarSubmenu } from "./browser-toolbar-submenu";
20
+
21
+ type Theme = PluginHostProps["theme"];
22
+ const STYLES = StyleSheet.create({
23
+ overlay: { ...StyleSheet.absoluteFillObject, zIndex: 100 },
24
+ menu: { position: "absolute", borderWidth: 1, borderRadius: 8, overflow: "hidden", elevation: 8 },
25
+ content: { paddingVertical: 4 },
26
+ item: {
27
+ minHeight: 36,
28
+ paddingHorizontal: 12,
29
+ paddingVertical: 8,
30
+ flexDirection: "row",
31
+ alignItems: "center",
32
+ gap: 8,
33
+ },
34
+ label: { fontSize: 13, flexShrink: 1, flex: 1 },
35
+ heading: {
36
+ fontSize: 11,
37
+ fontWeight: "600",
38
+ paddingHorizontal: 12,
39
+ paddingTop: 10,
40
+ paddingBottom: 4,
41
+ },
42
+ separator: { borderBottomWidth: 1, marginVertical: 4 },
43
+ });
44
+
45
+ export interface BrowserToolbarMenuProps {
46
+ theme: Theme;
47
+ title: string;
48
+ compact: boolean;
49
+ paneRef: RefObject<View | null>;
50
+ anchorRef: RefObject<View | null>;
51
+ paneSize: { width: number; height: number };
52
+ /** Estimated body height; overflow remains scrollable instead of growing outside the pane. */
53
+ preferredHeight: number;
54
+ onClose(): void;
55
+ shouldRestoreFocus(): boolean;
56
+ children: ReactNode;
57
+ submenu?: BrowserSubmenu | undefined;
58
+ onSubmenuOpen?(): void;
59
+ }
60
+
61
+ /** Measure after layout, reject obsolete callbacks, and re-anchor on pane resizing. */
62
+ export function BrowserToolbarMenu(props: BrowserToolbarMenuProps) {
63
+ const compact = props.compact || Platform.OS !== "web";
64
+ const previousSubmenu = useRef<BrowserSubmenu | undefined>(undefined);
65
+ const [placement, setPlacement] = useState<MenuRect | null>(null);
66
+ const menuRef = useRef<View | null>(null);
67
+ const overlayRef = useRef<View | null>(null);
68
+ const current = useRef(props);
69
+ current.current = props;
70
+ useLayoutEffect(() => {
71
+ let active = true;
72
+ props.paneRef.current?.measureInWindow((paneX, paneY) => {
73
+ if (!active) return;
74
+ props.anchorRef.current?.measureInWindow((x, y, width, height) => {
75
+ if (!active) return;
76
+ setPlacement(
77
+ browserMenuPlacement(
78
+ props.paneSize,
79
+ { x: x - paneX, y: y - paneY, width, height },
80
+ compact && props.submenu ? props.submenu.preferredHeight + 52 : props.preferredHeight,
81
+ ),
82
+ );
83
+ });
84
+ });
85
+ return () => {
86
+ active = false;
87
+ };
88
+ }, [
89
+ props.paneRef,
90
+ props.anchorRef,
91
+ props.paneSize.width,
92
+ props.paneSize.height,
93
+ props.preferredHeight,
94
+ compact,
95
+ Boolean(props.submenu),
96
+ ]);
97
+
98
+ useEffect(() => {
99
+ if (!placement) return;
100
+ const dispose = bindBrowserToolbarMenuWeb(
101
+ menuRef.current,
102
+ props.anchorRef.current,
103
+ () => current.current.onClose(),
104
+ () => current.current.shouldRestoreFocus(),
105
+ overlayRef.current,
106
+ {
107
+ openSubmenu: () => current.current.onSubmenuOpen?.(),
108
+ ...(compact && props.submenu ? { back: () => current.current.submenu?.onBack() } : {}),
109
+ },
110
+ );
111
+ if (compact && previousSubmenu.current && !props.submenu) {
112
+ focusBrowserMenuTrigger(previousSubmenu.current.anchorRef.current);
113
+ }
114
+ previousSubmenu.current = props.submenu;
115
+ return dispose;
116
+ }, [Boolean(placement), props.anchorRef, compact, compact && Boolean(props.submenu)]);
117
+ useEffect(() => {
118
+ if (Platform.OS === "web") return;
119
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
120
+ if (current.current.submenu) current.current.submenu.onBack();
121
+ else current.current.onClose();
122
+ return true;
123
+ });
124
+ return () => subscription.remove();
125
+ }, []);
126
+
127
+ return (
128
+ <View ref={overlayRef} style={STYLES.overlay} accessibilityViewIsModal>
129
+ <Pressable
130
+ accessible={false}
131
+ focusable={false}
132
+ accessibilityLabel={`Close ${props.title}`}
133
+ style={StyleSheet.absoluteFillObject}
134
+ onPress={props.onClose}
135
+ />
136
+ {placement ? (
137
+ <View
138
+ ref={menuRef}
139
+ accessibilityRole="menu"
140
+ accessibilityLabel={props.title}
141
+ onAccessibilityEscape={compact && props.submenu ? props.submenu.onBack : props.onClose}
142
+ style={[
143
+ STYLES.menu,
144
+ { overflow: "visible" },
145
+ {
146
+ left: placement.x,
147
+ top: placement.y,
148
+ width: placement.width,
149
+ maxHeight: placement.height,
150
+ borderColor: props.theme.colors.border,
151
+ backgroundColor: props.theme.colors.surface1,
152
+ },
153
+ ]}
154
+ >
155
+ <ScrollView
156
+ style={{ maxHeight: placement.height - 2, borderRadius: 8, overflow: "hidden" }}
157
+ keyboardShouldPersistTaps="handled"
158
+ contentContainerStyle={STYLES.content}
159
+ >
160
+ {compact && props.submenu ? (
161
+ <>
162
+ <BrowserMenuItem
163
+ theme={props.theme}
164
+ compact
165
+ label="Back"
166
+ icon="ChevronLeft"
167
+ onPress={props.submenu.onBack}
168
+ />
169
+ <BrowserMenuHeading theme={props.theme}>{props.submenu.title}</BrowserMenuHeading>
170
+ {props.submenu.children}
171
+ </>
172
+ ) : (
173
+ props.children
174
+ )}
175
+ </ScrollView>
176
+ {!compact && props.submenu ? (
177
+ <BrowserToolbarSubmenu
178
+ theme={props.theme}
179
+ submenu={props.submenu}
180
+ paneRef={props.paneRef}
181
+ paneSize={props.paneSize}
182
+ parent={placement}
183
+ onClose={props.onClose}
184
+ />
185
+ ) : null}
186
+ </View>
187
+ ) : null}
188
+ </View>
189
+ );
190
+ }
191
+
192
+ /** Standard menu row; checked choices use an indicator instead of a raised action button. */
193
+ export function BrowserMenuItem({
194
+ theme,
195
+ compact,
196
+ label,
197
+ icon,
198
+ selected,
199
+ disabled = false,
200
+ expanded,
201
+ onPress,
202
+ }: {
203
+ theme: Theme;
204
+ compact: boolean;
205
+ label: string;
206
+ icon?: string;
207
+ selected?: boolean;
208
+ disabled?: boolean;
209
+ expanded?: boolean;
210
+ onPress(): void;
211
+ }) {
212
+ const [hovered, setHovered] = useState(false);
213
+ const [focused, setFocused] = useState(false);
214
+ return (
215
+ <Pressable
216
+ accessibilityRole="menuitem"
217
+ accessibilityLabel={label}
218
+ accessibilityState={{
219
+ disabled,
220
+ ...(expanded === undefined ? {} : { expanded }),
221
+ ...(selected === undefined ? {} : { selected }),
222
+ }}
223
+ disabled={disabled}
224
+ onHoverIn={() => setHovered(true)}
225
+ onHoverOut={() => setHovered(false)}
226
+ onFocus={() => setFocused(true)}
227
+ onBlur={() => setFocused(false)}
228
+ onPress={onPress}
229
+ style={({ pressed }) => [
230
+ STYLES.item,
231
+ {
232
+ minHeight: compact ? 44 : 36,
233
+ backgroundColor: hovered || focused ? theme.colors.surface2 : "transparent",
234
+ opacity: disabled ? 0.45 : pressed ? 0.72 : 1,
235
+ },
236
+ ]}
237
+ >
238
+ {icon ? <Icon name={icon} size={16} color={theme.colors.foregroundMuted} /> : null}
239
+ <Text style={[STYLES.label, { color: theme.colors.foreground }]}>{label}</Text>
240
+ {expanded !== undefined ? (
241
+ <Icon name="ChevronRight" size={16} color={theme.colors.foregroundMuted} />
242
+ ) : null}
243
+ {selected ? <Icon name="Check" size={16} color={theme.colors.accent} /> : null}
244
+ </Pressable>
245
+ );
246
+ }
247
+
248
+ /** Group labels and separators share the same themed menu chrome. */
249
+ export function BrowserMenuHeading({ theme, children }: { theme: Theme; children: ReactNode }) {
250
+ return <Text style={[STYLES.heading, { color: theme.colors.foregroundMuted }]}>{children}</Text>;
251
+ }
252
+
253
+ export function BrowserMenuSeparator({ theme }: { theme: Theme }) {
254
+ return <View style={[STYLES.separator, { borderBottomColor: theme.colors.border }]} />;
255
+ }
@@ -0,0 +1,93 @@
1
+ /** Desktop flyouts remain descendants of the root menu and share its one backdrop. */
2
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
3
+ import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react";
4
+ import { ScrollView, View } from "react-native";
5
+ import { browserSubmenuPlacement, type MenuRect } from "./browser-menu-placement";
6
+ import { bindBrowserToolbarMenuWeb } from "./browser-toolbar-menu-web";
7
+
8
+ export interface BrowserSubmenu {
9
+ title: string;
10
+ anchorRef: RefObject<View | null>;
11
+ preferredHeight: number;
12
+ children: ReactNode;
13
+ onBack(): void;
14
+ }
15
+
16
+ /** Measure the actual trigger row; refuse obsolete callbacks after close/resize. */
17
+ export function BrowserToolbarSubmenu(props: {
18
+ theme: PluginHostProps["theme"];
19
+ submenu: BrowserSubmenu;
20
+ paneRef: RefObject<View | null>;
21
+ paneSize: { width: number; height: number };
22
+ parent: MenuRect;
23
+ onClose(): void;
24
+ }) {
25
+ const [placement, setPlacement] = useState<MenuRect | null>(null);
26
+ const menu = useRef<View | null>(null);
27
+ const current = useRef(props);
28
+ current.current = props;
29
+ useLayoutEffect(() => {
30
+ let active = true;
31
+ props.paneRef.current?.measureInWindow((paneX, paneY) => {
32
+ props.submenu.anchorRef.current?.measureInWindow((x, y, width, height) => {
33
+ if (!active) return;
34
+ setPlacement(
35
+ browserSubmenuPlacement(
36
+ props.paneSize,
37
+ props.parent,
38
+ { x: x - paneX, y: y - paneY, width, height },
39
+ props.submenu.preferredHeight,
40
+ ),
41
+ );
42
+ });
43
+ });
44
+ return () => {
45
+ active = false;
46
+ };
47
+ }, [props.paneSize.width, props.paneSize.height, props.parent, props.submenu.anchorRef]);
48
+ useLayoutEffect(() => {
49
+ if (!placement) return;
50
+ return bindBrowserToolbarMenuWeb(
51
+ menu.current,
52
+ props.submenu.anchorRef.current,
53
+ () => current.current.submenu.onBack(),
54
+ () => true,
55
+ undefined,
56
+ {
57
+ nested: true,
58
+ back: () => current.current.submenu.onBack(),
59
+ closeRoot: () => current.current.onClose(),
60
+ },
61
+ );
62
+ }, [Boolean(placement), props.submenu.anchorRef]);
63
+ if (!placement) return null;
64
+ return (
65
+ <View
66
+ ref={menu}
67
+ accessibilityRole="menu"
68
+ accessibilityLabel={props.submenu.title}
69
+ onAccessibilityEscape={props.submenu.onBack}
70
+ style={{
71
+ position: "absolute",
72
+ left: placement.x - props.parent.x,
73
+ top: placement.y - props.parent.y,
74
+ width: placement.width,
75
+ maxHeight: placement.height,
76
+ borderWidth: 1,
77
+ borderRadius: 8,
78
+ overflow: "hidden",
79
+ backgroundColor: props.theme.colors.surface1,
80
+ borderColor: props.theme.colors.border,
81
+ elevation: 9,
82
+ }}
83
+ >
84
+ <ScrollView
85
+ style={{ maxHeight: placement.height - 2 }}
86
+ keyboardShouldPersistTaps="handled"
87
+ contentContainerStyle={{ paddingVertical: 4 }}
88
+ >
89
+ {props.submenu.children}
90
+ </ScrollView>
91
+ </View>
92
+ );
93
+ }
@@ -0,0 +1,44 @@
1
+ /** Exact large-packet conversion across native byte APIs and older web clients. */
2
+ import { expect, it, vi } from "vitest";
3
+
4
+ vi.mock("react-native", () => ({ Platform: { OS: "web" } }));
5
+
6
+ import { decodeBrowserVideoBytes } from "./web";
7
+
8
+ /** The test runner can predate the byte API; model presence without a polyfill
9
+ * in production and always restore its exact original property descriptor. */
10
+ function withByteDecoder(decode: ((value: string) => Uint8Array) | undefined, perform: () => void) {
11
+ const original = Object.getOwnPropertyDescriptor(Uint8Array, "fromBase64");
12
+ Object.defineProperty(Uint8Array, "fromBase64", { value: decode, configurable: true });
13
+ try {
14
+ perform();
15
+ } finally {
16
+ if (original) Object.defineProperty(Uint8Array, "fromBase64", original);
17
+ else Reflect.deleteProperty(Uint8Array, "fromBase64");
18
+ }
19
+ }
20
+
21
+ it("decodes large and padded packets exactly using the native byte API", () => {
22
+ const native = vi.fn((value: string) => new Uint8Array(Buffer.from(value, "base64")));
23
+ withByteDecoder(native, () => {
24
+ for (const size of [0, 1, 2, 65537, 1048576]) {
25
+ const source = Buffer.alloc(size);
26
+ for (let i = 0; i < size; i += 1) source[i] = (i * 17 + 13) % 256;
27
+ expect(Buffer.from(decodeBrowserVideoBytes(source.toString("base64"))).equals(source)).toBe(
28
+ true,
29
+ );
30
+ }
31
+ });
32
+ expect(native).toHaveBeenCalledTimes(5);
33
+ });
34
+
35
+ it("decodes identical bytes without native conversion and rejects malformed payloads", () => {
36
+ withByteDecoder(undefined, () => {
37
+ const source = Buffer.alloc(1048577);
38
+ for (let i = 0; i < source.length; i += 1) source[i] = (i * 17 + 13) % 256;
39
+ expect(Buffer.from(decodeBrowserVideoBytes(source.toString("base64"))).equals(source)).toBe(
40
+ true,
41
+ );
42
+ expect(() => decodeBrowserVideoBytes("%%invalid%%")).toThrow();
43
+ });
44
+ });