feat: Add hardened error handling and retries (#2451)
- Closes #1830 - Closes #2099
This commit is contained in:
committed by
dermotduffy
parent
4bc787e2b7
commit
47bcce93d3
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { dispatchActionExecutionRequest } from '../../../src/card-controller/actions/utils/execution-request';
|
||||
import { handleControlAction } from '../../../src/components-lib/notification/action';
|
||||
import { NotificationControl } from '../../../src/config/schema/actions/types';
|
||||
import {
|
||||
getActionConfigGivenAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../../../src/utils/action';
|
||||
|
||||
vi.mock('../../../src/card-controller/actions/utils/execution-request.js');
|
||||
vi.mock('../../../src/utils/action.js');
|
||||
|
||||
describe('handleControlAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const createControl = (
|
||||
overrides?: Partial<NotificationControl>,
|
||||
): NotificationControl => ({
|
||||
dismiss: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should stop the event from activating card-wide actions', () => {
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
handleControlAction(ev, createControl(), host);
|
||||
|
||||
expect(stopEventFromActivatingCardWideActions).toBeCalledWith(ev);
|
||||
});
|
||||
|
||||
it('should dispatch action when getActionConfigGivenAction returns an action', () => {
|
||||
const action = { action: 'navigate' as const, navigation_path: '/foo' };
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(action);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const control = createControl({ actions: { tap_action: action } });
|
||||
const host = document.createElement('div');
|
||||
|
||||
handleControlAction(ev, control, host);
|
||||
|
||||
expect(dispatchActionExecutionRequest).toBeCalledWith(host, {
|
||||
actions: [action],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not dispatch when getActionConfigGivenAction returns null', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
|
||||
handleControlAction(ev, createControl(), host);
|
||||
|
||||
expect(dispatchActionExecutionRequest).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should call onDismiss when dismiss is not false', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
handleControlAction(ev, createControl({ dismiss: true }), host, onDismiss);
|
||||
|
||||
expect(onDismiss).toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call onDismiss when dismiss is false', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
handleControlAction(ev, createControl({ dismiss: false }), host, onDismiss);
|
||||
|
||||
expect(onDismiss).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not call onDismiss when no onDismiss is provided', () => {
|
||||
vi.mocked(getActionConfigGivenAction).mockReturnValue(null);
|
||||
|
||||
const ev = new CustomEvent('action', { detail: { action: 'tap' } });
|
||||
const host = document.createElement('div');
|
||||
|
||||
// Should not throw when onDismiss is undefined
|
||||
expect(() => handleControlAction(ev, createControl(), host)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dataToContext } from '../../../src/components-lib/notification/data-to-context';
|
||||
|
||||
describe('dataToContext', () => {
|
||||
it('should return an array of string items unchanged when input is an array of strings', () => {
|
||||
expect(dataToContext(['line one', 'line two'])).toEqual(['line one', 'line two']);
|
||||
});
|
||||
|
||||
it('should YAML-dump object items when input is an array containing objects', () => {
|
||||
const result = dataToContext([{ key: 'value' }]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('key: value');
|
||||
});
|
||||
|
||||
it('should handle a mixed array of strings and objects', () => {
|
||||
const result = dataToContext(['plain string', { foo: 'bar' }]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe('plain string');
|
||||
expect(result[1]).toContain('foo: bar');
|
||||
});
|
||||
|
||||
it('should return a single YAML-dumped string for a plain object', () => {
|
||||
const result = dataToContext({ error: 'something went wrong', code: 42 });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toContain('error: something went wrong');
|
||||
expect(result[0]).toContain('code: 42');
|
||||
});
|
||||
|
||||
it('should return an empty array for an empty array input', () => {
|
||||
expect(dataToContext([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createNotificationFromError,
|
||||
createNotificationFromText,
|
||||
} from '../../../src/components-lib/notification/factory';
|
||||
import { AdvancedCameraCardError } from '../../../src/types';
|
||||
|
||||
describe('createNotificationFromText', () => {
|
||||
it('should create a notification with body text', () => {
|
||||
const notification = createNotificationFromText('Something failed');
|
||||
expect(notification.body?.text).toBe('Something failed');
|
||||
});
|
||||
|
||||
it('should add the default error icon to body when no heading and no icon provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.body?.icon).toBe('mdi:alert');
|
||||
});
|
||||
|
||||
it('should add a custom icon to body when specified', () => {
|
||||
const notification = createNotificationFromText('oops', { icon: 'mdi:wifi-off' });
|
||||
expect(notification.body?.icon).toBe('mdi:wifi-off');
|
||||
});
|
||||
|
||||
it('should include the heading when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
heading: { text: 'Error heading' },
|
||||
});
|
||||
expect(notification.heading?.text).toBe('Error heading');
|
||||
});
|
||||
|
||||
it('should omit icon from body when heading is provided and no icon is specified', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
heading: { text: 'Error heading' },
|
||||
});
|
||||
expect(notification.body?.icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add icon to body when heading is provided but icon is also specified', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
heading: { text: 'Error heading' },
|
||||
icon: 'mdi:alert-circle',
|
||||
});
|
||||
expect(notification.body?.icon).toBe('mdi:alert-circle');
|
||||
});
|
||||
|
||||
it('should include metadata when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
metadata: [{ text: 'meta line' }],
|
||||
});
|
||||
expect(notification.metadata).toEqual([{ text: 'meta line' }]);
|
||||
});
|
||||
|
||||
it('should omit metadata when not provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include link when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
link: { url: 'https://example.com', title: 'Docs' },
|
||||
});
|
||||
expect(notification.link?.url).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('should include context when provided', () => {
|
||||
const notification = createNotificationFromText('oops', {
|
||||
context: { detail: 'extra info' },
|
||||
});
|
||||
expect(notification.context).toBeDefined();
|
||||
expect(notification.context?.join(' ')).toContain('detail: extra info');
|
||||
});
|
||||
|
||||
it('should omit context when not provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include in_progress when true', () => {
|
||||
const notification = createNotificationFromText('oops', { in_progress: true });
|
||||
expect(notification.in_progress).toBe(true);
|
||||
});
|
||||
|
||||
it('should include in_progress when false', () => {
|
||||
const notification = createNotificationFromText('oops', { in_progress: false });
|
||||
expect(notification.in_progress).toBe(false);
|
||||
});
|
||||
|
||||
it('should omit in_progress when not provided', () => {
|
||||
const notification = createNotificationFromText('oops');
|
||||
expect(notification.in_progress).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNotificationFromError', () => {
|
||||
it('should create a notification from an Error object', () => {
|
||||
const notification = createNotificationFromError(new Error('something failed'));
|
||||
assert(notification);
|
||||
expect(notification.body?.text).toBe('something failed');
|
||||
});
|
||||
|
||||
it('should create a notification from a string error', () => {
|
||||
const notification = createNotificationFromError('plain string error');
|
||||
assert(notification);
|
||||
expect(notification.body?.text).toBe('plain string error');
|
||||
});
|
||||
|
||||
it('should create a notification from a non-error object', () => {
|
||||
const notification = createNotificationFromError({ code: 404 });
|
||||
assert(notification);
|
||||
expect(notification.body?.text).toBe('{"code":404}');
|
||||
});
|
||||
|
||||
it('should merge the default error icon into heading when heading is provided', () => {
|
||||
const notification = createNotificationFromError(new Error('failed'), {
|
||||
heading: { text: 'Init Error' },
|
||||
});
|
||||
assert(notification);
|
||||
expect(notification.heading?.text).toBe('Init Error');
|
||||
expect(notification.heading?.icon).toBe('mdi:alert');
|
||||
expect(notification.heading?.severity).toBe('high');
|
||||
});
|
||||
|
||||
it('should preserve custom heading properties alongside defaults', () => {
|
||||
const notification = createNotificationFromError(new Error('failed'), {
|
||||
heading: { text: 'My Heading', icon: 'mdi:custom', severity: 'medium' },
|
||||
});
|
||||
assert(notification);
|
||||
// Spread order: { icon: DEFAULT, severity: 'high', ...options.heading }
|
||||
// so custom values win
|
||||
expect(notification.heading?.icon).toBe('mdi:custom');
|
||||
expect(notification.heading?.severity).toBe('medium');
|
||||
});
|
||||
|
||||
it('should use context from AdvancedCameraCardError when no explicit context is provided', () => {
|
||||
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
|
||||
const notification = createNotificationFromError(error);
|
||||
assert(notification);
|
||||
expect(notification.context).toBeDefined();
|
||||
expect(notification.context?.join(' ')).toContain('reason: network');
|
||||
});
|
||||
|
||||
it('should use explicit context option over AdvancedCameraCardError context', () => {
|
||||
const error = new AdvancedCameraCardError('boom', { reason: 'network' });
|
||||
const notification = createNotificationFromError(error, {
|
||||
context: { override: 'explicit' },
|
||||
});
|
||||
assert(notification);
|
||||
expect(notification.context?.join(' ')).toContain('override: explicit');
|
||||
expect(notification.context?.join(' ')).not.toContain('reason: network');
|
||||
});
|
||||
|
||||
it('should not include context when AdvancedCameraCardError has a non-object context', () => {
|
||||
const error = new AdvancedCameraCardError('boom', 'not an object');
|
||||
const notification = createNotificationFromError(error);
|
||||
assert(notification);
|
||||
expect(notification.context).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include context when AdvancedCameraCardError context is null', () => {
|
||||
const error = new AdvancedCameraCardError('boom', null);
|
||||
const notification = createNotificationFromError(error);
|
||||
assert(notification);
|
||||
expect(notification.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { summarizeNotification } from '../../../src/components-lib/notification/summarize';
|
||||
import { Notification } from '../../../src/config/schema/actions/types';
|
||||
|
||||
describe('summarizeNotification', () => {
|
||||
it('should return the body text when present', () => {
|
||||
const notification: Notification = { body: { text: 'Body text' } };
|
||||
expect(summarizeNotification(notification)).toBe('Body text');
|
||||
});
|
||||
|
||||
it('should return the heading text when body is absent', () => {
|
||||
const notification: Notification = {
|
||||
heading: { text: 'Heading text' },
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe('Heading text');
|
||||
});
|
||||
|
||||
it('should return the body text even when heading is also present', () => {
|
||||
const notification: Notification = {
|
||||
heading: { text: 'Heading text' },
|
||||
body: { text: 'Body text' },
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe('Body text');
|
||||
});
|
||||
|
||||
it('should return null when neither body nor heading has text', () => {
|
||||
const notification: Notification = {};
|
||||
expect(summarizeNotification(notification)).toBeNull();
|
||||
});
|
||||
|
||||
it('should append metadata text in brackets', () => {
|
||||
const notification: Notification = {
|
||||
body: { text: 'Media not loading' },
|
||||
metadata: [{ text: 'camera.office' }, { text: 'camera.garden' }],
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe(
|
||||
'Media not loading [camera.office, camera.garden]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not append brackets when metadata is empty', () => {
|
||||
const notification: Notification = {
|
||||
body: { text: 'Body text' },
|
||||
metadata: [],
|
||||
};
|
||||
expect(summarizeNotification(notification)).toBe('Body text');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user