@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,475 @@
1
+ /**
2
+ * Vanira SDK — Cross-platform adapter unit tests
3
+ *
4
+ * Tests cover:
5
+ * 1. BrowserPeerAdapter — wraps RTCPeerConnection creation
6
+ * 2. BrowserDataChannelAdapter — normalises browser message formats
7
+ * 3. RNDataChannelAdapter — handles RN string-only messages
8
+ * 4. BrowserMediaAdapter — wraps getUserMedia
9
+ * 5. BrowserAudioAdapter — wraps HTMLAudioElement
10
+ * 6. Runtime capabilities — correct flags per runtime
11
+ * 7. WebRTCClient — ICE server propagation, runtime injection
12
+ * 8. DataChannel protocol — JSON parsing, client_tool_call ack
13
+ */
14
+
15
+ import { describe, it, expect, vi } from 'vitest';
16
+
17
+ // ─── Helpers: minimal browser global mocks ────────────────────────────────────
18
+
19
+ function makeMockRTCPeerConnection() {
20
+ return {
21
+ addTrack: vi.fn(),
22
+ createDataChannel: vi.fn(() => makeMockRTCDataChannel()),
23
+ createOffer: vi.fn(() => Promise.resolve({ type: 'offer', sdp: 'mock-sdp' })),
24
+ setLocalDescription: vi.fn(() => Promise.resolve()),
25
+ setRemoteDescription: vi.fn(() => Promise.resolve()),
26
+ getSenders: vi.fn(() => []),
27
+ close: vi.fn(),
28
+ iceGatheringState: 'complete',
29
+ connectionState: 'new',
30
+ iceConnectionState: 'new',
31
+ localDescription: { type: 'offer', sdp: 'mock-sdp' },
32
+ ontrack: null,
33
+ onconnectionstatechange: null,
34
+ oniceconnectionstatechange: null,
35
+ addEventListener: vi.fn(),
36
+ removeEventListener: vi.fn(),
37
+ };
38
+ }
39
+
40
+ function makeMockRTCDataChannel() {
41
+ return {
42
+ send: vi.fn(),
43
+ close: vi.fn(),
44
+ readyState: 'open' as RTCDataChannelState,
45
+ onopen: null as (() => void) | null,
46
+ onmessage: null as ((e: MessageEvent) => void) | null,
47
+ onerror: null as ((e: Event) => void) | null,
48
+ };
49
+ }
50
+
51
+ // ─── 1. BrowserPeerAdapter ────────────────────────────────────────────────────
52
+
53
+ describe('BrowserPeerAdapter', () => {
54
+ it('creates an RTCPeerConnection with correct ICE server config', async () => {
55
+ const mockPc = makeMockRTCPeerConnection();
56
+ // BrowserPeerAdapter uses `new RTCPeerConnection()` which requires the global
57
+ // to be a constructor function
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ function MockRTC(this: any, config: unknown) {
60
+ Object.assign(this, mockPc);
61
+ (MockRTC as unknown as { lastConfig: unknown }).lastConfig = config;
62
+ }
63
+ (globalThis as unknown as Record<string, unknown>).RTCPeerConnection = MockRTC;
64
+
65
+ const { BrowserPeerAdapter } = await import('../adapters/browser/BrowserPeerAdapter');
66
+ const adapter = new BrowserPeerAdapter();
67
+ adapter.create({
68
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
69
+ iceTransportPolicy: 'all',
70
+ });
71
+
72
+ expect((MockRTC as unknown as { lastConfig: unknown }).lastConfig).toEqual({
73
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
74
+ iceTransportPolicy: 'all',
75
+ });
76
+ });
77
+ });
78
+
79
+ // ─── 2. BrowserDataChannelAdapter ────────────────────────────────────────────
80
+
81
+ describe('BrowserDataChannelAdapter', () => {
82
+ it('forwards string messages directly', async () => {
83
+ const { BrowserDataChannelAdapter } = await import('../adapters/browser/BrowserDataChannelAdapter');
84
+ const channel = makeMockRTCDataChannel();
85
+ const onMessage = vi.fn();
86
+
87
+ const adapter = new BrowserDataChannelAdapter();
88
+ adapter.bind(channel as unknown as RTCDataChannel, {
89
+ onOpen: vi.fn(),
90
+ onMessage,
91
+ onError: vi.fn(),
92
+ });
93
+
94
+ const event = { data: '{"event":"transcription","text":"hello","isFinal":true}' } as MessageEvent;
95
+ channel.onmessage!(event);
96
+
97
+ expect(onMessage).toHaveBeenCalledWith({
98
+ text: '{"event":"transcription","text":"hello","isFinal":true}',
99
+ });
100
+ });
101
+
102
+ it('decodes ArrayBuffer client_tool_call events and forwards them', async () => {
103
+ const { BrowserDataChannelAdapter } = await import('../adapters/browser/BrowserDataChannelAdapter');
104
+ const channel = makeMockRTCDataChannel();
105
+ const onMessage = vi.fn();
106
+
107
+ const adapter = new BrowserDataChannelAdapter();
108
+ adapter.bind(channel as unknown as RTCDataChannel, {
109
+ onOpen: vi.fn(),
110
+ onMessage,
111
+ onError: vi.fn(),
112
+ });
113
+
114
+ const payload = JSON.stringify({ event: 'client_tool_call', tool_call_id: 'tc_1' });
115
+ const encoder = new TextEncoder();
116
+ const buffer = encoder.encode(payload).buffer;
117
+
118
+ channel.onmessage!({ data: buffer } as MessageEvent);
119
+
120
+ expect(onMessage).toHaveBeenCalledWith({ text: payload });
121
+ });
122
+
123
+ it('drops non-client_tool_call binary frames', async () => {
124
+ const { BrowserDataChannelAdapter } = await import('../adapters/browser/BrowserDataChannelAdapter');
125
+ const channel = makeMockRTCDataChannel();
126
+ const onMessage = vi.fn();
127
+
128
+ const adapter = new BrowserDataChannelAdapter();
129
+ adapter.bind(channel as unknown as RTCDataChannel, {
130
+ onOpen: vi.fn(),
131
+ onMessage,
132
+ onError: vi.fn(),
133
+ });
134
+
135
+ const payload = JSON.stringify({ event: 'clearAudio' });
136
+ const encoder = new TextEncoder();
137
+ const buffer = encoder.encode(payload).buffer;
138
+
139
+ channel.onmessage!({ data: buffer } as MessageEvent);
140
+
141
+ expect(onMessage).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('controller.isOpen() reflects channel state', async () => {
145
+ const { BrowserDataChannelAdapter } = await import('../adapters/browser/BrowserDataChannelAdapter');
146
+ const channel = makeMockRTCDataChannel();
147
+ channel.readyState = 'open';
148
+
149
+ const adapter = new BrowserDataChannelAdapter();
150
+ const controller = adapter.bind(channel as unknown as RTCDataChannel, {
151
+ onOpen: vi.fn(),
152
+ onMessage: vi.fn(),
153
+ onError: vi.fn(),
154
+ });
155
+
156
+ expect(controller.isOpen()).toBe(true);
157
+
158
+ channel.readyState = 'closed';
159
+ expect(controller.isOpen()).toBe(false);
160
+ });
161
+
162
+ it('controller.send() only fires when channel is open', async () => {
163
+ const { BrowserDataChannelAdapter } = await import('../adapters/browser/BrowserDataChannelAdapter');
164
+ const channel = makeMockRTCDataChannel();
165
+
166
+ const adapter = new BrowserDataChannelAdapter();
167
+ const controller = adapter.bind(channel as unknown as RTCDataChannel, {
168
+ onOpen: vi.fn(),
169
+ onMessage: vi.fn(),
170
+ onError: vi.fn(),
171
+ });
172
+
173
+ channel.readyState = 'open';
174
+ controller.send('{"event":"test"}');
175
+ expect(channel.send).toHaveBeenCalledWith('{"event":"test"}');
176
+
177
+ vi.clearAllMocks();
178
+ channel.readyState = 'closed';
179
+ controller.send('{"event":"test"}');
180
+ expect(channel.send).not.toHaveBeenCalled();
181
+ });
182
+ });
183
+
184
+ // ─── 3. RNDataChannelAdapter ──────────────────────────────────────────────────
185
+
186
+ describe('RNDataChannelAdapter', () => {
187
+ it('forwards string messages', async () => {
188
+ const { RNDataChannelAdapter } = await import('../adapters/react-native/RNDataChannelAdapter');
189
+ const channel = makeMockRTCDataChannel();
190
+ const onMessage = vi.fn();
191
+
192
+ const adapter = new RNDataChannelAdapter();
193
+ adapter.bind(channel as unknown as RTCDataChannel, {
194
+ onOpen: vi.fn(),
195
+ onMessage,
196
+ onError: vi.fn(),
197
+ });
198
+
199
+ channel.onmessage!({ data: '{"event":"transcription","text":"hi","isFinal":false}' } as MessageEvent);
200
+ expect(onMessage).toHaveBeenCalledWith({
201
+ text: '{"event":"transcription","text":"hi","isFinal":false}',
202
+ });
203
+ });
204
+
205
+ it('decodes ArrayBuffer client_tool_call without global TextDecoder', async () => {
206
+ const original = globalThis.TextDecoder;
207
+ // @ts-expect-error simulate Hermes without TextDecoder
208
+ delete globalThis.TextDecoder;
209
+
210
+ const { RNDataChannelAdapter } = await import('../adapters/react-native/RNDataChannelAdapter');
211
+ const channel = makeMockRTCDataChannel();
212
+ const onMessage = vi.fn();
213
+
214
+ const adapter = new RNDataChannelAdapter();
215
+ adapter.bind(channel as unknown as RTCDataChannel, {
216
+ onOpen: vi.fn(),
217
+ onMessage,
218
+ onError: vi.fn(),
219
+ });
220
+
221
+ const payload = JSON.stringify({
222
+ event: 'client_tool_call',
223
+ tool_call: {
224
+ tool_call_id: 'tc_rn_1',
225
+ name: 'display_booking_calendar',
226
+ arguments: { user_id: '113', reason: 'book' },
227
+ },
228
+ });
229
+ const encoder = new TextEncoder();
230
+ const buffer = encoder.encode(payload).buffer;
231
+
232
+ channel.onmessage!({ data: buffer } as MessageEvent);
233
+
234
+ expect(onMessage).toHaveBeenCalledWith({ text: payload });
235
+
236
+ if (original) {
237
+ globalThis.TextDecoder = original;
238
+ }
239
+ });
240
+ });
241
+
242
+ // ─── 4. Runtime capabilities ──────────────────────────────────────────────────
243
+
244
+ describe('Runtime capabilities', () => {
245
+ it('browserCapabilities has supportsAudioEndedEvent=true', async () => {
246
+ const { browserCapabilities } = await import('../runtime/browserRuntime');
247
+ expect(browserCapabilities.supportsAudioEndedEvent).toBe(true);
248
+ expect(browserCapabilities.supportsDom).toBeDefined();
249
+ });
250
+
251
+ it('reactNativeCapabilities has all flags false', async () => {
252
+ const { reactNativeCapabilities } = await import('../runtime/reactNativeRuntime');
253
+ expect(reactNativeCapabilities.supportsAudioEndedEvent).toBe(false);
254
+ expect(reactNativeCapabilities.supportsScreenShare).toBe(false);
255
+ expect(reactNativeCapabilities.supportsDom).toBe(false);
256
+ expect(reactNativeCapabilities.supportsHtmlAudio).toBe(false);
257
+ expect(reactNativeCapabilities.supportsCustomElements).toBe(false);
258
+ expect(reactNativeCapabilities.supportsLocalStorage).toBe(false);
259
+ expect(reactNativeCapabilities.supportsBroadcastChannel).toBe(false);
260
+ });
261
+
262
+ it('reactNativeRuntime has correct protocol metadata', async () => {
263
+ const { reactNativeRuntime } = await import('../runtime/reactNativeRuntime');
264
+ expect(reactNativeRuntime.callType).toBe('web');
265
+ expect(reactNativeRuntime.runtimeName).toBe('react-native');
266
+ expect(reactNativeRuntime.callIdPrefix).toBe('rn_');
267
+ });
268
+
269
+ it('browserRuntime has correct protocol metadata', async () => {
270
+ const { browserRuntime } = await import('../runtime/browserRuntime');
271
+ expect(browserRuntime.callType).toBe('web');
272
+ expect(browserRuntime.runtimeName).toBe('browser');
273
+ expect(browserRuntime.callIdPrefix).toBe('web_');
274
+ });
275
+ });
276
+
277
+ // ─── 5. DataChannel protocol — JSON parsing ───────────────────────────────────
278
+
279
+ describe('DataChannel protocol parsing', () => {
280
+ it('correctly identifies client_tool_call events', () => {
281
+ const payload = {
282
+ event: 'client_tool_call',
283
+ tool_call_id: 'tc_abc123',
284
+ tool_call: {
285
+ tool_call_id: 'tc_abc123',
286
+ name: 'show_product',
287
+ arguments: { product_id: '42' },
288
+ execution_mode: 'blocking',
289
+ },
290
+ };
291
+ const text = JSON.stringify(payload);
292
+ const parsed = JSON.parse(text) as { event: string };
293
+ expect(parsed.event).toBe('client_tool_call');
294
+ });
295
+
296
+ it('distinguishes transcription events with isFinal flag', () => {
297
+ const interim = JSON.parse('{"event":"transcription","text":"hello","isFinal":false}') as {
298
+ event: string; text: string; isFinal: boolean;
299
+ };
300
+ const final = JSON.parse('{"event":"transcription","text":"hello world","isFinal":true}') as {
301
+ event: string; text: string; isFinal: boolean;
302
+ };
303
+ expect(interim.isFinal).toBe(false);
304
+ expect(final.isFinal).toBe(true);
305
+ });
306
+ });
307
+
308
+ // ─── 6. ICE server propagation ────────────────────────────────────────────────
309
+
310
+ describe('ICE server propagation', () => {
311
+ it('WebRTCClient stores provided iceServers', async () => {
312
+ // Just test config storage — no actual RTCPeerConnection created here
313
+ const customServers = [{ urls: 'stun:custom.example.com:3478' }];
314
+
315
+ const { WebRTCClient } = await import('../core/WebRTCClient');
316
+
317
+ // We can't easily test the private iceServers field without a real connect()
318
+ // but we can verify the config is accepted without throwing
319
+ expect(() => {
320
+ new WebRTCClient({
321
+ agentId: 'test-agent',
322
+ serverUrl: 'https://worker.example.com',
323
+ iceServers: customServers as RTCIceServer[],
324
+ });
325
+ }).not.toThrow();
326
+ });
327
+
328
+ it('WebRTCClient throws when neither serverUrl nor apiKey is provided', async () => {
329
+ const { WebRTCClient } = await import('../core/WebRTCClient');
330
+ expect(() => {
331
+ new WebRTCClient({ agentId: 'test-agent' } as never);
332
+ }).toThrow('[VaniraSDK] Provide either serverUrl or apiKey');
333
+ });
334
+
335
+ it('WebRTCClient throws when agentId is missing', async () => {
336
+ const { WebRTCClient } = await import('../core/WebRTCClient');
337
+ expect(() => {
338
+ new WebRTCClient({ serverUrl: 'https://worker.example.com' } as never);
339
+ }).toThrow('[VaniraSDK] agentId is required');
340
+ });
341
+ });
342
+
343
+ // ─── 7. createCall runtime metadata ──────────────────────────────────────────
344
+
345
+ describe('createCall runtime metadata', () => {
346
+ it('sends correct type and runtime for browser', async () => {
347
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
348
+ ok: true,
349
+ json: () => Promise.resolve({ call_id: 'call_1', worker_url: 'https://w.example.com' }),
350
+ } as Response).mockResolvedValueOnce({
351
+ ok: true,
352
+ json: () => Promise.resolve({ answer: { type: 'answer', sdp: '' } }),
353
+ } as Response);
354
+
355
+ // Mock RTCPeerConnection for the connect() call
356
+ const mockPc = makeMockRTCPeerConnection();
357
+ (globalThis as unknown as Record<string, unknown>).RTCPeerConnection = vi.fn(() => mockPc);
358
+
359
+ const mockGetUserMedia = vi.fn(() => Promise.resolve({
360
+ getTracks: () => [{ kind: 'audio', stop: vi.fn() }],
361
+ } as unknown as MediaStream));
362
+ Object.defineProperty(globalThis, 'navigator', {
363
+ value: { mediaDevices: { getUserMedia: mockGetUserMedia } },
364
+ configurable: true,
365
+ });
366
+
367
+ const { WebRTCClient } = await import('../core/WebRTCClient');
368
+ const { browserRuntime } = await import('../runtime/browserRuntime');
369
+
370
+ const client = new WebRTCClient({
371
+ agentId: 'agent_1',
372
+ apiKey: 'sk_live_test',
373
+ runtime: browserRuntime,
374
+ });
375
+
376
+ // Only check the first fetch (createCall) — stop before connect succeeds
377
+ try {
378
+ await client.createCall();
379
+ } catch {
380
+ // connect() may fail due to mock limitations, ignore
381
+ }
382
+
383
+ const firstCallArgs = fetchSpy.mock.calls[0];
384
+ expect(firstCallArgs[0]).toContain('/calls/create');
385
+ const body = JSON.parse(firstCallArgs[1]?.body as string);
386
+ expect(body.type).toBe('web');
387
+ expect(body.runtime).toBe('browser');
388
+
389
+ vi.restoreAllMocks();
390
+ });
391
+
392
+ it('sends type=web runtime=react-native for RN runtime', async () => {
393
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
394
+ ok: true,
395
+ json: () => Promise.resolve({ call_id: 'call_rn_1', worker_url: 'https://w.example.com' }),
396
+ } as Response).mockResolvedValueOnce({
397
+ ok: true,
398
+ json: () => Promise.resolve({ answer: { type: 'answer', sdp: '' } }),
399
+ } as Response);
400
+
401
+ const mockPc = makeMockRTCPeerConnection();
402
+ (globalThis as unknown as Record<string, unknown>).RTCPeerConnection = vi.fn(() => mockPc);
403
+
404
+ const { WebRTCClient } = await import('../core/WebRTCClient');
405
+ const { reactNativeRuntime } = await import('../runtime/reactNativeRuntime');
406
+
407
+ const client = new WebRTCClient({
408
+ agentId: 'agent_1',
409
+ apiKey: 'sk_live_test',
410
+ runtime: reactNativeRuntime,
411
+ });
412
+
413
+ try {
414
+ await client.createCall();
415
+ } catch {
416
+ // ignore connect() failures in test environment
417
+ }
418
+
419
+ const firstCallArgs = fetchSpy.mock.calls[0];
420
+ const body = JSON.parse(firstCallArgs[1]?.body as string);
421
+ expect(body.type).toBe('web');
422
+ expect(body.runtime).toBe('react-native');
423
+
424
+ vi.restoreAllMocks();
425
+ });
426
+ });
427
+
428
+ // ─── 8. playedStream capability flag ─────────────────────────────────────────
429
+
430
+ describe('playedStream capability flag', () => {
431
+ it('supportsAudioEndedEvent=false skips playedStream registration', async () => {
432
+ const { reactNativeCapabilities } = await import('../runtime/reactNativeRuntime');
433
+ // The flag itself is what the WebRTCClient reads to decide whether to
434
+ // register onEnded. Verify the RN runtime correctly signals this.
435
+ expect(reactNativeCapabilities.supportsAudioEndedEvent).toBe(false);
436
+ });
437
+
438
+ it('supportsAudioEndedEvent=true registers onEnded on browser', async () => {
439
+ const { browserCapabilities } = await import('../runtime/browserRuntime');
440
+ expect(browserCapabilities.supportsAudioEndedEvent).toBe(true);
441
+ });
442
+
443
+ it('sends playedStream after final transcription on RN runtime (not on mark)', async () => {
444
+ vi.useFakeTimers();
445
+ const { reactNativeRuntime } = await import('../runtime/reactNativeRuntime');
446
+ const { WebRTCClient } = await import('../core/WebRTCClient');
447
+
448
+ const client = new WebRTCClient({
449
+ agentId: 'agent_1',
450
+ serverUrl: 'https://worker.example.com',
451
+ runtime: reactNativeRuntime,
452
+ });
453
+
454
+ const send = vi.fn();
455
+ Object.assign(client, {
456
+ dcController: { isOpen: () => true, send },
457
+ });
458
+
459
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
460
+ (client as any).handleControlEvent({ event: 'mark', name: 'tts_end' });
461
+ vi.advanceTimersByTime(2000);
462
+ expect(send).not.toHaveBeenCalled();
463
+
464
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
+ (client as any).handleControlEvent({
466
+ event: 'transcription',
467
+ text: 'Hello',
468
+ isFinal: true,
469
+ });
470
+ vi.advanceTimersByTime(1800);
471
+
472
+ expect(send).toHaveBeenCalledWith(JSON.stringify({ event: 'playedStream' }));
473
+ vi.useRealTimers();
474
+ });
475
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readResponseJson } from '../core/httpResponse';
3
+
4
+ function mockResponse(status: number, body: string): Response {
5
+ return {
6
+ status,
7
+ text: async () => body,
8
+ } as Response;
9
+ }
10
+
11
+ describe('readResponseJson', () => {
12
+ it('parses valid JSON', async () => {
13
+ const data = await readResponseJson(
14
+ mockResponse(200, '{"answer":{"type":"answer","sdp":"v=0"}}'),
15
+ 'test',
16
+ );
17
+ expect(data.answer).toBeDefined();
18
+ });
19
+
20
+ it('throws with body snippet when response is plain text "error"', async () => {
21
+ await expect(
22
+ readResponseJson(mockResponse(502, 'error'), 'WebRTC offer exchange'),
23
+ ).rejects.toThrow(/expected JSON but got.*error/);
24
+ });
25
+ });
@@ -0,0 +1,8 @@
1
+ const InCallManager = {
2
+ start: () => {},
3
+ stop: () => {},
4
+ setForceSpeakerphoneOn: () => {},
5
+ setSpeakerphoneOn: () => {},
6
+ };
7
+
8
+ export default InCallManager;
@@ -0,0 +1,15 @@
1
+ export const PERMISSIONS = {
2
+ IOS: {PHOTO_LIBRARY: 'ios.permission.PHOTO_LIBRARY'},
3
+ ANDROID: {
4
+ READ_MEDIA_IMAGES: 'android.permission.READ_MEDIA_IMAGES',
5
+ READ_EXTERNAL_STORAGE: 'android.permission.READ_EXTERNAL_STORAGE',
6
+ },
7
+ };
8
+
9
+ export const RESULTS = {
10
+ GRANTED: 'granted',
11
+ LIMITED: 'limited',
12
+ DENIED: 'denied',
13
+ };
14
+
15
+ export const request = async () => RESULTS.GRANTED;
@@ -0,0 +1,6 @@
1
+ export const RTCPeerConnection = class {};
2
+ export const RTCSessionDescription = class {};
3
+ export const RTCIceCandidate = class {};
4
+ export const mediaDevices = {getUserMedia: async () => ({})};
5
+ export const registerGlobals = () => {};
6
+ export const RTCView = 'RTCView';
@@ -0,0 +1,28 @@
1
+ export const NativeModules = {
2
+ InCallManager: {},
3
+ };
4
+
5
+ export const Platform = {
6
+ OS: 'ios' as const,
7
+ Version: '17.0',
8
+ select<T>(specifics: {ios?: T; android?: T; default?: T}): T | undefined {
9
+ return specifics.ios ?? specifics.default;
10
+ },
11
+ };
12
+
13
+ export const Linking = {
14
+ openURL: async () => {},
15
+ canOpenURL: async () => true,
16
+ };
17
+
18
+ export const Modal = 'Modal';
19
+ export const View = 'View';
20
+ export const Text = 'Text';
21
+ export const Pressable = 'Pressable';
22
+ export const StyleSheet = {
23
+ create<T extends Record<string, unknown>>(styles: T): T {
24
+ return styles;
25
+ },
26
+ };
27
+ export const ActivityIndicator = 'ActivityIndicator';
28
+ export const Alert = {alert: () => {}};