fix: Clear stale media unavailable errors when a camera recovers (#2627)
- Closes: #2576
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user