@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.
- package/README.md +239 -0
- package/package.json +53 -0
- package/src/__tests__/WebRTCClient.integration.test.ts +396 -0
- package/src/__tests__/adapters.test.ts +475 -0
- package/src/__tests__/httpResponse.test.ts +25 -0
- package/src/__tests__/mocks/react-native-incall-manager.ts +8 -0
- package/src/__tests__/mocks/react-native-permissions.ts +15 -0
- package/src/__tests__/mocks/react-native-webrtc.ts +6 -0
- package/src/__tests__/mocks/react-native.ts +28 -0
- package/src/__tests__/preset.test.ts +239 -0
- package/src/__tests__/resolveRuntimeConfig.test.ts +90 -0
- package/src/__tests__/storage.test.ts +211 -0
- package/src/__tests__/webrtcSignaling.test.ts +42 -0
- package/src/adapters/PeerConnectionAdapter.ts +101 -0
- package/src/adapters/browser/BrowserAudioAdapter.ts +43 -0
- package/src/adapters/browser/BrowserDataChannelAdapter.ts +69 -0
- package/src/adapters/browser/BrowserMediaAdapter.ts +15 -0
- package/src/adapters/browser/BrowserPeerAdapter.ts +14 -0
- package/src/adapters/browser/index.ts +4 -0
- package/src/adapters/interfaces.ts +84 -0
- package/src/adapters/react-native/RNAudioAdapter.ts +42 -0
- package/src/adapters/react-native/RNDataChannelAdapter.ts +79 -0
- package/src/adapters/react-native/RNMediaAdapter.ts +46 -0
- package/src/adapters/react-native/RNPeerAdapter.ts +28 -0
- package/src/adapters/react-native/callAudioRouting.ts +115 -0
- package/src/adapters/react-native/decodeUtf8.ts +72 -0
- package/src/adapters/react-native/index.ts +4 -0
- package/src/adapters/react-native/rnUploadFile.ts +76 -0
- package/src/adapters/storage/BrowserDualStorageAdapter.ts +71 -0
- package/src/adapters/storage/MemoryStorageAdapter.ts +50 -0
- package/src/adapters/storage/StorageAdapter.ts +21 -0
- package/src/adapters/storage/createSyncStorageAdapter.ts +40 -0
- package/src/adapters/storage/index.ts +7 -0
- package/src/api/services/ChatService.ts +304 -0
- package/src/api/services/ConfigService.ts +33 -0
- package/src/assets/icons.js +35 -0
- package/src/cdn.ts +68 -0
- package/src/core/CallSessionStore.ts +137 -0
- package/src/core/DraggableController.ts +83 -0
- package/src/core/SessionManager.ts +322 -0
- package/src/core/VaniraAI.ts +464 -0
- package/src/core/WebRTCClient.ts +1012 -0
- package/src/core/httpResponse.ts +22 -0
- package/src/core/iceServers.ts +18 -0
- package/src/core/toolCallNormalize.ts +80 -0
- package/src/core/voice-client.js +236 -0
- package/src/core/webrtcSignaling.ts +72 -0
- package/src/index.js +34 -0
- package/src/index.ts +6 -0
- package/src/platforms/browser.ts +67 -0
- package/src/platforms/react-native.ts +105 -0
- package/src/presets/BookingCalendarModal.tsx +457 -0
- package/src/presets/CameraModal.tsx +576 -0
- package/src/presets/DynamicFormModal.tsx +378 -0
- package/src/presets/NativePresetRenderer.tsx +350 -0
- package/src/presets/NavigateHandler.tsx +75 -0
- package/src/presets/PresetHost.tsx +155 -0
- package/src/presets/PresetShellModal.tsx +97 -0
- package/src/presets/UploadModal.tsx +321 -0
- package/src/presets/calendar/calendarUtils.ts +386 -0
- package/src/presets/call/CallSpeakerToggle.tsx +59 -0
- package/src/presets/call/callAudioRouting.ts +2 -0
- package/src/presets/call/useCallSpeaker.ts +31 -0
- package/src/presets/camera/cameraPermissions.ts +18 -0
- package/src/presets/camera/cameraStream.ts +19 -0
- package/src/presets/camera/cameraUtils.ts +21 -0
- package/src/presets/camera/useLivenessFlow.ts +95 -0
- package/src/presets/chalkboard/ChalkboardOverlay.tsx +156 -0
- package/src/presets/chalkboard/EraseTextHandler.tsx +95 -0
- package/src/presets/chalkboard/TypeTextHandler.tsx +107 -0
- package/src/presets/chalkboard/boardAbort.ts +36 -0
- package/src/presets/chalkboard/boardQueue.ts +620 -0
- package/src/presets/chalkboard/chalkboardSession.ts +75 -0
- package/src/presets/chalkboard/drawUtils.ts +123 -0
- package/src/presets/chalkboard/textUtils.ts +109 -0
- package/src/presets/clipRegion/ClipRegionModal.tsx +261 -0
- package/src/presets/clipRegion/clipRegionBridge.ts +19 -0
- package/src/presets/form/formValidation.ts +104 -0
- package/src/presets/form/parseFormFields.ts +171 -0
- package/src/presets/host/HostElementPresetHandler.tsx +155 -0
- package/src/presets/host/hostPresetBridge.ts +71 -0
- package/src/presets/index.ts +63 -0
- package/src/presets/liveScreen/CloseLiveScreenHandler.tsx +36 -0
- package/src/presets/liveScreen/LiveScreenCaptureHost.tsx +312 -0
- package/src/presets/liveScreen/LiveScreenHandler.tsx +25 -0
- package/src/presets/liveScreen/LiveScreenPipOverlay.tsx +6 -0
- package/src/presets/liveScreen/liveScreenSession.ts +73 -0
- package/src/presets/liveVision/CloseLiveVisionHandler.tsx +29 -0
- package/src/presets/liveVision/LiveVisionCameraHost.tsx +317 -0
- package/src/presets/liveVision/LiveVisionHandler.tsx +26 -0
- package/src/presets/liveVision/LiveVisionPipOverlay.tsx +7 -0
- package/src/presets/liveVision/liveVisionFrameLoop.ts +38 -0
- package/src/presets/liveVision/liveVisionSession.ts +75 -0
- package/src/presets/liveVision/liveVisionUpload.ts +62 -0
- package/src/presets/navigation/internalRouteRegistry.ts +25 -0
- package/src/presets/navigation/navigationBridge.ts +76 -0
- package/src/presets/navigation/navigationTypes.ts +12 -0
- package/src/presets/parseToolCall.ts +60 -0
- package/src/presets/presetClientAdapter.ts +29 -0
- package/src/presets/presetCompletion.ts +91 -0
- package/src/presets/presetEventHelpers.ts +45 -0
- package/src/presets/registry.ts +128 -0
- package/src/presets/streaming/mediaFrameUpload.ts +93 -0
- package/src/presets/types.ts +74 -0
- package/src/presets/upload/pickUploadFile.ts +256 -0
- package/src/presets/upload/uploadFormats.ts +163 -0
- package/src/presets/upload/uploadUtils.ts +68 -0
- package/src/react/PresetRenderer.tsx +144 -0
- package/src/react/index.ts +1 -0
- package/src/runtime/browserRuntime.ts +54 -0
- package/src/runtime/platform.ts +17 -0
- package/src/runtime/reactNativeRuntime.ts +68 -0
- package/src/runtime/resolveRuntimeConfig.ts +75 -0
- package/src/runtime/runtimeBundles.ts +74 -0
- package/src/runtime/types.ts +135 -0
- package/src/types/react-native-incall-manager.d.ts +17 -0
- package/src/types/react-native-webrtc.d.ts +47 -0
- package/src/types.ts +133 -0
- package/src/ui/VaniraWidget.ts +87 -0
- package/src/ui/abstraction/AbstractWidgetProvider.ts +18 -0
- package/src/ui/abstraction/interfaces.ts +12 -0
- package/src/ui/adapters/VaniraChatAdapter.ts +42 -0
- package/src/ui/components/AvatarView.ts +81 -0
- package/src/ui/components/ChatWindow.ts +263 -0
- package/src/ui/components/FloatingButton.ts +163 -0
- package/src/ui/components/FloatingWelcomeChips.ts +137 -0
- package/src/ui/components/Panel.ts +120 -0
- package/src/ui/components/VoiceOrb.ts +79 -0
- package/src/ui/components/VoiceOverlay.ts +497 -0
- package/src/ui/components/index.ts +7 -0
- package/src/ui/factory/WidgetFactory.ts +16 -0
- package/src/ui/icons_data.ts +2 -0
- package/src/ui/presets/WidgetPresetRenderer.ts +1802 -0
- package/src/ui/presets/types.ts +16 -0
- package/src/ui/providers/VaniraInternalProvider.ts +1066 -0
- package/src/ui/styles/index.ts +323 -0
- package/src/ui/styles/keyframes.ts +76 -0
- package/src/ui/styles/theme.ts +57 -0
- package/src/ui/styles/widget.css.ts +838 -0
- package/src/ui/utils.ts +37 -0
- package/src/ui/views/AbstractChatView.ts +93 -0
- package/src/ui/views/AbstractVoiceView.ts +57 -0
- package/src/ui/views/AvatarOnlyView.ts +78 -0
- package/src/ui/views/ChatAvatarView.ts +66 -0
- package/src/ui/views/ChatOnlyView.ts +28 -0
- package/src/ui/views/ChatVoiceView.ts +15 -0
- package/src/ui/views/VoiceOnlyView.ts +25 -0
- 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,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: () => {}};
|