test: Add a test harness for the built card (#2653)
Mounts the card from `dist/` the way Home Assistant does, by URL with a HACS tag, and asserts the entry stays a facade, that no chunk imports it, that the browser is served the file unmodified, and that the card starts up and fetches a language chunk lazily. Runs in Chromium, Firefox and WebKit, so a change of bundler or minifier is checked against every engine.
This commit is contained in:
@@ -72,6 +72,7 @@
|
|||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||||
"docsify-cli": "^4.4.4",
|
"docsify-cli": "^4.4.4",
|
||||||
|
"es-module-lexer": "^2.3.1",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"jsdom": "^21.1.2",
|
"jsdom": "^21.1.2",
|
||||||
@@ -193,6 +194,7 @@
|
|||||||
"prune": "knip",
|
"prune": "knip",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:browser": "vitest run --config vitest.browser.config.ts",
|
"test:browser": "vitest run --config vitest.browser.config.ts",
|
||||||
|
"test:dist": "vitest run --config vitest.dist.config.ts",
|
||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const BROWSERS = ['chromium', 'firefox', 'webkit'] as const;
|
||||||
|
|
||||||
|
export type Browser = (typeof BROWSERS)[number];
|
||||||
|
|
||||||
|
const isValidBrowser = (name: string): name is Browser =>
|
||||||
|
BROWSERS.some((browser) => browser === name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which browsers to run, all of them unless one is named. CI names one per job
|
||||||
|
* so that the three run at the same time on separate machines rather than one
|
||||||
|
* after another on one.
|
||||||
|
*
|
||||||
|
* Shared by every suite that mounts the card, so that naming a browser means
|
||||||
|
* the same thing to each of them.
|
||||||
|
*/
|
||||||
|
export const getBrowsers = (): readonly Browser[] => {
|
||||||
|
const requested = process.env.VITEST_BROWSER;
|
||||||
|
if (requested === undefined) {
|
||||||
|
return BROWSERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidBrowser(requested)) {
|
||||||
|
throw new Error(`Unknown browser: ${requested}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [requested];
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { init, parse } from 'es-module-lexer';
|
||||||
|
|
||||||
|
const DIST_DIRECTORY = 'dist';
|
||||||
|
|
||||||
|
const listFiles = () =>
|
||||||
|
existsSync(DIST_DIRECTORY)
|
||||||
|
? readdirSync(DIST_DIRECTORY, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isFile())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a file imports, split into static imports, which the browser fetches
|
||||||
|
* before the file runs, and dynamic ones, which it fetches only when reached.
|
||||||
|
*/
|
||||||
|
const getImports = async (source) => {
|
||||||
|
await init;
|
||||||
|
const [imports] = parse(source);
|
||||||
|
|
||||||
|
// `n` is the file named in the import, and is absent when that name is put
|
||||||
|
// together at runtime, which names no one file.
|
||||||
|
const named = imports.filter((entry) => entry.n);
|
||||||
|
|
||||||
|
// `d` says how it is imported: -1 static, -2 `import.meta`, otherwise the
|
||||||
|
// position of the `import(`.
|
||||||
|
return {
|
||||||
|
staticImports: named.filter((entry) => entry.d === -1).map((entry) => entry.n),
|
||||||
|
dynamicImports: named.filter((entry) => entry.d >= 0).map((entry) => entry.n),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facts about the built output, gathered in Node and handed to a test running
|
||||||
|
* in the browser.
|
||||||
|
*
|
||||||
|
* The imports are parsed rather than searched for: the card holds its own
|
||||||
|
* filenames as plain strings, which a text search would mistake for imports.
|
||||||
|
*
|
||||||
|
* @type {Record<string, import('vitest/node').BrowserCommand<[], unknown>>}
|
||||||
|
*/
|
||||||
|
export const distCommands = {
|
||||||
|
listDistFiles: () => listFiles(),
|
||||||
|
|
||||||
|
getDistImportGraph: async () => {
|
||||||
|
const graph = {};
|
||||||
|
for (const file of listFiles().filter((name) => name.endsWith('.js'))) {
|
||||||
|
graph[file] = await getImports(
|
||||||
|
readFileSync(path.resolve(DIST_DIRECTORY, file), 'utf8'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return graph;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type DirectiveParameters,
|
type DirectiveParameters,
|
||||||
} from 'lit/directive.js';
|
} from 'lit/directive.js';
|
||||||
|
|
||||||
|
import { ACTION_HANDLER_HOLD_SECONDS } from './const.js';
|
||||||
import { fireHASSEvent } from './ha/fire-hass-event.js';
|
import { fireHASSEvent } from './ha/fire-hass-event.js';
|
||||||
import type { ActionHandlerDetail, ActionHandlerOptions } from './ha/types.js';
|
import type { ActionHandlerDetail, ActionHandlerOptions } from './ha/types.js';
|
||||||
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
||||||
@@ -24,9 +25,6 @@ interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions {
|
|||||||
allowPropagation?: boolean;
|
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 {
|
class ActionHandler extends HTMLElement implements ActionHandlerInterface {
|
||||||
public holdTime = ACTION_HANDLER_HOLD_SECONDS;
|
public holdTime = ACTION_HANDLER_HOLD_SECONDS;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export const TROUBLESHOOTING_LEGACY_RESOURCE_URL =
|
|||||||
export const TROUBLESHOOTING_MEDIA_URL =
|
export const TROUBLESHOOTING_MEDIA_URL =
|
||||||
`${TROUBLESHOOTING_URL}?id=media-unavailable` as const;
|
`${TROUBLESHOOTING_URL}?id=media-unavailable` as const;
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Interaction Constants
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// How long a press must last to count as a hold rather than a tap.
|
||||||
|
export const ACTION_HANDLER_HOLD_SECONDS = 0.4;
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Media Constants
|
// Media Constants
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { expect, onTestFinished, vi } from 'vitest';
|
import { expect, onTestFinished, vi } from 'vitest';
|
||||||
|
|
||||||
import { ACTION_HANDLER_HOLD_SECONDS } from '../../src/action-handler-directive';
|
|
||||||
import type { AdvancedCameraCard } from '../../src/card';
|
import type { AdvancedCameraCard } from '../../src/card';
|
||||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||||
|
import { ACTION_HANDLER_HOLD_SECONDS } from '../../src/const';
|
||||||
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
|
import type { FakeEntityOptions, FakeHASS } from './fake-hass';
|
||||||
import { defineHAElementStubs } from './ha-element-stubs';
|
import { defineHAElementStubs } from './ha-element-stubs';
|
||||||
import { clickElement, deepQuery, deepQueryAll, getAllShadowRoots } from './test-utils';
|
import { clickElement, deepQuery, deepQueryAll, getAllShadowRoots } from './test-utils';
|
||||||
@@ -400,21 +400,24 @@ export class MountedCard {
|
|||||||
/**
|
/**
|
||||||
* The card is ready to observe but not yet rendered: initializing happens
|
* 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.
|
* after this returns, so a test waits for whatever it will assert on.
|
||||||
|
*
|
||||||
|
* `loadCard` puts the card's elements into the page. Where they come from is
|
||||||
|
* the caller's to decide: `MountedCardFactory` takes them from `src/`, and the
|
||||||
|
* suite covering a build ("dist") takes them from the file the build
|
||||||
|
* produced.
|
||||||
*/
|
*/
|
||||||
public static async create(
|
public static async create(
|
||||||
|
loadCard: () => Promise<unknown>,
|
||||||
config: RawAdvancedCameraCardConfig,
|
config: RawAdvancedCameraCardConfig,
|
||||||
hass: FakeHASS,
|
hass: FakeHASS,
|
||||||
options?: MountOptions,
|
options?: MountOptions,
|
||||||
): Promise<MountedCard> {
|
): Promise<MountedCard> {
|
||||||
// `src/patches` subclasses Home Assistant's three player elements as soon as
|
await loadCard();
|
||||||
// those are defined, so the stubs must come first.
|
|
||||||
defineHAElementStubs();
|
|
||||||
await import('../../src/card');
|
|
||||||
|
|
||||||
return new MountedCard(config, hass, options);
|
return new MountedCard(config, hass, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(
|
protected constructor(
|
||||||
config: RawAdvancedCameraCardConfig,
|
config: RawAdvancedCameraCardConfig,
|
||||||
hass: FakeHASS,
|
hass: FakeHASS,
|
||||||
options?: MountOptions,
|
options?: MountOptions,
|
||||||
@@ -613,3 +616,24 @@ export class MountedCard {
|
|||||||
this.console.destroy();
|
this.console.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MountedCardFactory {
|
||||||
|
// A card built from `src/`. Constrast with dist.browsers.test.ts .
|
||||||
|
public static async createFromSource(
|
||||||
|
config: RawAdvancedCameraCardConfig,
|
||||||
|
hass: FakeHASS,
|
||||||
|
options?: MountOptions,
|
||||||
|
): Promise<MountedCard> {
|
||||||
|
return await MountedCard.create(
|
||||||
|
async () => {
|
||||||
|
// `src/patches` subclasses Home Assistant's three player elements as
|
||||||
|
// soon as those are defined, so the stubs must come first.
|
||||||
|
defineHAElementStubs();
|
||||||
|
await import('../../src/card');
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
hass,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export interface StillCameraHASSOptions {
|
|||||||
|
|
||||||
// Anything else the card should be able to see, as entity ID to state.
|
// Anything else the card should be able to see, as entity ID to state.
|
||||||
entities?: Record<string, FakeEntityOptions | string>;
|
entities?: Record<string, FakeEntityOptions | string>;
|
||||||
|
|
||||||
|
// The language Home Assistant is set to for translation tests.
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +114,7 @@ export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHAS
|
|||||||
...options?.entities,
|
...options?.entities,
|
||||||
},
|
},
|
||||||
registry: Object.fromEntries(cameras.map((camera) => [camera, {}])),
|
registry: Object.fromEntries(cameras.map((camera) => [camera, {}])),
|
||||||
|
...(options?.language && { language: options.language }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { MountedCard } from '../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
createStillCameraHASS,
|
createStillCameraHASS,
|
||||||
createStillImageCardConfig,
|
createStillImageCardConfig,
|
||||||
@@ -10,7 +10,7 @@ const TRIGGER_ENTITY = 'input_boolean.zoom';
|
|||||||
|
|
||||||
const mount = async (): Promise<MountedCard> => {
|
const mount = async (): Promise<MountedCard> => {
|
||||||
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
|
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
|
||||||
return await MountedCard.create(
|
return await MountedCardFactory.createFromSource(
|
||||||
createStillImageCardConfig({
|
createStillImageCardConfig({
|
||||||
automations: [
|
automations: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, onTestFinished } from 'vitest';
|
import { describe, expect, it, onTestFinished } from 'vitest';
|
||||||
|
|
||||||
import { createLogAction } from '../../src/utils/action';
|
import { createLogAction } from '../../src/utils/action';
|
||||||
import { MountedCard } from '../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
CARD_INITIALIZED_MESSAGE,
|
CARD_INITIALIZED_MESSAGE,
|
||||||
createInitializedAutomation,
|
createInitializedAutomation,
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
const KEY_MESSAGE = /key pressed/;
|
const KEY_MESSAGE = /key pressed/;
|
||||||
|
|
||||||
const mountCard = async (): Promise<MountedCard> => {
|
const mountCard = async (): Promise<MountedCard> => {
|
||||||
const card = await MountedCard.create(
|
const card = await MountedCardFactory.createFromSource(
|
||||||
createStillImageCardConfig({
|
createStillImageCardConfig({
|
||||||
automations: [
|
automations: [
|
||||||
createInitializedAutomation(),
|
createInitializedAutomation(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||||
import { MountedCard } from '../../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
CARD_INITIALIZED_MESSAGE,
|
CARD_INITIALIZED_MESSAGE,
|
||||||
createInitializedAutomation,
|
createInitializedAutomation,
|
||||||
@@ -24,7 +24,7 @@ const createConfig = (
|
|||||||
const mount = async (
|
const mount = async (
|
||||||
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
overrides?: Partial<RawAdvancedCameraCardConfig>,
|
||||||
): Promise<MountedCard> =>
|
): Promise<MountedCard> =>
|
||||||
await MountedCard.create(
|
await MountedCardFactory.createFromSource(
|
||||||
createConfig(overrides),
|
createConfig(overrides),
|
||||||
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { MountedCard } from '../../../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
CARD_INITIALIZED_MESSAGE,
|
CARD_INITIALIZED_MESSAGE,
|
||||||
createInitializedAutomation,
|
createInitializedAutomation,
|
||||||
@@ -31,7 +31,7 @@ const waitForInitializationFailures = async (card: MountedCard): Promise<void> =
|
|||||||
* is what makes it initializable, which a test does with `setEntityState`.
|
* is what makes it initializable, which a test does with `setEntityState`.
|
||||||
*/
|
*/
|
||||||
const mountBrokenCard = async (): Promise<MountedCard> =>
|
const mountBrokenCard = async (): Promise<MountedCard> =>
|
||||||
await MountedCard.create(
|
await MountedCardFactory.createFromSource(
|
||||||
createStillImageCardConfig({
|
createStillImageCardConfig({
|
||||||
cameras: [createStillImageCameraConfig(MISSING_CAMERA_ENTITY)],
|
cameras: [createStillImageCameraConfig(MISSING_CAMERA_ENTITY)],
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/card-controller/i
|
|||||||
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
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 { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||||
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
||||||
import { MountedCard, type MountOptions } from '../../../browser/mounted-card';
|
import {
|
||||||
|
MountedCardFactory,
|
||||||
|
type MountedCard,
|
||||||
|
type MountOptions,
|
||||||
|
} from '../../../browser/mounted-card';
|
||||||
import { useTestMedia } from '../../../browser/test-media';
|
import { useTestMedia } from '../../../browser/test-media';
|
||||||
import {
|
import {
|
||||||
createFailingMediaURL,
|
createFailingMediaURL,
|
||||||
@@ -58,7 +62,7 @@ const mountCard = async (
|
|||||||
): Promise<MountedCard> => {
|
): Promise<MountedCard> => {
|
||||||
const { cameras, ...mountOptions } = options ?? {};
|
const { cameras, ...mountOptions } = options ?? {};
|
||||||
|
|
||||||
return await MountedCard.create(
|
return await MountedCardFactory.createFromSource(
|
||||||
createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }),
|
createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }),
|
||||||
createStillCameraHASS({ cameras }),
|
createStillCameraHASS({ cameras }),
|
||||||
mountOptions,
|
mountOptions,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { assert, describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import type { LogActionConfig } from '../../src/config/schema/actions/custom/log';
|
import type { LogActionConfig } from '../../src/config/schema/actions/custom/log';
|
||||||
import { createLogAction } from '../../src/utils/action';
|
import { createLogAction } from '../../src/utils/action';
|
||||||
import { MountedCard, type MountOptions } from '../browser/mounted-card';
|
import {
|
||||||
|
MountedCardFactory,
|
||||||
|
type MountedCard,
|
||||||
|
type MountOptions,
|
||||||
|
} from '../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
CARD_INITIALIZED_MESSAGE,
|
CARD_INITIALIZED_MESSAGE,
|
||||||
clickElement,
|
clickElement,
|
||||||
@@ -44,7 +48,7 @@ interface MountCardOptions extends MountOptions {
|
|||||||
const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
|
const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
|
||||||
const { keyMessage, ...mountOptions } = options ?? {};
|
const { keyMessage, ...mountOptions } = options ?? {};
|
||||||
|
|
||||||
const card = await MountedCard.create(
|
const card = await MountedCardFactory.createFromSource(
|
||||||
createStillImageCardConfig({
|
createStillImageCardConfig({
|
||||||
menu: { style: 'outside', buttons: { expand: { enabled: true } } },
|
menu: { style: 'outside', buttons: { expand: { enabled: true } } },
|
||||||
elements: [
|
elements: [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||||
import { MountedCard } from '../browser/mounted-card';
|
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||||
import {
|
import {
|
||||||
createStillCameraHASS,
|
createStillCameraHASS,
|
||||||
createStillImageCardConfig,
|
createStillImageCardConfig,
|
||||||
@@ -23,7 +23,7 @@ interface RenderedElement extends Element {
|
|||||||
|
|
||||||
const mount = async (): Promise<MountedCard> => {
|
const mount = async (): Promise<MountedCard> => {
|
||||||
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
||||||
return await MountedCard.create(createStillImageCardConfig(), hass);
|
return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMediaLoadedInfos = (card: MountedCard): MediaLoadedInfoEventDetail[] =>
|
const getMediaLoadedInfos = (card: MountedCard): MediaLoadedInfoEventDetail[] =>
|
||||||
|
|||||||
Vendored
+229
@@ -0,0 +1,229 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { commands } from 'vitest/browser';
|
||||||
|
|
||||||
|
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||||
|
import type { FakeHASS } from '../browser/fake-hass';
|
||||||
|
import { defineHAElementStubs } from '../browser/ha-element-stubs';
|
||||||
|
import {
|
||||||
|
MountedCard,
|
||||||
|
MountedCardFactory,
|
||||||
|
type MountOptions,
|
||||||
|
} from '../browser/mounted-card';
|
||||||
|
import {
|
||||||
|
createStillCameraHASS,
|
||||||
|
createStillImageCardConfig,
|
||||||
|
isLiveMediaShowing,
|
||||||
|
} from '../browser/test-utils';
|
||||||
|
|
||||||
|
// The two filenames a dashboard resource can name. Home Assistant loads one of
|
||||||
|
// these directly, so their names are fixed and the rest of the output is
|
||||||
|
// hashed.
|
||||||
|
const PUBLIC_ENTRY = 'advanced-camera-card.js';
|
||||||
|
const LEGACY_ENTRY = 'frigate-hass-card.js';
|
||||||
|
const PUBLIC_ENTRIES = [PUBLIC_ENTRY, LEGACY_ENTRY];
|
||||||
|
|
||||||
|
// A facade holds one re-export and nothing else. Generous size, so that this
|
||||||
|
// fails on an entry carrying the (whole) card rather than on formatting.
|
||||||
|
const MAX_FACADE_BYTES = 1024;
|
||||||
|
|
||||||
|
// What HACS appends to a dashboard resource URL. The value is arbitrary; that
|
||||||
|
// there is one is the point.
|
||||||
|
const HACSTAG = '1234567890';
|
||||||
|
|
||||||
|
const CARD_ELEMENT = 'advanced-camera-card';
|
||||||
|
const LEGACY_CARD_ELEMENT = 'frigate-card';
|
||||||
|
|
||||||
|
interface DistImports {
|
||||||
|
staticImports: string[];
|
||||||
|
dynamicImports: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vitest/browser' {
|
||||||
|
interface BrowserCommands {
|
||||||
|
listDistFiles: () => Promise<string[]>;
|
||||||
|
getDistImportGraph: () => Promise<Record<string, DistImports>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card types registered in Home Assistant's card picker.
|
||||||
|
*/
|
||||||
|
const getRegisteredCardTypes = (): string[] => {
|
||||||
|
const registry: unknown = Reflect.get(window, 'customCards');
|
||||||
|
if (!Array.isArray(registry)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return registry.flatMap((entry: unknown) =>
|
||||||
|
typeof entry === 'object' && entry !== null && 'type' in entry
|
||||||
|
? [String(entry.type)]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read before the bundle is loaded below, so a test can tell what loading it
|
||||||
|
// registered.
|
||||||
|
const registrationsBeforeLoad = {
|
||||||
|
card: !!customElements.get(CARD_ELEMENT),
|
||||||
|
legacy: !!customElements.get(LEGACY_CARD_ELEMENT),
|
||||||
|
cardTypes: getRegisteredCardTypes(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileName = (specifier: string): string =>
|
||||||
|
specifier.split('/').pop() ?? specifier;
|
||||||
|
|
||||||
|
class BuildMountedCardFactory extends MountedCardFactory {
|
||||||
|
/**
|
||||||
|
* A card loaded from `url`, the way Home Assistant loads one: as a dashboard
|
||||||
|
* resource, with a HACS tag attached.
|
||||||
|
*/
|
||||||
|
public static async createFromBuild(
|
||||||
|
url: string,
|
||||||
|
config: RawAdvancedCameraCardConfig,
|
||||||
|
hass: FakeHASS,
|
||||||
|
options?: MountOptions,
|
||||||
|
): Promise<MountedCard> {
|
||||||
|
return await MountedCard.create(
|
||||||
|
async () => {
|
||||||
|
// The card side-loads Home Assistant's own elements as a required step
|
||||||
|
// of initializing, and gives up if they cannot be had. Before the card
|
||||||
|
// loads, because the build subclasses the three player elements as soon
|
||||||
|
// as they exist.
|
||||||
|
defineHAElementStubs();
|
||||||
|
|
||||||
|
// The backticks matter! Vite leaves a dynamic import alone
|
||||||
|
// when the file is a quoted string, and rewrites anything
|
||||||
|
// else to append `?import`, which asks for the file as a module and
|
||||||
|
// makes Vite refuse to serve a static one.
|
||||||
|
//
|
||||||
|
// The comment only silences the warning that the name could not be read
|
||||||
|
// at build time.
|
||||||
|
await import(/* @vite-ignore */ `${url}`);
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
hass,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built card on the page, mounted from the file Home Assistant would load
|
||||||
|
* rather than from `src/`.
|
||||||
|
*/
|
||||||
|
const mountBuiltCard = async (
|
||||||
|
hass: FakeHASS = createStillCameraHASS(),
|
||||||
|
): Promise<MountedCard> =>
|
||||||
|
await BuildMountedCardFactory.createFromBuild(
|
||||||
|
`/${PUBLIC_ENTRY}?hacstag=${HACSTAG}`,
|
||||||
|
createStillImageCardConfig(),
|
||||||
|
hass,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// These tests read a build rather than making one. Insist on existence.
|
||||||
|
const files = await commands.listDistFiles();
|
||||||
|
if (!files.includes(PUBLIC_ENTRY)) {
|
||||||
|
throw new Error(`No build to test. Run \`yarn run build\` first.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the built card', () => {
|
||||||
|
it('should ship a public entry that only re-exports a hashed chunk', async () => {
|
||||||
|
const files = await commands.listDistFiles();
|
||||||
|
const graph = await commands.getDistImportGraph();
|
||||||
|
|
||||||
|
for (const entry of PUBLIC_ENTRIES) {
|
||||||
|
expect(files).toContain(entry);
|
||||||
|
|
||||||
|
const source = await commands.readFile(`dist/${entry}`);
|
||||||
|
expect(source.length).toBeLessThan(MAX_FACADE_BYTES);
|
||||||
|
|
||||||
|
// Everything it names is a real file, and none of it is another entry.
|
||||||
|
const specifiers = [...graph[entry].staticImports, ...graph[entry].dynamicImports];
|
||||||
|
expect(specifiers.length).toBeGreaterThan(0);
|
||||||
|
for (const specifier of specifiers) {
|
||||||
|
expect(files).toContain(getFileName(specifier));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hand the browser the file exactly as it was built', async () => {
|
||||||
|
const served = await (await fetch(`/${PUBLIC_ENTRY}`)).text();
|
||||||
|
|
||||||
|
// Every other test here assumes the card it loads is the built one, which
|
||||||
|
// stops being true if Vite transforms the file on the way in.
|
||||||
|
expect(served).toBe(await commands.readFile(`dist/${PUBLIC_ENTRY}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never import a public entry from another file', async () => {
|
||||||
|
const graph = await commands.getDistImportGraph();
|
||||||
|
|
||||||
|
// The browser treats a different URL as a different file, so a chunk
|
||||||
|
// importing the untagged entry would fetch and run the card a second time.
|
||||||
|
const offenders = Object.entries(graph).flatMap(([file, imports]) =>
|
||||||
|
[...imports.staticImports, ...imports.dynamicImports]
|
||||||
|
.filter((specifier) => PUBLIC_ENTRIES.includes(getFileName(specifier)))
|
||||||
|
.map((specifier) => `${file} -> ${specifier}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only have JavaScript output', async () => {
|
||||||
|
const files = await commands.listDistFiles();
|
||||||
|
|
||||||
|
// Home Assistant is pointed at one file and a release ships `dist/*.js`, so
|
||||||
|
// a stylesheet or an image emitted beside the bundle would never reach a
|
||||||
|
// user. Sourcemaps are allowed: a development build writes them, and they
|
||||||
|
// are not something the card fetches.
|
||||||
|
const assets = files.filter(
|
||||||
|
(file) => !file.endsWith('.js') && !file.endsWith('.js.map'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register the card exactly once when loaded with a HACS tag', async () => {
|
||||||
|
expect(registrationsBeforeLoad.card).toBe(false);
|
||||||
|
expect(registrationsBeforeLoad.legacy).toBe(false);
|
||||||
|
expect(registrationsBeforeLoad.cardTypes).not.toContain(CARD_ELEMENT);
|
||||||
|
|
||||||
|
await mountBuiltCard();
|
||||||
|
|
||||||
|
expect(customElements.get(CARD_ELEMENT)).toBeDefined();
|
||||||
|
expect(customElements.get(LEGACY_CARD_ELEMENT)).toBeDefined();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getRegisteredCardTypes().filter((type) => type.includes(CARD_ELEMENT)),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start up and show its camera', async () => {
|
||||||
|
const mounted = await mountBuiltCard();
|
||||||
|
|
||||||
|
// Starting up runs the whole of Lit's update cycle, which reaches the card
|
||||||
|
// only through the accessors `@property` installs. Were the decorator
|
||||||
|
// output wrong, writes would land on plain instance fields and none of this
|
||||||
|
// would work. This verifies the build's treatment of decorators rather than
|
||||||
|
// the card's behaviour per se (already well covered in other tests).
|
||||||
|
await mounted.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||||
|
|
||||||
|
expect(isLiveMediaShowing(mounted.card)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch a language chunk only when that language is used', async () => {
|
||||||
|
const getLanguageChunks = (): string[] =>
|
||||||
|
performance
|
||||||
|
.getEntriesByType('resource')
|
||||||
|
.map((entry) => getFileName(entry.name))
|
||||||
|
.filter((file) => file.startsWith('lang-'));
|
||||||
|
|
||||||
|
// Clear resource timings to only measure the impact of mounting the card.
|
||||||
|
performance.clearResourceTimings();
|
||||||
|
|
||||||
|
const mounted = await mountBuiltCard(createStillCameraHASS({ language: 'de' }));
|
||||||
|
await mounted.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||||
|
|
||||||
|
expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-26
@@ -1,36 +1,19 @@
|
|||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
import { getBrowsers, type Browser } from './scripts/browsers.js';
|
||||||
import { releaseVersion } from './scripts/release-version-plugin.js';
|
import { releaseVersion } from './scripts/release-version-plugin.js';
|
||||||
import { scssString } from './scripts/scss-string-plugin.js';
|
import { scssString } from './scripts/scss-string-plugin.js';
|
||||||
import { svgPath } from './scripts/svg-path-plugin.js';
|
import { svgPath } from './scripts/svg-path-plugin.js';
|
||||||
|
|
||||||
const BROWSERS = ['chromium', 'firefox', 'webkit'] as const;
|
// Browser tests mount the real card in a browser. Their own config rather than
|
||||||
type Browser = (typeof BROWSERS)[number];
|
// a fourth project in `vitest.config.ts`, because `vitest run` executes every
|
||||||
|
// project: they would become a way to satisfy the 100% per-file thresholds.
|
||||||
const isBrowser = (name: string): name is Browser =>
|
|
||||||
BROWSERS.some((browser) => browser === name);
|
|
||||||
|
|
||||||
const requestedBrowser = process.env.VITEST_BROWSER;
|
|
||||||
if (requestedBrowser !== undefined && !isBrowser(requestedBrowser)) {
|
|
||||||
throw new Error(`Unknown browser: ${requestedBrowser}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which browsers to run, all of them unless one is named. CI names one per job
|
|
||||||
// so that the three run at the same time on separate machines rather than one
|
|
||||||
// after another on one.
|
|
||||||
const browsers = requestedBrowser ? [requestedBrowser] : BROWSERS;
|
|
||||||
|
|
||||||
// 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({
|
export default defineConfig({
|
||||||
// Tests import `src/` directly rather than a built bundle, so a test and the
|
// Tests import `src/` directly rather than a built bundle, so Vite has to
|
||||||
// card share one process and one set of objects. Vite then has to supply the
|
// supply the same asset shapes the build's plugins do: an SVG becomes the
|
||||||
// same asset shapes the Rollup build's plugins do: an SVG becomes the `{
|
// `{ path, viewBox }` a custom iconset serves, SCSS the string `unsafeCSS`
|
||||||
// path, viewBox }` a custom iconset serves, SCSS becomes the string
|
// takes. `svgPath` is the build's own plugin, reused unchanged.
|
||||||
// `unsafeCSS` takes. `svgPath` is the build's own plugin, reused unchanged.
|
|
||||||
plugins: [releaseVersion(), scssString(), svgPath()],
|
plugins: [releaseVersion(), scssString(), svgPath()],
|
||||||
|
|
||||||
// Where the Mock Service Worker script is served from, which Vite serves at
|
// Where the Mock Service Worker script is served from, which Vite serves at
|
||||||
@@ -86,6 +69,11 @@ export default defineConfig({
|
|||||||
name: 'browser',
|
name: 'browser',
|
||||||
include: ['tests/**/*.browser.test.ts'],
|
include: ['tests/**/*.browser.test.ts'],
|
||||||
|
|
||||||
|
// The tests under `tests/dist` need a card build to exist (in dist/), which
|
||||||
|
// this suite does not require. They have their own config, see
|
||||||
|
// vitest.dist.config.ts .
|
||||||
|
exclude: ['tests/dist/**'],
|
||||||
|
|
||||||
// Cosmetic: Style the pages as HA does for screenshots.
|
// Cosmetic: Style the pages as HA does for screenshots.
|
||||||
setupFiles: ['./tests/browser/style.ts'],
|
setupFiles: ['./tests/browser/style.ts'],
|
||||||
|
|
||||||
@@ -118,7 +106,7 @@ export default defineConfig({
|
|||||||
// screenshot taken when one fails.
|
// screenshot taken when one fails.
|
||||||
viewport: { width: 1280, height: 800 },
|
viewport: { width: 1280, height: 800 },
|
||||||
|
|
||||||
instances: browsers.map((browser: Browser) => ({ browser })),
|
instances: getBrowsers().map((browser: Browser) => ({ browser })),
|
||||||
|
|
||||||
screenshotDirectory: '.vitest/screenshots',
|
screenshotDirectory: '.vitest/screenshots',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
import { getBrowsers, type Browser } from './scripts/browsers.js';
|
||||||
|
import { distCommands } from './scripts/dist-commands.js';
|
||||||
|
|
||||||
|
// This is the only test suite that runs against the built bundle rather than
|
||||||
|
// `src/`. They need a build to already exist: run `yarn run build` first.
|
||||||
|
export default defineConfig({
|
||||||
|
// Serves the built card at the root of the page entirely unmodified.
|
||||||
|
publicDir: 'dist',
|
||||||
|
|
||||||
|
optimizeDeps: {
|
||||||
|
// The npm packages these *tests* use. Vite bundles each one into a single
|
||||||
|
// file before the page opens. A package left out is bundled the first time
|
||||||
|
// a test imports it, and Vite reloads the page to serve it, failing
|
||||||
|
// whichever test was running.
|
||||||
|
//
|
||||||
|
// Take the list from the `dependencies optimized:` line printed by a run
|
||||||
|
// with an empty cache. Subpaths are separate entries (i.e. `lit` does not cover
|
||||||
|
// `lit/decorators.js`).
|
||||||
|
include: [
|
||||||
|
'date-fns',
|
||||||
|
'home-assistant-js-websocket',
|
||||||
|
'lit',
|
||||||
|
'lit/decorators.js',
|
||||||
|
'lodash-es',
|
||||||
|
'screenfull',
|
||||||
|
'vitest-mock-extended',
|
||||||
|
'zod',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
|
||||||
|
test: {
|
||||||
|
name: 'dist',
|
||||||
|
include: ['tests/dist/**/*.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',
|
||||||
|
|
||||||
|
// When to name a test in the output for taking too long. Browser tests blow
|
||||||
|
// past the default of 300ms.
|
||||||
|
slowTestThreshold: 10000,
|
||||||
|
|
||||||
|
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: getBrowsers().map((browser: Browser) => ({ browser })),
|
||||||
|
|
||||||
|
screenshotDirectory: '.vitest/screenshots',
|
||||||
|
|
||||||
|
// Functions that run in Node (not in the browser under test). A test
|
||||||
|
// calls one from the browser by importing `commands` from
|
||||||
|
// `vitest/browser`, and vitest forwards the call and hands back the
|
||||||
|
// return value.
|
||||||
|
//
|
||||||
|
// The browser page cannot read the filesystem, and several assertions
|
||||||
|
// here are about the files in `dist/`: which are there, what they hold,
|
||||||
|
// and what they import -- so distCommands supports that introspection.
|
||||||
|
commands: distCommands,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Keep the output clean, as the unit tests do, but hand over everything a
|
||||||
|
// failing test wrote: much of what the card reports is written nowhere
|
||||||
|
// else, and on CI nobody can look at the card themselves.
|
||||||
|
silent: 'passed-only',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2527,6 +2527,7 @@ __metadata:
|
|||||||
docsify-cli: "npm:^4.4.4"
|
docsify-cli: "npm:^4.4.4"
|
||||||
embla-carousel: "npm:^8.6.0"
|
embla-carousel: "npm:^8.6.0"
|
||||||
embla-carousel-wheel-gestures: "npm:^8.0.1"
|
embla-carousel-wheel-gestures: "npm:^8.0.1"
|
||||||
|
es-module-lexer: "npm:^2.3.1"
|
||||||
eslint: "npm:^9.24.0"
|
eslint: "npm:^9.24.0"
|
||||||
eslint-config-prettier: "npm:^9.1.0"
|
eslint-config-prettier: "npm:^9.1.0"
|
||||||
ha-nunjucks: "npm:~1.6.2"
|
ha-nunjucks: "npm:~1.6.2"
|
||||||
@@ -4216,7 +4217,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"es-module-lexer@npm:^2.0.0":
|
"es-module-lexer@npm:^2.0.0, es-module-lexer@npm:^2.3.1":
|
||||||
version: 2.3.1
|
version: 2.3.1
|
||||||
resolution: "es-module-lexer@npm:2.3.1"
|
resolution: "es-module-lexer@npm:2.3.1"
|
||||||
checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9
|
checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9
|
||||||
|
|||||||
Reference in New Issue
Block a user