test: Add a browser based test harness (#2641)

This commit is contained in:
Dermot Duffy
2026-07-31 10:16:58 -07:00
committed by GitHub
parent 51c0fab1ce
commit e590f72783
26 changed files with 2224 additions and 37 deletions
+163
View File
@@ -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',
);
});
});
});
+291
View File
@@ -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

+169
View File
@@ -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));
}
};
+388
View File
@@ -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();
}
}
+49
View File
@@ -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);
+167
View File
@@ -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 -1
View File
@@ -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);
});
});