Skip to content

Commit d2c67ee

Browse files
test(recording): add component tests for client-side recording start
Six tests covering useCallStartSignaling behavior: - REC-001: startRecording called on join when recording enabled - REC-002: no startRecording when recording disabled - REC-003: deduplication prevents duplicate calls within a stage - REC-004: Sentry suppressed when recording-started confirms peer success - REC-005: Sentry fires when no participant confirms recording - REC-006: Sentry fires on recording-error when not confirmed Supporting changes: - MockCallObject: track startRecording calls, configurable reject behavior - MockStage: add stable `id` property for stage-dependent effects Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0b16571 commit d2c67ee

3 files changed

Lines changed: 268 additions & 1 deletion

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import React from 'react';
2+
import { test, expect } from '@playwright/experimental-ct-react';
3+
import { VideoCall } from '../../../../client/src/call/VideoCall';
4+
5+
/**
6+
* Component Tests for Client-Side Recording (Issue #949)
7+
*
8+
* Tests: REC-001 to REC-006
9+
*
10+
* These tests verify that useCallStartSignaling correctly:
11+
* 1. Starts raw-tracks recording when a participant joins the call
12+
* 2. Deduplicates within a stage (only one call per stage)
13+
* 3. Does NOT start recording when recordingEnabled is false
14+
* 4. Defers Sentry alerts and suppresses them when another participant succeeds
15+
* 5. Fires Sentry when no participant confirms recording
16+
*
17+
* Infrastructure:
18+
* - window.mockCallObject._startRecordingCalls: array of call logs
19+
* - window.__mockStartRecordingBehavior: set BEFORE mount to 'reject' for failure tests
20+
* - window.mockSentryCaptures: Sentry mock capture store
21+
*/
22+
23+
// Base config: recording enabled, participant joined
24+
const recordingEnabledConfig = {
25+
empirica: {
26+
currentPlayerId: 'p0',
27+
players: [{ id: 'p0', attrs: { position: '0', dailyId: 'daily-p0', name: 'Test User' } }],
28+
game: { attrs: { dailyUrl: 'https://test.daily.co/room', recordingEnabled: true } },
29+
stage: { attrs: {}, id: 'stage-1' },
30+
stageTimer: { elapsed: 0 },
31+
},
32+
daily: {
33+
localSessionId: 'daily-p0',
34+
participantIds: ['daily-p0'],
35+
videoTracks: { 'daily-p0': { isOff: false, subscribed: true } },
36+
audioTracks: { 'daily-p0': { isOff: false, subscribed: true } },
37+
},
38+
};
39+
40+
// Config with recording disabled
41+
const recordingDisabledConfig = {
42+
empirica: {
43+
currentPlayerId: 'p0',
44+
players: [{ id: 'p0', attrs: { position: '0', dailyId: 'daily-p0', name: 'Test User' } }],
45+
game: { attrs: { dailyUrl: 'https://test.daily.co/room' } },
46+
stage: { attrs: {}, id: 'stage-1' },
47+
stageTimer: { elapsed: 0 },
48+
},
49+
daily: {
50+
localSessionId: 'daily-p0',
51+
participantIds: ['daily-p0'],
52+
videoTracks: { 'daily-p0': { isOff: false, subscribed: true } },
53+
audioTracks: { 'daily-p0': { isOff: false, subscribed: true } },
54+
},
55+
};
56+
57+
test.describe('Client-Side Recording (useCallStartSignaling)', () => {
58+
/**
59+
* REC-001: startRecording called on join when recordingEnabled=true
60+
*
61+
* Validates:
62+
* - callObject.startRecording({ type: "raw-tracks" }) is called
63+
* - Called when meetingState is already "joined-meeting" at effect time
64+
*/
65+
test('REC-001: startRecording called when participant joins with recording enabled', async ({ mount, page }) => {
66+
test.slow();
67+
const component = await mount(<VideoCall showSelfView />, { hooksConfig: recordingEnabledConfig });
68+
await expect(component).toBeVisible({ timeout: 15000 });
69+
70+
// The mock call object starts in 'joined-meeting' state, so the hook
71+
// should detect that and call startRecording immediately.
72+
// Poll until the call appears (avoids flaky fixed timeouts).
73+
await expect(async () => {
74+
const calls = await page.evaluate(() => window.mockCallObject._startRecordingCalls);
75+
expect(calls.length).toBeGreaterThanOrEqual(1);
76+
}).toPass({ timeout: 5000 });
77+
78+
const calls = await page.evaluate(() => window.mockCallObject._startRecordingCalls);
79+
expect(calls[0].options).toEqual({ type: 'raw-tracks' });
80+
});
81+
82+
/**
83+
* REC-002: startRecording NOT called when recordingEnabled is false/absent
84+
*
85+
* Validates:
86+
* - No startRecording call when game.recordingEnabled is not set
87+
*/
88+
test('REC-002: startRecording not called when recording disabled', async ({ mount, page }) => {
89+
test.slow();
90+
const component = await mount(<VideoCall showSelfView />, { hooksConfig: recordingDisabledConfig });
91+
await expect(component).toBeVisible({ timeout: 15000 });
92+
93+
// Give effects time to run, then verify no recording calls
94+
await page.waitForTimeout(1000);
95+
96+
const calls = await page.evaluate(() => window.mockCallObject._startRecordingCalls);
97+
expect(calls.length).toBe(0);
98+
});
99+
100+
/**
101+
* REC-003: Deduplication — second joined-meeting in same stage doesn't
102+
* trigger a duplicate startRecording call
103+
*
104+
* Validates:
105+
* - recordingStartedRef guard prevents redundant calls within a stage
106+
*/
107+
test('REC-003: deduplication prevents duplicate startRecording in same stage', async ({ mount, page }) => {
108+
test.slow();
109+
const component = await mount(<VideoCall showSelfView />, { hooksConfig: recordingEnabledConfig });
110+
await expect(component).toBeVisible({ timeout: 15000 });
111+
112+
// Wait for initial startRecording to fire
113+
await expect(async () => {
114+
const calls = await page.evaluate(() => window.mockCallObject._startRecordingCalls);
115+
expect(calls.length).toBeGreaterThanOrEqual(1);
116+
}).toPass({ timeout: 5000 });
117+
118+
const initialCount = await page.evaluate(() => window.mockCallObject._startRecordingCalls.length);
119+
120+
// Emit joined-meeting again (simulating a reconnection within the same stage)
121+
await page.evaluate(() => {
122+
window.mockCallObject.emit('joined-meeting', {});
123+
});
124+
await page.waitForTimeout(500);
125+
126+
// Should NOT have triggered another startRecording (ref guard)
127+
const finalCount = await page.evaluate(() => window.mockCallObject._startRecordingCalls.length);
128+
expect(finalCount).toBe(initialCount);
129+
});
130+
131+
/**
132+
* REC-004: Sentry suppressed when recording-started event confirms recording
133+
*
134+
* Validates:
135+
* - startRecording fails (rejects) on mount
136+
* - recording-started event fires (another participant started recording)
137+
* - No Sentry error captured (false alarm suppressed)
138+
*/
139+
test('REC-004: Sentry suppressed when another participant starts recording', async ({ mount, page }) => {
140+
test.slow();
141+
142+
// Configure startRecording to reject BEFORE mount
143+
await page.evaluate(() => {
144+
window.__mockStartRecordingBehavior = 'reject';
145+
});
146+
147+
const component = await mount(<VideoCall showSelfView />, {
148+
hooksConfig: recordingEnabledConfig,
149+
});
150+
await expect(component).toBeVisible({ timeout: 15000 });
151+
152+
// Wait for the rejected startRecording to run (sets up 5s Sentry timer)
153+
await page.waitForTimeout(500);
154+
155+
// Reset Sentry so we only see recording-related captures
156+
await page.evaluate(() => window.mockSentryCaptures.reset());
157+
158+
// Simulate another participant successfully starting recording
159+
await page.evaluate(() => {
160+
window.mockCallObject.emit('recording-started', {});
161+
});
162+
163+
// Wait past the 5s deferred Sentry timer
164+
await page.waitForTimeout(6000);
165+
166+
// Sentry should NOT have a recording failure message
167+
const captures = await page.evaluate(() => window.mockSentryCaptures);
168+
const recordingErrors = captures.messages.filter(
169+
m => m.message === 'Recording not started for stage'
170+
);
171+
expect(recordingErrors.length).toBe(0);
172+
});
173+
174+
/**
175+
* REC-005: Sentry fires when no participant confirms recording
176+
*
177+
* Validates:
178+
* - startRecording fails (rejects) on mount
179+
* - No recording-started event fires within 5s
180+
* - Sentry.captureMessage fires with level "error"
181+
*/
182+
test('REC-005: Sentry fires when recording never confirmed', async ({ mount, page }) => {
183+
test.slow();
184+
185+
// Configure startRecording to reject BEFORE mount
186+
await page.evaluate(() => {
187+
window.__mockStartRecordingBehavior = 'reject';
188+
});
189+
190+
const component = await mount(<VideoCall showSelfView />, {
191+
hooksConfig: recordingEnabledConfig,
192+
});
193+
await expect(component).toBeVisible({ timeout: 15000 });
194+
195+
// Reset Sentry after mount so we start clean
196+
await page.evaluate(() => window.mockSentryCaptures.reset());
197+
198+
// Wait past the 5s deferred Sentry timer (no recording-started event)
199+
await page.waitForTimeout(6000);
200+
201+
const captures = await page.evaluate(() => window.mockSentryCaptures);
202+
const recordingErrors = captures.messages.filter(
203+
m => m.message === 'Recording not started for stage'
204+
);
205+
expect(recordingErrors.length).toBeGreaterThanOrEqual(1);
206+
expect(recordingErrors[0].hint.level).toBe('error');
207+
expect(recordingErrors[0].hint.extra.triggeringError).toBeTruthy();
208+
});
209+
210+
/**
211+
* REC-006: Sentry fires on recording-error when no recording confirmed
212+
*
213+
* Validates:
214+
* - Daily fires recording-error event
215+
* - No recording-started event fires within 5s
216+
* - Sentry captures with appropriate message
217+
*/
218+
test('REC-006: Sentry fires on recording-error when not confirmed', async ({ mount, page }) => {
219+
test.slow();
220+
221+
const component = await mount(<VideoCall showSelfView />, {
222+
hooksConfig: recordingEnabledConfig,
223+
});
224+
await expect(component).toBeVisible({ timeout: 15000 });
225+
226+
// Reset Sentry after mount
227+
await page.evaluate(() => window.mockSentryCaptures.reset());
228+
229+
// Fire a recording-error event
230+
await page.evaluate(() => {
231+
window.mockCallObject.emit('recording-error', {
232+
errorMsg: 'recording failed',
233+
error: { type: 'recording-error' },
234+
});
235+
});
236+
237+
// Wait past the 5s deferred Sentry timer
238+
await page.waitForTimeout(6000);
239+
240+
const captures = await page.evaluate(() => window.mockSentryCaptures);
241+
const recordingErrors = captures.messages.filter(
242+
m => m.message === 'Daily recording-error \u2014 no recording confirmed'
243+
);
244+
expect(recordingErrors.length).toBeGreaterThanOrEqual(1);
245+
expect(recordingErrors[0].hint.level).toBe('error');
246+
});
247+
});

playwright/mocks/daily/MockDailyProvider.jsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ class MockCallObject extends MockEventEmitter {
7979
this._localUserData = null; // userData passed in join() options
8080
this._localSessionId = null; // session ID of local participant (set via setLocalSessionId)
8181
this._joinCalled = false; // tracks whether join() was called (for rejoin tests)
82+
this._startRecordingCalls = []; // tracks startRecording() calls for recording tests
83+
// Allow pre-configuration via window global (set before mount in tests)
84+
this._startRecordingBehavior = (typeof window !== 'undefined' && window.__mockStartRecordingBehavior) || 'resolve';
8285
}
8386

8487
meetingState() { return this._meetingState; }
@@ -93,7 +96,18 @@ class MockCallObject extends MockEventEmitter {
9396
}
9497

9598
leave() { return Promise.resolve(); }
96-
startRecording() { return Promise.resolve(); }
99+
100+
startRecording(options) {
101+
this._startRecordingCalls.push({ options, timestamp: Date.now() });
102+
if (typeof this._startRecordingBehavior === 'function') {
103+
return this._startRecordingBehavior(options);
104+
}
105+
if (this._startRecordingBehavior === 'reject') {
106+
return Promise.reject(new Error('Recording failed (mock)'));
107+
}
108+
return Promise.resolve();
109+
}
110+
97111
stopRecording() { return Promise.resolve(); }
98112
setUserName() {}
99113

playwright/mocks/empirica/MockStage.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export class MockStage {
1818
this._attributes = { ...initialAttributes };
1919
this._onChange = onChange;
2020
}
21+
// Expose an `id` property so hooks that depend on stage.id (e.g.
22+
// useCallStartSignaling) see a meaningful value. Use a stable
23+
// default to avoid triggering effect re-runs when MockStage is
24+
// recreated across renders. Tests that need distinct stage IDs
25+
// (e.g. multi-stage recording tests) should pass an explicit `id`.
26+
this.id = initialAttributes?.id ?? 'mock-stage-default';
2127
this._setCalls = [];
2228
this._appendCalls = [];
2329
}

0 commit comments

Comments
 (0)