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
+26 -1
View File
@@ -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
+3
View File
@@ -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/*
+3
View File
@@ -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": {
+2 -2
View File
@@ -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),
+25
View File
@@ -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 };
},
});
+12
View File
@@ -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__';
+26
View File
@@ -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);
},
});
+75
View File
@@ -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/<file>`, 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));
});
},
});
+4 -1
View File
@@ -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();
@@ -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
+1 -1
View File
@@ -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
+34 -27
View File
@@ -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<void> => {
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;
}
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { getHassDifferences } from '../../src/ha/get-hass-differences';
import { FakeHASS } from './fake-hass';
const CAMERA = 'camera.office';
const SWITCH = 'input_boolean.zoom';
const createFakeHASS = (): FakeHASS =>
new FakeHASS({
entities: { [CAMERA]: { state: 'idle' }, [SWITCH]: { state: 'off' } },
registry: { [CAMERA]: { device_id: 'device-1' } },
});
// @vitest-environment jsdom
describe('FakeHASS', () => {
describe('identity', () => {
it('should hand out a new object every time it is renewed', () => {
const hass = createFakeHASS();
const before = hass.getHASS();
hass.renew();
expect(hass.getHASS()).not.toBe(before);
// The states map itself is only rebuilt when a state changed, as Home
// Assistant rebuilds it. A renewal carrying no state change hands back the
// same map, so a card gating on the map's identity sees the truth.
expect(hass.getHASS().states).toBe(before.states);
});
it('should hand out a new states map when a state changed', () => {
const hass = createFakeHASS();
const before = hass.getHASS();
hass.setState(SWITCH, 'on');
expect(hass.getHASS().states).not.toBe(before.states);
});
it('should replace the state object only for the entity that changed', () => {
const hass = createFakeHASS();
const before = hass.getHASS();
hass.setState(SWITCH, 'on');
const after = hass.getHASS();
expect(after.states[SWITCH]).not.toBe(before.states[SWITCH]);
expect(after.states[CAMERA]).toBe(before.states[CAMERA]);
});
// Updating immutably is what lets any Home Assistant consumer identify the
// entities that changed by comparing references. `getHassDifferences` is
// borrowed here as a ready-made reference comparison over a named set of
// entities: the instrument, not the thing being specified.
it('should report exactly the entities that changed under a reference comparison', () => {
const hass = createFakeHASS();
const before = hass.getHASS();
hass.setState(SWITCH, 'on');
expect(
getHassDifferences(hass.getHASS(), before, [CAMERA, SWITCH]).map(
(difference) => difference.entityID,
),
).toEqual([SWITCH]);
});
it('should report no differences when nothing changed', () => {
const hass = createFakeHASS();
const before = hass.getHASS();
hass.renew();
expect(getHassDifferences(hass.getHASS(), before, [CAMERA, SWITCH])).toEqual([]);
});
it('should report a removed entity as a difference', () => {
const hass = createFakeHASS();
const before = hass.getHASS();
hass.removeState(SWITCH);
expect(hass.getHASS().states[SWITCH]).toBeUndefined();
expect(
getHassDifferences(hass.getHASS(), before, [SWITCH]).map(
(difference) => difference.entityID,
),
).toEqual([SWITCH]);
});
});
describe('websocket commands', () => {
it('should answer a registered command', async () => {
const hass = createFakeHASS();
await expect(
hass.getHASS().callWS({ type: 'config/entity_registry/get', entity_id: CAMERA }),
).resolves.toEqual(expect.objectContaining({ entity_id: CAMERA }));
});
it('should reject an unregistered command', async () => {
const hass = createFakeHASS();
await expect(
hass.getHASS().callWS({ type: 'camera/stream', entity_id: CAMERA }),
).rejects.toThrow('FakeHASS received an unregistered WS command: camera/stream');
});
it('should let a test replace a command handler', async () => {
const hass = createFakeHASS();
hass.registerCommand('camera/stream', () => ({ url: '/stream.m3u8' }));
await expect(
hass.getHASS().callWS({ type: 'camera/stream', entity_id: CAMERA }),
).resolves.toEqual({ url: '/stream.m3u8' });
});
it('should answer a command sent through the connection from the same table', async () => {
const hass = createFakeHASS();
hass.registerCommand('camera/stream', () => ({ url: '/stream.m3u8' }));
await expect(
hass.getHASS().connection.sendMessagePromise({ type: 'camera/stream' }),
).resolves.toEqual({ url: '/stream.m3u8' });
});
it('should reject an unregistered command sent through the connection', async () => {
const hass = createFakeHASS();
await expect(
hass.getHASS().connection.sendMessagePromise({ type: 'camera/stream' }),
).rejects.toThrow('FakeHASS received an unregistered WS command: camera/stream');
});
it('should record every command in order', async () => {
const hass = createFakeHASS();
await hass.getHASS().callWS({ type: 'lovelace/resources' });
await hass
.getHASS()
.callWS({ type: 'config/entity_registry/get', entity_id: CAMERA });
expect(hass.getCommandLog().map((message) => message.type)).toEqual([
'lovelace/resources',
'config/entity_registry/get',
]);
});
});
describe('unimplemented methods', () => {
it('should throw rather than quietly do nothing', () => {
const hass = createFakeHASS().getHASS();
expect(() => hass.callService('camera', 'turn_on')).toThrow(
'FakeHASS does not implement callService',
);
expect(() => hass.sendWS({ type: 'ping' })).toThrow(
'FakeHASS does not implement sendWS',
);
});
});
});
+291
View File
@@ -0,0 +1,291 @@
import {
STATE_RUNNING,
type Connection,
type HassConfig,
type HassEntities,
type HassEntity,
type MessageBase,
} from 'home-assistant-js-websocket';
import { mock } from 'vitest-mock-extended';
import type { Entity } from '../../src/ha/registry/entity/types';
import type { HomeAssistant } from '../../src/ha/types';
import { createRegistryEntity, createStateEntity } from '../test-utils';
// A WebSocket command handler. Returning a rejected promise models a command
// Home Assistant refuses; throwing models a malformed request.
export type WSCommandHandler = (message: MessageBase) => Promise<unknown> | unknown;
export interface FakeEntityOptions {
state?: string;
attributes?: Record<string, unknown>;
lastChanged?: Date;
lastUpdated?: Date;
}
export interface FakeHASSOptions {
// Entities present from the start, as entity ID to state options (or a
// bare state string).
entities?: Record<string, FakeEntityOptions | string>;
// Entity registry entries, as entity ID to a partial registry entry.
registry?: Record<string, Partial<Entity>>;
language?: string;
isAdmin?: boolean;
}
// Home Assistant sends real timestamps, and the card measures a state's age
// against them: must use a dynamic (vs static) date.
const createDefaultTime = (): Date => new Date();
// `Auth` is a class built from real credentials, and `locale` is typed with
// ambient enums that have no runtime value, so neither can be constructed here.
// The card reads neither.
const { auth: INERT_AUTH, locale: INERT_LOCALE } = mock<HomeAssistant>();
const createConfig = (state: HassConfig['state']): HassConfig => ({
latitude: 0,
longitude: 0,
elevation: 0,
radius: 0,
unit_system: {
length: 'km',
mass: 'kg',
volume: 'L',
temperature: '°C',
pressure: 'Pa',
wind_speed: 'km/h',
accumulated_precipitation: 'mm',
},
location_name: 'Fake',
time_zone: 'UTC',
components: [],
config_dir: '/config',
allowlist_external_dirs: [],
allowlist_external_urls: [],
version: '2026.7.0',
config_source: 'storage',
recovery_mode: false,
safe_mode: false,
state,
external_url: null,
internal_url: null,
currency: 'USD',
country: null,
language: 'en',
});
const createRegistryEntry = (entityID: string, overrides?: Partial<Entity>): Entity =>
createRegistryEntity({ entity_id: entityID, unique_id: entityID, ...overrides });
/**
* A driveable stand-in for the `hass` object the card is handed by Home
* Assistant.
*
* Home Assistant updates immutably, and that is the behaviour reproduced here:
* a change hands consumers a new top-level object and a new `states` map
* holding a new state object for the entity that changed, while every other
* entity keeps the object it already had. Anything the card can observe about
* a real `hass` it must be able to observe about this one, so a divergence here
* is a bug in the fake rather than a shortcut worth taking.
*/
export class FakeHASS {
private _hass: HomeAssistant;
private _states: HassEntities = {};
private _registry = new Map<string, Entity>();
private _config: HassConfig = createConfig(STATE_RUNNING);
private _connected = true;
private _connection: Connection;
private _language: string;
private _isAdmin: boolean;
private _handlers = new Map<string, WSCommandHandler>();
private _commandLog: MessageBase[] = [];
constructor(options?: FakeHASSOptions) {
this._language = options?.language ?? 'en';
this._isAdmin = options?.isAdmin ?? true;
for (const [entityID, entityOptions] of Object.entries(options?.entities ?? {})) {
this._states[entityID] = this._createEntity(entityID, entityOptions);
}
for (const [entityID, entry] of Object.entries(options?.registry ?? {})) {
this._registry.set(entityID, createRegistryEntry(entityID, entry));
}
this._connection = this._createConnection();
this._registerDefaultHandlers();
this._hass = this._createHASS();
}
public getHASS(): HomeAssistant {
return this._hass;
}
/**
* Register a handler for a WebSocket command, replacing any existing one.
*/
public registerCommand(type: string, handler: WSCommandHandler): void {
this._handlers.set(type, handler);
}
/**
* Every WebSocket command the card has issued, in order.
*/
public getCommandLog(): readonly MessageBase[] {
return this._commandLog;
}
/**
* Change one entity, then renew. The changed entity gets a new state object;
* every other entity keeps the one it already had.
*/
public setState(entityID: string, options: FakeEntityOptions | string): void {
this._states = {
...this._states,
[entityID]: this._createEntity(entityID, options),
};
this._renew();
}
/**
* Remove an entity from the state map, as happens when its integration is
* unloaded.
*/
public removeState(entityID: string): void {
const states = { ...this._states };
delete states[entityID];
this._states = states;
this._renew();
}
/**
* Replace the `hass` object without changing any entity, as Home Assistant
* does constantly. Every entity keeps its identity, so a card that re-does
* work after this is reacting to the object rather than to what is in it.
*/
public renew(): void {
this._renew();
}
private _createEntity(
entityID: string,
options: FakeEntityOptions | string,
): HassEntity {
const resolved: FakeEntityOptions =
typeof options === 'string' ? { state: options } : options;
return createStateEntity({
entity_id: entityID,
state: resolved.state ?? 'unknown',
attributes: resolved.attributes ?? {},
last_changed: (resolved.lastChanged ?? createDefaultTime()).toISOString(),
last_updated: (resolved.lastUpdated ?? createDefaultTime()).toISOString(),
context: { id: entityID, user_id: null, parent_id: null },
});
}
private _renew(): void {
this._hass = this._createHASS();
}
private _createConnection(): Connection {
const connection = mock<Connection>();
connection.subscribeMessage.mockResolvedValue(() => Promise.resolve());
connection.subscribeEvents.mockResolvedValue(() => Promise.resolve());
// `callWS` and `sendMessagePromise` are the same request/response channel,
// so both go through one handler table. Given two tables, a command
// registered by a test would be silently ignored for any caller that
// reached the connection directly.
connection.sendMessagePromise.mockImplementation((message) => this._callWS(message));
return connection;
}
private _registerDefaultHandlers(): void {
this.registerCommand('config/entity_registry/get', (message) => {
const entry = this._registry.get(String(message.entity_id));
if (!entry) {
return Promise.reject(
new Error(`Entity not found in the fake registry: ${message.entity_id}`),
);
}
return entry;
});
this.registerCommand('config/entity_registry/list', () => [
...this._registry.values(),
]);
this.registerCommand('lovelace/resources', () => []);
// Not a card command: `ha-nunjucks` fetches the label registry once when a
// template first renders.
this.registerCommand('config/label_registry/list', () => []);
}
private async _callWS<T>(message: MessageBase): Promise<T> {
this._commandLog.push(message);
const handler = this._handlers.get(message.type);
if (!handler) {
// Loudly, because a card that starts issuing a new command must not
// appear to work against a fake that knows nothing about it.
throw new Error(`FakeHASS received an unregistered WS command: ${message.type}`);
}
// A WebSocket response is untyped on the wire, and the caller's generic is
// its claim about the shape. Callers parse what they get with Zod, so a
// handler returning the wrong shape surfaces there.
return (await handler(message)) as unknown as T;
}
private _unsupported(name: string): () => never {
return () => {
throw new Error(`FakeHASS does not implement ${name}`);
};
}
private _createHASS(): HomeAssistant {
return {
auth: INERT_AUTH,
locale: INERT_LOCALE,
states: this._states,
config: this._config,
connected: this._connected,
connection: this._connection,
language: this._language,
selectedLanguage: null,
selectedTheme: null,
resources: {},
services: {},
themes: {
default_theme: 'default',
themes: {},
},
panels: {},
panelUrl: 'lovelace',
translationMetadata: { fragments: [], translations: {} },
dockedSidebar: false,
moreInfoEntityId: '',
user: {
id: 'fake-user',
is_owner: this._isAdmin,
is_admin: this._isAdmin,
name: 'Fake User',
credentials: [],
mfa_modules: [],
},
callWS: (message) => this._callWS(message),
hassUrl: (path?: string) => new URL(path ?? '/', window.location.href).href,
localize: (key: string) => key,
// Everything the card can call has to either work or fail loudly. A
// method that quietly returns nothing would let a card start depending on
// it without any test noticing.
callService: this._unsupported('callService'),
callApi: this._unsupported('callApi'),
fetchWithAuth: this._unsupported('fetchWithAuth'),
sendWS: this._unsupported('sendWS'),
};
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

+169
View File
@@ -0,0 +1,169 @@
import { css, html, LitElement, type TemplateResult } from 'lit';
import { SIDE_LOADED_ELEMENTS } from '../../src/ha/side-load-ha-elements';
/**
* The base the three `src/patches` classes subclass at runtime. Home
* Assistant's real players own a `<video>` and a transport; this carries only
* the members the patches touch, so the subclasses can be defined and
* constructed. It plays nothing.
*/
class HAPlayerStandIn extends LitElement {
static styles = css`
:host {
display: block;
}
`;
public entityid?: string;
public posterUrl?: string;
public autoPlay = false;
public muted = true;
public playsInline = true;
public controls = false;
protected _error?: string;
protected _errorIsFatal = false;
protected _loadedData(): void {
// The patches call through to this on the <video>'s `loadeddata`.
}
protected async _startWebRtc(): Promise<void> {
// WebRTC negotiation, which this stand-in does not perform.
}
protected _cleanUp(): void {
// Transport teardown, which this stand-in has none of.
}
}
/**
* A stand-in that renders its children and nothing else. Behaviour is added
* only for elements the card is observed to depend on.
*/
class HAElementStub extends LitElement {
static styles = css`
:host {
display: block;
}
`;
protected render(): TemplateResult {
return html`<slot></slot>`;
}
}
/**
* The surface a card is drawn on. Home Assistant's own `:host` styles to ensure
* the card has background, border and corners for screenshots.
*/
class HACardStub extends HAElementStub {
static styles = css`
:host {
background: var(--ha-card-background, var(--card-background-color, white));
box-shadow: var(--ha-card-box-shadow, none);
box-sizing: border-box;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
border-width: var(--ha-card-border-width, 1px);
border-style: solid;
border-color: var(--ha-card-border-color, var(--divider-color, #e0e0e0));
color: var(--primary-text-color);
display: block;
position: relative;
}
`;
}
/**
* A round tap target sized by `--ha-icon-button-size`, which is how Home
* Assistant's own icon button sizes itself and what the card sets to lay its
* menu out. Without it a button is only as big as whatever it contains.
*/
class HAIconButtonStub extends HAElementStub {
static styles = css`
:host {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--ha-icon-button-size, 48px);
height: var(--ha-icon-button-size, 48px);
border-radius: 50%;
box-sizing: border-box;
outline: none;
}
`;
}
/**
* Home Assistant fetches `mdi:` icons from Home Assistant, so there is nothing
* to draw here. It still occupies an icon's space, so that the layout around it
* is the layout a user would see.
*/
class HAIconStub extends LitElement {
static styles = css`
:host {
display: inline-block;
width: var(--mdc-icon-size, 24px);
height: var(--mdc-icon-size, 24px);
}
`;
}
/**
* Home Assistant's conditional picture element, which the card builds one of on
* every mount to host whatever picture elements are configured.
*/
class HuiConditionalElementStub extends HTMLElement {
public hass?: unknown;
public setConfig(): void {
// The card only needs the call to succeed; nothing renders from it here.
}
}
// A fresh anonymous subclass every call: `customElements.define` rejects a
// constructor that is already registered under another name.
const createStub = (element: string): CustomElementConstructor => {
switch (element) {
case 'ha-camera-stream':
case 'ha-hls-player':
case 'ha-web-rtc-player':
return class extends HAPlayerStandIn {};
case 'ha-card':
return class extends HACardStub {};
case 'ha-icon-button':
case 'ha-icon-button-prev':
return class extends HAIconButtonStub {};
case 'ha-icon':
case 'ha-state-icon':
case 'state-badge':
return class extends HAIconStub {};
case 'hui-conditional-element':
return class extends HuiConditionalElementStub {};
default:
return class extends HAElementStub {};
}
};
const defineElement = (name: string, constructor: CustomElementConstructor): void => {
if (!customElements.get(name)) {
customElements.define(name, constructor);
}
};
/**
* Define every Home Assistant element the card expects to already exist, so
* that sideLoadHomeAssistantElements() returns at its first check and never
* reaches its `picture-glance` side-load trick.
*/
export const defineHAElementStubs = (): void => {
for (const element of SIDE_LOADED_ELEMENTS) {
defineElement(element, createStub(element));
}
};
+388
View File
@@ -0,0 +1,388 @@
import { expect, onTestFinished, vi } from 'vitest';
import { ACTION_HANDLER_HOLD_SECONDS } from '../../src/action-handler-directive';
import type { AdvancedCameraCard } from '../../src/card';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
import { defineHAElementStubs } from './ha-element-stubs';
import { deepQuery, deepQueryAll } from './test-utils';
// Home Assistant's masonry columns are `max-width: 500px`, so this is the width
// a card usually gets. The card derives height from the media it is showing.
const DEFAULT_CONTAINER_WIDTH = '500px';
// The card events worth recording by default. There is no way to listen for a
// prefix, so every name a ledger reports has to be named somewhere; this is the
// set that describes what the card is doing rather than what an editor control
// was asked to do.
const DEFAULT_LEDGER_EVENTS = [
'advanced-camera-card:action:execution-request',
'advanced-camera-card:issue:notify',
'advanced-camera-card:issue:resolve',
'advanced-camera-card:issue:trigger',
'advanced-camera-card:live:error',
'advanced-camera-card:media:loaded',
'advanced-camera-card:media:pause',
'advanced-camera-card:media:play',
'advanced-camera-card:media:volumechange',
'advanced-camera-card:zoom:change',
'advanced-camera-card:zoom:unzoomed',
'advanced-camera-card:zoom:zoomed',
];
interface ConsoleEntry {
level: ConsoleLevel;
args: unknown[];
}
const CONSOLE_LEVELS = ['debug', 'error', 'info', 'log', 'warn'] as const;
type ConsoleLevel = (typeof CONSOLE_LEVELS)[number];
interface EventWaiter {
// The number of occurrences required to satisfy this waiter.
count: number;
resolve: (entry: EventEntry) => void;
}
interface EventEntry {
type: string;
detail: unknown;
target: EventTarget | null;
}
interface LabelledElement extends Element {
label?: string;
}
const hasLabel = (element: Element): element is LabelledElement => 'label' in element;
/**
* What a control calls itself to the user. The card titles the controls it
* draws itself. A menu button is Home Assistant's `ha-icon-button`, which takes
* a `label` and renders the title onto a button within its own shadow root, so
* the name that is reachable from outside is 'label' not 'title'.
*/
const getControlName = (element: Element): string | null => {
const title = element.getAttribute('title');
return title ? title : hasLabel(element) ? element.label ?? null : null;
};
/**
* Records the card events named at construction.
*/
class EventLedger {
private _entries: EventEntry[] = [];
private _target: EventTarget;
private _types: string[];
private _waiting = new Map<string, EventWaiter[]>();
private _handler = (ev: Event): void => {
const entry: EventEntry = {
type: ev.type,
detail: ev instanceof CustomEvent ? ev.detail : null,
target: ev.target,
};
this._entries.push(entry);
const seen = this.getEntries(ev.type).length;
const stillWaiting: EventWaiter[] = [];
for (const waiter of this._waiting.get(ev.type) ?? []) {
if (waiter.count <= seen) {
waiter.resolve(entry);
} else {
stillWaiting.push(waiter);
}
}
this._waiting.set(ev.type, stillWaiting);
};
constructor(target: EventTarget, types: string[]) {
this._target = target;
this._types = types;
for (const type of types) {
target.addEventListener(type, this._handler);
}
}
public getEntries(type?: string): EventEntry[] {
return type ? this._entries.filter((entry) => entry.type === type) : this._entries;
}
public async waitForFirst(type: string): Promise<EventEntry> {
return await this.waitForCount(type, 1);
}
public async waitForNext(type: string): Promise<EventEntry> {
return await this.waitForCount(type, this.getEntries(type).length + 1);
}
public async waitForCount(type: string, count: number): Promise<EventEntry> {
if (!this._types.includes(type)) {
throw new Error(`The event ledger is not recording: ${type}`);
}
const recorded = this.getEntries(type);
if (recorded.length >= count) {
return recorded[count - 1];
}
return await new Promise<EventEntry>((resolve) => {
this._waiting.set(type, [...(this._waiting.get(type) ?? []), { count, resolve }]);
});
}
public destroy(): void {
for (const type of this._types) {
this._target.removeEventListener(type, this._handler);
}
this._entries = [];
this._waiting.clear();
}
}
/**
* Records what the card writes to the console.
*
* Some of what the card reports is only ever visible there: `errorToConsole`
* is the sole outlet for many failures, and a `log` action writes its message
* directly. Absence matters as much as presence, since a pair of outcomes that
* look identical in the DOM can differ only in what was logged.
*/
class ConsoleLedger {
private _entries: ConsoleEntry[] = [];
private _originals = new Map<ConsoleLevel, (...args: unknown[]) => void>();
constructor() {
for (const level of CONSOLE_LEVELS) {
const original = console[level];
this._originals.set(level, original);
console[level] = (...args: unknown[]): void => {
this._entries.push({ level, args });
original(...args);
};
}
}
public getEntries(level?: ConsoleLevel): ConsoleEntry[] {
return level
? this._entries.filter((entry) => entry.level === level)
: this._entries;
}
public getMessages(level?: ConsoleLevel): string[] {
return this.getEntries(level).map((entry) => entry.args.map(String).join(' '));
}
public destroy(): void {
for (const [level, original] of this._originals) {
console[level] = original;
}
this._originals.clear();
this._entries = [];
}
}
export interface MountOptions {
// Event names to record alongside `DEFAULT_LEDGER_EVENTS`, for a test
// interested in something those do not name.
ledgerEvents?: string[];
// The container the card is mounted in, standing in for a dashboard column.
// Set these to put the card in a box of a particular size, for a test about
// how it responds to the room it is given.
width?: string;
height?: string;
// Console errors required to be logged.
expectedConsoleErrors?: RegExp[];
// Console errors to allow without limit. Reserve this for errors whose number
// is not the card's to control (e.g. the browsers).
toleratedConsoleErrors?: RegExp[];
}
/**
* A card in the document, driven by a `FakeHASS`, with the observers a test
* needs to watch what it does.
*/
export class MountedCard {
public readonly card: AdvancedCameraCard;
public readonly events: EventLedger;
public readonly console: ConsoleLedger;
private _container: HTMLElement;
private _hass: FakeHASS;
private _expectedConsoleErrors: RegExp[];
private _toleratedConsoleErrors: RegExp[];
/**
* The card is ready to observe but not yet rendered: initializing happens
* after this returns, so a test waits for whatever it will assert on.
*/
public static async create(
config: RawAdvancedCameraCardConfig,
hass: FakeHASS,
options?: MountOptions,
): Promise<MountedCard> {
// `src/patches` subclasses Home Assistant's three player elements as soon as
// those are defined, so the stubs must come first.
defineHAElementStubs();
await import('../../src/card');
return new MountedCard(config, hass, options);
}
private constructor(
config: RawAdvancedCameraCardConfig,
hass: FakeHASS,
options?: MountOptions,
) {
this._hass = hass;
this._container = document.createElement('div');
this._container.style.width = options?.width ?? DEFAULT_CONTAINER_WIDTH;
if (options?.height) {
this._container.style.height = options.height;
}
document.body.append(this._container);
// Before the card exists, so that nothing it does during its first render is
// missed.
this.events = new EventLedger(this._container, [
...DEFAULT_LEDGER_EVENTS,
...(options?.ledgerEvents ?? []),
]);
this.console = new ConsoleLedger();
this.card = document.createElement('advanced-camera-card');
this.card.setConfig(config);
this.card.hass = hass.getHASS();
this._container.append(this.card);
this._expectedConsoleErrors = options?.expectedConsoleErrors ?? [];
this._toleratedConsoleErrors = options?.toleratedConsoleErrors ?? [];
onTestFinished(() => this._onTestFinished());
}
/**
* Be certain of destruction, then hold the card to exactly the errors the test
* said it would provoke. A card that reported a failure has not done what a
* passing test says it did, and much of what it reports is visible nowhere
* else. An expectation that stops matching is checked too, so a test cannot go
* on claiming an error the card no longer produces.
*/
private _onTestFinished(): void {
// Read before destroying, which clears the ledger.
const logged = this.console
.getMessages('error')
.filter(
(message) =>
!this._toleratedConsoleErrors.some((tolerated) => tolerated.test(message)),
);
this.destroy();
// One error per expectation, in the order they were reported. Comparing the
// whole array rather than searching it is what makes an expectation that
// never matched, and an error logged more times than expected, both fail.
expect(logged).toEqual(
this._expectedConsoleErrors.map((expected) => expect.stringMatching(expected)),
);
}
/**
* Change an entity and hand the card the resulting `hass`. Both halves
* together, because a state the card was never given changes nothing.
*/
public setEntityState(entityID: string, state: FakeEntityOptions | string): void {
this._hass.setState(entityID, state);
this.card.hass = this._hass.getHASS();
}
/**
* Hand the card a new `hass` with nothing in it changed.
*/
public renewHASS(): void {
this._hass.renew();
this.card.hass = this._hass.getHASS();
}
/**
* Resolves once the card itself has rendered, which says nothing about the
* elements beneath it.
*/
public get updateComplete(): Promise<boolean> {
return this.card.updateComplete;
}
/**
* Move the card's clock on, then wait for the card itself to render.
*/
public async advanceSeconds(seconds: number): Promise<void> {
await vi.advanceTimersByTimeAsync(seconds * 1000);
await this.card.updateComplete;
}
/**
* Wait for something the card has rendered, searching its shadow roots.
*/
public async waitForSelector<T extends Element = Element>(
selector: string,
): Promise<T> {
return await vi.waitFor(() => {
const found = deepQuery<T>(this.card, selector);
if (!found) {
throw new Error(`No element matched: ${selector}`);
}
return found;
});
}
/**
* Click a control by the name the user sees on it, waiting for it to appear.
*/
public async clickControl(name: string): Promise<void> {
const control = await this._findControl(name);
control.click();
}
/**
* Press and keep holding a control until the card takes it as a hold, which
* is a second action several controls carry alongside their tap.
*/
public async holdControl(name: string): Promise<void> {
const control = await this._findControl(name);
control.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
await vi.advanceTimersByTimeAsync(ACTION_HANDLER_HOLD_SECONDS * 1000);
control.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
// The card takes the click, not the mouseup, as the end of a press. A real
// pointer sends both, in this order.
control.click();
}
private async _findControl(name: string): Promise<HTMLElement> {
return await vi.waitFor(() => {
const found = deepQueryAll(this.card, '*').find(
(element) => getControlName(element) === name,
);
if (!(found instanceof HTMLElement)) {
throw new Error(`Could not find control named: ${name}`);
}
return found;
});
}
public destroy(): void {
this.card.remove();
this._container.remove();
this.events.destroy();
this.console.destroy();
}
}
+49
View File
@@ -0,0 +1,49 @@
// Home Assistant's own values, so that a screenshot taken when a test fails
// looks like the thing a user would have been looking at. Taken from
// hass-frontend's theme globals: resources/theme/typography.globals.ts and
// resources/theme/color/. Only the tokens the card actually reads are here.
const HOME_ASSISTANT_THEME = `
html {
--ha-font-family-body: Roboto, Noto, sans-serif;
--primary-text-color: #141414;
--secondary-text-color: #5e5e5e;
--text-primary-color: #ffffff;
--disabled-text-color: #bdbdbd;
--primary-color: #009ac7;
--accent-color: #ff9800;
--divider-color: rgba(0, 0, 0, 0.12);
--state-icon-color: #44739e;
--error-color: #db4437;
--warning-color: #ffa600;
--success-color: #43a047;
--info-color: #039be5;
--rgb-primary-color: 0, 154, 199;
--rgb-primary-text-color: 33, 33, 33;
--rgb-secondary-text-color: 114, 114, 114;
--rgb-card-background-color: 255, 255, 255;
--card-background-color: #ffffff;
--primary-background-color: #fafafa;
--secondary-background-color: #e5e5e5;
--ha-border-radius-lg: 12px;
font-family: var(--ha-font-family-body);
color: var(--primary-text-color);
}
/* A dashboard sits the card on the background colour with room around it. */
body {
background: var(--primary-background-color);
margin: 0;
padding: 16px;
}
`;
const style = document.createElement('style');
style.textContent = HOME_ASSISTANT_THEME;
document.head.append(style);
+167
View File
@@ -0,0 +1,167 @@
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import { FakeHASS, type FakeEntityOptions } from './fake-hass';
export const STILL_CAMERA_ENTITY = 'camera.office';
const STILL_FIXTURE_FILENAME = 'still-red.png';
// A same-origin still red image, served by the Vite dev server. The same image
// is also served by the test-media plugin, which can be asked to misbehave in
// useful ways. See test-media-server-plugin.js .
const STILL_FIXTURE_URL = `/tests/browser/fixtures/${STILL_FIXTURE_FILENAME}`;
/**
* A card showing one still image and nothing else: no stream, no transport and
* no refresh timer, so what is under test is the card rather than the media.
*
* The `image` provider requires a `camera_entity` with a state even in
* `mode: url`, where nothing reads that entity. Without one it renders a
* configuration error instead of the image.
*/
export const createStillImageCameraConfig = (
cameraEntity: string = STILL_CAMERA_ENTITY,
url: string = STILL_FIXTURE_URL,
): RawAdvancedCameraCardConfig => ({
camera_entity: cameraEntity,
live_provider: 'image',
image: {
mode: 'url',
url,
refresh_seconds: 0,
},
});
const HTTP_NOT_FOUND = 404;
const HTTP_OK = 200;
/**
* A media URL answered with the given statuses in order, and never answered at
* all once they run out.
*
* Every URL carries its own counter, since one server serves a whole run and a
* shared counter would make a test depend on what ran before it.
*/
const createMediaURL = (responses: number[]): string =>
`/test-media/${STILL_FIXTURE_FILENAME}?` +
new URLSearchParams({
token: crypto.randomUUID(),
responses: responses.join(','),
}).toString();
/**
* A media URL that fails the given number of times and then works, so a test
* can make a camera recover rather than only fail.
*/
export const createTemporarilyFailingMediaURL = (failures: number): string =>
createMediaURL([...Array(failures).fill(HTTP_NOT_FOUND), HTTP_OK]);
/**
* A media URL that never works, for a camera that is simply broken.
*/
export const createFailingMediaURL = (): string => createMediaURL([HTTP_NOT_FOUND]);
/**
* A media URL that is never answered, for a camera that accepts the request and
* then says nothing. Silence is a different failure from a refusal, and the
* only one that can run a loading timeout out.
*/
export const createUnansweredMediaURL = (): string => createMediaURL([]);
/**
* A media URL that answers once and is then never answered again, for a camera
* that delivers a picture and goes quiet behind it.
*/
export const createStallingMediaURL = (): string => createMediaURL([HTTP_OK]);
export interface StillCameraHASSOptions {
// Camera entities beyond the default one.
cameras?: string[];
// Anything else the card should be able to see, as entity ID to state.
entities?: Record<string, FakeEntityOptions | string>;
}
/**
* A Home Assistant holding the cameras a card is about to be given, which is
* the minimum any browser test needs before it can mount anything.
*/
export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHASS => {
const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])];
return new FakeHASS({
entities: {
...Object.fromEntries(cameras.map((camera) => [camera, { state: 'idle' }])),
...options?.entities,
},
registry: Object.fromEntries(cameras.map((camera) => [camera, {}])),
});
};
export const createStillImageCardConfig = (
overrides?: Partial<RawAdvancedCameraCardConfig>,
): RawAdvancedCameraCardConfig => ({
type: 'custom:advanced-camera-card',
cameras: [createStillImageCameraConfig()],
// The loading screen fades out over a second and a half once the card is
// ready. Disable it to improve screenshot fidelity.
performance: { features: { card_loading_indicator: false } },
...overrides,
});
// `querySelectorAll` does not look inside a shadow root, so a full search has
// to step through them a level at a time. The node's own root counts because a
// Lit element renders into that, not into its children.
const getImmediateShadowRoots = (root: ParentNode): ShadowRoot[] => {
const roots = root instanceof Element && root.shadowRoot ? [root.shadowRoot] : [];
for (const child of root.querySelectorAll('*')) {
if (child.shadowRoot) {
roots.push(child.shadowRoot);
}
}
return roots;
};
/**
* Search an element and every shadow root beneath it. The card nests its own
* components several roots deep, and neither the source tree nor the node
* suite has a helper for this.
*/
export const deepQuery = <T extends Element = Element>(
root: ParentNode,
selector: string,
): T | null => {
const direct = root.querySelector<T>(selector);
if (direct) {
return direct;
}
for (const child of getImmediateShadowRoots(root)) {
const found = deepQuery<T>(child, selector);
if (found) {
return found;
}
}
return null;
};
/**
* Every match for a selector across an element and the shadow roots beneath it,
* for asking how many of something the card rendered rather than whether it
* rendered any.
*/
export const deepQueryAll = <T extends Element = Element>(
root: ParentNode,
selector: string,
): T[] => [
...root.querySelectorAll<T>(selector),
...getImmediateShadowRoots(root).flatMap((child) => deepQueryAll<T>(child, selector)),
];
// Everything a provider can draw media on: an image, a video, or a canvas.
const MEDIA_SELECTOR = 'img, video, canvas';
export const isLiveMediaShowing = (root: ParentNode): boolean =>
deepQueryAll(root, 'advanced-camera-card-live-provider').some(
(provider) => !!deepQuery(provider, MEDIA_SELECTOR),
);
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { MountedCard } from '../browser/mounted-card';
import {
createStillCameraHASS,
createStillImageCardConfig,
} from '../browser/test-utils';
const TRIGGER_ENTITY = 'input_boolean.zoom';
const mount = async (): Promise<MountedCard> => {
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
return await MountedCard.create(
createStillImageCardConfig({
automations: [
{
triggers: [{ trigger: 'state', entity: TRIGGER_ENTITY, to: 'on' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'ptz_digital',
absolute: { zoom: 2 },
},
],
},
],
}),
hass,
);
};
describe('AutomationsManager', () => {
it('should run an automation only when a watched entity changes', async () => {
const card = await mount();
const zoomer = await card.waitForSelector('advanced-camera-card-zoomer');
expect(zoomer.hasAttribute('zoomed')).toBe(false);
// A new `hass` carrying no change at all. A card that acted on this would
// be reacting to the object rather than to what is in it.
card.renewHASS();
await card.card.updateComplete;
expect(zoomer.hasAttribute('zoomed')).toBe(false);
// The zoom is the far end of a chain that starts at the configuration: an
// entity the card was told to watch changed, the trigger matched, and the
// action it named actually ran. It also proves the assertion above was
// reporting a card that did nothing rather than a probe that never moves.
card.setEntityState(TRIGGER_ENTITY, 'on');
await card.events.waitForFirst('advanced-camera-card:zoom:zoomed');
expect(zoomer.hasAttribute('zoomed')).toBe(true);
});
});
@@ -0,0 +1,395 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager';
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/card-controller/issues/issues/media-unavailable';
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
import { MountedCard, type MountOptions } from '../../../browser/mounted-card';
import {
createFailingMediaURL,
createStallingMediaURL,
createStillCameraHASS,
createStillImageCameraConfig,
createStillImageCardConfig,
createTemporarilyFailingMediaURL,
createUnansweredMediaURL,
deepQuery,
deepQueryAll,
isLiveMediaShowing,
STILL_CAMERA_ENTITY,
} from '../../../browser/test-utils';
const SECOND_CAMERA_ENTITY = 'camera.hallway';
const REPORT_TITLE = 'Media unavailable';
// Holding this reaches the diagnostics view, the only view showing no media
// that a camera without a media browsing engine can get to.
const IRIS_CONTROL = 'Iris / Default View / Unhide menu';
// Only the status bar counts as the report. The notification behind it carries
// the same title, so a wider search would answer a different question.
const findReport = (card: MountedCard): Element | null =>
deepQuery(card.card, 'advanced-camera-card-status-bar')?.shadowRoot?.querySelector(
`[title="${REPORT_TITLE}"]`,
) ?? null;
const isIssueReported = (card: MountedCard): boolean => !!findReport(card);
const waitForIssueReported = async (card: MountedCard): Promise<void> => {
await vi.waitFor(() => {
if (!findReport(card)) {
throw new Error(`The issue was not reported: ${REPORT_TITLE}`);
}
});
};
interface MountCardOptions extends MountOptions {
cameras?: string[];
}
/**
* Every test here needs the status bar rendered, since that is where the report
* appears.
*/
const mountCard = async (
config?: Partial<RawAdvancedCameraCardConfig>,
options?: MountCardOptions,
): Promise<MountedCard> => {
const { cameras, ...mountOptions } = options ?? {};
return await MountedCard.create(
createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }),
createStillCameraHASS({ cameras }),
mountOptions,
);
};
const mountCardSingleCamera = async (): Promise<MountedCard> => {
const card = await mountCard();
await card.events.waitForFirst('advanced-camera-card:media:loaded');
return card;
};
const mountCardDualCameras = async (): Promise<MountedCard> => {
const card = await mountCard(
{
live: { display: { mode: 'grid' } },
cameras: [
createStillImageCameraConfig(),
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
],
},
{
cameras: [SECOND_CAMERA_ENTITY],
// The grid observes both its own size and its cells', so a cell resize
// can resize the host and vice versa. Chromium reports each round it has
// to defer as an uncaught error. How many rounds that takes follows the
// browser's frame scheduling, not the card: measured anywhere between
// none and three for the same test. So it cannot be counted, only
// tolerated, and only for the grid: anywhere else it would mean
// something new.
toleratedConsoleErrors: [/ResizeObserver loop completed/],
},
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
return card;
};
const getNotificationText = (card: MountedCard): string =>
deepQuery(card.card, 'advanced-camera-card-notification-block')?.shadowRoot
?.textContent ?? '';
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('MediaUnavailableIssue', () => {
it('should not report a healthy camera', async () => {
const card = await mountCardSingleCamera();
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
expect(isIssueReported(card)).toBe(false);
});
it('should wait out the grace period before reporting an unavailable camera', async () => {
const card = await mountCardSingleCamera();
card.setEntityState(STILL_CAMERA_ENTITY, 'unavailable');
// Just short of the grace period: reporting here would alarm on a camera
// that is about to come back.
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS - 1);
expect(isIssueReported(card)).toBe(false);
await card.advanceSeconds(1);
expect(isIssueReported(card)).toBe(true);
});
it('should never report a camera that recovers within the grace period', async () => {
const card = await mountCardSingleCamera();
card.setEntityState(STILL_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS - 1);
card.setEntityState(STILL_CAMERA_ENTITY, 'idle');
// Well past the point the report would have appeared had the blip not
// ended. Nothing should ever have been shown.
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
expect(isIssueReported(card)).toBe(false);
});
it('should name the camera that failed and why', async () => {
const card = await mountCardDualCameras();
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
// Which camera, not just that something is wrong: with several on screen a
// report that does not say which one leaves the user to guess.
expect(getNotificationText(card)).toContain('Camera entity unavailable');
expect(getNotificationText(card)).toContain(SECOND_CAMERA_ENTITY);
});
it('should leave the cameras that are still working alone', async () => {
const card = await mountCardDualCameras();
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
// One camera failing must not take the other down with it.
expect(
deepQueryAll(card.card, 'advanced-camera-card-notification-block'),
).toHaveLength(1);
expect(isLiveMediaShowing(card.card)).toBe(true);
});
it('should report a camera whose media fails to load', async () => {
const card = await mountCard({
cameras: [
createStillImageCameraConfig(STILL_CAMERA_ENTITY, createFailingMediaURL()),
],
});
// The entity is present and healthy, so the failed fetch is the only thing
// that can be reported. It is reported as soon as the load fails rather
// than after the loading timeout, because a failure is not a slow load.
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getNotificationText(card)).toContain('Could not load image');
expect(getNotificationText(card)).toContain(STILL_CAMERA_ENTITY);
});
it('should clear the report once the camera delivers media again', async () => {
const card = await mountCard({
cameras: [
createStillImageCameraConfig(
STILL_CAMERA_ENTITY,
createTemporarilyFailingMediaURL(1),
),
],
});
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
// Nothing here asks the card to try again. A camera that has come back must
// be picked up by the card's own retry, or the report stays up forever for
// a user who is looking at a working camera.
await card.advanceSeconds(RETRY_EXPONENTIAL_BASE_SECONDS);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
expect(isIssueReported(card)).toBe(false);
// The picture is back, so the cleared report is not the card having thrown
// the whole live view away.
expect(isLiveMediaShowing(card.card)).toBe(true);
});
it('should wait out the loading timeout before reporting a slow camera', async () => {
const card = await mountCard({
cameras: [
createStillImageCameraConfig(STILL_CAMERA_ENTITY, createUnansweredMediaURL()),
],
});
// Nothing is waited on until there is a player asking for media, so let one
// render before the clock is run forward.
await card.waitForSelector('img');
// A camera answering slowly is not a camera that has failed. Reporting here
// would fire on every sluggish connection.
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS - 1);
expect(isIssueReported(card)).toBe(false);
// Past the timeout, silence is indistinguishable from failure and has to be
// reported: nothing else will ever say so, since the request never answers
// and there is no error to catch.
await card.advanceSeconds(1);
expect(isIssueReported(card)).toBe(true);
});
it('should keep retrying a camera that is still broken', async () => {
// Must use the real clock: fake time moves the card's timers instantly, so
// a request would never get a chance to answer between one retry and the
// next.
vi.useRealTimers();
const card = await mountCard({
// A fixed interval rather than the default backoff, which jitters every
// delay by a random half. Backoff itself is tested in unittests. Short,
// because this is real time, but long enough that a failed request is
// reported before the next attempt replaces it.
//
// Caution: As this test must use the real clock, this directly adds to
// the test runtime.
view: { issues: { retry_seconds: 0.1 } },
cameras: [
createStillImageCameraConfig(
STILL_CAMERA_ENTITY,
createTemporarilyFailingMediaURL(2),
),
],
});
// Two failures: the first attempt, and a retry after it. Giving up after
// one would leave a camera dark that was about to come back.
await card.events.waitForCount('advanced-camera-card:issue:trigger', 2);
await waitForIssueReported(card);
// The third attempt is served.
await card.events.waitForFirst('advanced-camera-card:media:loaded');
expect(isIssueReported(card)).toBe(false);
expect(isLiveMediaShowing(card.card)).toBe(true);
// Exactly the two attempts that failed, so the retry ran once rather than
// spinning until something happened to work.
expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(2);
});
it('should re-attempt when the retry control is used', async () => {
const card = await mountCard({
// Automatic retries switched off.
view: { issues: { retry_seconds: 0 } },
cameras: [
createStillImageCameraConfig(
STILL_CAMERA_ENTITY,
createTemporarilyFailingMediaURL(1),
),
],
});
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
// The status bar only summarises. Everything a user can do about the
// failure is behind it, which is the point of the report being clickable.
await card.clickControl(REPORT_TITLE);
await card.clickControl('Retry');
await card.events.waitForFirst('advanced-camera-card:media:loaded');
expect(isIssueReported(card)).toBe(false);
expect(isLiveMediaShowing(card.card)).toBe(true);
});
it('should not report while a non-media view is showing', async () => {
const card = await mountCard({
menu: { style: 'outside' },
cameras: [
createStillImageCameraConfig(STILL_CAMERA_ENTITY, createFailingMediaURL()),
],
});
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
// Diagnostics shows no media at all, so there is nothing for the report to
// be about and complaining there would be noise on an unrelated screen.
await card.holdControl(IRIS_CONTROL);
await card.waitForSelector('advanced-camera-card-diagnostics');
expect(isIssueReported(card)).toBe(false);
// Returning to the camera brings the report back. Leaving the view is not
// an answer to the failure, and coming back to a silently broken camera
// would be worse than never having been told.
await card.clickControl('Live view');
await waitForIssueReported(card);
expect(isIssueReported(card)).toBe(true);
});
it('should report media that stalls after it has loaded', async () => {
const refreshSeconds = 2;
const card = await mountCard({
cameras: [
{
camera_entity: STILL_CAMERA_ENTITY,
live_provider: 'image',
image: {
mode: 'url',
// The first request is answered and every one after it is left
// hanging, which is a camera that delivered a picture and then went
// quiet. One that refused outright would be reported as a failure
// rather than a stall.
url: createStallingMediaURL(),
refresh_seconds: refreshSeconds,
},
},
],
});
await card.events.waitForFirst('advanced-camera-card:media:loaded');
// One missed refresh is not a stall. A camera gets a whole refresh interval
// on top of the standard window before silence is held against it.
await card.advanceSeconds(refreshSeconds + FRAME_STALL_SECONDS - 1);
expect(isIssueReported(card)).toBe(false);
// Past it. The window is measured from a real media load rather than from
// anything on the card's clock, so landing exactly on the deadline is a
// race: step over it instead.
await card.advanceSeconds(2);
expect(isIssueReported(card)).toBe(true);
// Stalled rather than failed: the picture on screen is real but frozen, and
// saying so is the difference between "this is old" and "this is broken".
expect(getNotificationText(card)).toContain('Stream stalled');
});
it('should report a player that reports a playback error', async () => {
const card = await mountCard({
// A provider given nothing to play. It reports that it failed without
// saying why, which is what a playback error is: every other reason
// here is one the card was able to name.
cameras: [{ camera_entity: STILL_CAMERA_ENTITY, live_provider: 'go2rtc' }],
});
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
await waitForIssueReported(card);
expect(getNotificationText(card)).toContain('Could not get camera endpoint');
await card.clickControl(REPORT_TITLE);
await card.waitForSelector('advanced-camera-card-notification');
expect(
deepQuery(card.card, 'advanced-camera-card-notification')?.shadowRoot?.textContent,
).toContain('Playback error');
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import type { MediaLoadedInfoEventDetail } from '../../src/types';
import { MountedCard } from '../browser/mounted-card';
import {
createStillCameraHASS,
createStillImageCardConfig,
STILL_CAMERA_ENTITY,
} from '../browser/test-utils';
const UNRELATED_ENTITY = 'input_boolean.unrelated';
// The size of the static image fixture the player loads. Everything downstream
// sizes the card from what the player measured, so these are read from the
// media rather than declared anywhere in the configuration.
const FIXTURE_WIDTH = 320;
const FIXTURE_HEIGHT = 180;
interface RenderedElement extends Element {
updateComplete: Promise<boolean>;
}
const mount = async (): Promise<MountedCard> => {
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
return await MountedCard.create(createStillImageCardConfig(), hass);
};
const isMediaLoadedInfoEventDetail = (
detail: unknown,
): detail is MediaLoadedInfoEventDetail =>
!!detail &&
typeof detail === 'object' &&
'info' in detail &&
'signal' in detail &&
detail.signal instanceof AbortSignal;
const getMediaLoadedInfos = (card: MountedCard): MediaLoadedInfoEventDetail[] =>
card.events
.getEntries('advanced-camera-card:media:loaded')
.map((entry) => entry.detail)
.filter(isMediaLoadedInfoEventDetail);
describe('AdvancedCameraCardImageUpdatingPlayer', () => {
it('should announce the media load once even when the card re-renders', async () => {
const card = await mount();
const player = await card.waitForSelector<RenderedElement>(
'advanced-camera-card-image-updating-player',
);
await card.events.waitForFirst('advanced-camera-card:media:loaded');
// Something changes that the media knows nothing about. Announcing the
// media load here would mean the player had quietly reloaded, which is how
// a stream that churns looks from the outside.
card.setEntityState(UNRELATED_ENTITY, 'on');
// The card renders, and then the player does: each level only asks the next
// to render once it has rendered itself, so a second announcement would
// arrive by the end of this.
await card.updateComplete;
await player.updateComplete;
const loads = getMediaLoadedInfos(card);
expect(loads).toHaveLength(1);
expect(loads[0].info.targetID).toBe(STILL_CAMERA_ENTITY);
});
it('should announce the size of the media itself', async () => {
const card = await mount();
await card.events.waitForFirst('advanced-camera-card:media:loaded');
const [load] = getMediaLoadedInfos(card);
expect(load.info.width).toBe(FIXTURE_WIDTH);
expect(load.info.height).toBe(FIXTURE_HEIGHT);
});
});
+2 -1
View File
@@ -2,6 +2,7 @@ import type { HassConfig } from 'home-assistant-js-websocket';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { RELEASE_VERSION_TOKEN } from '../../scripts/release-version.js';
import type { DeviceRegistryManager } from '../../src/ha/registry/device';
import { homeAssistantWSRequest } from '../../src/ha/ws-request';
import { getLanguage } from '../../src/localize/localize';
@@ -22,7 +23,7 @@ vi.mock('../../src/ha/ws-request.js');
describe('getReleaseVersion', () => {
it('should get release version', () => {
expect(getReleaseVersion()).toBe('__ADVANCED_CAMERA_CARD_RELEASE_VERSION__');
expect(getReleaseVersion()).toBe(RELEASE_VERSION_TOKEN);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
import { releaseVersion } from './scripts/release-version-plugin.js';
import { scssString } from './scripts/scss-string-plugin.js';
import { svgPath } from './scripts/svg-path-plugin.js';
import { testMediaServer } from './scripts/test-media-server-plugin.js';
// Browser tests mount the real card in Chromium. They live in their own config
// rather than as a fourth project in `vitest.config.ts` because `vitest run`
// executes every configured project: without splitting browser tests would
// become a way to satisfy the 100% per-file thresholds vs unittests.
export default defineConfig({
// Tests import `src/` directly rather than a built bundle, so a test and the
// card share one process and one set of objects. Vite then has to supply the
// same asset shapes the Rollup build's plugins do: an SVG becomes the `{
// path, viewBox }` a custom iconset serves, SCSS becomes the string
// `unsafeCSS` takes. `svgPath` is the build's own plugin, reused unchanged.
plugins: [releaseVersion(), scssString(), svgPath(), testMediaServer()],
resolve: {
// Several dependencies declare their own Lit. Two copies in one page do not
// recognise each other's directives and template results, which surfaces as
// "Multiple versions of Lit loaded" followed by render-time type errors.
// The build gets one copy from Rollup's resolver; Vite has to be told.
dedupe: ['lit', 'lit-html', 'lit-element', '@lit/reactive-element'],
},
optimizeDeps: {
// `dedupe` above is not enough on its own: Vite also bundles dependencies
// ahead of time, and each of those bundles carries its own copy of Lit.
// Listed here, they are served as source and import the same Lit as `src/`.
exclude: [
'@lit-labs/scoped-registry-mixin',
'@lit-labs/task',
'@lit/reactive-element',
'lit',
'lit-element',
'lit-html',
],
},
server: {
// Vite mirrors the page's console into the terminal, which for a mounted
// card is a stream of Lit development warnings on every run. The page's
// console is still intercepted in-page, where a test can assert on it.
forwardConsole: false,
},
css: {
preprocessorOptions: {
scss: {
// A couple of stylesheets are pulled in by package name (e.g. `@use
// '@graphiteds/core/css/core.css'`). sass resolves a bare name like
// that only if node_modules is on its load path. The build passes the
// same directory to `rollup-plugin-styler`.
loadPaths: ['./node_modules/'],
},
},
},
test: {
name: 'browser',
include: ['tests/**/*.browser.test.ts'],
// Cosmetic: Style the pages as HA does for screenshots.
setupFiles: ['./tests/browser/style.ts'],
// A failing test leaves a screenshot and any attachments behind. Both
// default to somewhere else -- a `__screenshots__` directory next to the
// test file, and `.vitest-attachments` at the root -- so they are pointed
// at one directory that `.gitignore` can name once.
attachmentsDir: '.vitest/attachments',
server: {
deps: {
// These dependencies import without extensions.
// Related: https://github.com/vitest-dev/vitest/issues/2313
inline: ['ha-nunjucks', 'ts-py-datetime'],
},
},
browser: {
enabled: true,
provider: playwright(),
headless: true,
// An ordinary desktop window. The default is a phone (414x896), which
// would put every test on the card's narrow-screen paths and crop the
// screenshot taken when one fails.
viewport: { width: 1280, height: 800 },
instances: [{ browser: 'chromium' }],
screenshotDirectory: '.vitest/screenshots',
},
// Hide console writing to keep output clean, as the unit tests do. What
// the card writes is still captured in the page, where a test can assert on
// it -- a `log` action is only observable there.
onConsoleLog: () => false,
},
});
+11 -3
View File
@@ -24,13 +24,21 @@ const EXCLUSIONS = [
const TEST_DIRECTORY = 'tests';
// Tests that mount the card in a real browser. They run from
// `vitest.browser.config.ts` and cannot run in any of the projects below, so
// they are kept out of the sweep.
const BROWSER_TEST_SUFFIX = '.browser.test.ts';
const findTestFiles = (directory: string): string[] => {
const files: string[] = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...findTestFiles(path));
} else if (entry.name.endsWith('.test.ts')) {
} else if (
entry.name.endsWith('.test.ts') &&
!entry.name.endsWith(BROWSER_TEST_SUFFIX)
) {
files.push(path);
}
}
@@ -78,7 +86,7 @@ export default defineConfig({
// Nothing stops these sharing, so they run against a single loaded
// copy of the source tree. Most of the suite is here, and anything
// moved out of here pays to import that tree again.
name: 'shared-node',
name: 'shared',
include: getInclusions('shared-node'),
isolate: false,
},
@@ -100,7 +108,7 @@ export default defineConfig({
//
// They share one `document` as well, so a file that redefines part of
// it must leave the property configurable for the files that follow.
name: 'shared-jsdom',
name: 'shared (jsdom)',
include: getInclusions('shared-jsdom'),
environment: 'jsdom',
isolate: false,
+141
View File
@@ -195,6 +195,13 @@ __metadata:
languageName: node
linkType: hard
"@blazediff/core@npm:1.9.1":
version: 1.9.1
resolution: "@blazediff/core@npm:1.9.1"
checksum: 10c0/fd45cdd0544002341d74831a179ef693a81414abd348c1ff0c01086c0ea03f5e5ee284c4e16c2e6fb3670c265f90a3d85752b9360320efa9a835928e604dae77
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
@@ -1333,6 +1340,13 @@ __metadata:
languageName: node
linkType: hard
"@polka/url@npm:^1.0.0-next.24":
version: 1.0.0-next.29
resolution: "@polka/url@npm:1.0.0-next.29"
checksum: 10c0/0d58e081844095cb029d3c19a659bfefd09d5d51a2f791bc61eba7ea826f13d6ee204a8a448c2f5a855c17df07b37517373ff916dd05801063c0568ae9937684
languageName: node
linkType: hard
"@popperjs/core@npm:^2.11.5":
version: 2.11.8
resolution: "@popperjs/core@npm:2.11.8"
@@ -2148,6 +2162,41 @@ __metadata:
languageName: node
linkType: hard
"@vitest/browser-playwright@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/browser-playwright@npm:4.1.10"
dependencies:
"@vitest/browser": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
playwright: "*"
vitest: 4.1.10
peerDependenciesMeta:
playwright:
optional: false
checksum: 10c0/161530da4da9c061875c0e80c2c94e93658bf92514f2e5173c04299e17a6021feb209ef8fb5e14e880c8c8b2b9e7fc4f3ec7a346c8c4a77831864597331257fc
languageName: node
linkType: hard
"@vitest/browser@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/browser@npm:4.1.10"
dependencies:
"@blazediff/core": "npm:1.9.1"
"@vitest/mocker": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
magic-string: "npm:^0.30.21"
pngjs: "npm:^7.0.0"
sirv: "npm:^3.0.2"
tinyrainbow: "npm:^3.1.0"
ws: "npm:^8.19.0"
peerDependencies:
vitest: 4.1.10
checksum: 10c0/61c86b8c0fcc78bd01de559914525e768dc16d565ee8a40a41d51ef6d99595c3c5e976defe32d090b3dda5f77a980caa11a2072e9c4d279cfb8d55d31c565fc7
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:^4.1.10":
version: 4.1.10
resolution: "@vitest/coverage-v8@npm:4.1.10"
@@ -2346,6 +2395,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": "npm:^8.30.1"
"@typescript-eslint/parser": "npm:^8.30.1"
"@use-gesture/vanilla": "npm:^10.3.1"
"@vitest/browser-playwright": "npm:4.1.10"
"@vitest/coverage-v8": "npm:^4.1.10"
any-date-parser: "npm:^2.2.0"
component-emitter: "npm:^1.3.1"
@@ -2368,6 +2418,7 @@ __metadata:
masonry-layout: "npm:^4.2.2"
moment: "npm:^2.30.1"
p-queue: "npm:^8.0.1"
playwright: "npm:1.62.0"
prettier: "npm:^3.3.2"
propagating-hammerjs: "npm:^2.0.1"
quick-lru: "npm:^6.1.2"
@@ -4636,6 +4687,16 @@ __metadata:
languageName: node
linkType: hard
"fsevents@npm:2.3.2":
version: 2.3.2
resolution: "fsevents@npm:2.3.2"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/be78a3efa3e181cda3cf7a4637cb527bcebb0bd0ea0440105a3bb45b86f9245b307dc10a2507e8f4498a7d4ec349d1910f4d73e4d4495b16103106e07eee735b
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
@@ -4646,6 +4707,15 @@ __metadata:
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
dependencies:
node-gyp: "npm:latest"
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
@@ -6779,6 +6849,13 @@ __metadata:
languageName: node
linkType: hard
"mrmime@npm:^2.0.0":
version: 2.0.1
resolution: "mrmime@npm:2.0.1"
checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761
languageName: node
linkType: hard
"ms@npm:2.0.0":
version: 2.0.0
resolution: "ms@npm:2.0.0"
@@ -7934,6 +8011,37 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.62.0":
version: 1.62.0
resolution: "playwright-core@npm:1.62.0"
bin:
playwright-core: cli.js
checksum: 10c0/bc7c770cc4118a7ea65d2c357fc271aaef49af6b5b8964aa19d765d4ea3473e56af926f693123fd63ed6e580830059905722ac68fbf22a31a5b1533e11600228
languageName: node
linkType: hard
"playwright@npm:1.62.0":
version: 1.62.0
resolution: "playwright@npm:1.62.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.62.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/14d2d2c35e4ef88cf69d9e6c5bf351ff47fe9cd00da09da9f48da8aaf75ebdb266330c073cb217821785fb35d7e5d9d89789220b208329e4934aaaa43da260f3
languageName: node
linkType: hard
"pngjs@npm:^7.0.0":
version: 7.0.0
resolution: "pngjs@npm:7.0.0"
checksum: 10c0/0d4c7a0fd476a9c33df7d0a2a73e1d56537628a668841f6995c2bca070cf30819f9254a64363266bc14ef2fee47659dd3b4f2b18eec7ab65143015139f497b38
languageName: node
linkType: hard
"postcss-calc@npm:^9.0.1":
version: 9.0.1
resolution: "postcss-calc@npm:9.0.1"
@@ -9229,6 +9337,17 @@ __metadata:
languageName: node
linkType: hard
"sirv@npm:^3.0.2":
version: 3.0.2
resolution: "sirv@npm:3.0.2"
dependencies:
"@polka/url": "npm:^1.0.0-next.24"
mrmime: "npm:^2.0.0"
totalist: "npm:^3.0.0"
checksum: 10c0/5930e4397afdb14fbae13751c3be983af4bda5c9aadec832607dc2af15a7162f7d518c71b30e83ae3644b9a24cea041543cc969e5fe2b80af6ce8ea3174b2d04
languageName: node
linkType: hard
"skin-tone@npm:^2.0.0":
version: 2.0.0
resolution: "skin-tone@npm:2.0.0"
@@ -9853,6 +9972,13 @@ __metadata:
languageName: node
linkType: hard
"totalist@npm:^3.0.0":
version: 3.0.1
resolution: "totalist@npm:3.0.1"
checksum: 10c0/4bb1fadb69c3edbef91c73ebef9d25b33bbf69afe1e37ce544d5f7d13854cda15e47132f3e0dc4cafe300ddb8578c77c50a65004d8b6e97e77934a69aa924863
languageName: node
linkType: hard
"tough-cookie@npm:^4.1.2":
version: 4.1.3
resolution: "tough-cookie@npm:4.1.3"
@@ -10690,6 +10816,21 @@ __metadata:
languageName: node
linkType: hard
"ws@npm:^8.19.0":
version: 8.21.1
resolution: "ws@npm:8.21.1"
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ">=5.0.2"
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
checksum: 10c0/c4c6f1d95f6d465262de2037c57c715725d67e078dd49420ede4e19115668aba159cb64d85c5e89c06eb2826be599e62e5095860ad6cc54ff42e8bd7684e1db8
languageName: node
linkType: hard
"xdg-basedir@npm:^4.0.0":
version: 4.0.0
resolution: "xdg-basedir@npm:4.0.0"