feat: Surface the provider's error cause in the media_unavailable notification (#2599)
- Closes: #2592
This commit is contained in:
@@ -431,6 +431,29 @@ describe('MediaUnavailableIssue', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should drop a stale pending-timer target once its timer has stopped', () => {
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
|
||||
// A slow load arms the pending timer for camera.garden.
|
||||
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
|
||||
|
||||
// The view moves to a different target that already has a hard error.
|
||||
// That path activates immediately and stops the timer, but the stale
|
||||
// _timerTargetID (camera.garden) lingers.
|
||||
issue.trigger({ targetID: 'camera.office', reason: 'playback_error' });
|
||||
issue.detectDynamic({ targetID: 'camera.office', view: 'live' });
|
||||
|
||||
// Only the real error shows; the stale, no-longer-running pending target
|
||||
// must not paint a "not loading" line.
|
||||
const notification = issue.getNotification();
|
||||
expect(notification.metadata).not.toContainEqual(
|
||||
expect.objectContaining({ text: 'camera.garden: Media not loading' }),
|
||||
);
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: 'camera.office: Playback error' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use camera title when available', () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({
|
||||
@@ -471,6 +494,37 @@ describe('MediaUnavailableIssue', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render the free-text cause as context, keyed by camera title', () => {
|
||||
const api = createAPI();
|
||||
vi.mocked(api.getCameraManager().getCameraMetadata).mockReturnValue({
|
||||
title: 'Office',
|
||||
icon: { icon: 'mdi:cctv' },
|
||||
});
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
issue.trigger({
|
||||
targetID: 'camera.office',
|
||||
reason: 'playback_error',
|
||||
description: 'Failed to start WebRTC stream: no candidates',
|
||||
});
|
||||
|
||||
const notification = issue.getNotification();
|
||||
|
||||
// The metadata line stays scannable; the long cause sits below it.
|
||||
expect(notification.metadata).toEqual([
|
||||
expect.objectContaining({ text: 'Office: Playback error' }),
|
||||
]);
|
||||
expect(notification.context).toEqual([
|
||||
'Office: Failed to start WebRTC stream: no candidates',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should omit context for targets without a free-text cause', () => {
|
||||
const issue = new MediaUnavailableIssue(createAPI());
|
||||
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
|
||||
|
||||
expect(issue.getNotification().context).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include a retry control with wired callback', async () => {
|
||||
const api = createCardAPI();
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error';
|
||||
import { dispatchLiveErrorEvent } from '../../../../../src/components-lib/live/utils/dispatch-live-error';
|
||||
|
||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||
|
||||
@@ -24,7 +25,7 @@ describe('ProviderErrorDetector', () => {
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
dispatchLiveErrorEvent(host);
|
||||
|
||||
// not_live but no renderPlaceholder: the provider renders its own error.
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
@@ -35,19 +36,21 @@ describe('ProviderErrorDetector', () => {
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should adopt the specific reason carried on the event', () => {
|
||||
it('should adopt the specific reason and description carried on the event', () => {
|
||||
const host = createHostInDocument();
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(
|
||||
new CustomEvent(LIVE_ERROR_EVENT, { bubbles: true, detail: 'unsupported' }),
|
||||
);
|
||||
dispatchLiveErrorEvent(host, {
|
||||
reason: 'unsupported',
|
||||
detail: 'Codec not supported',
|
||||
});
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'hard',
|
||||
reason: 'unsupported',
|
||||
description: 'Codec not supported',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,8 +60,8 @@ describe('ProviderErrorDetector', () => {
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
dispatchLiveErrorEvent(host);
|
||||
dispatchLiveErrorEvent(host);
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -70,7 +73,7 @@ describe('ProviderErrorDetector', () => {
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
dispatchLiveErrorEvent(host);
|
||||
|
||||
expect(parentListener).not.toHaveBeenCalled();
|
||||
|
||||
@@ -81,7 +84,7 @@ describe('ProviderErrorDetector', () => {
|
||||
const host = createHostInDocument();
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
dispatchLiveErrorEvent(host);
|
||||
expect(detector.getVerdict().state).toBe('not_live');
|
||||
|
||||
detector.reset();
|
||||
@@ -96,7 +99,7 @@ describe('ProviderErrorDetector', () => {
|
||||
detector.subscribe();
|
||||
detector.unsubscribe();
|
||||
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
dispatchLiveErrorEvent(host);
|
||||
|
||||
expect(detector.getVerdict().state).toBe('unknown');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { Camera } from '../../../../src/camera-manager/camera';
|
||||
import type { StateWatcherSubscriptionInterface } from '../../../../src/card-controller/hass/state-watcher';
|
||||
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||
import { StreamLivenessController } from '../../../../src/components-lib/live/liveness/stream-liveness-controller';
|
||||
import {
|
||||
dispatchLiveErrorEvent,
|
||||
type LiveError,
|
||||
} from '../../../../src/components-lib/live/utils/dispatch-live-error';
|
||||
import type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
|
||||
import {
|
||||
callIntersectionHandler,
|
||||
@@ -18,7 +22,6 @@ import {
|
||||
IntersectionObserverMock,
|
||||
} from '../../../test-utils';
|
||||
|
||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
||||
|
||||
const setup = (options?: { targetID?: string | null }) => {
|
||||
@@ -38,8 +41,8 @@ const setup = (options?: { targetID?: string | null }) => {
|
||||
issueTriggers.push((ev as CustomEvent).detail),
|
||||
);
|
||||
|
||||
const failViaProviderError = (): void => {
|
||||
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||
const failViaProviderError = (error?: LiveError): void => {
|
||||
dispatchLiveErrorEvent(host, error);
|
||||
};
|
||||
|
||||
return { host, controller, issueTriggers, failViaProviderError };
|
||||
@@ -132,6 +135,27 @@ describe('StreamLivenessController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should carry the provider's error description into the failure and the issue", () => {
|
||||
const { controller, issueTriggers, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
|
||||
failViaProviderError({ detail: 'Failed to start WebRTC stream: no candidates' });
|
||||
|
||||
expect(controller.getFailure()).toEqual({
|
||||
reason: 'playback_error',
|
||||
description: 'Failed to start WebRTC stream: no candidates',
|
||||
renderPlaceholder: false,
|
||||
});
|
||||
expect(issueTriggers).toEqual([
|
||||
{
|
||||
key: 'media_unavailable',
|
||||
targetID: 'camera.office',
|
||||
reason: 'playback_error',
|
||||
description: 'Failed to start WebRTC stream: no candidates',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not fire the issue without a target', () => {
|
||||
const { host, controller, issueTriggers, failViaProviderError } = setup({
|
||||
targetID: null,
|
||||
|
||||
@@ -3,22 +3,27 @@ import { expect, it, vi } from 'vitest';
|
||||
import { dispatchLiveErrorEvent } from '../../../../src/components-lib/live/utils/dispatch-live-error';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
it('should dispatch live error event', () => {
|
||||
it('should dispatch live error event with an empty error when none is given', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('advanced-camera-card:live:error', handler);
|
||||
|
||||
dispatchLiveErrorEvent(element);
|
||||
expect(handler).toBeCalled();
|
||||
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ detail: {} }));
|
||||
});
|
||||
|
||||
it('should forward the reason as the event detail', () => {
|
||||
it('should forward the reason and detail as the event detail', () => {
|
||||
const element = document.createElement('div');
|
||||
const handler = vi.fn();
|
||||
element.addEventListener('advanced-camera-card:live:error', handler);
|
||||
|
||||
dispatchLiveErrorEvent(element, 'unsupported');
|
||||
dispatchLiveErrorEvent(element, {
|
||||
reason: 'unsupported',
|
||||
detail: 'Codec not supported',
|
||||
});
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ detail: 'unsupported' }),
|
||||
expect.objectContaining({
|
||||
detail: { reason: 'unsupported', detail: 'Codec not supported' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { ReactiveControllerHost } from 'lit';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { SignedURLController } from '../../src/components-lib/signed-url-controller';
|
||||
import {
|
||||
getSignedURLErrorText,
|
||||
SignedURLController,
|
||||
} from '../../src/components-lib/signed-url-controller';
|
||||
import { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path';
|
||||
import { createProxiedEndpointIfNecessary } from '../../src/ha/web-proxy';
|
||||
import type { Endpoint } from '../../src/types';
|
||||
@@ -997,3 +1000,13 @@ describe('SignedURLController', () => {
|
||||
expect(host.requestUpdate).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSignedURLErrorText', () => {
|
||||
it('should describe a signing failure', () => {
|
||||
expect(getSignedURLErrorText('sign')).toBe('Could not sign Home Assistant URL');
|
||||
});
|
||||
|
||||
it('should describe a proxy failure', () => {
|
||||
expect(getSignedURLErrorText('proxy')).toBe('Could not proxy via Home Assistant');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user