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),
);