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
@@ -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);