@vanira/sdk-react-native 0.0.2

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 (148) hide show
  1. package/README.md +239 -0
  2. package/package.json +53 -0
  3. package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
  4. package/src/__tests__/adapters.test.ts +475 -0
  5. package/src/__tests__/httpResponse.test.ts +25 -0
  6. package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
  7. package/src/__tests__/mocks/react-native-permissions.ts +15 -0
  8. package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
  9. package/src/__tests__/mocks/react-native.ts +28 -0
  10. package/src/__tests__/preset.test.ts +239 -0
  11. package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
  12. package/src/__tests__/storage.test.ts +211 -0
  13. package/src/__tests__/webrtcSignaling.test.ts +42 -0
  14. package/src/adapters/PeerConnectionAdapter.ts +101 -0
  15. package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
  16. package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
  17. package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
  18. package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
  19. package/src/adapters/browser/index.ts +4 -0
  20. package/src/adapters/interfaces.ts +84 -0
  21. package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
  22. package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
  23. package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
  24. package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
  25. package/src/adapters/react-native/callAudioRouting.ts +115 -0
  26. package/src/adapters/react-native/decodeUtf8.ts +72 -0
  27. package/src/adapters/react-native/index.ts +4 -0
  28. package/src/adapters/react-native/rnUploadFile.ts +76 -0
  29. package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
  30. package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
  31. package/src/adapters/storage/StorageAdapter.ts +21 -0
  32. package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
  33. package/src/adapters/storage/index.ts +7 -0
  34. package/src/api/services/ChatService.ts +304 -0
  35. package/src/api/services/ConfigService.ts +33 -0
  36. package/src/assets/icons.js +35 -0
  37. package/src/cdn.ts +68 -0
  38. package/src/core/CallSessionStore.ts +137 -0
  39. package/src/core/DraggableController.ts +83 -0
  40. package/src/core/SessionManager.ts +322 -0
  41. package/src/core/VaniraAI.ts +464 -0
  42. package/src/core/WebRTCClient.ts +1012 -0
  43. package/src/core/httpResponse.ts +22 -0
  44. package/src/core/iceServers.ts +18 -0
  45. package/src/core/toolCallNormalize.ts +80 -0
  46. package/src/core/voice-client.js +236 -0
  47. package/src/core/webrtcSignaling.ts +72 -0
  48. package/src/index.js +34 -0
  49. package/src/index.ts +6 -0
  50. package/src/platforms/browser.ts +67 -0
  51. package/src/platforms/react-native.ts +105 -0
  52. package/src/presets/BookingCalendarModal.tsx +457 -0
  53. package/src/presets/CameraModal.tsx +576 -0
  54. package/src/presets/DynamicFormModal.tsx +378 -0
  55. package/src/presets/NativePresetRenderer.tsx +350 -0
  56. package/src/presets/NavigateHandler.tsx +75 -0
  57. package/src/presets/PresetHost.tsx +155 -0
  58. package/src/presets/PresetShellModal.tsx +97 -0
  59. package/src/presets/UploadModal.tsx +321 -0
  60. package/src/presets/calendar/calendarUtils.ts +386 -0
  61. package/src/presets/call/CallSpeakerToggle.tsx +59 -0
  62. package/src/presets/call/callAudioRouting.ts +2 -0
  63. package/src/presets/call/useCallSpeaker.ts +31 -0
  64. package/src/presets/camera/cameraPermissions.ts +18 -0
  65. package/src/presets/camera/cameraStream.ts +19 -0
  66. package/src/presets/camera/cameraUtils.ts +21 -0
  67. package/src/presets/camera/useLivenessFlow.ts +95 -0
  68. package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
  69. package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
  70. package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
  71. package/src/presets/chalkboard/boardAbort.ts +36 -0
  72. package/src/presets/chalkboard/boardQueue.ts +620 -0
  73. package/src/presets/chalkboard/chalkboardSession.ts +75 -0
  74. package/src/presets/chalkboard/drawUtils.ts +123 -0
  75. package/src/presets/chalkboard/textUtils.ts +109 -0
  76. package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
  77. package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
  78. package/src/presets/form/formValidation.ts +104 -0
  79. package/src/presets/form/parseFormFields.ts +171 -0
  80. package/src/presets/host/HostElementPresetHandler.tsx +155 -0
  81. package/src/presets/host/hostPresetBridge.ts +71 -0
  82. package/src/presets/index.ts +63 -0
  83. package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
  84. package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
  85. package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
  86. package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
  87. package/src/presets/liveScreen/liveScreenSession.ts +73 -0
  88. package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
  89. package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
  90. package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
  91. package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
  92. package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
  93. package/src/presets/liveVision/liveVisionSession.ts +75 -0
  94. package/src/presets/liveVision/liveVisionUpload.ts +62 -0
  95. package/src/presets/navigation/internalRouteRegistry.ts +25 -0
  96. package/src/presets/navigation/navigationBridge.ts +76 -0
  97. package/src/presets/navigation/navigationTypes.ts +12 -0
  98. package/src/presets/parseToolCall.ts +60 -0
  99. package/src/presets/presetClientAdapter.ts +29 -0
  100. package/src/presets/presetCompletion.ts +91 -0
  101. package/src/presets/presetEventHelpers.ts +45 -0
  102. package/src/presets/registry.ts +128 -0
  103. package/src/presets/streaming/mediaFrameUpload.ts +93 -0
  104. package/src/presets/types.ts +74 -0
  105. package/src/presets/upload/pickUploadFile.ts +256 -0
  106. package/src/presets/upload/uploadFormats.ts +163 -0
  107. package/src/presets/upload/uploadUtils.ts +68 -0
  108. package/src/react/PresetRenderer.tsx +144 -0
  109. package/src/react/index.ts +1 -0
  110. package/src/runtime/browserRuntime.ts +54 -0
  111. package/src/runtime/platform.ts +17 -0
  112. package/src/runtime/reactNativeRuntime.ts +68 -0
  113. package/src/runtime/resolveRuntimeConfig.ts +75 -0
  114. package/src/runtime/runtimeBundles.ts +74 -0
  115. package/src/runtime/types.ts +135 -0
  116. package/src/types/react-native-incall-manager.d.ts +17 -0
  117. package/src/types/react-native-webrtc.d.ts +47 -0
  118. package/src/types.ts +133 -0
  119. package/src/ui/VaniraWidget.ts +87 -0
  120. package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
  121. package/src/ui/abstraction/interfaces.ts +12 -0
  122. package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
  123. package/src/ui/components/AvatarView.ts +81 -0
  124. package/src/ui/components/ChatWindow.ts +263 -0
  125. package/src/ui/components/FloatingButton.ts +163 -0
  126. package/src/ui/components/FloatingWelcomeChips.ts +137 -0
  127. package/src/ui/components/Panel.ts +120 -0
  128. package/src/ui/components/VoiceOrb.ts +79 -0
  129. package/src/ui/components/VoiceOverlay.ts +497 -0
  130. package/src/ui/components/index.ts +7 -0
  131. package/src/ui/factory/WidgetFactory.ts +16 -0
  132. package/src/ui/icons_data.ts +2 -0
  133. package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
  134. package/src/ui/presets/types.ts +16 -0
  135. package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
  136. package/src/ui/styles/index.ts +323 -0
  137. package/src/ui/styles/keyframes.ts +76 -0
  138. package/src/ui/styles/theme.ts +57 -0
  139. package/src/ui/styles/widget.css.ts +838 -0
  140. package/src/ui/utils.ts +37 -0
  141. package/src/ui/views/AbstractChatView.ts +93 -0
  142. package/src/ui/views/AbstractVoiceView.ts +57 -0
  143. package/src/ui/views/AvatarOnlyView.ts +78 -0
  144. package/src/ui/views/ChatAvatarView.ts +66 -0
  145. package/src/ui/views/ChatOnlyView.ts +28 -0
  146. package/src/ui/views/ChatVoiceView.ts +15 -0
  147. package/src/ui/views/VoiceOnlyView.ts +25 -0
  148. package/src/ui/views/index.ts +5 -0
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Preset event bridge tests (Task 0.4)
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ const PRESET_TOOL_CALL = {
8
+ event: 'client_tool_call',
9
+ tool_call: {
10
+ tool_call_id: 'tc_preset_1',
11
+ name: 'show_preset',
12
+ arguments: { preset_id: 'product_card' },
13
+ execution_mode: 'blocking',
14
+ },
15
+ };
16
+
17
+ const NON_PRESET_TOOL_CALL = {
18
+ event: 'client_tool_call',
19
+ tool_call: {
20
+ tool_call_id: 'tc_custom_1',
21
+ name: 'custom_action',
22
+ arguments: { foo: 'bar' },
23
+ execution_mode: 'fire_and_forget',
24
+ },
25
+ };
26
+
27
+ function dispatchPreset(client: import('../core/WebRTCClient').WebRTCClient, msg: unknown) {
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ (client as any).handleControlEvent(msg);
30
+ }
31
+
32
+ /** Minimal browser globals for DOM preset tests (vitest node env) */
33
+ function stubBrowserEventGlobals() {
34
+ class MockCustomEvent {
35
+ readonly type: string;
36
+ readonly detail: unknown;
37
+ constructor(type: string, init?: { detail?: unknown }) {
38
+ this.type = type;
39
+ this.detail = init?.detail;
40
+ }
41
+ }
42
+
43
+ const listeners = new Map<string, Set<(event: MockCustomEvent) => void>>();
44
+
45
+ vi.stubGlobal('CustomEvent', MockCustomEvent);
46
+ vi.stubGlobal('addEventListener', (type: string, listener: (event: MockCustomEvent) => void) => {
47
+ if (!listeners.has(type)) listeners.set(type, new Set());
48
+ listeners.get(type)!.add(listener);
49
+ });
50
+ vi.stubGlobal('removeEventListener', (type: string, listener: (event: MockCustomEvent) => void) => {
51
+ listeners.get(type)?.delete(listener);
52
+ });
53
+ vi.stubGlobal('dispatchEvent', (event: MockCustomEvent) => {
54
+ listeners.get(event.type)?.forEach((listener) => listener(event));
55
+ return true;
56
+ });
57
+
58
+ return { listeners };
59
+ }
60
+
61
+ describe('WebRTCClient preset dispatch', () => {
62
+ afterEach(() => {
63
+ vi.unstubAllGlobals();
64
+ vi.restoreAllMocks();
65
+ });
66
+
67
+ it('ordering: ack → onPreset → onClientToolCall', async () => {
68
+ const order: string[] = [];
69
+ const { WebRTCClient } = await import('../core/WebRTCClient');
70
+
71
+ const client = new WebRTCClient({
72
+ agentId: 'agent_1',
73
+ serverUrl: 'https://worker.example.com',
74
+ onPreset: () => order.push('onPreset'),
75
+ onClientToolCall: () => order.push('onClientToolCall'),
76
+ });
77
+
78
+ vi.spyOn(client, 'sendToolAck').mockImplementation(() => {
79
+ order.push('ack');
80
+ });
81
+
82
+ dispatchPreset(client, PRESET_TOOL_CALL);
83
+
84
+ expect(order).toEqual(['ack', 'onPreset', 'onClientToolCall']);
85
+ });
86
+
87
+ it('dispatches vanira:preset DOM event on browser when CustomEvent is available', async () => {
88
+ stubBrowserEventGlobals();
89
+ const domListener = vi.fn();
90
+ globalThis.addEventListener('vanira:preset', domListener);
91
+
92
+ const { WebRTCClient } = await import('../core/WebRTCClient');
93
+
94
+ const client = new WebRTCClient({
95
+ agentId: 'agent_1',
96
+ serverUrl: 'https://worker.example.com',
97
+ });
98
+
99
+ dispatchPreset(client, PRESET_TOOL_CALL);
100
+
101
+ expect(domListener).toHaveBeenCalledTimes(1);
102
+ const event = domListener.mock.calls[0][0] as CustomEvent;
103
+ expect(event.detail.toolCall).toEqual({
104
+ tool_call_id: 'tc_preset_1',
105
+ name: 'show_preset',
106
+ arguments: { preset_id: 'product_card' },
107
+ execution_mode: 'blocking',
108
+ client_fields: {},
109
+ });
110
+ expect(event.detail.client).toBe(client);
111
+ });
112
+
113
+ it('does not dispatch DOM event when canDispatchBrowserEvent is false', async () => {
114
+ stubBrowserEventGlobals();
115
+ const platform = await import('../runtime/platform');
116
+ vi.spyOn(platform, 'canDispatchBrowserEvent').mockReturnValue(false);
117
+
118
+ const domListener = vi.fn();
119
+ globalThis.addEventListener('vanira:preset', domListener);
120
+
121
+ const { WebRTCClient } = await import('../core/WebRTCClient');
122
+ const onPreset = vi.fn();
123
+
124
+ const client = new WebRTCClient({
125
+ agentId: 'agent_1',
126
+ serverUrl: 'https://worker.example.com',
127
+ onPreset,
128
+ });
129
+
130
+ dispatchPreset(client, PRESET_TOOL_CALL);
131
+
132
+ expect(onPreset).toHaveBeenCalledTimes(1);
133
+ expect(domListener).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('does not call onPreset or DOM for non-preset tools', async () => {
137
+ stubBrowserEventGlobals();
138
+ const platform = await import('../runtime/platform');
139
+ const canDispatchSpy = vi.spyOn(platform, 'canDispatchBrowserEvent');
140
+
141
+ const domListener = vi.fn();
142
+ globalThis.addEventListener('vanira:preset', domListener);
143
+
144
+ const { WebRTCClient } = await import('../core/WebRTCClient');
145
+ const onPreset = vi.fn();
146
+ const onClientToolCall = vi.fn();
147
+
148
+ const client = new WebRTCClient({
149
+ agentId: 'agent_1',
150
+ serverUrl: 'https://worker.example.com',
151
+ onPreset,
152
+ onClientToolCall,
153
+ });
154
+
155
+ dispatchPreset(client, NON_PRESET_TOOL_CALL);
156
+
157
+ expect(onPreset).not.toHaveBeenCalled();
158
+ expect(onClientToolCall).toHaveBeenCalledTimes(1);
159
+ expect(domListener).not.toHaveBeenCalled();
160
+ expect(canDispatchSpy).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('onPreset receives raw toolCall and client reference', async () => {
164
+ const { WebRTCClient } = await import('../core/WebRTCClient');
165
+ const onPreset = vi.fn();
166
+
167
+ const client = new WebRTCClient({
168
+ agentId: 'agent_1',
169
+ serverUrl: 'https://worker.example.com',
170
+ onPreset,
171
+ });
172
+
173
+ dispatchPreset(client, PRESET_TOOL_CALL);
174
+
175
+ expect(onPreset).toHaveBeenCalledWith({
176
+ toolCall: {
177
+ tool_call_id: 'tc_preset_1',
178
+ name: 'show_preset',
179
+ arguments: { preset_id: 'product_card' },
180
+ execution_mode: 'blocking',
181
+ client_fields: {},
182
+ },
183
+ client,
184
+ });
185
+ });
186
+ });
187
+
188
+ describe('VaniraAI preset events', () => {
189
+ beforeEach(() => {
190
+ Object.defineProperty(globalThis, 'navigator', {
191
+ value: { product: 'Gecko' },
192
+ configurable: true,
193
+ });
194
+ });
195
+
196
+ afterEach(() => {
197
+ vi.restoreAllMocks();
198
+ });
199
+
200
+ it('emits preset before tool_call for preset tools', async () => {
201
+ const { VaniraAI } = await import('../core/VaniraAI');
202
+ const { WebRTCClient } = await import('../core/WebRTCClient');
203
+
204
+ vi.spyOn(WebRTCClient.prototype, 'connect').mockResolvedValue(undefined);
205
+
206
+ const order: string[] = [];
207
+ const ai = new VaniraAI({ agentId: 'agent_1', apiKey: 'sk_test' });
208
+ ai.on('preset', () => order.push('preset'));
209
+ ai.on('tool_call', () => order.push('tool_call'));
210
+
211
+ await ai.start();
212
+
213
+ const client = (ai as unknown as { client: import('../core/WebRTCClient').WebRTCClient }).client;
214
+ dispatchPreset(client, PRESET_TOOL_CALL);
215
+
216
+ expect(order).toEqual(['preset', 'tool_call']);
217
+ });
218
+
219
+ it('does not emit preset for non-preset tools', async () => {
220
+ const { VaniraAI } = await import('../core/VaniraAI');
221
+ const { WebRTCClient } = await import('../core/WebRTCClient');
222
+
223
+ vi.spyOn(WebRTCClient.prototype, 'connect').mockResolvedValue(undefined);
224
+
225
+ const presetSpy = vi.fn();
226
+ const toolSpy = vi.fn();
227
+ const ai = new VaniraAI({ agentId: 'agent_1', apiKey: 'sk_test' });
228
+ ai.on('preset', presetSpy);
229
+ ai.on('tool_call', toolSpy);
230
+
231
+ await ai.start();
232
+
233
+ const client = (ai as unknown as { client: import('../core/WebRTCClient').WebRTCClient }).client;
234
+ dispatchPreset(client, NON_PRESET_TOOL_CALL);
235
+
236
+ expect(presetSpy).not.toHaveBeenCalled();
237
+ expect(toolSpy).toHaveBeenCalledTimes(1);
238
+ });
239
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * resolveRuntimeConfig unit tests (Task 0.2)
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ describe('resolveRuntimeConfig', () => {
8
+ beforeEach(() => {
9
+ Object.defineProperty(globalThis, 'navigator', {
10
+ value: { product: 'Gecko' },
11
+ configurable: true,
12
+ });
13
+ });
14
+
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ it('returns browserDefaultBundle when no runtime on web', async () => {
20
+ const { resolveRuntimeConfig } = await import('../runtime/resolveRuntimeConfig');
21
+ const { browserDefaultBundle } = await import('../runtime/runtimeBundles');
22
+
23
+ const resolved = resolveRuntimeConfig({ agentId: 'a1', serverUrl: 'https://w.example.com' });
24
+
25
+ expect(resolved.runtimeName).toBe('browser');
26
+ expect(resolved.callIdPrefix).toBe('web_');
27
+ expect(resolved.audio).toBe(browserDefaultBundle.audio);
28
+ expect(resolved.storage).toBe(browserDefaultBundle.storage);
29
+ });
30
+
31
+ it('returns reactNativeDefaultBundle when config.runtime is RN', async () => {
32
+ const { resolveRuntimeConfig } = await import('../runtime/resolveRuntimeConfig');
33
+ const { reactNativeDefaultBundle } = await import('../runtime/runtimeBundles');
34
+
35
+ const resolved = resolveRuntimeConfig({
36
+ agentId: 'a1',
37
+ serverUrl: 'https://w.example.com',
38
+ runtime: reactNativeDefaultBundle,
39
+ });
40
+
41
+ expect(resolved.runtimeName).toBe('react-native');
42
+ expect(resolved.callIdPrefix).toBe('rn_');
43
+ expect(resolved.storage).toBe(reactNativeDefaultBundle.storage);
44
+ });
45
+
46
+ it('throws on React Native without config.runtime', async () => {
47
+ Object.defineProperty(globalThis, 'navigator', {
48
+ value: { product: 'ReactNative' },
49
+ configurable: true,
50
+ });
51
+
52
+ const { resolveRuntimeConfig } = await import('../runtime/resolveRuntimeConfig');
53
+
54
+ expect(() => {
55
+ resolveRuntimeConfig({ agentId: 'a1', apiKey: 'sk_test' });
56
+ }).toThrow('[VaniraSDK] runtime is required');
57
+ });
58
+
59
+ it('config.storageAdapter overrides bundle storage', async () => {
60
+ const { resolveRuntimeConfig } = await import('../runtime/resolveRuntimeConfig');
61
+ const { browserDefaultBundle } = await import('../runtime/runtimeBundles');
62
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
63
+
64
+ const customStorage = new MemoryStorageAdapter();
65
+ const resolved = resolveRuntimeConfig({
66
+ agentId: 'a1',
67
+ serverUrl: 'https://w.example.com',
68
+ runtime: browserDefaultBundle,
69
+ storageAdapter: customStorage,
70
+ });
71
+
72
+ expect(resolved.storage).toBe(customStorage);
73
+ expect(resolved.storage).not.toBe(browserDefaultBundle.storage);
74
+ });
75
+
76
+ it('config.peerAdapter overrides bundle peer adapter', async () => {
77
+ const { resolveRuntimeConfig } = await import('../runtime/resolveRuntimeConfig');
78
+ const { browserDefaultBundle } = await import('../runtime/runtimeBundles');
79
+
80
+ const customPeer = { create: vi.fn() };
81
+ const resolved = resolveRuntimeConfig({
82
+ agentId: 'a1',
83
+ serverUrl: 'https://w.example.com',
84
+ runtime: browserDefaultBundle,
85
+ peerAdapter: customPeer,
86
+ });
87
+
88
+ expect(resolved.peer).toBe(customPeer);
89
+ });
90
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Storage adapter unit tests (Task 0.3)
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ // ─── BrowserDualStorageAdapter ────────────────────────────────────────────────
8
+
9
+ describe('BrowserDualStorageAdapter', () => {
10
+ let sessionStore: Record<string, string>;
11
+ let localStore: Record<string, string>;
12
+
13
+ beforeEach(() => {
14
+ sessionStore = {};
15
+ localStore = {};
16
+
17
+ vi.stubGlobal('sessionStorage', {
18
+ getItem: vi.fn((key: string) => (key in sessionStore ? sessionStore[key] : null)),
19
+ setItem: vi.fn((key: string, value: string) => { sessionStore[key] = value; }),
20
+ removeItem: vi.fn((key: string) => { delete sessionStore[key]; }),
21
+ });
22
+
23
+ vi.stubGlobal('localStorage', {
24
+ getItem: vi.fn((key: string) => (key in localStore ? localStore[key] : null)),
25
+ setItem: vi.fn((key: string, value: string) => { localStore[key] = value; }),
26
+ removeItem: vi.fn((key: string) => { delete localStore[key]; }),
27
+ });
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals();
32
+ });
33
+
34
+ it('setItem writes to both sessionStorage and localStorage', async () => {
35
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
36
+ const adapter = new BrowserDualStorageAdapter();
37
+
38
+ adapter.setItem('vanira_prospect_id', 'prospect_abc');
39
+
40
+ expect(sessionStorage.setItem).toHaveBeenCalledWith('vanira_prospect_id', 'prospect_abc');
41
+ expect(localStorage.setItem).toHaveBeenCalledWith('vanira_prospect_id', 'prospect_abc');
42
+ expect(sessionStore['vanira_prospect_id']).toBe('prospect_abc');
43
+ expect(localStore['vanira_prospect_id']).toBe('prospect_abc');
44
+ });
45
+
46
+ it('getItem prefers sessionStorage over localStorage', async () => {
47
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
48
+ const adapter = new BrowserDualStorageAdapter();
49
+
50
+ sessionStore['vanira_latest_call_id'] = 'call_from_session';
51
+ localStore['vanira_latest_call_id'] = 'call_from_local';
52
+
53
+ expect(adapter.getItem('vanira_latest_call_id')).toBe('call_from_session');
54
+ });
55
+
56
+ it('getItem falls back to localStorage when sessionStorage has no value', async () => {
57
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
58
+ const adapter = new BrowserDualStorageAdapter();
59
+
60
+ localStore['vanira_prospect_id'] = 'prospect_from_local';
61
+
62
+ expect(adapter.getItem('vanira_prospect_id')).toBe('prospect_from_local');
63
+ expect(sessionStorage.getItem).toHaveBeenCalledWith('vanira_prospect_id');
64
+ expect(localStorage.getItem).toHaveBeenCalledWith('vanira_prospect_id');
65
+ });
66
+
67
+ it('removeItem removes from both sessionStorage and localStorage', async () => {
68
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
69
+ const adapter = new BrowserDualStorageAdapter();
70
+
71
+ sessionStore['vanira_prospect_id'] = 'p1';
72
+ localStore['vanira_prospect_id'] = 'p1';
73
+
74
+ adapter.removeItem('vanira_prospect_id');
75
+
76
+ expect(sessionStorage.removeItem).toHaveBeenCalledWith('vanira_prospect_id');
77
+ expect(localStorage.removeItem).toHaveBeenCalledWith('vanira_prospect_id');
78
+ expect(sessionStore['vanira_prospect_id']).toBeUndefined();
79
+ expect(localStore['vanira_prospect_id']).toBeUndefined();
80
+ });
81
+
82
+ it('returns null when key is absent from both stores', async () => {
83
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
84
+ const adapter = new BrowserDualStorageAdapter();
85
+
86
+ expect(adapter.getItem('missing_key')).toBeNull();
87
+ });
88
+ });
89
+
90
+ // ─── MemoryStorageAdapter ─────────────────────────────────────────────────────
91
+
92
+ describe('MemoryStorageAdapter', () => {
93
+ it('round-trips getItem / setItem / removeItem', async () => {
94
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
95
+ const adapter = new MemoryStorageAdapter();
96
+
97
+ expect(adapter.getItem('vanira_prospect_id')).toBeNull();
98
+
99
+ adapter.setItem('vanira_prospect_id', 'prospect_123');
100
+ expect(adapter.getItem('vanira_prospect_id')).toBe('prospect_123');
101
+
102
+ adapter.removeItem('vanira_prospect_id');
103
+ expect(adapter.getItem('vanira_prospect_id')).toBeNull();
104
+ });
105
+
106
+ it('getItem does not throw on missing keys', async () => {
107
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
108
+ const adapter = new MemoryStorageAdapter();
109
+
110
+ expect(() => adapter.getItem('vanira_latest_call_id')).not.toThrow();
111
+ expect(adapter.getItem('vanira_latest_call_id')).toBeNull();
112
+ });
113
+
114
+ it('logs a one-time production warning on setItem', async () => {
115
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
116
+ const originalEnv = process.env.NODE_ENV;
117
+ process.env.NODE_ENV = 'production';
118
+
119
+ try {
120
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
121
+ const adapter = new MemoryStorageAdapter();
122
+
123
+ adapter.setItem('k1', 'v1');
124
+ adapter.setItem('k2', 'v2');
125
+
126
+ expect(warnSpy).toHaveBeenCalledTimes(1);
127
+ expect(warnSpy.mock.calls[0][0]).toContain('MemoryStorageAdapter');
128
+ } finally {
129
+ process.env.NODE_ENV = originalEnv;
130
+ warnSpy.mockRestore();
131
+ }
132
+ });
133
+ });
134
+
135
+ // ─── createSyncStorageAdapter ─────────────────────────────────────────────────
136
+
137
+ describe('createSyncStorageAdapter', () => {
138
+ it('delegates getItem, setItem, and removeItem to the backend', async () => {
139
+ const { createSyncStorageAdapter } = await import('../adapters/storage/createSyncStorageAdapter');
140
+
141
+ const store = new Map<string, string>();
142
+ const getString = vi.fn((key: string) => store.get(key));
143
+ const setString = vi.fn((key: string, value: string) => { store.set(key, value); });
144
+ const del = vi.fn((key: string) => { store.delete(key); });
145
+
146
+ const adapter = createSyncStorageAdapter({ getString, setString, delete: del });
147
+
148
+ expect(adapter.getItem('missing')).toBeNull();
149
+ expect(getString).toHaveBeenCalledWith('missing');
150
+
151
+ adapter.setItem('vanira_prospect_id', 'p_sync');
152
+ expect(setString).toHaveBeenCalledWith('vanira_prospect_id', 'p_sync');
153
+ expect(adapter.getItem('vanira_prospect_id')).toBe('p_sync');
154
+
155
+ adapter.removeItem('vanira_prospect_id');
156
+ expect(del).toHaveBeenCalledWith('vanira_prospect_id');
157
+ expect(adapter.getItem('vanira_prospect_id')).toBeNull();
158
+ });
159
+
160
+ it('maps undefined getString to null', async () => {
161
+ const { createSyncStorageAdapter } = await import('../adapters/storage/createSyncStorageAdapter');
162
+
163
+ const adapter = createSyncStorageAdapter({
164
+ getString: () => undefined,
165
+ setString: vi.fn(),
166
+ });
167
+
168
+ expect(adapter.getItem('any')).toBeNull();
169
+ });
170
+
171
+ it('removeItem is a no-op when backend has no delete', async () => {
172
+ const { createSyncStorageAdapter } = await import('../adapters/storage/createSyncStorageAdapter');
173
+
174
+ const store = new Map<string, string>([['k', 'v']]);
175
+ const adapter = createSyncStorageAdapter({
176
+ getString: (key) => store.get(key),
177
+ setString: (key, value) => { store.set(key, value); },
178
+ });
179
+
180
+ adapter.removeItem('k');
181
+ expect(adapter.getItem('k')).toBe('v');
182
+ });
183
+ });
184
+
185
+ // ─── Runtime bundle wiring ────────────────────────────────────────────────────
186
+
187
+ describe('runtime bundle storage defaults', () => {
188
+ it('browserDefaultBundle uses BrowserDualStorageAdapter', async () => {
189
+ const { browserDefaultBundle } = await import('../runtime/runtimeBundles');
190
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
191
+
192
+ expect(browserDefaultBundle.storage).toBeInstanceOf(BrowserDualStorageAdapter);
193
+ });
194
+
195
+ it('reactNativeDefaultBundle uses MemoryStorageAdapter', async () => {
196
+ const { reactNativeDefaultBundle } = await import('../runtime/runtimeBundles');
197
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
198
+
199
+ expect(reactNativeDefaultBundle.storage).toBeInstanceOf(MemoryStorageAdapter);
200
+ });
201
+
202
+ it('browserRuntime and reactNativeRuntime re-export bundle storage', async () => {
203
+ const { browserRuntime } = await import('../runtime/browserRuntime');
204
+ const { reactNativeRuntime } = await import('../runtime/reactNativeRuntime');
205
+ const { BrowserDualStorageAdapter } = await import('../adapters/storage/BrowserDualStorageAdapter');
206
+ const { MemoryStorageAdapter } = await import('../adapters/storage/MemoryStorageAdapter');
207
+
208
+ expect(browserRuntime.storage).toBeInstanceOf(BrowserDualStorageAdapter);
209
+ expect(reactNativeRuntime.storage).toBeInstanceOf(MemoryStorageAdapter);
210
+ });
211
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getIceTrickleUrl, isWorkerSdpUrl, mapIceServersFromCreate } from '../core/webrtcSignaling';
3
+
4
+ describe('webrtcSignaling', () => {
5
+ it('derives /webrtc/ice URL from worker_url', () => {
6
+ const worker =
7
+ 'https://in-godspeed.travelr.club/webrtc?agent_id=a&call_id=c&token=t';
8
+ expect(getIceTrickleUrl(worker)).toBe(
9
+ 'https://in-godspeed.travelr.club/webrtc/ice?agent_id=a&call_id=c&token=t',
10
+ );
11
+ expect(isWorkerSdpUrl(worker)).toBe(true);
12
+ expect(isWorkerSdpUrl(getIceTrickleUrl(worker))).toBe(false);
13
+ });
14
+
15
+ it('falls back when worker_url is not parseable', () => {
16
+ expect(getIceTrickleUrl('bad-url', 'agent-1', 'call-1')).toBe(
17
+ 'bad-url/webrtc/ice?agent_id=agent-1&call_id=call-1',
18
+ );
19
+ });
20
+
21
+ it('maps ice_servers from create response', () => {
22
+ const servers = mapIceServersFromCreate([
23
+ { urls: ['stun:stun.example.com:3478'], username: null, credential: null },
24
+ {
25
+ urls: ['turn:turn.example.com:3478'],
26
+ username: 'user',
27
+ credential: 'pass',
28
+ },
29
+ ]);
30
+ expect(servers).toHaveLength(2);
31
+ expect(servers[1]).toEqual({
32
+ urls: ['turn:turn.example.com:3478'],
33
+ username: 'user',
34
+ credential: 'pass',
35
+ });
36
+ });
37
+
38
+ it('throws when ice_servers missing', () => {
39
+ expect(() => mapIceServersFromCreate(undefined)).toThrow(/missing ice_servers/);
40
+ expect(() => mapIceServersFromCreate([])).toThrow(/missing ice_servers/);
41
+ });
42
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * PeerConnectionAdapter + DataChannelAdapter
3
+ *
4
+ * These interfaces abstract the two remaining WebRTC browser globals that the
5
+ * core session directly instantiated:
6
+ *
7
+ * new RTCPeerConnection(config) → PeerConnectionAdapter.create(config)
8
+ * RTCDataChannel event binding → DataChannelAdapter.bind(channel, handlers)
9
+ *
10
+ * The core (WebRTCClient) no longer calls these constructors directly.
11
+ * Each platform provides its own implementation.
12
+ *
13
+ * Platform notes:
14
+ * Browser — wraps native RTCPeerConnection and RTCDataChannel globals
15
+ * React Native — wraps react-native-webrtc polyfilled RTCPeerConnection (same API)
16
+ * Flutter — Dart flutter_webrtc package (createPeerConnection(), RTCDataChannel)
17
+ */
18
+
19
+ // ─── PeerConnectionAdapter ────────────────────────────────────────────────────
20
+
21
+ export interface PeerConnectionIceServer {
22
+ urls: string | string[];
23
+ username?: string;
24
+ credential?: string;
25
+ }
26
+
27
+ export interface PeerConnectionConfig {
28
+ iceServers: PeerConnectionIceServer[];
29
+ iceTransportPolicy?: 'all' | 'relay';
30
+ }
31
+
32
+ /**
33
+ * Abstracts RTCPeerConnection construction.
34
+ * Core session code calls adapter.create() instead of `new RTCPeerConnection()`.
35
+ *
36
+ * This is the minimal factory interface — once created, the returned
37
+ * RTCPeerConnection is used directly (its method surface is already standardised
38
+ * by W3C and polyfilled identically by react-native-webrtc).
39
+ */
40
+ export interface PeerConnectionAdapter {
41
+ /**
42
+ * Create and return a new peer connection.
43
+ * The returned object conforms to the standard RTCPeerConnection interface —
44
+ * callers may use it directly for addTrack, createDataChannel, createOffer, etc.
45
+ */
46
+ create(config: PeerConnectionConfig): RTCPeerConnection;
47
+ }
48
+
49
+ // ─── DataChannelAdapter ───────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Normalised message payload delivered to onMessage.
53
+ * The adapter handles the browser's string / ArrayBuffer / Blob variants
54
+ * and always delivers decoded UTF-8 text.
55
+ */
56
+ export interface DataChannelMessage {
57
+ /** Decoded text content — always a string regardless of wire format */
58
+ text: string;
59
+ }
60
+
61
+ export interface DataChannelHandlers {
62
+ onOpen(): void;
63
+ /** Always receives decoded text; binary frames are decoded by the adapter */
64
+ onMessage(msg: DataChannelMessage): void;
65
+ onError(error: unknown): void;
66
+ }
67
+
68
+ /**
69
+ * Returned by DataChannelAdapter.bind().
70
+ * Core code uses this controller to send messages and check state.
71
+ */
72
+ export interface DataChannelController {
73
+ /** Send a JSON-serialised text message */
74
+ send(text: string): void;
75
+ /** Whether the channel is in the 'open' state */
76
+ isOpen(): boolean;
77
+ /** Close the data channel */
78
+ close(): void;
79
+ }
80
+
81
+ /**
82
+ * Abstracts DataChannel event binding and message normalisation.
83
+ *
84
+ * Browser has three message wire formats: string, ArrayBuffer, and Blob.
85
+ * React Native (react-native-webrtc) delivers messages as strings only.
86
+ * The adapter normalises all formats to decoded UTF-8 text before calling
87
+ * onMessage, so the core never needs to handle binary framing.
88
+ *
89
+ * Binary frame handling policy (preserving existing behaviour):
90
+ * - Frames that decode to JSON with event === 'client_tool_call' are forwarded.
91
+ * - All other binary frames are logged but NOT forwarded to prevent false
92
+ * clearAudio events from being triggered by audio packet payloads.
93
+ */
94
+ export interface DataChannelAdapter {
95
+ /**
96
+ * Bind event handlers to a DataChannel and return a controller.
97
+ * @param channel The RTCDataChannel returned by pc.createDataChannel()
98
+ * @param handlers Event handlers — onMessage always receives decoded text
99
+ */
100
+ bind(channel: RTCDataChannel, handlers: DataChannelHandlers): DataChannelController;
101
+ }