test: Add a browser based test harness (#2641)
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getHassDifferences } from '../../src/ha/get-hass-differences';
|
||||
import { FakeHASS } from './fake-hass';
|
||||
|
||||
const CAMERA = 'camera.office';
|
||||
const SWITCH = 'input_boolean.zoom';
|
||||
|
||||
const createFakeHASS = (): FakeHASS =>
|
||||
new FakeHASS({
|
||||
entities: { [CAMERA]: { state: 'idle' }, [SWITCH]: { state: 'off' } },
|
||||
registry: { [CAMERA]: { device_id: 'device-1' } },
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('FakeHASS', () => {
|
||||
describe('identity', () => {
|
||||
it('should hand out a new object every time it is renewed', () => {
|
||||
const hass = createFakeHASS();
|
||||
const before = hass.getHASS();
|
||||
|
||||
hass.renew();
|
||||
|
||||
expect(hass.getHASS()).not.toBe(before);
|
||||
|
||||
// The states map itself is only rebuilt when a state changed, as Home
|
||||
// Assistant rebuilds it. A renewal carrying no state change hands back the
|
||||
// same map, so a card gating on the map's identity sees the truth.
|
||||
expect(hass.getHASS().states).toBe(before.states);
|
||||
});
|
||||
|
||||
it('should hand out a new states map when a state changed', () => {
|
||||
const hass = createFakeHASS();
|
||||
const before = hass.getHASS();
|
||||
|
||||
hass.setState(SWITCH, 'on');
|
||||
|
||||
expect(hass.getHASS().states).not.toBe(before.states);
|
||||
});
|
||||
|
||||
it('should replace the state object only for the entity that changed', () => {
|
||||
const hass = createFakeHASS();
|
||||
const before = hass.getHASS();
|
||||
|
||||
hass.setState(SWITCH, 'on');
|
||||
const after = hass.getHASS();
|
||||
|
||||
expect(after.states[SWITCH]).not.toBe(before.states[SWITCH]);
|
||||
expect(after.states[CAMERA]).toBe(before.states[CAMERA]);
|
||||
});
|
||||
|
||||
// Updating immutably is what lets any Home Assistant consumer identify the
|
||||
// entities that changed by comparing references. `getHassDifferences` is
|
||||
// borrowed here as a ready-made reference comparison over a named set of
|
||||
// entities: the instrument, not the thing being specified.
|
||||
it('should report exactly the entities that changed under a reference comparison', () => {
|
||||
const hass = createFakeHASS();
|
||||
const before = hass.getHASS();
|
||||
|
||||
hass.setState(SWITCH, 'on');
|
||||
|
||||
expect(
|
||||
getHassDifferences(hass.getHASS(), before, [CAMERA, SWITCH]).map(
|
||||
(difference) => difference.entityID,
|
||||
),
|
||||
).toEqual([SWITCH]);
|
||||
});
|
||||
|
||||
it('should report no differences when nothing changed', () => {
|
||||
const hass = createFakeHASS();
|
||||
const before = hass.getHASS();
|
||||
|
||||
hass.renew();
|
||||
|
||||
expect(getHassDifferences(hass.getHASS(), before, [CAMERA, SWITCH])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report a removed entity as a difference', () => {
|
||||
const hass = createFakeHASS();
|
||||
const before = hass.getHASS();
|
||||
|
||||
hass.removeState(SWITCH);
|
||||
|
||||
expect(hass.getHASS().states[SWITCH]).toBeUndefined();
|
||||
expect(
|
||||
getHassDifferences(hass.getHASS(), before, [SWITCH]).map(
|
||||
(difference) => difference.entityID,
|
||||
),
|
||||
).toEqual([SWITCH]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('websocket commands', () => {
|
||||
it('should answer a registered command', async () => {
|
||||
const hass = createFakeHASS();
|
||||
|
||||
await expect(
|
||||
hass.getHASS().callWS({ type: 'config/entity_registry/get', entity_id: CAMERA }),
|
||||
).resolves.toEqual(expect.objectContaining({ entity_id: CAMERA }));
|
||||
});
|
||||
|
||||
it('should reject an unregistered command', async () => {
|
||||
const hass = createFakeHASS();
|
||||
|
||||
await expect(
|
||||
hass.getHASS().callWS({ type: 'camera/stream', entity_id: CAMERA }),
|
||||
).rejects.toThrow('FakeHASS received an unregistered WS command: camera/stream');
|
||||
});
|
||||
|
||||
it('should let a test replace a command handler', async () => {
|
||||
const hass = createFakeHASS();
|
||||
hass.registerCommand('camera/stream', () => ({ url: '/stream.m3u8' }));
|
||||
|
||||
await expect(
|
||||
hass.getHASS().callWS({ type: 'camera/stream', entity_id: CAMERA }),
|
||||
).resolves.toEqual({ url: '/stream.m3u8' });
|
||||
});
|
||||
|
||||
it('should answer a command sent through the connection from the same table', async () => {
|
||||
const hass = createFakeHASS();
|
||||
hass.registerCommand('camera/stream', () => ({ url: '/stream.m3u8' }));
|
||||
|
||||
await expect(
|
||||
hass.getHASS().connection.sendMessagePromise({ type: 'camera/stream' }),
|
||||
).resolves.toEqual({ url: '/stream.m3u8' });
|
||||
});
|
||||
|
||||
it('should reject an unregistered command sent through the connection', async () => {
|
||||
const hass = createFakeHASS();
|
||||
|
||||
await expect(
|
||||
hass.getHASS().connection.sendMessagePromise({ type: 'camera/stream' }),
|
||||
).rejects.toThrow('FakeHASS received an unregistered WS command: camera/stream');
|
||||
});
|
||||
|
||||
it('should record every command in order', async () => {
|
||||
const hass = createFakeHASS();
|
||||
|
||||
await hass.getHASS().callWS({ type: 'lovelace/resources' });
|
||||
await hass
|
||||
.getHASS()
|
||||
.callWS({ type: 'config/entity_registry/get', entity_id: CAMERA });
|
||||
|
||||
expect(hass.getCommandLog().map((message) => message.type)).toEqual([
|
||||
'lovelace/resources',
|
||||
'config/entity_registry/get',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unimplemented methods', () => {
|
||||
it('should throw rather than quietly do nothing', () => {
|
||||
const hass = createFakeHASS().getHASS();
|
||||
|
||||
expect(() => hass.callService('camera', 'turn_on')).toThrow(
|
||||
'FakeHASS does not implement callService',
|
||||
);
|
||||
expect(() => hass.sendWS({ type: 'ping' })).toThrow(
|
||||
'FakeHASS does not implement sendWS',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
STATE_RUNNING,
|
||||
type Connection,
|
||||
type HassConfig,
|
||||
type HassEntities,
|
||||
type HassEntity,
|
||||
type MessageBase,
|
||||
} from 'home-assistant-js-websocket';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { Entity } from '../../src/ha/registry/entity/types';
|
||||
import type { HomeAssistant } from '../../src/ha/types';
|
||||
import { createRegistryEntity, createStateEntity } from '../test-utils';
|
||||
|
||||
// A WebSocket command handler. Returning a rejected promise models a command
|
||||
// Home Assistant refuses; throwing models a malformed request.
|
||||
export type WSCommandHandler = (message: MessageBase) => Promise<unknown> | unknown;
|
||||
|
||||
export interface FakeEntityOptions {
|
||||
state?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
lastChanged?: Date;
|
||||
lastUpdated?: Date;
|
||||
}
|
||||
|
||||
export interface FakeHASSOptions {
|
||||
// Entities present from the start, as entity ID to state options (or a
|
||||
// bare state string).
|
||||
entities?: Record<string, FakeEntityOptions | string>;
|
||||
|
||||
// Entity registry entries, as entity ID to a partial registry entry.
|
||||
registry?: Record<string, Partial<Entity>>;
|
||||
|
||||
language?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// Home Assistant sends real timestamps, and the card measures a state's age
|
||||
// against them: must use a dynamic (vs static) date.
|
||||
const createDefaultTime = (): Date => new Date();
|
||||
|
||||
// `Auth` is a class built from real credentials, and `locale` is typed with
|
||||
// ambient enums that have no runtime value, so neither can be constructed here.
|
||||
// The card reads neither.
|
||||
const { auth: INERT_AUTH, locale: INERT_LOCALE } = mock<HomeAssistant>();
|
||||
|
||||
const createConfig = (state: HassConfig['state']): HassConfig => ({
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
elevation: 0,
|
||||
radius: 0,
|
||||
unit_system: {
|
||||
length: 'km',
|
||||
mass: 'kg',
|
||||
volume: 'L',
|
||||
temperature: '°C',
|
||||
pressure: 'Pa',
|
||||
wind_speed: 'km/h',
|
||||
accumulated_precipitation: 'mm',
|
||||
},
|
||||
location_name: 'Fake',
|
||||
time_zone: 'UTC',
|
||||
components: [],
|
||||
config_dir: '/config',
|
||||
allowlist_external_dirs: [],
|
||||
allowlist_external_urls: [],
|
||||
version: '2026.7.0',
|
||||
config_source: 'storage',
|
||||
recovery_mode: false,
|
||||
safe_mode: false,
|
||||
state,
|
||||
external_url: null,
|
||||
internal_url: null,
|
||||
currency: 'USD',
|
||||
country: null,
|
||||
language: 'en',
|
||||
});
|
||||
|
||||
const createRegistryEntry = (entityID: string, overrides?: Partial<Entity>): Entity =>
|
||||
createRegistryEntity({ entity_id: entityID, unique_id: entityID, ...overrides });
|
||||
|
||||
/**
|
||||
* A driveable stand-in for the `hass` object the card is handed by Home
|
||||
* Assistant.
|
||||
*
|
||||
* Home Assistant updates immutably, and that is the behaviour reproduced here:
|
||||
* a change hands consumers a new top-level object and a new `states` map
|
||||
* holding a new state object for the entity that changed, while every other
|
||||
* entity keeps the object it already had. Anything the card can observe about
|
||||
* a real `hass` it must be able to observe about this one, so a divergence here
|
||||
* is a bug in the fake rather than a shortcut worth taking.
|
||||
*/
|
||||
export class FakeHASS {
|
||||
private _hass: HomeAssistant;
|
||||
private _states: HassEntities = {};
|
||||
private _registry = new Map<string, Entity>();
|
||||
private _config: HassConfig = createConfig(STATE_RUNNING);
|
||||
private _connected = true;
|
||||
private _connection: Connection;
|
||||
private _language: string;
|
||||
private _isAdmin: boolean;
|
||||
private _handlers = new Map<string, WSCommandHandler>();
|
||||
private _commandLog: MessageBase[] = [];
|
||||
|
||||
constructor(options?: FakeHASSOptions) {
|
||||
this._language = options?.language ?? 'en';
|
||||
this._isAdmin = options?.isAdmin ?? true;
|
||||
|
||||
for (const [entityID, entityOptions] of Object.entries(options?.entities ?? {})) {
|
||||
this._states[entityID] = this._createEntity(entityID, entityOptions);
|
||||
}
|
||||
for (const [entityID, entry] of Object.entries(options?.registry ?? {})) {
|
||||
this._registry.set(entityID, createRegistryEntry(entityID, entry));
|
||||
}
|
||||
|
||||
this._connection = this._createConnection();
|
||||
this._registerDefaultHandlers();
|
||||
this._hass = this._createHASS();
|
||||
}
|
||||
|
||||
public getHASS(): HomeAssistant {
|
||||
return this._hass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for a WebSocket command, replacing any existing one.
|
||||
*/
|
||||
public registerCommand(type: string, handler: WSCommandHandler): void {
|
||||
this._handlers.set(type, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every WebSocket command the card has issued, in order.
|
||||
*/
|
||||
public getCommandLog(): readonly MessageBase[] {
|
||||
return this._commandLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change one entity, then renew. The changed entity gets a new state object;
|
||||
* every other entity keeps the one it already had.
|
||||
*/
|
||||
public setState(entityID: string, options: FakeEntityOptions | string): void {
|
||||
this._states = {
|
||||
...this._states,
|
||||
[entityID]: this._createEntity(entityID, options),
|
||||
};
|
||||
this._renew();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entity from the state map, as happens when its integration is
|
||||
* unloaded.
|
||||
*/
|
||||
public removeState(entityID: string): void {
|
||||
const states = { ...this._states };
|
||||
delete states[entityID];
|
||||
this._states = states;
|
||||
this._renew();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the `hass` object without changing any entity, as Home Assistant
|
||||
* does constantly. Every entity keeps its identity, so a card that re-does
|
||||
* work after this is reacting to the object rather than to what is in it.
|
||||
*/
|
||||
public renew(): void {
|
||||
this._renew();
|
||||
}
|
||||
|
||||
private _createEntity(
|
||||
entityID: string,
|
||||
options: FakeEntityOptions | string,
|
||||
): HassEntity {
|
||||
const resolved: FakeEntityOptions =
|
||||
typeof options === 'string' ? { state: options } : options;
|
||||
|
||||
return createStateEntity({
|
||||
entity_id: entityID,
|
||||
state: resolved.state ?? 'unknown',
|
||||
attributes: resolved.attributes ?? {},
|
||||
last_changed: (resolved.lastChanged ?? createDefaultTime()).toISOString(),
|
||||
last_updated: (resolved.lastUpdated ?? createDefaultTime()).toISOString(),
|
||||
context: { id: entityID, user_id: null, parent_id: null },
|
||||
});
|
||||
}
|
||||
|
||||
private _renew(): void {
|
||||
this._hass = this._createHASS();
|
||||
}
|
||||
|
||||
private _createConnection(): Connection {
|
||||
const connection = mock<Connection>();
|
||||
connection.subscribeMessage.mockResolvedValue(() => Promise.resolve());
|
||||
connection.subscribeEvents.mockResolvedValue(() => Promise.resolve());
|
||||
|
||||
// `callWS` and `sendMessagePromise` are the same request/response channel,
|
||||
// so both go through one handler table. Given two tables, a command
|
||||
// registered by a test would be silently ignored for any caller that
|
||||
// reached the connection directly.
|
||||
connection.sendMessagePromise.mockImplementation((message) => this._callWS(message));
|
||||
return connection;
|
||||
}
|
||||
|
||||
private _registerDefaultHandlers(): void {
|
||||
this.registerCommand('config/entity_registry/get', (message) => {
|
||||
const entry = this._registry.get(String(message.entity_id));
|
||||
if (!entry) {
|
||||
return Promise.reject(
|
||||
new Error(`Entity not found in the fake registry: ${message.entity_id}`),
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
this.registerCommand('config/entity_registry/list', () => [
|
||||
...this._registry.values(),
|
||||
]);
|
||||
this.registerCommand('lovelace/resources', () => []);
|
||||
|
||||
// Not a card command: `ha-nunjucks` fetches the label registry once when a
|
||||
// template first renders.
|
||||
this.registerCommand('config/label_registry/list', () => []);
|
||||
}
|
||||
|
||||
private async _callWS<T>(message: MessageBase): Promise<T> {
|
||||
this._commandLog.push(message);
|
||||
const handler = this._handlers.get(message.type);
|
||||
if (!handler) {
|
||||
// Loudly, because a card that starts issuing a new command must not
|
||||
// appear to work against a fake that knows nothing about it.
|
||||
throw new Error(`FakeHASS received an unregistered WS command: ${message.type}`);
|
||||
}
|
||||
|
||||
// A WebSocket response is untyped on the wire, and the caller's generic is
|
||||
// its claim about the shape. Callers parse what they get with Zod, so a
|
||||
// handler returning the wrong shape surfaces there.
|
||||
return (await handler(message)) as unknown as T;
|
||||
}
|
||||
|
||||
private _unsupported(name: string): () => never {
|
||||
return () => {
|
||||
throw new Error(`FakeHASS does not implement ${name}`);
|
||||
};
|
||||
}
|
||||
|
||||
private _createHASS(): HomeAssistant {
|
||||
return {
|
||||
auth: INERT_AUTH,
|
||||
locale: INERT_LOCALE,
|
||||
|
||||
states: this._states,
|
||||
config: this._config,
|
||||
connected: this._connected,
|
||||
connection: this._connection,
|
||||
language: this._language,
|
||||
selectedLanguage: null,
|
||||
selectedTheme: null,
|
||||
resources: {},
|
||||
services: {},
|
||||
themes: {
|
||||
default_theme: 'default',
|
||||
themes: {},
|
||||
},
|
||||
panels: {},
|
||||
panelUrl: 'lovelace',
|
||||
translationMetadata: { fragments: [], translations: {} },
|
||||
dockedSidebar: false,
|
||||
moreInfoEntityId: '',
|
||||
user: {
|
||||
id: 'fake-user',
|
||||
is_owner: this._isAdmin,
|
||||
is_admin: this._isAdmin,
|
||||
name: 'Fake User',
|
||||
credentials: [],
|
||||
mfa_modules: [],
|
||||
},
|
||||
|
||||
callWS: (message) => this._callWS(message),
|
||||
hassUrl: (path?: string) => new URL(path ?? '/', window.location.href).href,
|
||||
localize: (key: string) => key,
|
||||
|
||||
// Everything the card can call has to either work or fail loudly. A
|
||||
// method that quietly returns nothing would let a card start depending on
|
||||
// it without any test noticing.
|
||||
callService: this._unsupported('callService'),
|
||||
callApi: this._unsupported('callApi'),
|
||||
fetchWithAuth: this._unsupported('fetchWithAuth'),
|
||||
sendWS: this._unsupported('sendWS'),
|
||||
};
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 459 B |
@@ -0,0 +1,169 @@
|
||||
import { css, html, LitElement, type TemplateResult } from 'lit';
|
||||
|
||||
import { SIDE_LOADED_ELEMENTS } from '../../src/ha/side-load-ha-elements';
|
||||
|
||||
/**
|
||||
* The base the three `src/patches` classes subclass at runtime. Home
|
||||
* Assistant's real players own a `<video>` and a transport; this carries only
|
||||
* the members the patches touch, so the subclasses can be defined and
|
||||
* constructed. It plays nothing.
|
||||
*/
|
||||
class HAPlayerStandIn extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
public entityid?: string;
|
||||
public posterUrl?: string;
|
||||
public autoPlay = false;
|
||||
public muted = true;
|
||||
public playsInline = true;
|
||||
public controls = false;
|
||||
|
||||
protected _error?: string;
|
||||
protected _errorIsFatal = false;
|
||||
|
||||
protected _loadedData(): void {
|
||||
// The patches call through to this on the <video>'s `loadeddata`.
|
||||
}
|
||||
|
||||
protected async _startWebRtc(): Promise<void> {
|
||||
// WebRTC negotiation, which this stand-in does not perform.
|
||||
}
|
||||
|
||||
protected _cleanUp(): void {
|
||||
// Transport teardown, which this stand-in has none of.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in that renders its children and nothing else. Behaviour is added
|
||||
* only for elements the card is observed to depend on.
|
||||
*/
|
||||
class HAElementStub extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface a card is drawn on. Home Assistant's own `:host` styles to ensure
|
||||
* the card has background, border and corners for screenshots.
|
||||
*/
|
||||
class HACardStub extends HAElementStub {
|
||||
static styles = css`
|
||||
:host {
|
||||
background: var(--ha-card-background, var(--card-background-color, white));
|
||||
box-shadow: var(--ha-card-box-shadow, none);
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
border-width: var(--ha-card-border-width, 1px);
|
||||
border-style: solid;
|
||||
border-color: var(--ha-card-border-color, var(--divider-color, #e0e0e0));
|
||||
color: var(--primary-text-color);
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A round tap target sized by `--ha-icon-button-size`, which is how Home
|
||||
* Assistant's own icon button sizes itself and what the card sets to lay its
|
||||
* menu out. Without it a button is only as big as whatever it contains.
|
||||
*/
|
||||
class HAIconButtonStub extends HAElementStub {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--ha-icon-button-size, 48px);
|
||||
height: var(--ha-icon-button-size, 48px);
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant fetches `mdi:` icons from Home Assistant, so there is nothing
|
||||
* to draw here. It still occupies an icon's space, so that the layout around it
|
||||
* is the layout a user would see.
|
||||
*/
|
||||
class HAIconStub extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
width: var(--mdc-icon-size, 24px);
|
||||
height: var(--mdc-icon-size, 24px);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home Assistant's conditional picture element, which the card builds one of on
|
||||
* every mount to host whatever picture elements are configured.
|
||||
*/
|
||||
class HuiConditionalElementStub extends HTMLElement {
|
||||
public hass?: unknown;
|
||||
|
||||
public setConfig(): void {
|
||||
// The card only needs the call to succeed; nothing renders from it here.
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh anonymous subclass every call: `customElements.define` rejects a
|
||||
// constructor that is already registered under another name.
|
||||
const createStub = (element: string): CustomElementConstructor => {
|
||||
switch (element) {
|
||||
case 'ha-camera-stream':
|
||||
case 'ha-hls-player':
|
||||
case 'ha-web-rtc-player':
|
||||
return class extends HAPlayerStandIn {};
|
||||
|
||||
case 'ha-card':
|
||||
return class extends HACardStub {};
|
||||
|
||||
case 'ha-icon-button':
|
||||
case 'ha-icon-button-prev':
|
||||
return class extends HAIconButtonStub {};
|
||||
|
||||
case 'ha-icon':
|
||||
case 'ha-state-icon':
|
||||
case 'state-badge':
|
||||
return class extends HAIconStub {};
|
||||
|
||||
case 'hui-conditional-element':
|
||||
return class extends HuiConditionalElementStub {};
|
||||
|
||||
default:
|
||||
return class extends HAElementStub {};
|
||||
}
|
||||
};
|
||||
|
||||
const defineElement = (name: string, constructor: CustomElementConstructor): void => {
|
||||
if (!customElements.get(name)) {
|
||||
customElements.define(name, constructor);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Define every Home Assistant element the card expects to already exist, so
|
||||
* that sideLoadHomeAssistantElements() returns at its first check and never
|
||||
* reaches its `picture-glance` side-load trick.
|
||||
*/
|
||||
export const defineHAElementStubs = (): void => {
|
||||
for (const element of SIDE_LOADED_ELEMENTS) {
|
||||
defineElement(element, createStub(element));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import { expect, onTestFinished, vi } from 'vitest';
|
||||
|
||||
import { ACTION_HANDLER_HOLD_SECONDS } from '../../src/action-handler-directive';
|
||||
import type { AdvancedCameraCard } from '../../src/card';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
|
||||
import { defineHAElementStubs } from './ha-element-stubs';
|
||||
import { deepQuery, deepQueryAll } from './test-utils';
|
||||
|
||||
// Home Assistant's masonry columns are `max-width: 500px`, so this is the width
|
||||
// a card usually gets. The card derives height from the media it is showing.
|
||||
const DEFAULT_CONTAINER_WIDTH = '500px';
|
||||
|
||||
// The card events worth recording by default. There is no way to listen for a
|
||||
// prefix, so every name a ledger reports has to be named somewhere; this is the
|
||||
// set that describes what the card is doing rather than what an editor control
|
||||
// was asked to do.
|
||||
const DEFAULT_LEDGER_EVENTS = [
|
||||
'advanced-camera-card:action:execution-request',
|
||||
'advanced-camera-card:issue:notify',
|
||||
'advanced-camera-card:issue:resolve',
|
||||
'advanced-camera-card:issue:trigger',
|
||||
'advanced-camera-card:live:error',
|
||||
'advanced-camera-card:media:loaded',
|
||||
'advanced-camera-card:media:pause',
|
||||
'advanced-camera-card:media:play',
|
||||
'advanced-camera-card:media:volumechange',
|
||||
'advanced-camera-card:zoom:change',
|
||||
'advanced-camera-card:zoom:unzoomed',
|
||||
'advanced-camera-card:zoom:zoomed',
|
||||
];
|
||||
|
||||
interface ConsoleEntry {
|
||||
level: ConsoleLevel;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
const CONSOLE_LEVELS = ['debug', 'error', 'info', 'log', 'warn'] as const;
|
||||
type ConsoleLevel = (typeof CONSOLE_LEVELS)[number];
|
||||
|
||||
interface EventWaiter {
|
||||
// The number of occurrences required to satisfy this waiter.
|
||||
count: number;
|
||||
resolve: (entry: EventEntry) => void;
|
||||
}
|
||||
|
||||
interface EventEntry {
|
||||
type: string;
|
||||
detail: unknown;
|
||||
target: EventTarget | null;
|
||||
}
|
||||
|
||||
interface LabelledElement extends Element {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const hasLabel = (element: Element): element is LabelledElement => 'label' in element;
|
||||
|
||||
/**
|
||||
* What a control calls itself to the user. The card titles the controls it
|
||||
* draws itself. A menu button is Home Assistant's `ha-icon-button`, which takes
|
||||
* a `label` and renders the title onto a button within its own shadow root, so
|
||||
* the name that is reachable from outside is 'label' not 'title'.
|
||||
*/
|
||||
const getControlName = (element: Element): string | null => {
|
||||
const title = element.getAttribute('title');
|
||||
return title ? title : hasLabel(element) ? element.label ?? null : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Records the card events named at construction.
|
||||
*/
|
||||
class EventLedger {
|
||||
private _entries: EventEntry[] = [];
|
||||
private _target: EventTarget;
|
||||
private _types: string[];
|
||||
|
||||
private _waiting = new Map<string, EventWaiter[]>();
|
||||
|
||||
private _handler = (ev: Event): void => {
|
||||
const entry: EventEntry = {
|
||||
type: ev.type,
|
||||
detail: ev instanceof CustomEvent ? ev.detail : null,
|
||||
target: ev.target,
|
||||
};
|
||||
this._entries.push(entry);
|
||||
|
||||
const seen = this.getEntries(ev.type).length;
|
||||
const stillWaiting: EventWaiter[] = [];
|
||||
for (const waiter of this._waiting.get(ev.type) ?? []) {
|
||||
if (waiter.count <= seen) {
|
||||
waiter.resolve(entry);
|
||||
} else {
|
||||
stillWaiting.push(waiter);
|
||||
}
|
||||
}
|
||||
this._waiting.set(ev.type, stillWaiting);
|
||||
};
|
||||
|
||||
constructor(target: EventTarget, types: string[]) {
|
||||
this._target = target;
|
||||
this._types = types;
|
||||
|
||||
for (const type of types) {
|
||||
target.addEventListener(type, this._handler);
|
||||
}
|
||||
}
|
||||
|
||||
public getEntries(type?: string): EventEntry[] {
|
||||
return type ? this._entries.filter((entry) => entry.type === type) : this._entries;
|
||||
}
|
||||
|
||||
public async waitForFirst(type: string): Promise<EventEntry> {
|
||||
return await this.waitForCount(type, 1);
|
||||
}
|
||||
|
||||
public async waitForNext(type: string): Promise<EventEntry> {
|
||||
return await this.waitForCount(type, this.getEntries(type).length + 1);
|
||||
}
|
||||
|
||||
public async waitForCount(type: string, count: number): Promise<EventEntry> {
|
||||
if (!this._types.includes(type)) {
|
||||
throw new Error(`The event ledger is not recording: ${type}`);
|
||||
}
|
||||
|
||||
const recorded = this.getEntries(type);
|
||||
if (recorded.length >= count) {
|
||||
return recorded[count - 1];
|
||||
}
|
||||
|
||||
return await new Promise<EventEntry>((resolve) => {
|
||||
this._waiting.set(type, [...(this._waiting.get(type) ?? []), { count, resolve }]);
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
for (const type of this._types) {
|
||||
this._target.removeEventListener(type, this._handler);
|
||||
}
|
||||
this._entries = [];
|
||||
this._waiting.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what the card writes to the console.
|
||||
*
|
||||
* Some of what the card reports is only ever visible there: `errorToConsole`
|
||||
* is the sole outlet for many failures, and a `log` action writes its message
|
||||
* directly. Absence matters as much as presence, since a pair of outcomes that
|
||||
* look identical in the DOM can differ only in what was logged.
|
||||
*/
|
||||
class ConsoleLedger {
|
||||
private _entries: ConsoleEntry[] = [];
|
||||
private _originals = new Map<ConsoleLevel, (...args: unknown[]) => void>();
|
||||
|
||||
constructor() {
|
||||
for (const level of CONSOLE_LEVELS) {
|
||||
const original = console[level];
|
||||
this._originals.set(level, original);
|
||||
console[level] = (...args: unknown[]): void => {
|
||||
this._entries.push({ level, args });
|
||||
original(...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getEntries(level?: ConsoleLevel): ConsoleEntry[] {
|
||||
return level
|
||||
? this._entries.filter((entry) => entry.level === level)
|
||||
: this._entries;
|
||||
}
|
||||
|
||||
public getMessages(level?: ConsoleLevel): string[] {
|
||||
return this.getEntries(level).map((entry) => entry.args.map(String).join(' '));
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
for (const [level, original] of this._originals) {
|
||||
console[level] = original;
|
||||
}
|
||||
this._originals.clear();
|
||||
this._entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface MountOptions {
|
||||
// Event names to record alongside `DEFAULT_LEDGER_EVENTS`, for a test
|
||||
// interested in something those do not name.
|
||||
ledgerEvents?: string[];
|
||||
|
||||
// The container the card is mounted in, standing in for a dashboard column.
|
||||
// Set these to put the card in a box of a particular size, for a test about
|
||||
// how it responds to the room it is given.
|
||||
width?: string;
|
||||
height?: string;
|
||||
|
||||
// Console errors required to be logged.
|
||||
expectedConsoleErrors?: RegExp[];
|
||||
|
||||
// Console errors to allow without limit. Reserve this for errors whose number
|
||||
// is not the card's to control (e.g. the browsers).
|
||||
toleratedConsoleErrors?: RegExp[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A card in the document, driven by a `FakeHASS`, with the observers a test
|
||||
* needs to watch what it does.
|
||||
*/
|
||||
export class MountedCard {
|
||||
public readonly card: AdvancedCameraCard;
|
||||
public readonly events: EventLedger;
|
||||
public readonly console: ConsoleLedger;
|
||||
|
||||
private _container: HTMLElement;
|
||||
private _hass: FakeHASS;
|
||||
private _expectedConsoleErrors: RegExp[];
|
||||
private _toleratedConsoleErrors: RegExp[];
|
||||
|
||||
/**
|
||||
* The card is ready to observe but not yet rendered: initializing happens
|
||||
* after this returns, so a test waits for whatever it will assert on.
|
||||
*/
|
||||
public static async create(
|
||||
config: RawAdvancedCameraCardConfig,
|
||||
hass: FakeHASS,
|
||||
options?: MountOptions,
|
||||
): Promise<MountedCard> {
|
||||
// `src/patches` subclasses Home Assistant's three player elements as soon as
|
||||
// those are defined, so the stubs must come first.
|
||||
defineHAElementStubs();
|
||||
await import('../../src/card');
|
||||
|
||||
return new MountedCard(config, hass, options);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
config: RawAdvancedCameraCardConfig,
|
||||
hass: FakeHASS,
|
||||
options?: MountOptions,
|
||||
) {
|
||||
this._hass = hass;
|
||||
|
||||
this._container = document.createElement('div');
|
||||
this._container.style.width = options?.width ?? DEFAULT_CONTAINER_WIDTH;
|
||||
if (options?.height) {
|
||||
this._container.style.height = options.height;
|
||||
}
|
||||
document.body.append(this._container);
|
||||
|
||||
// Before the card exists, so that nothing it does during its first render is
|
||||
// missed.
|
||||
this.events = new EventLedger(this._container, [
|
||||
...DEFAULT_LEDGER_EVENTS,
|
||||
...(options?.ledgerEvents ?? []),
|
||||
]);
|
||||
this.console = new ConsoleLedger();
|
||||
|
||||
this.card = document.createElement('advanced-camera-card');
|
||||
this.card.setConfig(config);
|
||||
this.card.hass = hass.getHASS();
|
||||
|
||||
this._container.append(this.card);
|
||||
|
||||
this._expectedConsoleErrors = options?.expectedConsoleErrors ?? [];
|
||||
this._toleratedConsoleErrors = options?.toleratedConsoleErrors ?? [];
|
||||
|
||||
onTestFinished(() => this._onTestFinished());
|
||||
}
|
||||
|
||||
/**
|
||||
* Be certain of destruction, then hold the card to exactly the errors the test
|
||||
* said it would provoke. A card that reported a failure has not done what a
|
||||
* passing test says it did, and much of what it reports is visible nowhere
|
||||
* else. An expectation that stops matching is checked too, so a test cannot go
|
||||
* on claiming an error the card no longer produces.
|
||||
*/
|
||||
private _onTestFinished(): void {
|
||||
// Read before destroying, which clears the ledger.
|
||||
const logged = this.console
|
||||
.getMessages('error')
|
||||
.filter(
|
||||
(message) =>
|
||||
!this._toleratedConsoleErrors.some((tolerated) => tolerated.test(message)),
|
||||
);
|
||||
|
||||
this.destroy();
|
||||
|
||||
// One error per expectation, in the order they were reported. Comparing the
|
||||
// whole array rather than searching it is what makes an expectation that
|
||||
// never matched, and an error logged more times than expected, both fail.
|
||||
expect(logged).toEqual(
|
||||
this._expectedConsoleErrors.map((expected) => expect.stringMatching(expected)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change an entity and hand the card the resulting `hass`. Both halves
|
||||
* together, because a state the card was never given changes nothing.
|
||||
*/
|
||||
public setEntityState(entityID: string, state: FakeEntityOptions | string): void {
|
||||
this._hass.setState(entityID, state);
|
||||
this.card.hass = this._hass.getHASS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the card a new `hass` with nothing in it changed.
|
||||
*/
|
||||
public renewHASS(): void {
|
||||
this._hass.renew();
|
||||
this.card.hass = this._hass.getHASS();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the card itself has rendered, which says nothing about the
|
||||
* elements beneath it.
|
||||
*/
|
||||
public get updateComplete(): Promise<boolean> {
|
||||
return this.card.updateComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the card's clock on, then wait for the card itself to render.
|
||||
*/
|
||||
public async advanceSeconds(seconds: number): Promise<void> {
|
||||
await vi.advanceTimersByTimeAsync(seconds * 1000);
|
||||
await this.card.updateComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for something the card has rendered, searching its shadow roots.
|
||||
*/
|
||||
public async waitForSelector<T extends Element = Element>(
|
||||
selector: string,
|
||||
): Promise<T> {
|
||||
return await vi.waitFor(() => {
|
||||
const found = deepQuery<T>(this.card, selector);
|
||||
if (!found) {
|
||||
throw new Error(`No element matched: ${selector}`);
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a control by the name the user sees on it, waiting for it to appear.
|
||||
*/
|
||||
public async clickControl(name: string): Promise<void> {
|
||||
const control = await this._findControl(name);
|
||||
|
||||
control.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Press and keep holding a control until the card takes it as a hold, which
|
||||
* is a second action several controls carry alongside their tap.
|
||||
*/
|
||||
public async holdControl(name: string): Promise<void> {
|
||||
const control = await this._findControl(name);
|
||||
|
||||
control.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(ACTION_HANDLER_HOLD_SECONDS * 1000);
|
||||
control.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
|
||||
// The card takes the click, not the mouseup, as the end of a press. A real
|
||||
// pointer sends both, in this order.
|
||||
control.click();
|
||||
}
|
||||
|
||||
private async _findControl(name: string): Promise<HTMLElement> {
|
||||
return await vi.waitFor(() => {
|
||||
const found = deepQueryAll(this.card, '*').find(
|
||||
(element) => getControlName(element) === name,
|
||||
);
|
||||
if (!(found instanceof HTMLElement)) {
|
||||
throw new Error(`Could not find control named: ${name}`);
|
||||
}
|
||||
return found;
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.card.remove();
|
||||
this._container.remove();
|
||||
this.events.destroy();
|
||||
this.console.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Home Assistant's own values, so that a screenshot taken when a test fails
|
||||
// looks like the thing a user would have been looking at. Taken from
|
||||
// hass-frontend's theme globals: resources/theme/typography.globals.ts and
|
||||
// resources/theme/color/. Only the tokens the card actually reads are here.
|
||||
const HOME_ASSISTANT_THEME = `
|
||||
html {
|
||||
--ha-font-family-body: Roboto, Noto, sans-serif;
|
||||
|
||||
--primary-text-color: #141414;
|
||||
--secondary-text-color: #5e5e5e;
|
||||
--text-primary-color: #ffffff;
|
||||
--disabled-text-color: #bdbdbd;
|
||||
|
||||
--primary-color: #009ac7;
|
||||
--accent-color: #ff9800;
|
||||
--divider-color: rgba(0, 0, 0, 0.12);
|
||||
--state-icon-color: #44739e;
|
||||
|
||||
--error-color: #db4437;
|
||||
--warning-color: #ffa600;
|
||||
--success-color: #43a047;
|
||||
--info-color: #039be5;
|
||||
|
||||
--rgb-primary-color: 0, 154, 199;
|
||||
--rgb-primary-text-color: 33, 33, 33;
|
||||
--rgb-secondary-text-color: 114, 114, 114;
|
||||
--rgb-card-background-color: 255, 255, 255;
|
||||
|
||||
--card-background-color: #ffffff;
|
||||
--primary-background-color: #fafafa;
|
||||
--secondary-background-color: #e5e5e5;
|
||||
|
||||
--ha-border-radius-lg: 12px;
|
||||
|
||||
font-family: var(--ha-font-family-body);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
/* A dashboard sits the card on the background colour with room around it. */
|
||||
body {
|
||||
background: var(--primary-background-color);
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = HOME_ASSISTANT_THEME;
|
||||
document.head.append(style);
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
|
||||
|
||||
export const STILL_CAMERA_ENTITY = 'camera.office';
|
||||
|
||||
const STILL_FIXTURE_FILENAME = 'still-red.png';
|
||||
|
||||
// A same-origin still red image, served by the Vite dev server. The same image
|
||||
// is also served by the test-media plugin, which can be asked to misbehave in
|
||||
// useful ways. See test-media-server-plugin.js .
|
||||
const STILL_FIXTURE_URL = `/tests/browser/fixtures/${STILL_FIXTURE_FILENAME}`;
|
||||
|
||||
/**
|
||||
* A card showing one still image and nothing else: no stream, no transport and
|
||||
* no refresh timer, so what is under test is the card rather than the media.
|
||||
*
|
||||
* The `image` provider requires a `camera_entity` with a state even in
|
||||
* `mode: url`, where nothing reads that entity. Without one it renders a
|
||||
* configuration error instead of the image.
|
||||
*/
|
||||
export const createStillImageCameraConfig = (
|
||||
cameraEntity: string = STILL_CAMERA_ENTITY,
|
||||
url: string = STILL_FIXTURE_URL,
|
||||
): RawAdvancedCameraCardConfig => ({
|
||||
camera_entity: cameraEntity,
|
||||
live_provider: 'image',
|
||||
image: {
|
||||
mode: 'url',
|
||||
url,
|
||||
refresh_seconds: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const HTTP_NOT_FOUND = 404;
|
||||
const HTTP_OK = 200;
|
||||
|
||||
/**
|
||||
* A media URL answered with the given statuses in order, and never answered at
|
||||
* all once they run out.
|
||||
*
|
||||
* Every URL carries its own counter, since one server serves a whole run and a
|
||||
* shared counter would make a test depend on what ran before it.
|
||||
*/
|
||||
const createMediaURL = (responses: number[]): string =>
|
||||
`/test-media/${STILL_FIXTURE_FILENAME}?` +
|
||||
new URLSearchParams({
|
||||
token: crypto.randomUUID(),
|
||||
responses: responses.join(','),
|
||||
}).toString();
|
||||
|
||||
/**
|
||||
* A media URL that fails the given number of times and then works, so a test
|
||||
* can make a camera recover rather than only fail.
|
||||
*/
|
||||
export const createTemporarilyFailingMediaURL = (failures: number): string =>
|
||||
createMediaURL([...Array(failures).fill(HTTP_NOT_FOUND), HTTP_OK]);
|
||||
|
||||
/**
|
||||
* A media URL that never works, for a camera that is simply broken.
|
||||
*/
|
||||
export const createFailingMediaURL = (): string => createMediaURL([HTTP_NOT_FOUND]);
|
||||
|
||||
/**
|
||||
* A media URL that is never answered, for a camera that accepts the request and
|
||||
* then says nothing. Silence is a different failure from a refusal, and the
|
||||
* only one that can run a loading timeout out.
|
||||
*/
|
||||
export const createUnansweredMediaURL = (): string => createMediaURL([]);
|
||||
|
||||
/**
|
||||
* A media URL that answers once and is then never answered again, for a camera
|
||||
* that delivers a picture and goes quiet behind it.
|
||||
*/
|
||||
export const createStallingMediaURL = (): string => createMediaURL([HTTP_OK]);
|
||||
|
||||
export interface StillCameraHASSOptions {
|
||||
// Camera entities beyond the default one.
|
||||
cameras?: string[];
|
||||
|
||||
// Anything else the card should be able to see, as entity ID to state.
|
||||
entities?: Record<string, FakeEntityOptions | string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Home Assistant holding the cameras a card is about to be given, which is
|
||||
* the minimum any browser test needs before it can mount anything.
|
||||
*/
|
||||
export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHASS => {
|
||||
const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])];
|
||||
|
||||
return new FakeHASS({
|
||||
entities: {
|
||||
...Object.fromEntries(cameras.map((camera) => [camera, { state: 'idle' }])),
|
||||
...options?.entities,
|
||||
},
|
||||
registry: Object.fromEntries(cameras.map((camera) => [camera, {}])),
|
||||
});
|
||||
};
|
||||
|
||||
export const createStillImageCardConfig = (
|
||||
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
||||
): RawAdvancedCameraCardConfig => ({
|
||||
type: 'custom:advanced-camera-card',
|
||||
cameras: [createStillImageCameraConfig()],
|
||||
|
||||
// The loading screen fades out over a second and a half once the card is
|
||||
// ready. Disable it to improve screenshot fidelity.
|
||||
performance: { features: { card_loading_indicator: false } },
|
||||
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// `querySelectorAll` does not look inside a shadow root, so a full search has
|
||||
// to step through them a level at a time. The node's own root counts because a
|
||||
// Lit element renders into that, not into its children.
|
||||
const getImmediateShadowRoots = (root: ParentNode): ShadowRoot[] => {
|
||||
const roots = root instanceof Element && root.shadowRoot ? [root.shadowRoot] : [];
|
||||
for (const child of root.querySelectorAll('*')) {
|
||||
if (child.shadowRoot) {
|
||||
roots.push(child.shadowRoot);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search an element and every shadow root beneath it. The card nests its own
|
||||
* components several roots deep, and neither the source tree nor the node
|
||||
* suite has a helper for this.
|
||||
*/
|
||||
export const deepQuery = <T extends Element = Element>(
|
||||
root: ParentNode,
|
||||
selector: string,
|
||||
): T | null => {
|
||||
const direct = root.querySelector<T>(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
for (const child of getImmediateShadowRoots(root)) {
|
||||
const found = deepQuery<T>(child, selector);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every match for a selector across an element and the shadow roots beneath it,
|
||||
* for asking how many of something the card rendered rather than whether it
|
||||
* rendered any.
|
||||
*/
|
||||
export const deepQueryAll = <T extends Element = Element>(
|
||||
root: ParentNode,
|
||||
selector: string,
|
||||
): T[] => [
|
||||
...root.querySelectorAll<T>(selector),
|
||||
...getImmediateShadowRoots(root).flatMap((child) => deepQueryAll<T>(child, selector)),
|
||||
];
|
||||
|
||||
// Everything a provider can draw media on: an image, a video, or a canvas.
|
||||
const MEDIA_SELECTOR = 'img, video, canvas';
|
||||
|
||||
export const isLiveMediaShowing = (root: ParentNode): boolean =>
|
||||
deepQueryAll(root, 'advanced-camera-card-live-provider').some(
|
||||
(provider) => !!deepQuery(provider, MEDIA_SELECTOR),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
} from '../browser/test-utils';
|
||||
|
||||
const TRIGGER_ENTITY = 'input_boolean.zoom';
|
||||
|
||||
const mount = async (): Promise<MountedCard> => {
|
||||
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
|
||||
return await MountedCard.create(
|
||||
createStillImageCardConfig({
|
||||
automations: [
|
||||
{
|
||||
triggers: [{ trigger: 'state', entity: TRIGGER_ENTITY, to: 'on' }],
|
||||
actions: [
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
absolute: { zoom: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
hass,
|
||||
);
|
||||
};
|
||||
|
||||
describe('AutomationsManager', () => {
|
||||
it('should run an automation only when a watched entity changes', async () => {
|
||||
const card = await mount();
|
||||
|
||||
const zoomer = await card.waitForSelector('advanced-camera-card-zoomer');
|
||||
expect(zoomer.hasAttribute('zoomed')).toBe(false);
|
||||
|
||||
// A new `hass` carrying no change at all. A card that acted on this would
|
||||
// be reacting to the object rather than to what is in it.
|
||||
card.renewHASS();
|
||||
await card.card.updateComplete;
|
||||
|
||||
expect(zoomer.hasAttribute('zoomed')).toBe(false);
|
||||
|
||||
// The zoom is the far end of a chain that starts at the configuration: an
|
||||
// entity the card was told to watch changed, the trigger matched, and the
|
||||
// action it named actually ran. It also proves the assertion above was
|
||||
// reporting a card that did nothing rather than a probe that never moves.
|
||||
card.setEntityState(TRIGGER_ENTITY, 'on');
|
||||
await card.events.waitForFirst('advanced-camera-card:zoom:zoomed');
|
||||
|
||||
expect(zoomer.hasAttribute('zoomed')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager';
|
||||
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||
import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
||||
import { MountedCard, type MountOptions } from '../../../browser/mounted-card';
|
||||
import {
|
||||
createFailingMediaURL,
|
||||
createStallingMediaURL,
|
||||
createStillCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
createTemporarilyFailingMediaURL,
|
||||
createUnansweredMediaURL,
|
||||
deepQuery,
|
||||
deepQueryAll,
|
||||
isLiveMediaShowing,
|
||||
STILL_CAMERA_ENTITY,
|
||||
} from '../../../browser/test-utils';
|
||||
|
||||
const SECOND_CAMERA_ENTITY = 'camera.hallway';
|
||||
|
||||
const REPORT_TITLE = 'Media unavailable';
|
||||
|
||||
// Holding this reaches the diagnostics view, the only view showing no media
|
||||
// that a camera without a media browsing engine can get to.
|
||||
const IRIS_CONTROL = 'Iris / Default View / Unhide menu';
|
||||
|
||||
// Only the status bar counts as the report. The notification behind it carries
|
||||
// the same title, so a wider search would answer a different question.
|
||||
const findReport = (card: MountedCard): Element | null =>
|
||||
deepQuery(card.card, 'advanced-camera-card-status-bar')?.shadowRoot?.querySelector(
|
||||
`[title="${REPORT_TITLE}"]`,
|
||||
) ?? null;
|
||||
|
||||
const isIssueReported = (card: MountedCard): boolean => !!findReport(card);
|
||||
|
||||
const waitForIssueReported = async (card: MountedCard): Promise<void> => {
|
||||
await vi.waitFor(() => {
|
||||
if (!findReport(card)) {
|
||||
throw new Error(`The issue was not reported: ${REPORT_TITLE}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface MountCardOptions extends MountOptions {
|
||||
cameras?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every test here needs the status bar rendered, since that is where the report
|
||||
* appears.
|
||||
*/
|
||||
const mountCard = async (
|
||||
config?: Partial<RawAdvancedCameraCardConfig>,
|
||||
options?: MountCardOptions,
|
||||
): Promise<MountedCard> => {
|
||||
const { cameras, ...mountOptions } = options ?? {};
|
||||
|
||||
return await MountedCard.create(
|
||||
createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }),
|
||||
createStillCameraHASS({ cameras }),
|
||||
mountOptions,
|
||||
);
|
||||
};
|
||||
|
||||
const mountCardSingleCamera = async (): Promise<MountedCard> => {
|
||||
const card = await mountCard();
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
const mountCardDualCameras = async (): Promise<MountedCard> => {
|
||||
const card = await mountCard(
|
||||
{
|
||||
live: { display: { mode: 'grid' } },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
|
||||
],
|
||||
},
|
||||
{
|
||||
cameras: [SECOND_CAMERA_ENTITY],
|
||||
|
||||
// The grid observes both its own size and its cells', so a cell resize
|
||||
// can resize the host and vice versa. Chromium reports each round it has
|
||||
// to defer as an uncaught error. How many rounds that takes follows the
|
||||
// browser's frame scheduling, not the card: measured anywhere between
|
||||
// none and three for the same test. So it cannot be counted, only
|
||||
// tolerated, and only for the grid: anywhere else it would mean
|
||||
// something new.
|
||||
toleratedConsoleErrors: [/ResizeObserver loop completed/],
|
||||
},
|
||||
);
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
return card;
|
||||
};
|
||||
|
||||
const getNotificationText = (card: MountedCard): string =>
|
||||
deepQuery(card.card, 'advanced-camera-card-notification-block')?.shadowRoot
|
||||
?.textContent ?? '';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('MediaUnavailableIssue', () => {
|
||||
it('should not report a healthy camera', async () => {
|
||||
const card = await mountCardSingleCamera();
|
||||
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
});
|
||||
|
||||
it('should wait out the grace period before reporting an unavailable camera', async () => {
|
||||
const card = await mountCardSingleCamera();
|
||||
|
||||
card.setEntityState(STILL_CAMERA_ENTITY, 'unavailable');
|
||||
|
||||
// Just short of the grace period: reporting here would alarm on a camera
|
||||
// that is about to come back.
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS - 1);
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
await card.advanceSeconds(1);
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should never report a camera that recovers within the grace period', async () => {
|
||||
const card = await mountCardSingleCamera();
|
||||
|
||||
card.setEntityState(STILL_CAMERA_ENTITY, 'unavailable');
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS - 1);
|
||||
|
||||
card.setEntityState(STILL_CAMERA_ENTITY, 'idle');
|
||||
|
||||
// Well past the point the report would have appeared had the blip not
|
||||
// ended. Nothing should ever have been shown.
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
});
|
||||
|
||||
it('should name the camera that failed and why', async () => {
|
||||
const card = await mountCardDualCameras();
|
||||
|
||||
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
|
||||
|
||||
// Which camera, not just that something is wrong: with several on screen a
|
||||
// report that does not say which one leaves the user to guess.
|
||||
expect(getNotificationText(card)).toContain('Camera entity unavailable');
|
||||
expect(getNotificationText(card)).toContain(SECOND_CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should leave the cameras that are still working alone', async () => {
|
||||
const card = await mountCardDualCameras();
|
||||
|
||||
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
|
||||
|
||||
// One camera failing must not take the other down with it.
|
||||
expect(
|
||||
deepQueryAll(card.card, 'advanced-camera-card-notification-block'),
|
||||
).toHaveLength(1);
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should report a camera whose media fails to load', async () => {
|
||||
const card = await mountCard({
|
||||
cameras: [
|
||||
createStillImageCameraConfig(STILL_CAMERA_ENTITY, createFailingMediaURL()),
|
||||
],
|
||||
});
|
||||
|
||||
// The entity is present and healthy, so the failed fetch is the only thing
|
||||
// that can be reported. It is reported as soon as the load fails rather
|
||||
// than after the loading timeout, because a failure is not a slow load.
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(getNotificationText(card)).toContain('Could not load image');
|
||||
expect(getNotificationText(card)).toContain(STILL_CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should clear the report once the camera delivers media again', async () => {
|
||||
const card = await mountCard({
|
||||
cameras: [
|
||||
createStillImageCameraConfig(
|
||||
STILL_CAMERA_ENTITY,
|
||||
createTemporarilyFailingMediaURL(1),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
// Nothing here asks the card to try again. A camera that has come back must
|
||||
// be picked up by the card's own retry, or the report stays up forever for
|
||||
// a user who is looking at a working camera.
|
||||
await card.advanceSeconds(RETRY_EXPONENTIAL_BASE_SECONDS);
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
// The picture is back, so the cleared report is not the card having thrown
|
||||
// the whole live view away.
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should wait out the loading timeout before reporting a slow camera', async () => {
|
||||
const card = await mountCard({
|
||||
cameras: [
|
||||
createStillImageCameraConfig(STILL_CAMERA_ENTITY, createUnansweredMediaURL()),
|
||||
],
|
||||
});
|
||||
|
||||
// Nothing is waited on until there is a player asking for media, so let one
|
||||
// render before the clock is run forward.
|
||||
await card.waitForSelector('img');
|
||||
|
||||
// A camera answering slowly is not a camera that has failed. Reporting here
|
||||
// would fire on every sluggish connection.
|
||||
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS - 1);
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
// Past the timeout, silence is indistinguishable from failure and has to be
|
||||
// reported: nothing else will ever say so, since the request never answers
|
||||
// and there is no error to catch.
|
||||
await card.advanceSeconds(1);
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should keep retrying a camera that is still broken', async () => {
|
||||
// Must use the real clock: fake time moves the card's timers instantly, so
|
||||
// a request would never get a chance to answer between one retry and the
|
||||
// next.
|
||||
vi.useRealTimers();
|
||||
|
||||
const card = await mountCard({
|
||||
// A fixed interval rather than the default backoff, which jitters every
|
||||
// delay by a random half. Backoff itself is tested in unittests. Short,
|
||||
// because this is real time, but long enough that a failed request is
|
||||
// reported before the next attempt replaces it.
|
||||
//
|
||||
// Caution: As this test must use the real clock, this directly adds to
|
||||
// the test runtime.
|
||||
view: { issues: { retry_seconds: 0.1 } },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(
|
||||
STILL_CAMERA_ENTITY,
|
||||
createTemporarilyFailingMediaURL(2),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
// Two failures: the first attempt, and a retry after it. Giving up after
|
||||
// one would leave a camera dark that was about to come back.
|
||||
await card.events.waitForCount('advanced-camera-card:issue:trigger', 2);
|
||||
await waitForIssueReported(card);
|
||||
|
||||
// The third attempt is served.
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
|
||||
// Exactly the two attempts that failed, so the retry ran once rather than
|
||||
// spinning until something happened to work.
|
||||
expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should re-attempt when the retry control is used', async () => {
|
||||
const card = await mountCard({
|
||||
// Automatic retries switched off.
|
||||
view: { issues: { retry_seconds: 0 } },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(
|
||||
STILL_CAMERA_ENTITY,
|
||||
createTemporarilyFailingMediaURL(1),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
|
||||
// The status bar only summarises. Everything a user can do about the
|
||||
// failure is behind it, which is the point of the report being clickable.
|
||||
await card.clickControl(REPORT_TITLE);
|
||||
await card.clickControl('Retry');
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not report while a non-media view is showing', async () => {
|
||||
const card = await mountCard({
|
||||
menu: { style: 'outside' },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(STILL_CAMERA_ENTITY, createFailingMediaURL()),
|
||||
],
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
// Diagnostics shows no media at all, so there is nothing for the report to
|
||||
// be about and complaining there would be noise on an unrelated screen.
|
||||
await card.holdControl(IRIS_CONTROL);
|
||||
await card.waitForSelector('advanced-camera-card-diagnostics');
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
// Returning to the camera brings the report back. Leaving the view is not
|
||||
// an answer to the failure, and coming back to a silently broken camera
|
||||
// would be worse than never having been told.
|
||||
await card.clickControl('Live view');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should report media that stalls after it has loaded', async () => {
|
||||
const refreshSeconds = 2;
|
||||
const card = await mountCard({
|
||||
cameras: [
|
||||
{
|
||||
camera_entity: STILL_CAMERA_ENTITY,
|
||||
live_provider: 'image',
|
||||
image: {
|
||||
mode: 'url',
|
||||
// The first request is answered and every one after it is left
|
||||
// hanging, which is a camera that delivered a picture and then went
|
||||
// quiet. One that refused outright would be reported as a failure
|
||||
// rather than a stall.
|
||||
url: createStallingMediaURL(),
|
||||
refresh_seconds: refreshSeconds,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
// One missed refresh is not a stall. A camera gets a whole refresh interval
|
||||
// on top of the standard window before silence is held against it.
|
||||
await card.advanceSeconds(refreshSeconds + FRAME_STALL_SECONDS - 1);
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
// Past it. The window is measured from a real media load rather than from
|
||||
// anything on the card's clock, so landing exactly on the deadline is a
|
||||
// race: step over it instead.
|
||||
await card.advanceSeconds(2);
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
|
||||
// Stalled rather than failed: the picture on screen is real but frozen, and
|
||||
// saying so is the difference between "this is old" and "this is broken".
|
||||
expect(getNotificationText(card)).toContain('Stream stalled');
|
||||
});
|
||||
|
||||
it('should report a player that reports a playback error', async () => {
|
||||
const card = await mountCard({
|
||||
// A provider given nothing to play. It reports that it failed without
|
||||
// saying why, which is what a playback error is: every other reason
|
||||
// here is one the card was able to name.
|
||||
cameras: [{ camera_entity: STILL_CAMERA_ENTITY, live_provider: 'go2rtc' }],
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(getNotificationText(card)).toContain('Could not get camera endpoint');
|
||||
|
||||
await card.clickControl(REPORT_TITLE);
|
||||
await card.waitForSelector('advanced-camera-card-notification');
|
||||
|
||||
expect(
|
||||
deepQuery(card.card, 'advanced-camera-card-notification')?.shadowRoot?.textContent,
|
||||
).toContain('Playback error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||
import { MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
STILL_CAMERA_ENTITY,
|
||||
} from '../browser/test-utils';
|
||||
|
||||
const UNRELATED_ENTITY = 'input_boolean.unrelated';
|
||||
|
||||
// The size of the static image fixture the player loads. Everything downstream
|
||||
// sizes the card from what the player measured, so these are read from the
|
||||
// media rather than declared anywhere in the configuration.
|
||||
const FIXTURE_WIDTH = 320;
|
||||
const FIXTURE_HEIGHT = 180;
|
||||
|
||||
interface RenderedElement extends Element {
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
|
||||
const mount = async (): Promise<MountedCard> => {
|
||||
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
||||
return await MountedCard.create(createStillImageCardConfig(), hass);
|
||||
};
|
||||
|
||||
const isMediaLoadedInfoEventDetail = (
|
||||
detail: unknown,
|
||||
): detail is MediaLoadedInfoEventDetail =>
|
||||
!!detail &&
|
||||
typeof detail === 'object' &&
|
||||
'info' in detail &&
|
||||
'signal' in detail &&
|
||||
detail.signal instanceof AbortSignal;
|
||||
|
||||
const getMediaLoadedInfos = (card: MountedCard): MediaLoadedInfoEventDetail[] =>
|
||||
card.events
|
||||
.getEntries('advanced-camera-card:media:loaded')
|
||||
.map((entry) => entry.detail)
|
||||
.filter(isMediaLoadedInfoEventDetail);
|
||||
|
||||
describe('AdvancedCameraCardImageUpdatingPlayer', () => {
|
||||
it('should announce the media load once even when the card re-renders', async () => {
|
||||
const card = await mount();
|
||||
|
||||
const player = await card.waitForSelector<RenderedElement>(
|
||||
'advanced-camera-card-image-updating-player',
|
||||
);
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
// Something changes that the media knows nothing about. Announcing the
|
||||
// media load here would mean the player had quietly reloaded, which is how
|
||||
// a stream that churns looks from the outside.
|
||||
card.setEntityState(UNRELATED_ENTITY, 'on');
|
||||
|
||||
// The card renders, and then the player does: each level only asks the next
|
||||
// to render once it has rendered itself, so a second announcement would
|
||||
// arrive by the end of this.
|
||||
await card.updateComplete;
|
||||
await player.updateComplete;
|
||||
|
||||
const loads = getMediaLoadedInfos(card);
|
||||
expect(loads).toHaveLength(1);
|
||||
expect(loads[0].info.targetID).toBe(STILL_CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should announce the size of the media itself', async () => {
|
||||
const card = await mount();
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
const [load] = getMediaLoadedInfos(card);
|
||||
expect(load.info.width).toBe(FIXTURE_WIDTH);
|
||||
expect(load.info.height).toBe(FIXTURE_HEIGHT);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { HassConfig } from 'home-assistant-js-websocket';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { RELEASE_VERSION_TOKEN } from '../../scripts/release-version.js';
|
||||
import type { DeviceRegistryManager } from '../../src/ha/registry/device';
|
||||
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
|
||||
import { getLanguage } from '../../src/localize/localize';
|
||||
@@ -22,7 +23,7 @@ vi.mock('../../src/ha/ws-request.js');
|
||||
|
||||
describe('getReleaseVersion', () => {
|
||||
it('should get release version', () => {
|
||||
expect(getReleaseVersion()).toBe('__ADVANCED_CAMERA_CARD_RELEASE_VERSION__');
|
||||
expect(getReleaseVersion()).toBe(RELEASE_VERSION_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user