Skip to content

Commit e3fcaec

Browse files
feat(call): cause-specific error titles and priority ordering (Issue #1190)
- Refactor deviceErrorCopy to key on [deviceType][dailyErrorType] instead of just deviceType, giving each error cause its own title and steps: - permissions → "Camera access denied" / "Microphone access denied" - in-use → "Camera in use" / "Microphone in use" - not-found → "Camera disconnected" / "Microphone disconnected" - constraints → "Camera unavailable" / "Microphone unavailable" - unknown → "Camera problem" / "Microphone problem" - Add priority ordering to setDeviceError so higher-priority errors (permissions > in-use > not-found > constraints > unknown) are not overwritten by lower-priority ones arriving later - Add DEVRECOV-012/013 tests verifying priority behavior - Update all existing test assertions for new cause-specific titles Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a7da9e7 commit e3fcaec

5 files changed

Lines changed: 186 additions & 61 deletions

File tree

client/src/call/UserMediaError.jsx

Lines changed: 72 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,6 @@ import {
77
PermissionDeniedGuidance,
88
} from "../components/PermissionRecovery";
99

10-
const safeText = (val, fallback) => {
11-
if (typeof val === "string" && val.trim()) {
12-
return val;
13-
}
14-
return fallback;
15-
};
16-
1710
const refreshPage = () => {
1811
console.log(
1912
"make sure to allow access to your microphone and camera in your browser's permissions"
@@ -46,43 +39,90 @@ function DevicePicker({ deviceType, devices, onSwitchDevice }) {
4639
);
4740
}
4841

42+
// Copy keyed by [deviceType][dailyErrorType] for cause-specific messaging.
43+
// "permissions" and "not-found" have their own UI branches (PermissionDeniedGuidance
44+
// and DevicePicker respectively), so the steps here are only shown for other causes.
4945
const deviceErrorCopy = {
5046
"camera-error": {
51-
title: "Camera blocked",
52-
message: "We couldn't access your camera.",
53-
steps: [
54-
"Use the lock icon in your browser's address bar to allow camera access.",
55-
"Close any other app (Zoom, Meet, FaceTime, etc.) that may be using your camera.",
56-
"Reload the page to retry connecting.",
57-
],
47+
permissions: {
48+
title: "Camera access denied",
49+
},
50+
"in-use": {
51+
title: "Camera in use",
52+
steps: [
53+
"Close any other app (Zoom, Meet, FaceTime, etc.) that may be using your camera.",
54+
"Reload the page to retry connecting.",
55+
],
56+
},
57+
"not-found": {
58+
title: "Camera disconnected",
59+
},
60+
constraints: {
61+
title: "Camera unavailable",
62+
steps: [
63+
"Your camera may not support the required settings.",
64+
"Try a different camera if one is available, or reload the page.",
65+
],
66+
},
67+
default: {
68+
title: "Camera problem",
69+
steps: [
70+
"Check that your camera is plugged in and not in use by another app.",
71+
"Reload the page to retry connecting.",
72+
],
73+
},
5874
},
5975
"mic-error": {
60-
title: "Microphone blocked",
61-
message: "We couldn't access your microphone.",
62-
steps: [
63-
"Use the lock icon in your browser's address bar to allow microphone access.",
64-
"Check that your headset or microphone is plugged in and not muted.",
65-
"Reload the page to retry connecting.",
66-
],
76+
permissions: {
77+
title: "Microphone access denied",
78+
},
79+
"in-use": {
80+
title: "Microphone in use",
81+
steps: [
82+
"Close any other app that may be using your microphone.",
83+
"Check that your headset or microphone is plugged in and not muted.",
84+
"Reload the page to retry connecting.",
85+
],
86+
},
87+
"not-found": {
88+
title: "Microphone disconnected",
89+
},
90+
constraints: {
91+
title: "Microphone unavailable",
92+
steps: [
93+
"Your microphone may not support the required settings.",
94+
"Try a different microphone if one is available, or reload the page.",
95+
],
96+
},
97+
default: {
98+
title: "Microphone problem",
99+
steps: [
100+
"Check that your microphone is plugged in and not in use by another app.",
101+
"Reload the page to retry connecting.",
102+
],
103+
},
67104
},
68105
default: {
69-
title: "Camera or mic blocked",
70-
message:
71-
"We couldn't access your camera or microphone. Please check your browser permissions.",
72-
steps: [
73-
"Use the lock icon in your browser's address bar to allow camera and microphone access.",
74-
"Close other applications that might already be using your camera or microphone.",
75-
"Once you've adjusted settings, reload the page.",
76-
],
106+
default: {
107+
title: "Camera or microphone problem",
108+
steps: [
109+
"Check that your camera and microphone are plugged in and not in use by another app.",
110+
"Reload the page to retry connecting.",
111+
],
112+
},
77113
},
78114
};
79115

116+
function getErrorCopy(errorType, dailyErrorType) {
117+
const deviceCopy = deviceErrorCopy[errorType] || deviceErrorCopy.default;
118+
return deviceCopy[dailyErrorType] || deviceCopy.default;
119+
}
120+
80121
export function UserMediaError({ error, onDismiss, onSwitchDevice }) {
81122
// ------------------- fallback UI when media permissions fail ---------------------
82-
const copy = deviceErrorCopy[error?.type] ?? deviceErrorCopy.default;
83-
const message = safeText(error?.message, copy.message);
84-
const { steps } = copy;
123+
const copy = getErrorCopy(error?.type, error?.dailyErrorType);
85124
const { title } = copy;
125+
const steps = copy.steps || [];
86126
const { audioOk, videoOk } = error?.details || {};
87127
const [deviceSurvey, setDeviceSurvey] = useState(null);
88128
// availableDevices holds full deviceIds for the picker (separate from deviceSurvey

client/src/call/VideoCall.jsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,27 @@ export function VideoCall({
434434
}, [attemptCallStartFlag, stage]);
435435

436436
// ------------------- capture device permission failures ---------------------
437-
const [deviceError, setDeviceError] = useState(null);
437+
// Priority: permissions > in-use > not-found > constraints > unknown
438+
// Higher-priority errors should not be overwritten by lower-priority ones.
439+
const deviceErrorPriority = {
440+
permissions: 5,
441+
"in-use": 4,
442+
"not-found": 3,
443+
constraints: 2,
444+
};
445+
const [deviceError, setDeviceErrorRaw] = useState(null);
446+
const setDeviceError = useCallback((newError) => {
447+
if (newError === null) {
448+
setDeviceErrorRaw(null);
449+
return;
450+
}
451+
setDeviceErrorRaw((prev) => {
452+
if (!prev) return newError;
453+
const prevPrio = deviceErrorPriority[prev.dailyErrorType] ?? 1;
454+
const newPrio = deviceErrorPriority[newError.dailyErrorType] ?? 1;
455+
return newPrio >= prevPrio ? newError : prev;
456+
});
457+
}, []);
438458
const [fatalError, setFatalError] = useState(null);
439459
const [networkInterrupted, setNetworkInterrupted] = useState(false);
440460
const [permissionRevoked, setPermissionRevoked] = useState(null);

playwright/component-tests/video-call/mocked/ErrorReporting.ct.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,8 @@ test.describe('A/V Error Reporting (Sentry)', () => {
105105
});
106106
});
107107

108-
// UserMediaError should render (device error screen shows "Camera blocked" title)
109-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
108+
// UserMediaError should render (device error screen shows cause-specific title)
109+
await expect(page.getByRole('heading', { name: 'Camera disconnected' })).toBeVisible({ timeout: 8000 });
110110

111111
// Sentry should have captured the error (UserMediaError's recordError effect runs async)
112112
await page.waitForTimeout(500);

playwright/component-tests/video-call/mocked/VideoCall.deviceRecovery.ct.jsx

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ test.describe('Device Error Recovery (Issue #1190)', () => {
6363
});
6464

6565
// Error message should be visible
66-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
66+
await expect(page.locator('text=Camera access denied')).toBeVisible({ timeout: 8000 });
6767

6868
// Fix A/V button should STILL be accessible so users can attempt to recover
6969
await expect(page.locator('[data-test="fixAV"]')).toBeVisible();
@@ -85,7 +85,7 @@ test.describe('Device Error Recovery (Issue #1190)', () => {
8585
});
8686
});
8787

88-
await expect(page.locator('text=Microphone blocked')).toBeVisible({ timeout: 8000 });
88+
await expect(page.locator('text=Microphone access denied')).toBeVisible({ timeout: 8000 });
8989

9090
// Fix A/V button should still be accessible
9191
await expect(page.locator('[data-test="fixAV"]')).toBeVisible();
@@ -109,7 +109,7 @@ test.describe('Device Error Recovery (Issue #1190)', () => {
109109
});
110110
});
111111

112-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
112+
await expect(page.locator('text=Camera in use')).toBeVisible({ timeout: 8000 });
113113

114114
// Modal close button (X) should be available
115115
await expect(page.locator('button[aria-label="Close"]')).toBeVisible();
@@ -135,13 +135,13 @@ test.describe('Device Error Recovery (Issue #1190)', () => {
135135
});
136136
});
137137

138-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
138+
await expect(page.locator('text=Camera in use')).toBeVisible({ timeout: 8000 });
139139

140140
// Dismiss the error via the modal's X close button
141141
await page.locator('button[aria-label="Close"]').click();
142142

143143
// Error message should be gone
144-
await expect(page.locator('text=Camera blocked')).not.toBeVisible();
144+
await expect(page.locator('text=Camera in use')).not.toBeVisible();
145145

146146
// Normal call tiles should be restored
147147
await expect(component.locator('[data-test="callTile"]')).toBeVisible({ timeout: 5000 });
@@ -173,13 +173,13 @@ test.describe('Device Error Recovery — Permission guidance (Issue #1190)', ()
173173
});
174174
});
175175

176-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
176+
await expect(page.locator('text=Camera access denied')).toBeVisible({ timeout: 8000 });
177177

178178
// Browser-specific guidance should appear
179179
await expect(page.locator('text=Please enable it in your browser settings')).toBeVisible();
180180

181-
// The generic lock-icon step should NOT appear when dailyErrorType is "permissions"
182-
await expect(page.locator("text=Use the lock icon in your browser's address bar to allow camera access")).not.toBeVisible();
181+
// Generic steps should NOT appear when dailyErrorType is "permissions"
182+
await expect(page.locator("text=Close any other app")).not.toBeVisible();
183183
});
184184

185185
/**
@@ -211,7 +211,7 @@ test.describe('Device Error Recovery — Permission guidance (Issue #1190)', ()
211211
});
212212
});
213213

214-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
214+
await expect(page.locator('text=Camera access denied')).toBeVisible({ timeout: 8000 });
215215

216216
// The image for the current browser should be rendered and actually loaded
217217
const img = page.locator(`img[src*="${expectedImageSubstring}"]`);
@@ -278,7 +278,7 @@ test.describe('Device Error Recovery — Permission guidance (Issue #1190)', ()
278278
});
279279
});
280280

281-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
281+
await expect(page.locator('text=Camera access denied')).toBeVisible({ timeout: 8000 });
282282

283283
// User grants permissions in browser settings
284284
await page.evaluate(() => window.simulatePermissionsGranted());
@@ -306,10 +306,10 @@ test.describe('Device Error Recovery — Permission guidance (Issue #1190)', ()
306306
});
307307
});
308308

309-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
309+
await expect(page.locator('text=Camera in use')).toBeVisible({ timeout: 8000 });
310310

311-
// For in-use errors, generic steps should be shown
312-
await expect(page.locator("text=Use the lock icon in your browser's address bar to allow camera access")).toBeVisible();
311+
// For in-use errors, cause-specific steps should be shown
312+
await expect(page.locator("text=Close any other app")).toBeVisible();
313313

314314
// Browser-specific permission guidance should NOT appear
315315
await expect(page.locator('text=Please enable it in your browser settings')).not.toBeVisible();
@@ -347,7 +347,7 @@ test.describe('Device Error Recovery — Device picker (Issue #1190)', () => {
347347
});
348348
});
349349

350-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
350+
await expect(page.getByRole('heading', { name: 'Camera disconnected' })).toBeVisible({ timeout: 8000 });
351351

352352
// Device picker should appear with available cameras
353353
await expect(page.locator('[data-test="devicePickerSelect"]')).toBeVisible({ timeout: 5000 });
@@ -359,7 +359,7 @@ test.describe('Device Error Recovery — Device picker (Issue #1190)', () => {
359359
expect(optionCount).toBeGreaterThanOrEqual(1);
360360

361361
// Generic steps should NOT appear when picker is shown
362-
await expect(page.locator("text=Use the lock icon in your browser's address bar to allow camera access")).not.toBeVisible();
362+
await expect(page.locator("text=Close any other app")).not.toBeVisible();
363363
});
364364

365365
/**
@@ -387,7 +387,7 @@ test.describe('Device Error Recovery — Device picker (Issue #1190)', () => {
387387
});
388388
});
389389

390-
await expect(page.locator('text=Microphone blocked')).toBeVisible({ timeout: 8000 });
390+
await expect(page.getByRole('heading', { name: 'Microphone disconnected' })).toBeVisible({ timeout: 8000 });
391391

392392
// Mic picker should appear
393393
await expect(page.locator('[data-test="devicePickerSelect"]')).toBeVisible({ timeout: 5000 });
@@ -423,7 +423,7 @@ test.describe('Device Error Recovery — Device picker (Issue #1190)', () => {
423423
});
424424
});
425425

426-
await expect(page.locator('text=Camera blocked')).toBeVisible({ timeout: 8000 });
426+
await expect(page.getByRole('heading', { name: 'Camera disconnected' })).toBeVisible({ timeout: 8000 });
427427
await expect(page.locator('[data-test="devicePickerSelect"]')).toBeVisible({ timeout: 5000 });
428428

429429
// Dispatch a click directly to bypass Playwright's actionability retry loop
@@ -438,7 +438,72 @@ test.describe('Device Error Recovery — Device picker (Issue #1190)', () => {
438438
expect(calls[calls.length - 1].videoDeviceId).not.toBeUndefined();
439439

440440
// Error overlay should be dismissed and call tiles restored
441-
await expect(page.locator('text=Camera blocked')).not.toBeVisible({ timeout: 5000 });
441+
await expect(page.getByRole('heading', { name: 'Camera disconnected' })).not.toBeVisible({ timeout: 5000 });
442442
await expect(component.locator('[data-test="callTile"]')).toBeVisible({ timeout: 5000 });
443443
});
444444
});
445+
446+
test.describe('Device Error Recovery — Error priority (Issue #1190)', () => {
447+
/**
448+
* DEVRECOV-012: permissions error takes priority over in-use error
449+
*
450+
* When a lower-priority error (in-use) is showing and a higher-priority
451+
* error (permissions) arrives, the modal should update to show the
452+
* permissions error. This ensures users see the most actionable guidance.
453+
*/
454+
test('DEVRECOV-012: permissions error overwrites in-use error', async ({ mount, page }) => {
455+
const component = await mount(<VideoCall showSelfView />, { hooksConfig: connectedConfig });
456+
await expect(component).toBeVisible({ timeout: 15000 });
457+
458+
// Fire in-use error first
459+
await page.evaluate(() => {
460+
window.mockCallObject.emit('camera-error', {
461+
error: { type: 'in-use', message: 'Camera in use' },
462+
});
463+
});
464+
465+
await expect(page.locator('text=Camera in use')).toBeVisible({ timeout: 8000 });
466+
467+
// Fire permissions error — should overwrite the in-use error
468+
await page.evaluate(() => {
469+
window.mockCallObject.emit('camera-error', {
470+
error: { type: 'permissions', message: 'Permission denied' },
471+
});
472+
});
473+
474+
await expect(page.locator('text=Camera access denied')).toBeVisible({ timeout: 5000 });
475+
await expect(page.locator('text=Camera in use')).not.toBeVisible();
476+
});
477+
478+
/**
479+
* DEVRECOV-013: lower-priority error does not overwrite higher-priority error
480+
*
481+
* When a permissions error is showing and an in-use error arrives,
482+
* the modal should keep showing the permissions error.
483+
*/
484+
test('DEVRECOV-013: in-use error does not overwrite permissions error', async ({ mount, page }) => {
485+
const component = await mount(<VideoCall showSelfView />, { hooksConfig: connectedConfig });
486+
await expect(component).toBeVisible({ timeout: 15000 });
487+
488+
// Fire permissions error first
489+
await page.evaluate(() => {
490+
window.mockCallObject.emit('camera-error', {
491+
error: { type: 'permissions', message: 'Permission denied' },
492+
});
493+
});
494+
495+
await expect(page.locator('text=Camera access denied')).toBeVisible({ timeout: 8000 });
496+
497+
// Fire in-use error — should NOT overwrite the permissions error
498+
await page.evaluate(() => {
499+
window.mockCallObject.emit('camera-error', {
500+
error: { type: 'in-use', message: 'Camera in use' },
501+
});
502+
});
503+
504+
// Wait briefly then verify permissions error is still showing
505+
await page.waitForTimeout(1000);
506+
await expect(page.locator('text=Camera access denied')).toBeVisible();
507+
await expect(page.locator('text=Camera in use')).not.toBeVisible();
508+
});
509+
});

0 commit comments

Comments
 (0)