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(); 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 { return await this.waitForCount(type, 1); } public async waitForNext(type: string): Promise { return await this.waitForCount(type, this.getEntries(type).length + 1); } public async waitForCount(type: string, count: number): Promise { 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((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 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 { // `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 { return this.card.updateComplete; } /** * Move the card's clock on, then wait for the card itself to render. */ public async advanceSeconds(seconds: number): Promise { await vi.advanceTimersByTimeAsync(seconds * 1000); await this.card.updateComplete; } /** * Wait for something the card has rendered, searching its shadow roots. */ public async waitForSelector( selector: string, ): Promise { return await vi.waitFor(() => { const found = deepQuery(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 { 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 { 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 { 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(); } }