feat: add problem detection framework for common problems (#2412)

Introduces a ProblemManager that detects and surfaces actionable issues
(stale config, legacy frigate-hass-card resources, slow/failed streams)
via status bar indicators and notification popups with fix actions.
This commit is contained in:
Dermot Duffy
2026-03-13 20:00:59 -07:00
committed by GitHub
parent 95faddd9a0
commit ab683df5e8
46 changed files with 2147 additions and 166 deletions
@@ -123,27 +123,6 @@ describe('ConfigManager', () => {
expect(manager.getConfig()).toBeNull();
expect(manager.getNonOverriddenConfig()).toBeNull();
expect(manager.getRawConfig()).toBeNull();
expect(manager.isUpgradeable()).toBe(false);
});
describe('isUpgradeable', () => {
it('should return true for upgradeable config', () => {
const manager = new ConfigManager(createCardAPI());
manager.setConfig({
type: 'custom:frigate-card',
cameras: [TEST_CAMERAS.OFFICE],
});
expect(manager.isUpgradeable()).toBe(true);
});
it('should return false for non-upgradeable config', () => {
const manager = new ConfigManager(createCardAPI());
manager.setConfig({
type: 'custom:advanced-camera-card',
cameras: [TEST_CAMERAS.OFFICE],
});
expect(manager.isUpgradeable()).toBe(false);
});
});
it('should successfully parse basic config', () => {
+8
View File
@@ -23,6 +23,7 @@ import { MessageManager } from '../../src/card-controller/message-manager';
import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
import { NotificationManager } from '../../src/card-controller/notification-manager';
import { PIPManager } from '../../src/card-controller/pip-manager';
import { ProblemManager } from '../../src/card-controller/problems/manager';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../../src/card-controller/style-manager';
@@ -56,6 +57,7 @@ vi.mock('../../src/card-controller/message-manager');
vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/notification-manager');
vi.mock('../../src/card-controller/pip-manager');
vi.mock('../../src/card-controller/problems/manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/status-bar-item-manager');
vi.mock('../../src/card-controller/style-manager');
@@ -238,6 +240,12 @@ describe('CardController', () => {
);
});
it('getProblemManager', () => {
expect(createController().getProblemManager()).toBe(
vi.mocked(ProblemManager).mock.instances[0],
);
});
it('getMicrophoneManager', () => {
expect(createController().getMicrophoneManager()).toBe(
vi.mocked(MicrophoneManager).mock.instances[0],
@@ -215,6 +215,65 @@ describe('InitializationManager', () => {
});
});
it('should report background initialization status', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(createCardAPI(), initializer);
initializer.isInitialized.mockReturnValue(false);
expect(manager.isInitializedBackground()).toBe(false);
initializer.isInitialized.mockReturnValue(true);
expect(manager.isInitializedBackground()).toBe(true);
expect(initializer.isInitialized).toBeCalledWith(InitializationAspect.PROBLEMS);
});
describe('should initialize background', () => {
it('without hass', async () => {
const api = createCardAPI();
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
await manager.initializeBackground();
expect(initializer.initializeIfNecessary).not.toBeCalled();
});
it('successfully', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const initializer = mock<Initializer>();
initializer.initializeIfNecessary.mockResolvedValue(true);
const manager = new InitializationManager(api, initializer);
await manager.initializeBackground();
expect(initializer.initializeIfNecessary).toBeCalledWith(
InitializationAspect.PROBLEMS,
expect.any(Function),
);
});
it('should call detectStatic on problem manager', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const initializer = mock<Initializer>();
initializer.initializeIfNecessary.mockImplementation(async (_aspect, callback) => {
return callback ? await callback() : true;
});
const manager = new InitializationManager(api, initializer);
await manager.initializeBackground();
expect(api.getProblemManager().detectStatic).toBeCalledWith(hass);
});
});
it('should uninitialize', () => {
const initializer = mock<Initializer>();
const manager = new InitializationManager(createCardAPI(), initializer);
@@ -0,0 +1,299 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ProblemManager } from '../../../src/card-controller/problems/manager';
import { ConfigUpgradeProblem } from '../../../src/card-controller/problems/problems/config-upgrade';
import { LegacyResourceProblem } from '../../../src/card-controller/problems/problems/legacy-resource';
import { StreamNotLoadingProblem } from '../../../src/card-controller/problems/problems/stream-not-loading';
import { Problem, ProblemResult } from '../../../src/card-controller/problems/types';
import { ConditionStateManager } from '../../../src/conditions/state-manager';
import { createCardAPI, createHASS } from '../../test-utils';
vi.mock('../../../src/card-controller/problems/problems/config-upgrade');
vi.mock('../../../src/card-controller/problems/problems/legacy-resource');
vi.mock('../../../src/card-controller/problems/problems/stream-not-loading');
const createProblemResult = (overrides?: Partial<ProblemResult>): ProblemResult => ({
icon: 'mdi:test',
severity: 'high',
notification: {
heading: {
text: 'Test heading',
icon: 'mdi:test',
severity: 'high',
},
text: 'Test text',
},
...overrides,
});
// @vitest-environment jsdom
describe('ProblemManager', () => {
let mockConfigUpgrade: Problem;
let mockLegacyResource: Problem;
let mockStreamNotLoading: Problem;
beforeEach(() => {
vi.resetAllMocks();
mockConfigUpgrade = mock<Problem>({ key: 'config_upgrade' });
mockLegacyResource = mock<Problem>({ key: 'legacy_resource' });
mockStreamNotLoading = mock<Problem>({ key: 'stream_not_loading' });
vi.mocked(ConfigUpgradeProblem).mockImplementation(
() => mockConfigUpgrade as unknown as ConfigUpgradeProblem,
);
vi.mocked(LegacyResourceProblem).mockImplementation(
() => mockLegacyResource as unknown as LegacyResourceProblem,
);
vi.mocked(StreamNotLoadingProblem).mockImplementation(
() => mockStreamNotLoading as unknown as StreamNotLoadingProblem,
);
});
it('should pass config getter to ConfigUpgradeProblem', () => {
const api = createCardAPI();
new ProblemManager(api);
const callback = vi.mocked(ConfigUpgradeProblem).mock.calls[0][0];
callback();
expect(api.getConfigManager().getRawConfig).toBeCalled();
});
it('should pass update callback to LegacyResourceProblem', () => {
const api = createCardAPI();
new ProblemManager(api);
const callback = vi.mocked(LegacyResourceProblem).mock.calls[0][0];
callback();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should pass update callback to StreamNotLoadingProblem', () => {
const api = createCardAPI();
new ProblemManager(api);
const callback = vi.mocked(StreamNotLoadingProblem).mock.calls[0][0];
callback();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should register all built-in problems on construction', () => {
const api = createCardAPI();
const manager = new ProblemManager(api);
const presence = manager.getProblemPresence();
expect('config_upgrade' in presence).toBe(true);
expect('legacy_resource' in presence).toBe(true);
expect('stream_not_loading' in presence).toBe(true);
});
describe('detectStatic', () => {
it('should call detectStatic on all problems', async () => {
const api = createCardAPI();
const manager = new ProblemManager(api);
const hass = createHASS();
await manager.detectStatic(hass);
expect(mockConfigUpgrade.detectStatic).toBeCalledWith(hass);
expect(mockLegacyResource.detectStatic).toBeCalledWith(hass);
expect(mockStreamNotLoading.detectStatic).toBeCalledWith(hass);
expect(api.getCardElementManager().update).toBeCalled();
});
});
describe('trigger', () => {
it('should trigger a problem and update when state changes', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new ProblemManager(api);
vi.mocked(mockStreamNotLoading.hasResult)
.mockReturnValueOnce(false)
.mockReturnValue(true);
manager.trigger('stream_not_loading');
expect(mockStreamNotLoading.trigger).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should do nothing for unknown key', () => {
const api = createCardAPI();
const manager = new ProblemManager(api);
manager.trigger(('stream_not_loading' + '_unknown') as never);
expect(mockStreamNotLoading.trigger).not.toBeCalled();
});
it('should not update when trigger does not change state', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new ProblemManager(api);
vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false);
manager.trigger('stream_not_loading');
expect(mockStreamNotLoading.trigger).toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
});
});
describe('forceNotify', () => {
it('should show notification from getNotification', () => {
const api = createCardAPI();
const manager = new ProblemManager(api);
const notification = { text: 'from getNotification' };
mockStreamNotLoading.getNotification = vi.fn().mockReturnValue(notification);
manager.forceNotify('stream_not_loading');
expect(api.getNotificationManager().setNotification).toBeCalledWith(notification);
});
it('should not show notification when getNotification returns null', () => {
const api = createCardAPI();
const manager = new ProblemManager(api);
manager.forceNotify('config_upgrade');
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
});
describe('getProblemResults', () => {
it('should return results for active problems', () => {
const api = createCardAPI();
const result = createProblemResult();
vi.mocked(mockConfigUpgrade.getResult).mockReturnValue(result);
const manager = new ProblemManager(api);
expect(manager.getProblemResults()).toEqual([
{ key: 'config_upgrade', problem: result },
]);
});
it('should return empty array when no problems active', () => {
const api = createCardAPI();
const manager = new ProblemManager(api);
expect(manager.getProblemResults()).toEqual([]);
});
});
describe('getProblemPresence', () => {
it('should return presence map', () => {
const api = createCardAPI();
vi.mocked(mockConfigUpgrade.hasResult).mockReturnValue(true);
vi.mocked(mockLegacyResource.hasResult).mockReturnValue(false);
const manager = new ProblemManager(api);
expect(manager.getProblemPresence()).toMatchObject({
['config_upgrade']: true,
['legacy_resource']: false,
});
});
});
describe('state change handling', () => {
it('should detect dynamic problems on view change', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new ProblemManager(api);
vi.mocked(mockStreamNotLoading.hasResult)
.mockReturnValueOnce(false)
.mockReturnValue(true);
manager.initialize();
stateManager.setState({ view: 'live' });
expect(mockStreamNotLoading.detectDynamic).toBeCalledWith({
view: 'live',
mediaLoaded: false,
});
expect(api.getCardElementManager().update).toBeCalled();
});
it('should detect dynamic problems on mediaLoadedInfo change', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new ProblemManager(api);
manager.initialize();
stateManager.setState({
mediaLoadedInfo: { width: 1920, height: 1080 },
});
expect(mockStreamNotLoading.detectDynamic).toBeCalledWith(
expect.objectContaining({ mediaLoaded: true }),
);
});
it('should not update when dynamic detection does not change state', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new ProblemManager(api);
vi.mocked(mockStreamNotLoading.hasResult).mockReturnValue(false);
manager.initialize();
stateManager.setState({ view: 'live' });
expect(mockStreamNotLoading.detectDynamic).toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
});
});
describe('uninitialize', () => {
it('should remove state listener', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new ProblemManager(api);
manager.initialize();
manager.uninitialize();
stateManager.setState({ view: 'live' });
expect(mockStreamNotLoading.detectDynamic).not.toBeCalled();
});
});
describe('destroy', () => {
it('should destroy all problems and clear', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new ProblemManager(api);
manager.initialize();
manager.destroy();
expect(mockConfigUpgrade.destroy).toBeCalled();
expect(mockLegacyResource.destroy).toBeCalled();
expect(mockStreamNotLoading.destroy).toBeCalled();
expect(manager.getProblemPresence()).toEqual({});
// State changes after destroy should not trigger detection.
stateManager.setState({ view: 'live' });
expect(mockStreamNotLoading.detectDynamic).not.toBeCalled();
});
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest';
import { ConfigUpgradeProblem } from '../../../../src/card-controller/problems/problems/config-upgrade';
import { isConfigUpgradeable } from '../../../../src/config/management';
vi.mock('../../../../src/config/management.js');
describe('ConfigUpgradeProblem', () => {
it('should have correct key', () => {
const problem = new ConfigUpgradeProblem(() => null);
expect(problem.key).toBe('config_upgrade');
});
it('should detect upgradeable config', async () => {
vi.mocked(isConfigUpgradeable).mockReturnValue(true);
const rawConfig = { type: 'custom:frigate-card' };
const problem = new ConfigUpgradeProblem(() => rawConfig);
await problem.detectStatic();
expect(problem.hasResult()).toBe(true);
expect(isConfigUpgradeable).toBeCalledWith(rawConfig);
});
it('should detect non-upgradeable config', async () => {
vi.mocked(isConfigUpgradeable).mockReturnValue(false);
const rawConfig = { type: 'custom:advanced-camera-card' };
const problem = new ConfigUpgradeProblem(() => rawConfig);
await problem.detectStatic();
expect(problem.hasResult()).toBe(false);
});
it('should handle null raw config', async () => {
const problem = new ConfigUpgradeProblem(() => null);
await problem.detectStatic();
expect(problem.hasResult()).toBe(false);
expect(problem.getResult()).toBeNull();
});
it('should return result when upgradeable', async () => {
vi.mocked(isConfigUpgradeable).mockReturnValue(true);
const problem = new ConfigUpgradeProblem(() => ({ type: 'custom:frigate-card' }));
await problem.detectStatic();
const result = problem.getResult();
expect(result).toEqual(
expect.objectContaining({
icon: 'mdi:update',
severity: 'medium',
notification: expect.objectContaining({
heading: expect.objectContaining({
icon: 'mdi:update',
severity: 'medium',
}),
}),
}),
);
});
});
@@ -0,0 +1,440 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LegacyResourceProblem } from '../../../../src/card-controller/problems/problems/legacy-resource';
import { HomeAssistant } from '../../../../src/ha/types';
import { createCardAPI, createHASS, createUser } from '../../../test-utils';
const setupHASSResources = (
hass: HomeAssistant,
resources: { id: string; type: string; url: string }[],
): void => {
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValue(resources);
};
describe('LegacyResourceProblem', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should have correct key', () => {
const problem = new LegacyResourceProblem(vi.fn());
expect(problem.key).toBe('legacy_resource');
});
describe('detectStatic', () => {
it('should skip non-admin users', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: false }));
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(false);
});
it('should detect legacy resource regardless of directory', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
setupHASSResources(hass, [
{
id: '1',
type: 'module',
url: '/some/arbitrary/path/frigate-hass-card.js?v=1',
},
]);
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(true);
});
it('should not detect when only advanced-camera-card exists', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
setupHASSResources(hass, [
{
id: '1',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(false);
});
it('should handle invalid resource data', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.callWS).mockResolvedValue('not-an-array');
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(false);
});
it('should handle websocket failure', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost'));
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(false);
});
it('should handle missing user', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS();
Object.defineProperty(hass, 'user', { value: undefined });
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(false);
});
});
describe('getResult', () => {
it('should return controls and link when both resources exist', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
setupHASSResources(hass, [
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
const result = problem.getResult();
expect(result).not.toBeNull();
expect(result?.notification.controls).toHaveLength(1);
expect(result?.notification.link).toBeDefined();
});
it('should return link without controls when only legacy exists', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
setupHASSResources(hass, [
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
]);
await problem.detectStatic(hass);
const result = problem.getResult();
expect(result).not.toBeNull();
expect(result?.notification.link).toBeDefined();
expect(result?.notification.controls).toBeUndefined();
});
it('should return null when no result', () => {
const problem = new LegacyResourceProblem(vi.fn());
expect(problem.getResult()).toBeNull();
});
});
describe('fix', () => {
it('should remove legacy resources when correct resource exists', async () => {
const triggerUpdate = vi.fn();
const problem = new LegacyResourceProblem(triggerUpdate);
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
vi.mocked(hass.callWS)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
const result = await problem.fix(hass);
expect(result).toBe(true);
expect(hass.callWS).toBeCalledWith(
expect.objectContaining({
type: 'lovelace/resources/delete',
resource_id: '1',
}),
);
expect(problem.hasResult()).toBe(false);
expect(triggerUpdate).toBeCalled();
});
it('should not fix when only legacy resource exists', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
setupHASSResources(hass, [
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
]);
await problem.detectStatic(hass);
const result = await problem.fix(hass);
expect(result).toBe(false);
});
it('should not fix for non-admin', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: false }));
const result = await problem.fix(hass);
expect(result).toBe(false);
});
it('should return false on websocket failure during fix', async () => {
const triggerUpdate = vi.fn();
const problem = new LegacyResourceProblem(triggerUpdate);
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
vi.mocked(hass.callWS).mockRejectedValue(new Error('connection lost'));
const result = await problem.fix(hass);
expect(result).toBe(false);
expect(triggerUpdate).not.toBeCalled();
});
it('should return false when re-detection still finds legacy resource', async () => {
const triggerUpdate = vi.fn();
const problem = new LegacyResourceProblem(triggerUpdate);
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
// Delete succeeds, but re-detection still finds the legacy resource.
vi.mocked(hass.callWS)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
const result = await problem.fix(hass);
expect(result).toBe(false);
expect(triggerUpdate).not.toBeCalled();
});
it('should fix multiple legacy resources', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{ id: '3', type: 'module', url: '/local/frigate-hass-card.js' },
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
vi.mocked(hass.callWS)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
expect(await problem.fix(hass)).toBe(true);
});
});
describe('getResourcePath fallback', () => {
it('should handle invalid URLs by stripping query string', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url');
vi.mocked(hass.callWS).mockResolvedValue([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js?v=1',
},
]);
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(true);
});
it('should handle invalid URLs without query string', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('not-a-valid-url');
vi.mocked(hass.callWS).mockResolvedValue([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
]);
await problem.detectStatic(hass);
expect(problem.hasResult()).toBe(true);
});
});
describe('callback action', () => {
const getCallback = (
problem: LegacyResourceProblem,
): ((api: unknown) => Promise<void>) | null => {
const result = problem.getResult();
const action = result?.notification.controls?.[0]?.actions?.tap_action;
if (action && 'callback' in action) {
return (action as { callback: (api: unknown) => Promise<void> }).callback;
}
return null;
};
it('should call fix via the notification control action', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
const callback = getCallback(problem);
expect(callback).toBeDefined();
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.mocked(hass.callWS)
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await callback?.(api);
expect(hass.callWS).toBeCalledWith(
expect.objectContaining({
type: 'lovelace/resources/delete',
}),
);
});
it('should handle missing hass in callback', async () => {
const problem = new LegacyResourceProblem(vi.fn());
const hass = createHASS(undefined, createUser({ is_admin: true }));
vi.mocked(hass.hassUrl).mockReturnValue('http://homeassistant.local:8123');
vi.mocked(hass.callWS).mockResolvedValueOnce([
{
id: '1',
type: 'module',
url: '/hacsfiles/frigate-hass-card/frigate-hass-card.js',
},
{
id: '2',
type: 'module',
url: '/hacsfiles/advanced-camera-card/advanced-camera-card.js',
},
]);
await problem.detectStatic(hass);
const callback = getCallback(problem);
expect(callback).toBeDefined();
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
await callback?.(api);
});
});
});
@@ -0,0 +1,311 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StreamNotLoadingProblem } from '../../../../src/card-controller/problems/problems/stream-not-loading';
// @vitest-environment jsdom
describe('StreamNotLoadingProblem', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should have correct key', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
expect(problem.key).toBe('stream_not_loading');
});
describe('detectDynamic', () => {
it('should start timer when live and not loaded', () => {
const triggerUpdate = vi.fn();
const problem = new StreamNotLoadingProblem(triggerUpdate);
problem.detectDynamic({ view: 'live', mediaLoaded: false });
expect(problem.hasResult()).toBe(false);
vi.advanceTimersByTime(10000);
expect(problem.hasResult()).toBe(true);
expect(triggerUpdate).toBeCalled();
});
it('should not start timer when not live', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.detectDynamic({ view: 'media', mediaLoaded: false });
vi.advanceTimersByTime(10000);
expect(problem.hasResult()).toBe(false);
});
it('should not start timer when media is loaded', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.detectDynamic({ view: 'live', mediaLoaded: true });
vi.advanceTimersByTime(10000);
expect(problem.hasResult()).toBe(false);
});
it('should clear timeout when media loads', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(5000);
problem.detectDynamic({ view: 'live', mediaLoaded: true });
vi.advanceTimersByTime(5000);
expect(problem.hasResult()).toBe(false);
});
it('should clear timeout when view changes away from live', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(5000);
problem.detectDynamic({ view: 'media', mediaLoaded: false });
vi.advanceTimersByTime(5000);
expect(problem.hasResult()).toBe(false);
});
it('should clear timed-out state when media loads', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(10000);
expect(problem.hasResult()).toBe(true);
problem.detectDynamic({ view: 'live', mediaLoaded: true });
expect(problem.hasResult()).toBe(false);
});
it('should restart timer when camera changes', () => {
const triggerUpdate = vi.fn();
const problem = new StreamNotLoadingProblem(triggerUpdate);
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: false,
});
vi.advanceTimersByTime(5000);
// Switch to camera-2: timer restarts from 0 for the new camera.
problem.detectDynamic({
cameraID: 'camera-2',
view: 'live',
mediaLoaded: false,
});
// 5 more seconds is not enough for the new 10s timer.
vi.advanceTimersByTime(5000);
expect(problem.hasResult()).toBe(false);
// Full 10s from camera-2's timer start.
vi.advanceTimersByTime(5000);
expect(problem.hasResult()).toBe(true);
expect(triggerUpdate).toBeCalledTimes(1);
});
it('should not restart timer for same camera while running', () => {
const triggerUpdate = vi.fn();
const problem = new StreamNotLoadingProblem(triggerUpdate);
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: false,
});
vi.advanceTimersByTime(5000);
// Same camera again: timer should continue, not restart.
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: false,
});
// 5 more seconds completes the original 10s timer.
vi.advanceTimersByTime(5000);
expect(problem.hasResult()).toBe(true);
expect(triggerUpdate).toBeCalledTimes(1);
});
it('should not restart timer when cameraID is undefined and matches', () => {
const triggerUpdate = vi.fn();
const problem = new StreamNotLoadingProblem(triggerUpdate);
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(5000);
// Same undefined cameraID: timer should continue.
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(5000);
expect(problem.hasResult()).toBe(true);
expect(triggerUpdate).toBeCalledTimes(1);
});
it('should not restart timer if already timed out', () => {
const triggerUpdate = vi.fn();
const problem = new StreamNotLoadingProblem(triggerUpdate);
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(10000);
expect(triggerUpdate).toBeCalledTimes(1);
// Calling detectDynamic again should not restart timer.
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(10000);
expect(triggerUpdate).toBeCalledTimes(1);
});
});
describe('trigger', () => {
it('should activate immediately when camera has error and view is live', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.trigger({ cameraID: 'camera-1' });
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: false,
});
expect(problem.hasResult()).toBe(true);
});
it('should not activate with only a trigger', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.trigger({ cameraID: 'camera-1' });
expect(problem.hasResult()).toBe(false);
});
it('should ignore trigger without cameraID', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.trigger();
problem.detectDynamic({ view: 'live', mediaLoaded: false });
// No camera error recorded, so falls back to timeout behavior.
expect(problem.hasResult()).toBe(false);
});
it('should clear camera error when stream loads', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.trigger({ cameraID: 'camera-1' });
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: false,
});
expect(problem.hasResult()).toBe(true);
// Stream loaded clears the error for this camera.
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: true,
});
// Camera error was cleared by the successful load, so this unloaded state
// falls back to the timer (problem would not activate until after the
// timer is reached).
problem.detectDynamic({
cameraID: 'camera-1',
view: 'live',
mediaLoaded: false,
});
expect(problem.hasResult()).toBe(false);
});
it('should not activate for a different camera', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.trigger({ cameraID: 'camera-1' });
problem.detectDynamic({
cameraID: 'camera-2',
view: 'live',
mediaLoaded: false,
});
// camera-2 has no error, so it falls back to timeout behavior.
expect(problem.hasResult()).toBe(false);
});
});
describe('getNotification', () => {
it('should return notification regardless of active state', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
const notification = problem.getNotification();
expect(notification).toEqual(
expect.objectContaining({
heading: expect.objectContaining({
text: expect.any(String),
}),
link: expect.objectContaining({
url: expect.any(String),
}),
}),
);
});
});
describe('getResult', () => {
it('should return result when timed out', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
problem.detectDynamic({ view: 'live', mediaLoaded: false });
vi.advanceTimersByTime(10000);
const result = problem.getResult();
expect(result).toEqual(
expect.objectContaining({
icon: 'mdi:cctv-off',
severity: 'high',
notification: expect.objectContaining({
link: expect.objectContaining({
url: expect.any(String),
}),
}),
}),
);
});
it('should return null when not timed out', () => {
const problem = new StreamNotLoadingProblem(vi.fn());
expect(problem.getResult()).toBeNull();
});
});
describe('destroy', () => {
it('should stop timer', () => {
const triggerUpdate = vi.fn();
const problem = new StreamNotLoadingProblem(triggerUpdate);
problem.detectDynamic({ view: 'live', mediaLoaded: false });
problem.destroy();
vi.advanceTimersByTime(10000);
expect(problem.hasResult()).toBe(false);
expect(triggerUpdate).not.toBeCalled();
});
});
});
@@ -241,12 +241,28 @@ describe('StatusBarItemManager', () => {
});
});
describe('upgrade', () => {
it('should show upgrade item when upgradeable', () => {
describe('problems', () => {
it('should show problem items', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems({
isUpgradeable: true,
problems: [
{
key: 'config_upgrade',
problem: {
icon: 'mdi:update',
severity: 'medium',
notification: {
heading: {
text: 'Upgrade available',
icon: 'mdi:update',
severity: 'medium',
},
text: 'Upgrade text',
},
},
},
],
});
expect(items).toContainEqual(
@@ -254,6 +270,7 @@ describe('StatusBarItemManager', () => {
type: 'custom:advanced-camera-card-status-bar-icon' as const,
icon: 'mdi:update',
severity: 'medium',
title: 'Upgrade available',
actions: expect.objectContaining({
tap_action: expect.objectContaining({
action: 'fire-dom-event',
@@ -264,11 +281,11 @@ describe('StatusBarItemManager', () => {
);
});
it('should not show upgrade item when not upgradeable', () => {
it('should not show problem items when empty', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems({
isUpgradeable: false,
problems: [],
});
expect(items).not.toContainEqual(
@@ -278,7 +295,7 @@ describe('StatusBarItemManager', () => {
);
});
it('should not show upgrade item by default', () => {
it('should not show problem items by default', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems();
@@ -289,6 +306,99 @@ describe('StatusBarItemManager', () => {
}),
);
});
it('should filter out disabled problems', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems({
statusConfig: {
position: 'bottom',
style: 'popup',
popup_seconds: 3,
height: 40,
items: {
engine: { enabled: true, priority: 50 },
resolution: { enabled: true, priority: 50 },
severity: { enabled: true, priority: 50 },
technology: { enabled: true, priority: 50 },
title: { enabled: true, priority: 50 },
problem_config_upgrade: { enabled: false, priority: 50 },
problem_legacy_resource: { enabled: true, priority: 50 },
problem_stream_not_loading: { enabled: true, priority: 50 },
},
},
problems: [
{
key: 'config_upgrade',
problem: {
icon: 'mdi:update',
severity: 'medium',
notification: {
heading: {
text: 'Upgrade available',
icon: 'mdi:update',
severity: 'medium',
},
text: 'Upgrade text',
},
},
},
],
});
expect(items).not.toContainEqual(
expect.objectContaining({
icon: 'mdi:update',
}),
);
});
it('should apply config overrides to problem items', () => {
const manager = new StatusBarItemManager(createCardAPI());
const items = manager.calculateItems({
statusConfig: {
position: 'bottom',
style: 'popup',
popup_seconds: 3,
height: 40,
items: {
engine: { enabled: true, priority: 50 },
resolution: { enabled: true, priority: 50 },
severity: { enabled: true, priority: 50 },
technology: { enabled: true, priority: 50 },
title: { enabled: true, priority: 50 },
problem_config_upgrade: { enabled: true, priority: 90 },
problem_legacy_resource: { enabled: true, priority: 50 },
problem_stream_not_loading: { enabled: true, priority: 50 },
},
},
problems: [
{
key: 'config_upgrade',
problem: {
icon: 'mdi:update',
severity: 'medium',
notification: {
heading: {
text: 'Upgrade available',
icon: 'mdi:update',
severity: 'medium',
},
text: 'Upgrade text',
},
},
},
],
});
expect(items).toContainEqual(
expect.objectContaining({
icon: 'mdi:update',
priority: 90,
}),
);
});
});
describe('severity', () => {
@@ -1,9 +1,10 @@
import yaml from 'js-yaml';
import { describe, expect, it } from 'vitest';
import { MessageController } from '../../../src/components-lib/message/controller';
import { Link } from '../../../src/config/schema/common/link';
import { TROUBLESHOOTING_URL } from '../../../src/const';
import { localize } from '../../../src/localize/localize';
import { Message, MessageType, MessageURL } from '../../../src/types';
import { Message, MessageType } from '../../../src/types';
describe('MessageController', () => {
describe('should return the correct message string', () => {
@@ -65,8 +66,8 @@ describe('MessageController', () => {
it('should show for errors', () => {
const controller = new MessageController();
const message: Message = { message: 'Error message', type: 'error' };
expect(controller.getURL(message)).toEqual({
link: TROUBLESHOOTING_URL,
expect(controller.getLink(message)).toEqual({
url: TROUBLESHOOTING_URL,
title: localize('error.troubleshooting'),
});
});
@@ -81,7 +82,7 @@ describe('MessageController', () => {
icon: 'mdi:car',
type,
};
expect(controller.getURL(message)).toBeNull();
expect(controller.getLink(message)).toBeNull();
},
);
});
@@ -90,21 +91,21 @@ describe('MessageController', () => {
it('by default', () => {
const controller = new MessageController();
const message: Message = { message: 'Error message', type: 'error' };
expect(controller.getURL(message)?.link).toBe(TROUBLESHOOTING_URL);
expect(controller.getLink(message)?.url).toBe(TROUBLESHOOTING_URL);
});
it('when specified', () => {
const controller = new MessageController();
const url: MessageURL = {
link: 'link',
const url: Link = {
url: 'link',
title: 'title',
};
const message: Message = {
message: 'Error message',
type: 'error',
url,
link: url,
};
expect(controller.getURL(message)).toBe(url);
expect(controller.getLink(message)).toBe(url);
});
});
});
+15 -15
View File
@@ -23,12 +23,12 @@ describe('ConditionsManager', () => {
it('should match named view change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'view' as const, views: ['foo'] }],
[{ condition: 'view' as const, views: ['live'] }],
stateManager,
);
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ view: 'foo' });
stateManager.setState({ view: 'live' });
expect(manager.getEvaluation().result).toBeTruthy();
});
@@ -1211,13 +1211,13 @@ describe('ConditionsManager', () => {
camera: { to: 'camera-1' },
});
stateManager.setState({ view: 'view-1' });
stateManager.setState({ view: 'live' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
view: { to: 'view-1' },
view: { to: 'live' },
});
stateManager.setState({ camera: 'camera-2', view: 'view-2' });
stateManager.setState({ camera: 'camera-2', view: 'clip' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
camera: { to: 'camera-2', from: 'camera-1' },
@@ -1282,17 +1282,17 @@ describe('ConditionsManager', () => {
stateManager.setState({ camera: 'camera-1' });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ view: 'view-1' });
stateManager.setState({ view: 'live' });
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ camera: 'camera-2', view: 'view-2' });
stateManager.setState({ camera: 'camera-2', view: 'clip' });
expect(manager.getEvaluation().result).toBeTruthy();
expect(manager.getEvaluation().triggerData).toEqual({
camera: { from: 'camera-1', to: 'camera-2' },
view: { from: 'view-1', to: 'view-2' },
view: { from: 'live', to: 'clip' },
});
stateManager.setState({ view: 'view-3' });
stateManager.setState({ view: 'snapshot' });
expect(manager.getEvaluation().result).toBeFalsy();
});
@@ -1404,26 +1404,26 @@ describe('ConditionsManager', () => {
it('with not call listeners when condition result does not change', () => {
const stateManager = new ConditionStateManager();
const manager = new ConditionsManager(
[{ condition: 'view' as const, views: ['foo'] }],
[{ condition: 'view' as const, views: ['live'] }],
stateManager,
);
const listener = vi.fn();
manager.addListener(listener);
stateManager.setState({ view: 'foo' });
stateManager.setState({ view: 'live' });
expect(listener).toBeCalledTimes(1);
stateManager.setState({ view: 'bar' });
stateManager.setState({ view: 'clip' });
expect(listener).toBeCalledTimes(2);
stateManager.setState({ view: 'bar' });
stateManager.setState({ view: 'clip' });
expect(listener).toBeCalledTimes(2);
stateManager.setState({ view: 'foo' });
stateManager.setState({ view: 'live' });
expect(listener).toBeCalledTimes(3);
stateManager.setState({ view: 'foo' });
stateManager.setState({ view: 'live' });
expect(listener).toBeCalledTimes(3);
});
});
+9 -1
View File
@@ -441,7 +441,15 @@ describe('config defaults', () => {
enabled: true,
priority: 50,
},
upgrade: {
problem_config_upgrade: {
enabled: true,
priority: 50,
},
problem_legacy_resource: {
enabled: true,
priority: 50,
},
problem_stream_not_loading: {
enabled: true,
priority: 50,
},
+3 -3
View File
@@ -44,7 +44,7 @@ describe('TemplateRenderer', () => {
it('should include triggers', () => {
const conditionState: ConditionState = {
camera: 'camera',
view: 'view',
view: 'live',
};
const triggerData: ConditionsTriggerData = {
camera: {
@@ -70,7 +70,7 @@ describe('TemplateRenderer', () => {
expect(renderTemplate).toHaveBeenCalledWith(hass, 'value', {
acc: {
camera: 'camera',
view: 'view',
view: 'live',
trigger: {
camera: {
to: 'camera',
@@ -80,7 +80,7 @@ describe('TemplateRenderer', () => {
},
advanced_camera_card: {
camera: 'camera',
view: 'view',
view: 'live',
trigger: {
camera: {
to: 'camera',
+2
View File
@@ -47,6 +47,7 @@ import { MessageManager } from '../src/card-controller/message-manager';
import { MicrophoneManager } from '../src/card-controller/microphone-manager';
import { NotificationManager } from '../src/card-controller/notification-manager';
import { PIPManager } from '../src/card-controller/pip-manager';
import { ProblemManager } from '../src/card-controller/problems/manager';
import { QueryStringManager } from '../src/card-controller/query-string-manager';
import { StatusBarItemManager } from '../src/card-controller/status-bar-item-manager';
import { StyleManager } from '../src/card-controller/style-manager';
@@ -680,6 +681,7 @@ export const createCardAPI = (): CardController => {
api.getMicrophoneManager.mockReturnValue(mock<MicrophoneManager>());
api.getNotificationManager.mockReturnValue(mock<NotificationManager>());
api.getPIPManager.mockReturnValue(mock<PIPManager>());
api.getProblemManager.mockReturnValue(mock<ProblemManager>());
api.getQueryStringManager.mockReturnValue(mock<QueryStringManager>());
api.getStatusBarItemManager.mockReturnValue(mock<StatusBarItemManager>());
api.getStyleManager.mockReturnValue(mock<StyleManager>());
+19
View File
@@ -151,6 +151,25 @@ describe('getDiagnostics', () => {
});
});
it('should include problems in diagnostics', async () => {
const deviceRegistryManager = mock<DeviceRegistryManager>();
deviceRegistryManager.getMatchingDevices.mockResolvedValue([]);
const problems = {
config_upgrade: true,
legacy_resource: false,
};
const result = await getDiagnostics(
hass,
deviceRegistryManager,
{ cameras: [{ camera_entity: 'camera.office' }] },
problems,
);
expect(result.problems).toEqual(problems);
});
it('should fetch diagnostics without device model', async () => {
const deviceRegistryManager = mock<DeviceRegistryManager>();
deviceRegistryManager.getMatchingDevices.mockResolvedValue([]);