test: Add a browser based test harness (#2641)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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__';
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user