import { expect, onTestFinished, vi } from 'vitest'; import type { AdvancedCameraCard } from '../../src/card'; import type { RawAdvancedCameraCardConfig } from '../../src/config/types'; import { ACTION_HANDLER_HOLD_SECONDS } from '../../src/const'; import { clickElement, deepQuery, deepQueryAll, getAllShadowRoots } from './dom'; import type { FakeEntityOptions, FakeHASS } from './fake-hass'; import { defineHAElementStubs } from './ha-element-stubs'; // 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'; // Everything a rendered element can arrive as: drawn, moved, retitled or // relabelled. const RENDER_MUTATIONS = { attributes: true, characterData: true, childList: true, subtree: true, }; /** * Provides debug information when a test ends (e.g. timeout), by reporting * expected things that didn't happen. Helps narrow down a hanging test to the * precise unmet expectation. */ const reportIfNeverHappens = ( description: string, cleanUp?: () => void, ): (() => void) => { let happened = false; onTestFinished(() => { cleanUp?.(); if (!happened) { throw new Error(`Never happened: ${description}`); } }); return () => { happened = true; }; }; /** * Wait for something the card draws. * * A `MutationObserver` reports a change when it happens and has no clock of its * own to run (so no clash with fake vs real timers used elsewhere in the test). * Alternatives on offer (e.g. `vi.waitFor`, `expect.element`) poll a timer, * which under a fake clock is the card's timer so each each poll advances the * card's own test clock by the interval between polls. * * If the predicate is never "found", the test will fail on the Vitest timeout, * which names only the test. `description` is reported alongside it, to say * which wait it was that never finished. * * Known limitation: the browser reports changes within a root being watched, * never the creation of a root itself. A new root is picked up because whatever * created it also changed a watched root; one created with nothing else * changing around it would be missed until the timeout. Everything the card * draws is a LIT element, which creates its root as the element is added to the * page, so the root above it always changes at the same moment and nothing is * missed. */ const waitForRender = async ( root: Element, find: () => T | null, description: string, ): Promise => { const observers: MutationObserver[] = []; const observed = new Set(); const stopObserving = (): void => observers.forEach((observer) => observer.disconnect()); const happened = reportIfNeverHappens(description, stopObserving); try { return await new Promise((resolve) => { const check = (): void => { // Watch all shadow roots we're not already watching. for (const node of [root, ...getAllShadowRoots(root)]) { if (!observed.has(node)) { observed.add(node); const observer = new MutationObserver(check); observer.observe(node, RENDER_MUTATIONS); observers.push(observer); } } // Attempt to find. const match = find(); if (match) { happened(); resolve(match); } }; check(); }); } finally { stopObserving(); } }; // 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[]; } interface ConsoleWaiter { // The number of occurrences required to satisfy this waiter. count: number; level: ConsoleLevel; message: RegExp; resolve: () => void; } interface ConsoleWaiterOptions { count?: number; level?: ConsoleLevel; } 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]; } const happened = reportIfNeverHappens(`${type} firing ${count} time(s)`); return await new Promise((resolve) => { this._waiting.set(type, [ ...(this._waiting.get(type) ?? []), { count, resolve: (entry: EventEntry): void => { happened(); resolve(entry); }, }, ]); }); } 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>(); private _waiting: ConsoleWaiter[] = []; 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 }); this._resolveWaiters(); 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 countMessages(message: RegExp, level: ConsoleLevel = 'info'): number { return this.getMessages(level).filter((written) => message.test(written)).length; } /** * Wait until a message has been written. The card acts on what a test does to * it without waiting to be asked, so a test that presses a key and reads the * log on the next line usually finds nothing there yet. */ public async waitForMessage( message: RegExp, options?: ConsoleWaiterOptions, ): Promise { const waiter = { count: options?.count ?? 1, level: options?.level ?? 'info', message, }; if (this._isSatisfied(waiter)) { return; } const happened = reportIfNeverHappens( `${waiter.level} being written ${waiter.count} time(s): ${message.source}`, ); return await new Promise((resolve) => { this._waiting.push({ ...waiter, resolve: (): void => { happened(); resolve(); }, }); }); } private _isSatisfied(waiter: Omit): boolean { return this.countMessages(waiter.message, waiter.level) >= waiter.count; } private _resolveWaiters(): void { this._waiting = this._waiting.filter((waiter) => { if (!this._isSatisfied(waiter)) { return true; } waiter.resolve(); return false; }); } public destroy(): void { for (const [level, original] of this._originals) { console[level] = original; } this._originals.clear(); this._entries = []; this._waiting = []; } } 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; // Where that container is placed, as CSS lengths from the page's top left // corner. The page grows to reach it, so a card put beyond the window can // only be brought into view by scrolling. position?: { top?: string; left?: 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. * * `loadCard` puts the card's elements into the page. Where they come from is * the caller's to decide: `MountedCardFactory` takes them from `src/`, and the * suite covering a build ("dist") takes them from the file the build * produced. */ public static async create( loadCard: () => Promise, config: RawAdvancedCameraCardConfig, hass: FakeHASS, options?: MountOptions, ): Promise { await loadCard(); return new MountedCard(config, hass, options); } protected 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; } if (options?.position) { this._container.style.position = 'absolute'; this._container.style.top = options.position.top ?? '0'; this._container.style.left = options.position.left ?? '0'; } 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(); } /** * How many Home Assistant event subscriptions the card currently holds open. */ public getOpenEventSubscriptionCount(): number { return this._hass.getOpenEventSubscriptionCount(); } /** * Hand the card a new `hass` with nothing in it changed. */ public renewHASS(): void { this._hass.renew(); this.card.hass = this._hass.getHASS(); } /** * Drop or restore the connection to Home Assistant, then hand the card the * resulting `hass`. */ public setConnected(connected: boolean): void { this._hass.setConnected(connected); this.card.hass = this._hass.getHASS(); } /** * Give the card a new configuration. */ public setConfig(config: RawAdvancedCameraCardConfig): void { this.card.setConfig(config); } /** * Take the card off the page. */ public detach(): void { this.card.remove(); } /** * Put the card back on the page. */ public attach(): void { this._container.append(this.card); } /** * 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 this.waitForRender( () => deepQuery(this.card, selector), `an element matching ${selector}`, ); } /** * Wait for something the card renders that a selector cannot describe. Use * instead of `vi.waitFor` which interferes with fake `card` time. * * `description` names what is being waited for (so it can be displayed if not * found, for debugging purposes). */ public async waitForRender(find: () => T | null, description: string): Promise { return await waitForRender(this.card, find, description); } /** * 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); await clickElement(control); } /** * 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. * * Assembled from events rather than driven with a real pointer, because * `userEvent` offers whole gestures only (click, hover, drag) and none of * them stops part way through a press. */ public async holdControl(name: string): Promise { const control = await this._findControl(name); // Composed as well as bubbling: a real press crosses the shadow boundaries // between a control and whatever is listening above it. const press = { bubbles: true, composed: true }; control.dispatchEvent(new MouseEvent('mousedown', press)); await vi.advanceTimersByTimeAsync(ACTION_HANDLER_HOLD_SECONDS * 1000); control.dispatchEvent(new MouseEvent('mouseup', press)); // The card takes the click, not the mouseup, as the end of a press. A real // pointer sends both, in this order. control.click(); } /** * Click the control that steps a carousel one item along. * * These carry the name of whatever they move to rather than a name of their * own, which several other controls also carry, so they are reached by the * side they sit on instead. Which side moves forward depends on the reading * direction of the page, exactly as it does for the user. */ public async clickNextPreviousControl(side: 'left' | 'right'): Promise { const control = await this.waitForRender( () => deepQuery(this.card, `ha-icon-button.controls.${side}`), `the ${side} carousel control`, ); await clickElement(control); } private async _findControl(name: string): Promise { return await this.waitForRender(() => { const found = deepQueryAll(this.card, '*').find( (element) => getControlName(element) === name && // A control the user can press occupies space. An element that only // wraps one can carry the same name while having no box of its own. !!element.getBoundingClientRect().width, ); return found instanceof HTMLElement ? found : null; }, `a control named ${name}`); } public destroy(): void { this.card.remove(); this._container.remove(); this.events.destroy(); this.console.destroy(); } } export class MountedCardFactory { // A card built from `src/`. Constrast with dist.browsers.test.ts . public static async createFromSource( config: RawAdvancedCameraCardConfig, hass: FakeHASS, options?: MountOptions, ): Promise { return await MountedCard.create( async () => { // `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'); }, config, hass, options, ); } }