diff --git a/package.json b/package.json index c1c02089..6dd3c413 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@vitest/coverage-v8": "^4.1.10", "conventional-changelog-conventionalcommits": "^8.0.0", "docsify-cli": "^4.4.4", + "es-module-lexer": "^2.3.1", "eslint": "^9.24.0", "eslint-config-prettier": "^9.1.0", "jsdom": "^21.1.2", @@ -193,6 +194,7 @@ "prune": "knip", "test": "vitest run", "test:browser": "vitest run --config vitest.browser.config.ts", + "test:dist": "vitest run --config vitest.dist.config.ts", "coverage": "vitest run --coverage" }, "volta": { diff --git a/scripts/browsers.ts b/scripts/browsers.ts new file mode 100644 index 00000000..4d145b77 --- /dev/null +++ b/scripts/browsers.ts @@ -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]; +}; diff --git a/scripts/dist-commands.js b/scripts/dist-commands.js new file mode 100644 index 00000000..324938af --- /dev/null +++ b/scripts/dist-commands.js @@ -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>} + */ +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; + }, +}; diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 0c02c803..a276f365 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -6,6 +6,7 @@ import { type DirectiveParameters, } from 'lit/directive.js'; +import { ACTION_HANDLER_HOLD_SECONDS } from './const.js'; import { fireHASSEvent } from './ha/fire-hass-event.js'; import type { ActionHandlerDetail, ActionHandlerOptions } from './ha/types.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js'; @@ -24,9 +25,6 @@ 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 = ACTION_HANDLER_HOLD_SECONDS; diff --git a/src/const.ts b/src/const.ts index 5fdd846b..1f1a91b3 100644 --- a/src/const.ts +++ b/src/const.ts @@ -15,6 +15,13 @@ export const TROUBLESHOOTING_LEGACY_RESOURCE_URL = export const TROUBLESHOOTING_MEDIA_URL = `${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 // =========================================================================== diff --git a/tests/browser/mounted-card.ts b/tests/browser/mounted-card.ts index cf029402..89536628 100644 --- a/tests/browser/mounted-card.ts +++ b/tests/browser/mounted-card.ts @@ -1,8 +1,8 @@ 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 { ACTION_HANDLER_HOLD_SECONDS } from '../../src/const'; import type { FakeEntityOptions, FakeHASS } from './fake-hass'; import { defineHAElementStubs } from './ha-element-stubs'; 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 * after this returns, so a test waits for whatever it will assert on. + * + * `loadCard` puts the card's elements into the page. Where they come from is + * the caller's to decide: `MountedCardFactory` takes them from `src/`, and the + * suite covering a build ("dist") takes them from the file the build + * produced. */ public static async create( + loadCard: () => Promise, config: RawAdvancedCameraCardConfig, hass: FakeHASS, options?: MountOptions, ): Promise { - // `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'); + await loadCard(); return new MountedCard(config, hass, options); } - private constructor( + protected constructor( config: RawAdvancedCameraCardConfig, hass: FakeHASS, options?: MountOptions, @@ -613,3 +616,24 @@ export class MountedCard { this.console.destroy(); } } + +export class MountedCardFactory { + // A card built from `src/`. Constrast with dist.browsers.test.ts . + public static async createFromSource( + config: RawAdvancedCameraCardConfig, + hass: FakeHASS, + options?: MountOptions, + ): Promise { + return await MountedCard.create( + async () => { + // `src/patches` subclasses Home Assistant's three player elements as + // soon as those are defined, so the stubs must come first. + defineHAElementStubs(); + await import('../../src/card'); + }, + config, + hass, + options, + ); + } +} diff --git a/tests/browser/test-utils.ts b/tests/browser/test-utils.ts index 56ddf61d..c980af4c 100644 --- a/tests/browser/test-utils.ts +++ b/tests/browser/test-utils.ts @@ -96,6 +96,9 @@ export interface StillCameraHASSOptions { // Anything else the card should be able to see, as entity ID to state. entities?: Record; + + // The language Home Assistant is set to for translation tests. + language?: string; } /** @@ -111,6 +114,7 @@ export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHAS ...options?.entities, }, registry: Object.fromEntries(cameras.map((camera) => [camera, {}])), + ...(options?.language && { language: options.language }), }); }; diff --git a/tests/card-controller/automations-manager.browser.test.ts b/tests/card-controller/automations-manager.browser.test.ts index 79a6ca4e..1faad4c9 100644 --- a/tests/card-controller/automations-manager.browser.test.ts +++ b/tests/card-controller/automations-manager.browser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MountedCard } from '../browser/mounted-card'; +import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { createStillCameraHASS, createStillImageCardConfig, @@ -10,7 +10,7 @@ const TRIGGER_ENTITY = 'input_boolean.zoom'; const mount = async (): Promise => { const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } }); - return await MountedCard.create( + return await MountedCardFactory.createFromSource( createStillImageCardConfig({ automations: [ { diff --git a/tests/card-controller/card-element-manager.browser.test.ts b/tests/card-controller/card-element-manager.browser.test.ts index 73b27dd4..d6629584 100644 --- a/tests/card-controller/card-element-manager.browser.test.ts +++ b/tests/card-controller/card-element-manager.browser.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, onTestFinished } from 'vitest'; import { createLogAction } from '../../src/utils/action'; -import { MountedCard } from '../browser/mounted-card'; +import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, createInitializedAutomation, @@ -18,7 +18,7 @@ import { const KEY_MESSAGE = /key pressed/; const mountCard = async (): Promise => { - const card = await MountedCard.create( + const card = await MountedCardFactory.createFromSource( createStillImageCardConfig({ automations: [ createInitializedAutomation(), diff --git a/tests/card-controller/initialization/session-manager.browser.test.ts b/tests/card-controller/initialization/session-manager.browser.test.ts index 38562786..40293d64 100644 --- a/tests/card-controller/initialization/session-manager.browser.test.ts +++ b/tests/card-controller/initialization/session-manager.browser.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; -import { MountedCard } from '../../browser/mounted-card'; +import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, createInitializedAutomation, @@ -24,7 +24,7 @@ const createConfig = ( const mount = async ( overrides?: Partial, ): Promise => - await MountedCard.create( + await MountedCardFactory.createFromSource( createConfig(overrides), createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }), ); diff --git a/tests/card-controller/issues/issues/initialization.browser.test.ts b/tests/card-controller/issues/issues/initialization.browser.test.ts index c207dffe..428d072f 100644 --- a/tests/card-controller/issues/issues/initialization.browser.test.ts +++ b/tests/card-controller/issues/issues/initialization.browser.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { MountedCard } from '../../../browser/mounted-card'; +import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card'; import { CARD_INITIALIZED_MESSAGE, createInitializedAutomation, @@ -31,7 +31,7 @@ const waitForInitializationFailures = async (card: MountedCard): Promise = * is what makes it initializable, which a test does with `setEntityState`. */ const mountBrokenCard = async (): Promise => - await MountedCard.create( + await MountedCardFactory.createFromSource( createStillImageCardConfig({ cameras: [createStillImageCameraConfig(MISSING_CAMERA_ENTITY)], diff --git a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts index 90fe2d2c..18953d2d 100644 --- a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts @@ -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 { 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 { + MountedCardFactory, + type MountedCard, + type MountOptions, +} from '../../../browser/mounted-card'; import { useTestMedia } from '../../../browser/test-media'; import { createFailingMediaURL, @@ -58,7 +62,7 @@ const mountCard = async ( ): Promise => { const { cameras, ...mountOptions } = options ?? {}; - return await MountedCard.create( + return await MountedCardFactory.createFromSource( createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }), createStillCameraHASS({ cameras }), mountOptions, diff --git a/tests/card-controller/keyboard-state-manager.browser.test.ts b/tests/card-controller/keyboard-state-manager.browser.test.ts index 869ed11d..6ec80672 100644 --- a/tests/card-controller/keyboard-state-manager.browser.test.ts +++ b/tests/card-controller/keyboard-state-manager.browser.test.ts @@ -2,7 +2,11 @@ import { assert, describe, expect, it } from 'vitest'; import type { LogActionConfig } from '../../src/config/schema/actions/custom/log'; 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 { CARD_INITIALIZED_MESSAGE, clickElement, @@ -44,7 +48,7 @@ interface MountCardOptions extends MountOptions { const mountCard = async (options?: MountCardOptions): Promise => { const { keyMessage, ...mountOptions } = options ?? {}; - const card = await MountedCard.create( + const card = await MountedCardFactory.createFromSource( createStillImageCardConfig({ menu: { style: 'outside', buttons: { expand: { enabled: true } } }, elements: [ diff --git a/tests/components/image-updating-player.browser.test.ts b/tests/components/image-updating-player.browser.test.ts index 7cf9b20c..7be59e76 100644 --- a/tests/components/image-updating-player.browser.test.ts +++ b/tests/components/image-updating-player.browser.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { MediaLoadedInfoEventDetail } from '../../src/types'; -import { MountedCard } from '../browser/mounted-card'; +import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { createStillCameraHASS, createStillImageCardConfig, @@ -23,7 +23,7 @@ interface RenderedElement extends Element { const mount = async (): Promise => { const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } }); - return await MountedCard.create(createStillImageCardConfig(), hass); + return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass); }; const getMediaLoadedInfos = (card: MountedCard): MediaLoadedInfoEventDetail[] => diff --git a/tests/dist/dist.browser.test.ts b/tests/dist/dist.browser.test.ts new file mode 100644 index 00000000..121ea4d5 --- /dev/null +++ b/tests/dist/dist.browser.test.ts @@ -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; + getDistImportGraph: () => Promise>; + } +} + +/** + * 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 { + 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 => + 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-/)]); + }); +}); diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index f40bacf1..8e2eec56 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -1,36 +1,19 @@ import { playwright } from '@vitest/browser-playwright'; import { defineConfig } from 'vitest/config'; +import { getBrowsers, type Browser } from './scripts/browsers.js'; import { releaseVersion } from './scripts/release-version-plugin.js'; import { scssString } from './scripts/scss-string-plugin.js'; import { svgPath } from './scripts/svg-path-plugin.js'; -const BROWSERS = ['chromium', 'firefox', 'webkit'] as const; -type Browser = (typeof BROWSERS)[number]; - -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. +// Browser tests mount the real card in a browser. Their own config rather than +// 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. 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. + // Tests import `src/` directly rather than a built bundle, so Vite has to + // supply the same asset shapes the build's plugins do: an SVG becomes the + // `{ path, viewBox }` a custom iconset serves, SCSS the string `unsafeCSS` + // takes. `svgPath` is the build's own plugin, reused unchanged. plugins: [releaseVersion(), scssString(), svgPath()], // Where the Mock Service Worker script is served from, which Vite serves at @@ -86,6 +69,11 @@ export default defineConfig({ name: 'browser', 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. setupFiles: ['./tests/browser/style.ts'], @@ -118,7 +106,7 @@ export default defineConfig({ // screenshot taken when one fails. viewport: { width: 1280, height: 800 }, - instances: browsers.map((browser: Browser) => ({ browser })), + instances: getBrowsers().map((browser: Browser) => ({ browser })), screenshotDirectory: '.vitest/screenshots', }, diff --git a/vitest.dist.config.ts b/vitest.dist.config.ts new file mode 100644 index 00000000..22ea1bed --- /dev/null +++ b/vitest.dist.config.ts @@ -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', + }, +}); diff --git a/yarn.lock b/yarn.lock index 32682022..6e45e364 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2527,6 +2527,7 @@ __metadata: docsify-cli: "npm:^4.4.4" embla-carousel: "npm:^8.6.0" embla-carousel-wheel-gestures: "npm:^8.0.1" + es-module-lexer: "npm:^2.3.1" eslint: "npm:^9.24.0" eslint-config-prettier: "npm:^9.1.0" ha-nunjucks: "npm:~1.6.2" @@ -4216,7 +4217,7 @@ __metadata: languageName: node 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 resolution: "es-module-lexer@npm:2.3.1" checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9