diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0edf781b..513ed71e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: run: yarn run format-check test: - name: Test + name: Unit Test runs-on: ubuntu-latest steps: - name: Checkout @@ -53,6 +53,31 @@ jobs: - name: Test & Coverage run: yarn run coverage + browser-test: + name: Browser Test + + # Not yet a required check: the browser suite is new/potentially unstable. + continue-on-error: true + + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node and Yarn + uses: volta-cli/action@v5 + + - name: Install dependencies + run: yarn install --immutable + + # The browser binary is not in the lockfile, and the media tests need the + # proprietary codecs the Chrome for Testing builds carry. + - name: Install Chromium + run: yarn playwright install --with-deps chromium + + - name: Browser test + run: yarn run test:browser + build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 6e919754..7eb19740 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ package-lock.json /visualizations/ /coverage/ +# Screenshots and attachments the browser test runner writes when a test fails. +/.vitest/ + # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored .pnp.* .yarn/* diff --git a/package.json b/package.json index ee578611..7c3f3939 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@types/masonry-layout": "^4.2.8", "@typescript-eslint/eslint-plugin": "^8.30.1", "@typescript-eslint/parser": "^8.30.1", + "@vitest/browser-playwright": "4.1.10", "@vitest/coverage-v8": "^4.1.10", "conventional-changelog-conventionalcommits": "^8.0.0", "docsify-cli": "^4.4.4", @@ -75,6 +76,7 @@ "eslint-config-prettier": "^9.1.0", "jsdom": "^21.1.2", "knip": "^6.29.0", + "playwright": "1.62.0", "prettier": "^3.3.2", "rollup": "^3.29.4", "rollup-plugin-git-info": "^1.0.0", @@ -189,6 +191,7 @@ "rollup": "rollup -c", "prune": "knip", "test": "vitest run", + "test:browser": "vitest run --config vitest.browser.config.ts", "coverage": "vitest run --coverage" }, "volta": { diff --git a/rollup.config.js b/rollup.config.js index 9b881031..9dd57a5d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -11,6 +11,7 @@ import styles from 'rollup-plugin-styler'; import { visualizer } from 'rollup-plugin-visualizer'; import { cleanDist } from './scripts/clean-dist-plugin.js'; +import { RELEASE_VERSION_TOKEN } from './scripts/release-version.js'; import { svgPath } from './scripts/svg-path-plugin.js'; const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1'; @@ -76,8 +77,7 @@ const plugins = [ preventAssignment: true, values: { 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production'), - __ADVANCED_CAMERA_CARD_RELEASE_VERSION__: - process.env.RELEASE_VERSION ?? (dev ? 'dev' : 'pkg'), + [RELEASE_VERSION_TOKEN]: process.env.RELEASE_VERSION ?? (dev ? 'dev' : 'pkg'), }, }), serveEnabled && serve(serveopts), diff --git a/scripts/release-version-plugin.js b/scripts/release-version-plugin.js new file mode 100644 index 00000000..cbaa1f4a --- /dev/null +++ b/scripts/release-version-plugin.js @@ -0,0 +1,25 @@ +import { RELEASE_VERSION_TOKEN } from './release-version.js'; + +// What the card substitutes to mean "the version in package.json". The +// development substitution is not used here because it appends a git hash that +// only a build step knows. +const PACKAGE_VERSION = 'pkg'; + +/** + * Substitutes the release version the way the build does. + * + * The card reads it out of a string literal that Rollup rewrites, so without + * this it renders the placeholder itself: the loading screen shows the raw + * token, which then appears in every failure screenshot. + */ +export const releaseVersion = () => ({ + name: 'release-version', + + transform(code, id) { + if (!id.includes('src/utils/diagnostics.ts')) { + return null; + } + + return { code: code.replace(RELEASE_VERSION_TOKEN, PACKAGE_VERSION), map: null }; + }, +}); diff --git a/scripts/release-version.js b/scripts/release-version.js new file mode 100644 index 00000000..4b63b24c --- /dev/null +++ b/scripts/release-version.js @@ -0,0 +1,12 @@ +/** + * The literal the card carries in place of its version, for the build to + * rewrite. + * + * `getReleaseVersion` cannot import this: it has to sit in that source as a + * plain string for the build to have something to replace. It lives in its own + * module because the build, the browser tests and a unit test all have to agree + * on it, and none of them should have to depend on either of the others. + * + * It must be in a JS file as Node's loader cannot import TypeScript. + */ +export const RELEASE_VERSION_TOKEN = '__ADVANCED_CAMERA_CARD_RELEASE_VERSION__'; diff --git a/scripts/scss-string-plugin.js b/scripts/scss-string-plugin.js new file mode 100644 index 00000000..1552d03c --- /dev/null +++ b/scripts/scss-string-plugin.js @@ -0,0 +1,26 @@ +/** + * Vite plugin: `import style from './foo.scss'` gives the compiled CSS as a + * string, which is what the source tree passes to Lit's `unsafeCSS`. Left + * alone, Vite adds those styles to the page and the import returns nothing + * useful. Appending Vite's own `?inline` asks for the text instead, compiled by + * the same sass the build uses. + * + * The build's `rollup-plugin-styler` cannot be used here: Vite always handles + * `.scss` itself, so it would take that plugin's JavaScript output and hand it + * to sass, which rejects it. + * + * @type {() => import('vite').Plugin} + */ +export const scssString = () => ({ + name: 'scss-string', + + // Vite: run before its builtin CSS handling. + enforce: 'pre', + + async resolveId(source, importer, options) { + if (!source.endsWith('.scss')) { + return null; + } + return await this.resolve(`${source}?inline`, importer, options); + }, +}); diff --git a/scripts/test-media-server-plugin.js b/scripts/test-media-server-plugin.js new file mode 100644 index 00000000..20085d2a --- /dev/null +++ b/scripts/test-media-server-plugin.js @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const FIXTURE_DIRECTORY = 'tests/browser/fixtures'; + +const CONTENT_TYPES = { + '.png': 'image/png', +}; + +const OK = 200; + +// Requests answered per token, so `responses` can be read as a sequence. +const requestCounts = new Map(); + +/** + * Serves a fixture at `/test-media/`, behaving as the query asks: + * + * token Which counter the request belongs to. Required, because one + * server serves every test in a run and a shared counter would + * make a test's behaviour depend on what ran before it. + * responses The status to answer each request with, in order: `200` serves + * the file and anything else is sent as an empty error. Once the + * list runs out, requests are never answered at all, which is how + * a camera goes quiet. + * + * Nothing here waits for a set time. Tests run on a fake clock while requests + * are served in real time, so a response that is merely slow is a race: run the + * suite on a loaded machine and it arrives in the middle of a test that assumed + * it would not. + */ +export const testMediaServer = () => ({ + name: 'test-media-server', + + configureServer(server) { + server.middlewares.use('/test-media', (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + + // A name rather than a path: nothing outside the fixtures is servable. + const file = path.basename(url.pathname); + const contentType = CONTENT_TYPES[path.extname(file)]; + const location = path.resolve(FIXTURE_DIRECTORY, file); + const token = url.searchParams.get('token'); + + if (!contentType || !token || !fs.existsSync(location)) { + res.statusCode = 404; + res.end(); + return; + } + + const responses = (url.searchParams.get('responses') ?? '') + .split(',') + .filter((status) => status !== '') + .map(Number); + + const answered = requestCounts.get(token) ?? 0; + requestCounts.set(token, answered + 1); + + // Held open deliberately. The socket is released when the test ends and + // the card that asked for it is torn down. + if (answered >= responses.length) { + return; + } + + const status = responses[answered]; + if (status !== OK) { + res.statusCode = status; + res.end(); + return; + } + + res.setHeader('Content-Type', contentType); + res.end(fs.readFileSync(location)); + }); + }, +}); diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 34faf6dd..0c02c803 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -24,8 +24,11 @@ interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions { allowPropagation?: boolean; } +// How long a press must last to count as a hold rather than a tap. +export const ACTION_HANDLER_HOLD_SECONDS = 0.4; + class ActionHandler extends HTMLElement implements ActionHandlerInterface { - public holdTime = 0.4; + public holdTime = ACTION_HANDLER_HOLD_SECONDS; private holdTimer = new Timer(); private doubleClickTimer = new Timer(); diff --git a/src/card-controller/issues/issues/media-unavailable.ts b/src/card-controller/issues/issues/media-unavailable.ts index 4ca9398c..01cadd38 100644 --- a/src/card-controller/issues/issues/media-unavailable.ts +++ b/src/card-controller/issues/issues/media-unavailable.ts @@ -50,7 +50,7 @@ interface TargetError { description?: string; } -const MEDIA_LOADING_TIMEOUT_SECONDS = 10; +export const MEDIA_LOADING_TIMEOUT_SECONDS = 10; // The per-cause presentation (localization key + icon), shared by the // notification metadata and the reconnecting placeholder so each cause is diff --git a/src/card.ts b/src/card.ts index 11d3edb4..2fcc8cc8 100644 --- a/src/card.ts +++ b/src/card.ts @@ -126,7 +126,7 @@ const advancedCameraCards: AdvancedCameraCard[] = (window.advancedCameraCards ?? // *************************************************************************** @customElement('advanced-camera-card') -class AdvancedCameraCard extends LitElement { +export class AdvancedCameraCard extends LitElement { protected _controller = new CardController( this, // Callback to scroll the main pane back to the top (example usecase: scrolling diff --git a/src/ha/side-load-ha-elements.ts b/src/ha/side-load-ha-elements.ts index 77664cbe..409fc601 100644 --- a/src/ha/side-load-ha-elements.ts +++ b/src/ha/side-load-ha-elements.ts @@ -11,39 +11,46 @@ class HomeAssistantElementsLoadError extends AdvancedCameraCardError { } } +/** + * The Home Assistant elements this card renders and expects to already be + * registered. + */ +export const SIDE_LOADED_ELEMENTS = [ + 'ha-alert', + 'ha-button', + 'ha-camera-stream', + 'ha-card', + 'ha-combo-box', + 'ha-dropdown-item', + 'ha-dropdown', + 'ha-expansion-panel', + 'ha-form', + 'ha-hls-player', + 'ha-icon-button-prev', + 'ha-icon-button', + 'ha-icon', + 'ha-md-list-item', + 'ha-md-list', + 'ha-menu-button', + 'ha-selector', + 'ha-sortable', + 'ha-spinner', + 'ha-state-icon', + 'ha-web-rtc-player', + + 'hui-conditional-element', + + 'mwc-list-item', + 'state-badge', +]; + /** * Side loads the HA elements this card needs. This trickery is unfortunate * necessary, see: * - https://github.com/thomasloven/hass-config/wiki/PreLoading-Lovelace-Elements */ export const sideLoadHomeAssistantElements = async (): Promise => { - const neededElements = [ - 'ha-alert', - 'ha-button', - 'ha-camera-stream', - 'ha-card', - 'ha-combo-box', - 'ha-dropdown-item', - 'ha-dropdown', - 'ha-expansion-panel', - 'ha-form', - 'ha-hls-player', - 'ha-icon-button-prev', - 'ha-icon-button', - 'ha-icon', - 'ha-md-list-item', - 'ha-md-list', - 'ha-menu-button', - 'ha-selector', - 'ha-sortable', - 'ha-spinner', - 'ha-state-icon', - 'ha-web-rtc-player', - 'mwc-list-item', - 'state-badge', - ]; - - if (neededElements.every((element) => customElements.get(element))) { + if (SIDE_LOADED_ELEMENTS.every((element) => customElements.get(element))) { return; } diff --git a/tests/browser/fake-hass.test.ts b/tests/browser/fake-hass.test.ts new file mode 100644 index 00000000..2bad579b --- /dev/null +++ b/tests/browser/fake-hass.test.ts @@ -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', + ); + }); + }); +}); diff --git a/tests/browser/fake-hass.ts b/tests/browser/fake-hass.ts new file mode 100644 index 00000000..748f1ddd --- /dev/null +++ b/tests/browser/fake-hass.ts @@ -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; + +export interface FakeEntityOptions { + state?: string; + attributes?: Record; + 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; + + // Entity registry entries, as entity ID to a partial registry entry. + registry?: Record>; + + 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(); + +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 => + 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(); + private _config: HassConfig = createConfig(STATE_RUNNING); + private _connected = true; + private _connection: Connection; + private _language: string; + private _isAdmin: boolean; + private _handlers = new Map(); + 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.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(message: MessageBase): Promise { + 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'), + }; + } +} diff --git a/tests/browser/fixtures/still-red.png b/tests/browser/fixtures/still-red.png new file mode 100644 index 00000000..9f94c470 Binary files /dev/null and b/tests/browser/fixtures/still-red.png differ diff --git a/tests/browser/ha-element-stubs.ts b/tests/browser/ha-element-stubs.ts new file mode 100644 index 00000000..3079dd33 --- /dev/null +++ b/tests/browser/ha-element-stubs.ts @@ -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 `