fix: Clear stale media unavailable errors when a camera recovers (#2627)
- Closes: #2576
This commit is contained in:
@@ -219,6 +219,22 @@ describe('EntityAvailabilityDetector', () => {
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should re-check the entity when reset', () => {
|
||||
// always_error makes the re-check produce a verdict immediately rather than
|
||||
// waiting out the grace window.
|
||||
const { detector, setEntityState } = setup({ alwaysError: true });
|
||||
detector.subscribe();
|
||||
|
||||
// An entity that is already unavailable never fires a state change, so
|
||||
// resetting must read it rather than wait to be told.
|
||||
setEntityState('unavailable');
|
||||
detector.reset();
|
||||
|
||||
expect(detector.getVerdict()).toEqual(
|
||||
expect.objectContaining({ state: 'not_live', authority: 'hard' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing on reset before subscribe', () => {
|
||||
const { detector, stateWatcher } = setup();
|
||||
|
||||
|
||||
@@ -163,6 +163,26 @@ describe('MediaPlayerLivenessDetector', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should discard a retained live verdict when watching resumes', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
// Away and back with nothing observed in between. The retained `live`
|
||||
// describes the previous watch, so it must not survive into this one.
|
||||
detector.unsubscribe();
|
||||
onChange.mockClear();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not watch a player without the liveness capability', async () => {
|
||||
const { detector, onChange, loadMedia } = setup();
|
||||
const player = mock<MediaPlayerController>();
|
||||
|
||||
@@ -13,6 +13,19 @@ const createHostInDocument = (): HTMLElement => {
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ProviderErrorDetector', () => {
|
||||
it('should not report a change when reset', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
dispatchLiveErrorEvent(host);
|
||||
onChange.mockClear();
|
||||
|
||||
detector.reset();
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start unknown', () => {
|
||||
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
|
||||
|
||||
@@ -43,7 +56,7 @@ describe('ProviderErrorDetector', () => {
|
||||
|
||||
dispatchLiveErrorEvent(host, {
|
||||
reason: 'unsupported',
|
||||
detail: 'Codec not supported',
|
||||
description: 'Codec not supported',
|
||||
});
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../../../test-utils';
|
||||
|
||||
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
||||
const ISSUE_RESOLVE_EVENT = 'advanced-camera-card:issue:resolve';
|
||||
|
||||
const setup = (options?: { targetID?: string | null }) => {
|
||||
const host = createLitElement();
|
||||
@@ -41,11 +42,16 @@ const setup = (options?: { targetID?: string | null }) => {
|
||||
issueTriggers.push((ev as CustomEvent).detail),
|
||||
);
|
||||
|
||||
const issueResolves: unknown[] = [];
|
||||
host.addEventListener(ISSUE_RESOLVE_EVENT, (ev) =>
|
||||
issueResolves.push((ev as CustomEvent).detail),
|
||||
);
|
||||
|
||||
const failViaProviderError = (error?: LiveError): void => {
|
||||
dispatchLiveErrorEvent(host, error);
|
||||
};
|
||||
|
||||
return { host, controller, issueTriggers, failViaProviderError };
|
||||
return { host, controller, issueTriggers, issueResolves, failViaProviderError };
|
||||
};
|
||||
|
||||
const createPlayer = (): {
|
||||
@@ -139,7 +145,9 @@ describe('StreamLivenessController', () => {
|
||||
const { controller, issueTriggers, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
|
||||
failViaProviderError({ detail: 'Failed to start WebRTC stream: no candidates' });
|
||||
failViaProviderError({
|
||||
description: 'Failed to start WebRTC stream: no candidates',
|
||||
});
|
||||
|
||||
expect(controller.getFailure()).toEqual({
|
||||
reason: 'playback_error',
|
||||
@@ -338,6 +346,129 @@ describe('StreamLivenessController', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('resolving the issue', () => {
|
||||
const setupWithLiveStream = async () => {
|
||||
const result = setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
|
||||
result.controller.hostConnected();
|
||||
result.host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
return { ...result, fireMediaPlayerLiveness };
|
||||
};
|
||||
|
||||
it('should resolve when frames confirm the stream is flowing', async () => {
|
||||
const { issueResolves, fireMediaPlayerLiveness } = await setupWithLiveStream();
|
||||
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
expect(issueResolves).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera.office' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not resolve while a hard failure exists', async () => {
|
||||
const { issueResolves, failViaProviderError, fireMediaPlayerLiveness } =
|
||||
await setupWithLiveStream();
|
||||
|
||||
// A provider has authoritatively condemned the stream. Frames continuing
|
||||
// to arrive must not talk the card out of it.
|
||||
failViaProviderError();
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
expect(issueResolves).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not resolve without a target', async () => {
|
||||
const result = setup({ targetID: null });
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
|
||||
result.controller.hostConnected();
|
||||
result.host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(true);
|
||||
|
||||
expect(result.issueResolves).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not resolve on reconnect without fresh evidence', async () => {
|
||||
const { controller, issueResolves, fireMediaPlayerLiveness } =
|
||||
await setupWithLiveStream();
|
||||
fireMediaPlayerLiveness(true);
|
||||
issueResolves.length = 0;
|
||||
|
||||
// Away and back with nothing observed in between: the previous `live` is
|
||||
// a memory of the old watch, not evidence about the new one.
|
||||
controller.hostDisconnected();
|
||||
controller.hostConnected();
|
||||
|
||||
expect(issueResolves).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not announce recovery for a stream that was reset away', async () => {
|
||||
const { controller, issueResolves, fireMediaPlayerLiveness } =
|
||||
await setupWithLiveStream();
|
||||
fireMediaPlayerLiveness(true);
|
||||
issueResolves.length = 0;
|
||||
|
||||
// The stream this `live` describes is being torn down, so resetting must
|
||||
// not report it as recovered.
|
||||
controller.reset();
|
||||
|
||||
expect(issueResolves).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not mix a stale live verdict with a freshly reset one', async () => {
|
||||
const { controller, host, issueResolves, issueTriggers, failViaProviderError } =
|
||||
setup();
|
||||
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||
controller.hostConnected();
|
||||
host.dispatchEvent(
|
||||
createMediaLoadedInfoEvent({
|
||||
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||
}),
|
||||
);
|
||||
await callIntersectionHandler(true);
|
||||
|
||||
// Frames say live, then a provider error condemns the stream. Both
|
||||
// verdicts are held at once, by different detectors.
|
||||
fireMediaPlayerLiveness(true);
|
||||
failViaProviderError();
|
||||
issueResolves.length = 0;
|
||||
issueTriggers.length = 0;
|
||||
|
||||
// Resetting clears them in turn. If any detector announced part-way
|
||||
// through, the cleared provider error would leave the stale `live`
|
||||
// unopposed and the card would report a recovery that never happened.
|
||||
controller.reset();
|
||||
|
||||
expect(issueResolves).toEqual([]);
|
||||
expect(issueTriggers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should re-read the detectors once they have all been reset', async () => {
|
||||
const { controller, host, fireMediaPlayerLiveness } = await setupWithLiveStream();
|
||||
fireMediaPlayerLiveness(true);
|
||||
vi.mocked(host.requestUpdate).mockClear();
|
||||
|
||||
controller.reset();
|
||||
|
||||
// Anything the detectors say while being reset is ignored, so this is the
|
||||
// single read the controller makes once they are all done.
|
||||
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(controller.isLive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should report not live despite confirmed frames when always_error overrides', async () => {
|
||||
const host = createLitElement();
|
||||
document.body.append(host);
|
||||
|
||||
Reference in New Issue
Block a user