|
1 | | -import React, { createContext, useMemo, useState, useEffect, useRef } from 'react'; |
2 | | - |
3 | | -/** |
4 | | - * Mock Daily.co Context for component tests |
5 | | - * |
6 | | - * Provides mocked Daily.co state that the aliased Daily hooks read from. |
7 | | - * Use this for tests that need to control video/audio track states |
8 | | - * without actually connecting to Daily.co. |
9 | | - * |
10 | | - * ## Test-Accessible Globals (exposed on window) |
11 | | - * |
12 | | - * ### window.mockCallObject |
13 | | - * The mock call object with a real EventEmitter. Use to fire Daily events: |
14 | | - * await page.evaluate(() => window.mockCallObject.emit('participant-joined', { |
15 | | - * participant: { session_id: 'daily-p1', tracks: {} } |
16 | | - * })); |
17 | | - * Track updateParticipants calls: |
18 | | - * const calls = await page.evaluate(() => window.mockCallObject._updateParticipantsCalls); |
19 | | - * |
20 | | - * Set participant userData for fast position mapping (issue #1187): |
21 | | - * await page.evaluate(() => { |
22 | | - * window.mockCallObject._participants = { |
23 | | - * 'daily-p1': { session_id: 'daily-p1', userData: { position: 1 }, tracks: {} } |
24 | | - * }; |
25 | | - * }); |
26 | | - * |
27 | | - * ### window.mockDailySetLocalSessionId(id) |
28 | | - * Update the local session ID mid-test to simulate reconnection: |
29 | | - * await page.evaluate(() => window.mockDailySetLocalSessionId('daily-p0-v2')); |
30 | | - * |
31 | | - * ### window.mockDailyDeviceOverrides |
32 | | - * Override device functions BEFORE mount to test error scenarios: |
33 | | - * await page.evaluate(() => { |
34 | | - * window.mockDailyDeviceOverrides = { |
35 | | - * setSpeaker: () => Promise.reject(new DOMException('NotAllowedError', 'NotAllowedError')), |
36 | | - * }; |
37 | | - * }); |
38 | | - * The device functions read this at call-time, so overrides work even after mount. |
39 | | - */ |
40 | | -export const MockDailyContext = createContext(null); |
41 | | - |
42 | | -/** |
43 | | - * Minimal EventEmitter for MockCallObject. |
44 | | - * Supports on/off/emit — same interface as Daily's call object. |
45 | | - */ |
46 | | -class MockEventEmitter { |
47 | | - constructor() { |
48 | | - this._handlers = {}; |
49 | | - } |
50 | | - |
51 | | - on(event, handler) { |
52 | | - if (!this._handlers[event]) this._handlers[event] = []; |
53 | | - this._handlers[event].push(handler); |
54 | | - } |
55 | | - |
56 | | - off(event, handler) { |
57 | | - if (!this._handlers[event]) return; |
58 | | - this._handlers[event] = this._handlers[event].filter(h => h !== handler); |
59 | | - } |
60 | | - |
61 | | - emit(event, data) { |
62 | | - const handlers = this._handlers[event] || []; |
63 | | - handlers.forEach(h => h(data)); |
64 | | - } |
65 | | -} |
66 | | - |
67 | | -/** |
68 | | - * Mock call object — a proper EventEmitter with spy tracking. |
69 | | - * |
70 | | - * Exposed on window.mockCallObject so tests can: |
71 | | - * - Fire Daily events: window.mockCallObject.emit('joined-meeting', {}) |
72 | | - * - Inspect calls: window.mockCallObject._updateParticipantsCalls |
73 | | - * - Control state: window.mockCallObject._meetingState = 'left-meeting' |
74 | | - * - Simulate muted mic: window.mockCallObject._audioEnabled = false |
75 | | - * - Simulate ended track: window.mockCallObject._audioReadyState = 'ended' |
76 | | - */ |
77 | | -class MockCallObject extends MockEventEmitter { |
78 | | - constructor() { |
79 | | - super(); |
80 | | - this._meetingState = 'joined-meeting'; |
81 | | - this._updateParticipantsCalls = []; |
82 | | - this._setInputDevicesCalls = []; // eslint-disable-line no-underscore-dangle |
83 | | - this._participants = {}; |
84 | | - this._audioEnabled = true; // false = mic muted; tests can set via _audioEnabled |
85 | | - this._videoEnabled = true; // false = camera muted; tests can set via _videoEnabled |
86 | | - this._audioReadyState = 'live'; // 'ended' = track ended; tests can set via _audioReadyState |
87 | | - this._videoReadyState = 'live'; // 'ended' = track ended; tests can set via _videoReadyState |
88 | | - this._localUserData = null; // userData passed in join() options |
89 | | - this._localSessionId = null; // session ID of local participant (set via setLocalSessionId) |
90 | | - this._joinCalled = false; // tracks whether join() was called (for rejoin tests) |
91 | | - } |
92 | | - |
93 | | - meetingState() { return this._meetingState; } |
94 | | - isDestroyed() { return false; } |
95 | | - |
96 | | - // join() accepts options including userData for immediate position mapping (issue #1187) |
97 | | - // The userData is stored and returned in participants() for the local participant. |
98 | | - join(options = {}) { |
99 | | - this._joinCalled = true; |
100 | | - if (options.userData) { |
101 | | - this._localUserData = options.userData; |
102 | | - } |
103 | | - return Promise.resolve(); |
104 | | - } |
105 | | - |
106 | | - leave() { return Promise.resolve(); } |
107 | | - setUserName() {} |
108 | | - |
109 | | - // Re-acquires a device track — simulates getUserMedia re-acquisition succeeding. |
110 | | - // Resets readyState to 'live' for whichever device type is being re-acquired. |
111 | | - setInputDevicesAsync({ audioDeviceId, videoDeviceId } = {}) { |
112 | | - if (audioDeviceId !== undefined) this._audioReadyState = 'live'; |
113 | | - if (videoDeviceId !== undefined) this._videoReadyState = 'live'; |
114 | | - this._setInputDevicesCalls.push({ audioDeviceId, videoDeviceId, timestamp: Date.now() }); // eslint-disable-line no-underscore-dangle |
115 | | - return Promise.resolve(); |
116 | | - } |
117 | | - |
118 | | - setSubscribeToTracksAutomatically() {} |
119 | | - |
120 | | - updateParticipants(updates) { |
121 | | - this._updateParticipantsCalls.push({ updates, timestamp: Date.now() }); |
122 | | - } |
123 | | - |
124 | | - // Returns participants with userData included. |
125 | | - // Tests can set _participants directly, but userData is also available |
126 | | - // from join() options for the local participant. |
127 | | - participants() { |
128 | | - // Merge any local userData into the local participant if session ID matches |
129 | | - if (this._localSessionId && this._localUserData && this._participants[this._localSessionId]) { |
130 | | - const localP = this._participants[this._localSessionId]; |
131 | | - return { |
132 | | - ...this._participants, |
133 | | - [this._localSessionId]: { ...localP, userData: this._localUserData }, |
134 | | - }; |
135 | | - } |
136 | | - return this._participants; |
137 | | - } |
138 | | - |
139 | | - // Helper for tests to set local session ID so userData gets merged correctly |
140 | | - setLocalSessionId(id) { |
141 | | - this._localSessionId = id; |
142 | | - } |
143 | | - getNetworkStats() { return Promise.resolve({}); } |
144 | | - getInputDevices() { return { mic: { deviceId: 'default-mic', label: 'Default Microphone' }, camera: { deviceId: 'default-cam', label: 'Default Camera' } }; } |
145 | | - getOutputDevices() { return { speaker: { deviceId: 'default-speaker', label: 'Default Speaker' } }; } |
146 | | - |
147 | | - // Local media state — avRecovery reads these to detect muted mic/camera or ended tracks |
148 | | - async localAudio() { return { enabled: this._audioEnabled, muted: false, readyState: this._audioReadyState }; } |
149 | | - async localVideo() { return { enabled: this._videoEnabled, muted: false, readyState: this._videoReadyState }; } |
150 | | - |
151 | | - // Soft-fix actions — avRecovery calls these to unmute mic/camera |
152 | | - async setLocalAudio(enabled) { this._audioEnabled = enabled; } |
153 | | - async setLocalVideo(enabled) { this._videoEnabled = enabled; } |
154 | | -} |
155 | | - |
156 | | -export function MockDailyProvider({ |
157 | | - localSessionId: initialLocalSessionId = null, |
158 | | - participantIds = [], |
159 | | - videoTracks = {}, |
160 | | - audioTracks = {}, |
161 | | - participants = {}, |
162 | | - callObject = null, |
163 | | - devices = null, |
164 | | - children, |
165 | | -}) { |
166 | | - // Allow tests to update localSessionId mid-test via window.mockDailySetLocalSessionId. |
167 | | - // This enables HISTORY-004: testing that a new history entry is logged when the |
168 | | - // Daily session ID changes (e.g., participant rejoins after network drop). |
169 | | - const [localSessionId, setLocalSessionId] = useState(initialLocalSessionId); |
170 | | - |
171 | | - // Stable MockCallObject instance — created once, exposed on window. |
172 | | - // Tests can fire events and inspect calls via window.mockCallObject. |
173 | | - const mockCallObjectRef = useRef(null); |
174 | | - if (!mockCallObjectRef.current) { |
175 | | - mockCallObjectRef.current = new MockCallObject(); |
176 | | - } |
177 | | - |
178 | | - useEffect(() => { |
179 | | - window.mockDailySetLocalSessionId = setLocalSessionId; |
180 | | - window.mockCallObject = mockCallObjectRef.current; |
181 | | - return () => { |
182 | | - delete window.mockDailySetLocalSessionId; |
183 | | - delete window.mockCallObject; |
184 | | - }; |
185 | | - }, []); |
186 | | - |
187 | | - // Sync localSessionId to mock call object so participants() can merge userData correctly |
188 | | - useEffect(() => { |
189 | | - if (mockCallObjectRef.current) { |
190 | | - mockCallObjectRef.current._localSessionId = localSessionId; |
191 | | - } |
192 | | - }, [localSessionId]); |
193 | | - |
194 | | - // Device functions read window.mockDailyDeviceOverrides at call-time, allowing |
195 | | - // tests to set up overrides via page.evaluate() before (or even after) mount. |
196 | | - // Pattern: set window.mockDailyDeviceOverrides = { setSpeaker: () => Promise.reject(...) } |
197 | | - // before mounting to simulate device errors like NotAllowedError. |
198 | | - // eslint-disable-next-line react-hooks/exhaustive-deps |
199 | | - const defaultDevices = useMemo(() => ({ |
200 | | - cameras: [], |
201 | | - microphones: [], |
202 | | - speakers: [], |
203 | | - currentCam: null, |
204 | | - currentMic: null, |
205 | | - currentSpeaker: null, |
206 | | - // Functions read window.mockDailyDeviceOverrides at call-time, so no render deps needed. |
207 | | - setSpeaker: (id) => (window.mockDailyDeviceOverrides?.setSpeaker || (() => Promise.resolve()))(id), |
208 | | - setCamera: (id) => (window.mockDailyDeviceOverrides?.setCamera || (() => Promise.resolve()))(id), |
209 | | - setMicrophone: (id) => (window.mockDailyDeviceOverrides?.setMicrophone || (() => Promise.resolve()))(id), |
210 | | - }), []); |
211 | | - |
212 | | - // Merge provided device data with default functions so tests can pass |
213 | | - // data-only devices (without functions) via serializable hooksConfig. |
214 | | - // The function properties from defaultDevices are preserved even when devices prop |
215 | | - // is provided, since JSON serialization strips functions from hooksConfig. |
216 | | - const mergedDevices = useMemo(() => (devices |
217 | | - ? { ...defaultDevices, ...devices, setSpeaker: defaultDevices.setSpeaker, setCamera: defaultDevices.setCamera, setMicrophone: defaultDevices.setMicrophone } |
218 | | - : defaultDevices), [devices, defaultDevices]); |
219 | | - |
220 | | - const contextValue = useMemo(() => ({ |
221 | | - localSessionId, |
222 | | - participantIds, |
223 | | - videoTracks, |
224 | | - audioTracks, |
225 | | - participants, |
226 | | - callObject: callObject || mockCallObjectRef.current, |
227 | | - devices: mergedDevices, |
228 | | - }), [ |
229 | | - localSessionId, participantIds, videoTracks, audioTracks, |
230 | | - participants, callObject, mergedDevices, |
231 | | - ]); |
232 | | - |
233 | | - return ( |
234 | | - <MockDailyContext.Provider value={contextValue}> |
235 | | - {children} |
236 | | - </MockDailyContext.Provider> |
237 | | - ); |
238 | | -} |
| 1 | +// Re-exported from new location — see mocks/daily/MockDailyProvider.jsx |
| 2 | +export { MockDailyProvider, MockDailyContext } from './daily/MockDailyProvider.jsx'; |
0 commit comments