feat: Automatically recover from frozen live streams (#2569)

- Closes: #2099
This commit is contained in:
Dermot Duffy
2026-07-07 21:37:24 -07:00
committed by GitHub
parent 9f956fe89f
commit fb1bbc739e
73 changed files with 3321 additions and 364 deletions
@@ -230,7 +230,7 @@ describe('InitializationManager', () => {
// First call (languages/side-load) succeeds, second (cameras) fails.
initializer.initializeMultipleIfNecessary
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('cameras failed'));
await manager.initializeMandatory();
@@ -250,10 +250,12 @@ describe('InitializationManager', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
// First initializeIfNecessary call (view) succeeds, second
// (initial_trigger) fails.
initializer.initializeIfNecessary
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error('triggers failed'));
await manager.initializeMandatory();
@@ -272,6 +274,7 @@ describe('InitializationManager', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
initializer.initializeIfNecessary.mockRejectedValueOnce(
new Error('view initialization failed'),
);
@@ -285,6 +288,29 @@ describe('InitializationManager', () => {
);
});
it('should stop without an error when an aspect reports failure', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
// An aspect that could not complete (e.g. the view when no view could be
// set) reports failure rather than throwing: the chain stops so a later
// attempt retries, and no initialization error is raised.
initializer.initializeIfNecessary.mockResolvedValueOnce(false);
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
expect(api.getIssueManager().trigger).not.toBeCalledWith(
'initialization',
expect.anything(),
);
});
it('should handle non-Error thrown during initialization', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
+8 -4
View File
@@ -38,7 +38,7 @@ describe('createIssueManager', () => {
'initialization',
'legacy_resource',
'media_query',
'media_load',
'media_unavailable',
'view_incompatible',
] as const;
@@ -84,15 +84,19 @@ describe('createIssueManager', () => {
const manager = createIssueManager(api, createSubscriptionHealth());
// Setting view starts the media_load timer (via the condition state
// Setting view starts the media_unavailable timer (via the condition state
// listener → evaluate → detectDynamic).
stateManager.setState({ targetID: 'camera-1', view: 'live' });
expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(false);
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
false,
);
// After the timeout, the changeCallback fires evaluate which
// updates the card element.
vi.advanceTimersByTime(10000);
expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(true);
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
true,
);
});
});
@@ -8,6 +8,7 @@ import {
RETRY_EXPONENTIAL_BASE_SECONDS,
RETRY_EXPONENTIAL_MAX_SECONDS,
} from '../../../src/card-controller/issues/issue-manager';
import { MediaUnavailableIssue } from '../../../src/card-controller/issues/issues/media-unavailable';
import { createRetryControl } from '../../../src/card-controller/issues/retry-control';
import type {
Issue,
@@ -20,6 +21,7 @@ import {
createCardAPI,
createConfig,
createHASS,
createMediaLoadedInfo,
flushPromises,
} from '../../test-utils';
@@ -74,7 +76,7 @@ const createRetriableSetup = (options?: {
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
const issue = createIssue('media_unavailable', {
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
needsRetry: vi.fn().mockReturnValue(true),
retry: vi.fn().mockReturnValue(false),
@@ -110,6 +112,35 @@ describe('IssueManager', () => {
expect(issue.detectDynamic).toBeCalled();
});
it('should keep a liveness-triggered error active while its media still reads as loaded', () => {
// Regression: a runtime liveness loss (stall / provider error) fires a
// trigger while the (now frozen) media is still loaded. The trigger's
// synchronous evaluate must not clear the just-set error via the stale
// loaded state.
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
const manager = new IssueManager(api);
const issue = new MediaUnavailableIssue(api);
manager.addIssue(issue);
conditionStateManager.setState({
view: 'live',
targetID: 'camera.office',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera.office' }),
});
expect(issue.hasIssue()).toBe(false);
manager.trigger('media_unavailable', {
targetID: 'camera.office',
reason: 'stalled',
});
expect(issue.hasIssue()).toBe(true);
expect(issue.needsRetry()).toBe(true);
});
describe('addIssue / getStateManager', () => {
it('should make added issues accessible via getManager', () => {
const manager = new IssueManager(createCardAPI());
@@ -240,7 +271,7 @@ describe('IssueManager', () => {
// Start the timer via evaluate, then immediately retry.
manager.evaluate();
manager.retry('media_load');
manager.retry('media_unavailable');
expect(issue.retry).toBeCalled();
@@ -255,12 +286,12 @@ describe('IssueManager', () => {
it('should force retry even when needsRetry is false', () => {
const api = createCardAPI();
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
const issue = createIssue('media_unavailable', {
retry: vi.fn().mockReturnValue(false),
});
manager.addIssue(issue);
manager.retry('media_load', true);
manager.retry('media_unavailable', true);
expect(issue.retry).toBeCalled();
});
@@ -370,12 +401,12 @@ describe('IssueManager', () => {
const api = createCardAPI();
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
const issue = createIssue('media_unavailable', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockImplementation(() =>
createIssueDescription({
notification: {
controls: [createRetryControl('media_load')],
controls: [createRetryControl('media_unavailable')],
},
}),
),
@@ -454,7 +485,7 @@ describe('IssueManager', () => {
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
const issue = createIssue('media_unavailable', {
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
needsRetry: vi.fn().mockReturnValue(true),
retry: vi.fn().mockReturnValue(false),
@@ -615,21 +646,31 @@ describe('IssueManager', () => {
});
it('should cap the backoff at the max delay', () => {
// Drive 5 pre-cap attempts (30, 60, 120, 240, 480 seconds), then assert
// the 6th attempt clamps to MAX instead of the would-be 960.
// Drive attempts through the exponential region (base, base*2, base*4,
// ...) until the delay reaches MAX, then assert the next attempt clamps to
// MAX instead of continuing to double, and stays there.
vi.spyOn(Math, 'random').mockReturnValue(1);
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 8 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 16 * 1000);
expect(issue.retry).toBeCalledTimes(5);
let attempts = 0;
for (
let delaySeconds = RETRY_EXPONENTIAL_BASE_SECONDS;
delaySeconds < RETRY_EXPONENTIAL_MAX_SECONDS;
delaySeconds *= 2
) {
vi.advanceTimersByTime(delaySeconds * 1000);
attempts++;
}
expect(issue.retry).toBeCalledTimes(attempts);
// The next attempt clamps to MAX instead of the would-be larger delay.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
expect(issue.retry).toBeCalledTimes(6);
expect(issue.retry).toBeCalledTimes(attempts + 1);
// And it stays capped at MAX rather than growing further.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
expect(issue.retry).toBeCalledTimes(attempts + 2);
});
it('should reset the attempt counter when the issue clears', () => {
@@ -834,7 +875,7 @@ describe('IssueManager', () => {
const api = createCardAPI();
const manager = new IssueManager(api);
const issue = createIssue('media_load', { suspend: vi.fn() });
const issue = createIssue('media_unavailable', { suspend: vi.fn() });
manager.addIssue(issue);
manager.suspend();
@@ -1,7 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
import { MediaUnavailableIssue } from '../../../../src/card-controller/issues/issues/media-unavailable';
import type { MediaLoadedInfoChange } from '../../../../src/card-controller/media-info-manager';
import type { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
import type { View } from '../../../../src/view/view';
@@ -9,8 +10,32 @@ import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
const createAPI = () => createCardAPI();
// Deliver a media change to the listener the issue registered with the
// media-loaded manager.
const fireMediaChange = (
api: ReturnType<typeof createAPI>,
change: MediaLoadedInfoChange,
): void => {
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 => {
fireMediaChange(api, {
type: 'load',
targetID,
info: createMediaLoadedInfo({ targetID }),
cached,
});
};
// @vitest-environment jsdom
describe('MediaLoadIssue', () => {
describe('MediaUnavailableIssue', () => {
beforeEach(() => {
vi.useFakeTimers();
});
@@ -20,8 +45,8 @@ describe('MediaLoadIssue', () => {
});
it('should have correct key', () => {
const issue = new MediaLoadIssue(createAPI());
expect(issue.key).toBe('media_load');
const issue = new MediaUnavailableIssue(createAPI());
expect(issue.key).toBe('media_unavailable');
});
describe('detectDynamic', () => {
@@ -35,7 +60,7 @@ describe('MediaLoadIssue', () => {
['review' as const],
])('should start timer when view is %s and not loaded', (view) => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'target-1', view });
@@ -48,7 +73,7 @@ describe('MediaLoadIssue', () => {
});
it('should not start timer when targetID is null (no provider rendering)', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
// Media view but no targetID, e.g. viewer showing "No media to display"
// instead of mounting a provider.
@@ -60,7 +85,7 @@ describe('MediaLoadIssue', () => {
});
it('should deactivate when targetID becomes null', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -72,7 +97,7 @@ describe('MediaLoadIssue', () => {
});
it('should not start timer when view is not a media view', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ view: 'timeline' });
@@ -82,7 +107,7 @@ describe('MediaLoadIssue', () => {
});
it('should not start timer when view is undefined', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({});
@@ -92,7 +117,7 @@ describe('MediaLoadIssue', () => {
});
it('should not start timer when media is loaded', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() });
@@ -102,7 +127,7 @@ describe('MediaLoadIssue', () => {
});
it('should clear timeout when media loads', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(5000);
@@ -115,7 +140,7 @@ describe('MediaLoadIssue', () => {
});
it('should clear timeout when view changes to a non-media view', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(5000);
@@ -128,7 +153,7 @@ describe('MediaLoadIssue', () => {
});
it('should remain active across media views for the same target', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -140,7 +165,7 @@ describe('MediaLoadIssue', () => {
});
it('should deactivate when target changes to non-errored target', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -157,10 +182,10 @@ describe('MediaLoadIssue', () => {
});
it('should stay active when target changes to errored target', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-2' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'camera-2', reason: 'stalled' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
@@ -171,7 +196,7 @@ describe('MediaLoadIssue', () => {
});
it('should clear timed-out state when media loads', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -183,7 +208,7 @@ describe('MediaLoadIssue', () => {
it('should restart timer when target changes', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({
targetID: 'camera-1',
@@ -209,7 +234,7 @@ describe('MediaLoadIssue', () => {
it('should not restart timer for same target while running', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({
targetID: 'camera-1',
@@ -231,7 +256,7 @@ describe('MediaLoadIssue', () => {
it('should not restart timer when targetID is undefined and matches', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(5000);
@@ -246,7 +271,7 @@ describe('MediaLoadIssue', () => {
it('should not restart timer if already timed out', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -261,9 +286,9 @@ describe('MediaLoadIssue', () => {
describe('trigger', () => {
it('should activate immediately when target has error and view is a media view', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
@@ -273,46 +298,97 @@ describe('MediaLoadIssue', () => {
});
it('should not activate with only a trigger', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
expect(issue.hasIssue()).toBe(false);
});
it('should clear target error when media loads', () => {
const issue = new MediaLoadIssue(createAPI());
it('should clear a target error on a genuine media load', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
// A genuine (re)load for the target clears the error.
fireMediaLoad(api, 'camera-1');
// The error is gone, so this unloaded state falls back to the timer
// (would not activate until the timeout).
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(false);
});
it('should keep an errored target active while its media still reads as loaded', () => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
// The (now frozen) media still reads as loaded, but the existing loaded
// level must not clear the error -- only a genuine media load does. The
// loaded media is the frozen stream a liveness detector just condemned.
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
});
expect(issue.hasIssue()).toBe(true);
});
// Media loaded clears the error for this target.
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
mediaLoadedInfo: createMediaLoadedInfo(),
});
it('should not clear a target error when a different target loads', () => {
const api = createAPI();
const issue = new MediaUnavailableIssue(api);
// Target error was cleared by the successful load, so this unloaded state
// falls back to the timer (issue would not activate until after the
// timer is reached).
issue.detectDynamic({
targetID: 'camera-1',
view: 'live',
});
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
expect(issue.hasIssue()).toBe(false);
// A load for a different target must not clear camera-1's error.
fireMediaLoad(api, 'camera-2');
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
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' });
// Only a genuine 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' });
expect(issue.hasIssue()).toBe(true);
});
it('should not activate for a different target', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.detectDynamic({
targetID: 'camera-2',
view: 'live',
@@ -325,7 +401,7 @@ describe('MediaLoadIssue', () => {
describe('getNotification', () => {
it('should return notification regardless of active state', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
const notification = issue.getNotification();
expect(notification).toEqual(
@@ -340,25 +416,18 @@ describe('MediaLoadIssue', () => {
);
});
it('should include metadata for errored targets', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: 'camera.office' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'camera.office', icon: 'mdi:cctv' }),
]);
});
it('should include the pending timer target in metadata', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
// Start a load timer (no explicit error yet, just slow-loading).
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'camera.garden', icon: 'mdi:cctv' }),
expect.objectContaining({
text: 'camera.garden: Media not loading',
icon: 'mdi:progress-helper',
}),
]);
});
@@ -368,28 +437,43 @@ describe('MediaLoadIssue', () => {
title: 'Office',
icon: { icon: 'mdi:cctv' },
});
const issue = new MediaLoadIssue(api);
issue.trigger({ targetID: 'camera.office' });
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'Office' }),
expect.objectContaining({ text: 'Office: Stream stalled' }),
]);
});
it('should use localized label and image icon for the image-view sentinel', () => {
const issue = new MediaLoadIssue(createAPI());
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: 'Image', icon: 'mdi:image' }),
expect.objectContaining({ text: 'Image: Stream stalled', icon: 'mdi:image' }),
]);
});
it.each([
['entity_unavailable' as const, 'Camera entity unavailable', 'mdi:cctv-off'],
['not_loading' as const, 'Media not loading', 'mdi:progress-helper'],
['playback_error' as const, 'Playback error', 'mdi:alert-circle'],
['stalled' as const, 'Stream stalled', 'mdi:motion-pause'],
])('should give the %s cause its own text and icon', (reason, text, icon) => {
const issue = new MediaUnavailableIssue(createAPI());
issue.trigger({ targetID: 'camera.office', reason });
const notification = issue.getNotification();
expect(notification.metadata).toEqual([
expect.objectContaining({ text: `camera.office: ${text}`, icon }),
]);
});
it('should include a retry control with wired callback', async () => {
const api = createCardAPI();
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
const control = issue.getNotification().controls?.[0];
expect(control).toMatchObject({ icon: 'mdi:refresh', dismiss: true });
@@ -397,13 +481,13 @@ describe('MediaLoadIssue', () => {
const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig;
await tapAction.callback(api);
expect(api.getIssueManager().retry).toBeCalledWith('media_load', true);
expect(api.getIssueManager().retry).toBeCalledWith('media_unavailable', true);
});
});
describe('getIssue', () => {
it('should return result when timed out', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -423,7 +507,7 @@ describe('MediaLoadIssue', () => {
});
it('should return null when not timed out', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
expect(issue.getIssue()).toBeNull();
});
@@ -431,7 +515,7 @@ describe('MediaLoadIssue', () => {
describe('needsRetry', () => {
it('should return true when issue is active', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -440,7 +524,7 @@ describe('MediaLoadIssue', () => {
});
it('should return false when issue is not active', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
expect(issue.needsRetry()).toBe(false);
});
@@ -449,7 +533,7 @@ describe('MediaLoadIssue', () => {
describe('retry', () => {
it('should keep issue active after retry so error stays visible', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(10000);
@@ -464,7 +548,7 @@ describe('MediaLoadIssue', () => {
it('should return false when no targets have errors', () => {
const api = createAPI();
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
expect(issue.retry()).toBe(false);
});
@@ -472,10 +556,10 @@ describe('MediaLoadIssue', () => {
it('should bump mediaEpoch for targets with errors and call setViewWithMergedContext', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'media-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.trigger({ targetID: 'media-1', reason: 'stalled' });
const result = issue.retry();
@@ -488,9 +572,9 @@ describe('MediaLoadIssue', () => {
it('should bump mediaEpoch for the image-view sentinel', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' });
const result = issue.retry();
@@ -505,9 +589,9 @@ describe('MediaLoadIssue', () => {
vi.mocked(api.getViewManager().getView).mockReturnValue(
mock<View>({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }),
);
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
const result = issue.retry();
@@ -520,7 +604,7 @@ describe('MediaLoadIssue', () => {
it('should include pending timer target in retry', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
// Start the timer for camera-1 (not yet timed out).
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
@@ -535,17 +619,17 @@ describe('MediaLoadIssue', () => {
it('should keep errored targets and issue state after retry', () => {
const api = createAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
const issue = new MediaLoadIssue(api);
const issue = new MediaUnavailableIssue(api);
issue.trigger({ targetID: 'camera-1' });
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
issue.retry();
// After retry, the issue stays active and the errored target is preserved
// -- no new 10s grace period. If media:loaded fires, the existing
// _handleMediaLoaded path will clear everything.
// -- no new 10s grace period. A genuine media load would clear everything
// (_onMediaLoad drops the errored target).
expect(issue.hasIssue()).toBe(true);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
expect(issue.hasIssue()).toBe(true);
@@ -555,7 +639,7 @@ describe('MediaLoadIssue', () => {
describe('reset', () => {
it('should stop timer', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
issue.reset();
@@ -567,10 +651,34 @@ describe('MediaLoadIssue', () => {
});
});
describe('media loads', () => {
it('should notify onChange when a media load clears an error', () => {
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).toBeCalled();
});
it('should unsubscribe from media loads on destroy', () => {
const api = createAPI();
const unsubscribe = vi.fn();
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mockReturnValue(unsubscribe);
const issue = new MediaUnavailableIssue(api);
issue.destroy();
expect(unsubscribe).toBeCalled();
});
});
describe('suspend', () => {
it('should stop the pending-load timer so it cannot mature offscreen', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
// Enter loading state. Timer arms but has not yet fired.
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
@@ -588,7 +696,7 @@ describe('MediaLoadIssue', () => {
});
it('should preserve an already-active issue across suspend', () => {
const issue = new MediaLoadIssue(createAPI());
const issue = new MediaUnavailableIssue(createAPI());
// Issue activates (timeout fires).
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
@@ -603,7 +711,7 @@ describe('MediaLoadIssue', () => {
it('should rearm a fresh timer window on resume via detectDynamic', () => {
const onChange = vi.fn();
const issue = new MediaLoadIssue(createAPI(), onChange);
const issue = new MediaUnavailableIssue(createAPI(), onChange);
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
vi.advanceTimersByTime(5000);
@@ -7,7 +7,7 @@ import { createCardAPI } from '../../test-utils';
describe('createRetryControl', () => {
it('should return a control with expected icon, tooltip, and dismiss', () => {
const control = createRetryControl('media_load');
const control = createRetryControl('media_unavailable');
expect(control.icon).toBe('mdi:refresh');
expect(control.tooltip).toBe('Retry');
@@ -43,7 +43,7 @@ describe('IssueStateManager', () => {
mockConfigUpgrade = mock<Issue>({ key: 'config_upgrade' });
mockLegacyResource = mock<Issue>({ key: 'legacy_resource' });
mockMediaLoad = mock<Issue>({ key: 'media_load' });
mockMediaLoad = mock<Issue>({ key: 'media_unavailable' });
});
it('should register all provided issues on construction', () => {
@@ -52,7 +52,7 @@ describe('IssueStateManager', () => {
expect(presence.has('config_upgrade')).toBe(false);
expect(presence.has('legacy_resource')).toBe(false);
expect(presence.has('media_load')).toBe(false);
expect(presence.has('media_unavailable')).toBe(false);
});
describe('detectStatic', () => {
@@ -97,10 +97,13 @@ describe('IssueStateManager', () => {
it('should call trigger on the matching issue', () => {
const manager = createManager();
manager.trigger('media_load', { targetID: 'cam1' });
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
assert(mockMediaLoad.trigger);
expect(mockMediaLoad.trigger).toBeCalledWith({ targetID: 'cam1' });
expect(mockMediaLoad.trigger).toBeCalledWith({
targetID: 'cam1',
reason: 'stalled',
});
});
it('should do nothing for unknown key', () => {
@@ -210,7 +213,7 @@ describe('IssueStateManager', () => {
const manager = createManager();
expect(manager.getNotification('media_load')).toBe(notification);
expect(manager.getNotification('media_unavailable')).toBe(notification);
});
it('should return null for unknown key', () => {
@@ -297,7 +300,7 @@ describe('IssueStateManager', () => {
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true);
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
createManager().retry('media_load');
createManager().retry('media_unavailable');
expect(mockMediaLoad.retry).toBeCalled();
});
@@ -306,7 +309,7 @@ describe('IssueStateManager', () => {
assert(mockMediaLoad.needsRetry);
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
createManager().retry('media_load');
createManager().retry('media_unavailable');
assert(mockMediaLoad.retry);
expect(mockMediaLoad.retry).not.toBeCalled();
@@ -318,7 +321,7 @@ describe('IssueStateManager', () => {
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
createManager().retry('media_load', true);
createManager().retry('media_unavailable', true);
expect(mockMediaLoad.retry).toBeCalled();
});
@@ -374,7 +377,7 @@ describe('IssueStateManager', () => {
manager.detectDynamic({ view: 'live' });
expect(spy).toBeCalledWith(
'Advanced Camera Card [issue=media_load]: Stream issue',
'Advanced Camera Card [issue=media_unavailable]: Stream issue',
);
spy.mockRestore();
});
@@ -387,9 +390,11 @@ describe('IssueStateManager', () => {
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result);
const manager = createManager();
manager.trigger('media_load', { targetID: 'cam1' });
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
expect(spy).toBeCalledWith('Advanced Camera Card [issue=media_load]: Triggered');
expect(spy).toBeCalledWith(
'Advanced Camera Card [issue=media_unavailable]: Triggered',
);
spy.mockRestore();
});
@@ -398,7 +403,7 @@ describe('IssueStateManager', () => {
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
const manager = createManager();
manager.trigger('media_load', { targetID: 'cam1' });
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
expect(spy).not.toBeCalled();
spy.mockRestore();
@@ -503,7 +508,7 @@ describe('IssueStateManager', () => {
const manager = createManager();
manager.detectDynamic({ view: 'live' });
manager.reset('media_load');
manager.reset('media_unavailable');
// After reset, the issue reports cleared on next detect, releasing the
// dedupe.
manager.detectDynamic({ view: 'live' });
@@ -518,7 +523,7 @@ describe('IssueStateManager', () => {
describe('reset', () => {
it('should reset a specific issue by key', () => {
const manager = createManager();
manager.reset('media_load');
manager.reset('media_unavailable');
assert(mockMediaLoad.reset);
expect(mockMediaLoad.reset).toBeCalled();
@@ -360,4 +360,116 @@ describe('MediaLoadedInfoManager', () => {
expect(manager.getLastKnown()).toBeNull();
});
});
describe('subscribe', () => {
it('should notify of a fresh load, even for a non-selected target', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const listener = vi.fn();
manager.setSelected('target-2');
manager.subscribe(listener);
manager.set(info, owner);
expect(listener).toBeCalledWith({
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).toBeCalledWith({
type: 'load',
targetID: 'target-1',
info,
cached: true,
});
});
it('should notify of selection changes', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const listener = vi.fn();
manager.subscribe(listener);
manager.setSelected('target-1');
manager.setSelected(null);
expect(listener).toBeCalledWith({ type: 'select', targetID: 'target-1' });
expect(listener).toBeCalledWith({ type: 'select', targetID: null });
});
it('should notify of an unload when a target is retired', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const source = document.createElement('div');
const info = createMediaLoadedInfo({ targetID: 'target-1' });
const ac = new AbortController();
const listener = vi.fn();
manager.subscribe(listener);
manager.handleLoadEvent(
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
);
ac.abort();
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-1' });
});
it('should notify of an unload for each active target on clear', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const listener = vi.fn();
manager.set(createMediaLoadedInfo({ targetID: 'target-1' }), owner);
manager.set(createMediaLoadedInfo({ targetID: 'target-2' }), owner);
manager.subscribe(listener);
manager.clear();
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-1' });
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-2' });
});
it('should notify of unloads and a deselect on initialize', () => {
const api = createCardAPI();
const manager = new MediaLoadedInfoManager(api);
const owner = document.createElement('div');
const listener = vi.fn();
manager.setSelected('target-1');
manager.set(createMediaLoadedInfo({ targetID: 'target-1' }), owner);
manager.subscribe(listener);
manager.initialize();
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-1' });
expect(listener).toBeCalledWith({ type: 'select', targetID: null });
});
it('should stop notifying after unsubscribe', () => {
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);
expect(listener).not.toBeCalled();
});
});
});
@@ -732,7 +732,7 @@ describe('should initialize', () => {
viewFactory: viewFactory,
});
await manager.initialize();
expect(await manager.initialize()).toBe(true);
expect(manager.getView()).toBe(view);
});
@@ -745,7 +745,23 @@ describe('should initialize', () => {
true,
);
await manager.initialize();
expect(await manager.initialize()).toBe(true);
expect(manager.hasView()).toBeFalsy();
});
it('should report failure when no view could be set', async () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockReturnValue(createView());
// Cameras not being initialized causes the default view set to decline to
// run, so initialization must report failure to allow a later retry.
const api = createInitializedCardAPI(false);
const manager = new ViewManager(api, {
viewFactory: viewFactory,
});
expect(await manager.initialize()).toBe(false);
expect(manager.hasView()).toBeFalsy();
});