fix: Clear stale media unavailable errors when a camera recovers (#2627)

- Closes: #2576
This commit is contained in:
Dermot Duffy
2026-07-28 21:21:50 -07:00
committed by GitHub
parent 231e3087b7
commit 15b08db3e8
49 changed files with 1128 additions and 229 deletions
@@ -945,6 +945,65 @@ describe('endIf', () => {
});
});
describe('reportCallMicrophoneError', () => {
it('should report a microphone that could not be attached', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getNotificationManager().setNotification).mockClear();
manager.reportCallMicrophoneError('camera.office', 'The peer connection is closed');
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith({
heading: { text: 'Two-way audio unavailable' },
body: { text: 'Your microphone could not be connected.' },
context: ['The peer connection is closed'],
});
});
it('should omit the context when the browser provided none', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getNotificationManager().setNotification).mockClear();
manager.reportCallMicrophoneError('camera.office');
expect(api.getNotificationManager().setNotification).toHaveBeenCalledWith(
expect.not.objectContaining({ context: expect.anything() }),
);
});
it('should not report without a call', () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
new CallManager(api).reportCallMicrophoneError('camera.office');
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
});
it('should not report while an inbound call is still ringing', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start({ inbound: true })).toBe(true);
manager.reportCallMicrophoneError('camera.office');
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
});
it('should not report for a camera the call is not on', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
const manager = new CallManager(api);
expect(await manager.start()).toBe(true);
vi.mocked(api.getNotificationManager().setNotification).mockClear();
manager.reportCallMicrophoneError('camera.other');
expect(api.getNotificationManager().setNotification).not.toHaveBeenCalled();
});
});
describe('condition state changes', () => {
it('should end the call when the selected camera changes away', async () => {
const api = createAPI({ view: createView({ camera: 'camera.office' }) });
@@ -265,6 +265,45 @@ describe('IssueManager', () => {
});
});
describe('resolve', () => {
it('should resolve the issue and update the card once it clears', () => {
const api = createCardAPI();
const manager = new IssueManager(api);
const issue = createIssue('media_unavailable', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
resolve: vi.fn(),
});
manager.addIssue(issue);
// Establish the issue as present, then let resolving remove it.
manager.evaluate();
vi.mocked(api.getCardElementManager().update).mockClear();
vi.mocked(issue.getIssue).mockReturnValue(null);
manager.resolve('media_unavailable', { targetID: 'camera-1' });
expect(issue.resolve).toHaveBeenCalledWith({ targetID: 'camera-1' });
expect(api.getCardElementManager().update).toHaveBeenCalled();
});
it('should stop retrying once resolve removes the last retryable problem', () => {
const { manager, issue } = createRetriableSetup();
assert(issue.resolve);
assert(issue.needsRetry);
// Arm the retry timer while the problem is unresolved.
manager.evaluate();
vi.mocked(issue.needsRetry).mockReturnValue(false);
manager.resolve('media_unavailable', { targetID: 'camera-1' });
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000 * 10);
expect(issue.retry).not.toHaveBeenCalled();
});
});
describe('retry', () => {
it('should call retry on the manager and reset the timer', () => {
const { manager, issue } = createRetriableSetup();
@@ -19,18 +19,12 @@ const fireMediaChange = (
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change);
};
// Simulate a media (re)load for `targetID`. `cached` marks a reconnect replay
// (a re-dispatch of the last load, not an actual reload).
const fireMediaLoad = (
api: ReturnType<typeof createAPI>,
targetID: string,
cached = false,
): void => {
// Simulate a media (re)load for `targetID`.
const fireMediaLoad = (api: ReturnType<typeof createAPI>, targetID: string): void => {
fireMediaChange(api, {
type: 'load',
targetID,
info: createMediaLoadedInfo({ targetID }),
cached,
});
};
@@ -305,15 +299,15 @@ describe('MediaUnavailableIssue', () => {
expect(issue.hasIssue()).toBe(false);
});
it('should clear a target error on a genuine media load', () => {
it('should clear a not-loading error on a media load', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
// A genuine (re)load for the target clears the error.
// An attached player disproves "media not loading", whoever recorded it.
fireMediaLoad(api, 'camera-1');
// The error is gone, so this unloaded state falls back to the timer
@@ -322,6 +316,21 @@ describe('MediaUnavailableIssue', () => {
expect(issue.hasIssue()).toBe(false);
});
it('should not clear a stream error on a media load', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
// A load only says a player attached -- for a stream that loaded and then
// froze, that includes a reconnect replay of the frozen player. Stream
// errors clear only via resolve, on real evidence of media flowing.
fireMediaLoad(api, 'camera-1');
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
});
it('should keep an errored target active while its media still reads as loaded', () => {
const issue = new MediaUnavailableIssue(createAPI());
@@ -343,7 +352,7 @@ describe('MediaUnavailableIssue', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
// A load for a different target must not clear camera-1's error.
fireMediaLoad(api, 'camera-2');
@@ -352,32 +361,13 @@ describe('MediaUnavailableIssue', () => {
expect(issue.hasIssue()).toBe(true);
});
it('should not clear a target error on a reconnect replay', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
fireMediaLoad(
api,
'camera-1',
// A cached replay (reconnect re-dispatch) did not actually reload the
// media, so it must not clear the error.
true,
);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
});
it('should not clear a target error on unload or select changes', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
// Only a genuine load clears; unload / select changes are irrelevant.
// Only a load clears; unload / select changes are irrelevant.
fireMediaChange(api, { type: 'unload', targetID: 'camera-1' });
fireMediaChange(api, { type: 'select', targetID: 'camera-1' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
@@ -399,6 +389,78 @@ describe('MediaUnavailableIssue', () => {
});
});
describe('resolve', () => {
it('should clear an errored target', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
expect(issue.getNotification().metadata).toEqual([
expect.objectContaining({ text: 'camera.office: Stream stalled' }),
]);
issue.resolve({ targetID: 'camera.office' });
expect(issue.getNotification().metadata).toBeUndefined();
});
it('should deactivate a target that is proven to be delivering media again', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
});
expect(issue.hasIssue()).toBe(true);
issue.resolve({ targetID: 'camera-1' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
});
expect(issue.hasIssue()).toBe(false);
});
it('should leave other targets errored', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
issue.resolve({ targetID: 'camera.garden' });
expect(issue.getNotification().metadata).toEqual([
expect.objectContaining({ text: 'camera.office: Stream stalled' }),
]);
});
it('should cancel the pending-load timer for its target', () => {
const onChange = vi.fn();
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.resolve({ targetID: 'camera-1' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
expect(onChange).not.toHaveBeenCalled();
});
it('should leave the pending-load timer alone for a different target', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.resolve({ targetID: 'camera-2' });
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(true);
});
});
describe('getNotification', () => {
it('should return notification regardless of active state', () => {
const issue = new MediaUnavailableIssue(createAPI());
@@ -673,6 +735,28 @@ describe('MediaUnavailableIssue', () => {
});
});
it('should not retry a stale pending-timer target once its timer has stopped', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaUnavailableIssue(api);
// A slow load arms the pending timer for camera.garden.
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
// The view moves to a target that already has a hard error. That path
// activates immediately and stops the timer, but the stale
// _timerTargetID (camera.garden) lingers -- and that target may since
// have loaded, so reloading it would be gratuitous.
issue.trigger({ targetID: 'camera.office', reason: 'playback_error' });
issue.detectDynamic({ targetID: 'camera.office', view: 'live' });
issue.retry();
expect(api.getViewManager().setViewWithMergedContext).toHaveBeenCalledWith({
mediaEpoch: { 'camera.office': 1 },
});
});
it('should keep errored targets and issue state after retry', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
@@ -685,8 +769,8 @@ describe('MediaUnavailableIssue', () => {
issue.retry();
// After retry, the issue stays active and the errored target is preserved
// -- no new 10s grace period. A genuine media load would clear everything
// (_onMediaLoad drops the errored target).
// -- no new 10s grace period. Recovery clears it: a load for a
// not-loading error, a resolve for a stream error.
expect(issue.hasIssue()).toBe(true);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
@@ -714,12 +798,63 @@ describe('MediaUnavailableIssue', () => {
const onChange = vi.fn();
const issue = new MediaUnavailableIssue(api, onChange);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-1', reason: 'not_loading' });
fireMediaLoad(api, 'camera-1');
expect(onChange).toHaveBeenCalled();
});
it('should not notify onChange when a load changes nothing', () => {
const api = createAPI();
const onChange = vi.fn();
const issue = new MediaUnavailableIssue(api, onChange);
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
fireMediaLoad(api, 'camera-1');
expect(onChange).not.toHaveBeenCalled();
});
it('should clear a timer-recorded error when the media later loads', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
// A viewer target has no liveness observer, so a load is the only
// recovery signal it will ever produce.
issue.detectDynamic({ targetID: 'media-1', view: 'clip' });
vi.advanceTimersByTime(10000);
expect(issue.getNotification().metadata).toEqual([
expect.objectContaining({ text: 'media-1: Media not loading' }),
]);
fireMediaLoad(api, 'media-1');
expect(issue.getNotification().metadata).toBeUndefined();
issue.detectDynamic({
targetID: 'media-1',
view: 'clip',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'media-1' }),
});
expect(issue.hasIssue()).toBe(false);
});
it('should cancel the pending-load timer when its target genuinely loads', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
// A target starts loading, arming the pending-load timer.
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
// It loads in the background, so no further detection pass runs for it
// (detection only ever covers the current target).
fireMediaLoad(api, 'camera-1');
vi.advanceTimersByTime(10000);
expect(issue.hasIssue()).toBe(false);
expect(issue.getNotification().metadata).toBeUndefined();
});
it('should unsubscribe from media loads on destroy', () => {
const api = createAPI();
const unsubscribe = vi.fn();
@@ -116,6 +116,46 @@ describe('IssueStateManager', () => {
});
});
describe('resolve', () => {
it('should call resolve on the matching issue', () => {
const manager = createManager();
manager.resolve('media_unavailable', { targetID: 'cam1' });
assert(mockMediaLoad.resolve);
expect(mockMediaLoad.resolve).toHaveBeenCalledWith({ targetID: 'cam1' });
});
it('should do nothing for unknown key', () => {
const manager = createManager();
manager.resolve('unknown' as never, {} as never);
assert(mockMediaLoad.resolve);
expect(mockMediaLoad.resolve).not.toHaveBeenCalled();
});
it('should log the issue again after resolving cleared it', () => {
const spy = vi.spyOn(console, 'warn').mockReturnValue(undefined);
const manager = createManager([mockMediaLoad]);
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription());
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
expect(spy).toHaveBeenCalledTimes(1);
// Resolving clears the issue, which releases the dedupe so the next
// failure is logged as a new episode rather than silently swallowed.
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
manager.resolve('media_unavailable', { targetID: 'cam1' });
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(createIssueDescription());
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
expect(spy).toHaveBeenCalledTimes(2);
spy.mockRestore();
});
});
describe('detectDynamic', () => {
it('should call detectDynamic on issues with the given state', () => {
const manager = createManager();
@@ -441,25 +441,6 @@ describe('MediaLoadedInfoManager', () => {
type: 'load',
targetID: 'target-1',
info,
cached: false,
});
});
it('should mark a cached load', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const listener = vi.fn();
manager.subscribe(listener);
manager.set(info, owner, true);
expect(listener).toHaveBeenCalledWith({
type: 'load',
targetID: 'target-1',
info,
cached: true,
});
});