import { beforeAll, describe, expect, it } from 'vitest'; import { commands } from 'vitest/browser'; import { PUBLIC_ENTRIES, PUBLIC_ENTRY } from '../../scripts/vite/public-entries.js'; 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 { createGenericCameraHASS, createStillImageCardConfig, isLiveMediaShowing, } from '../browser/test-utils'; import { loadModule } from './test-utils'; // 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(); await loadModule(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 = createGenericCameraHASS(), ): 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(); const facadeImports = new Set(); for (const entry of PUBLIC_ENTRIES) { expect(files).toContain(entry); const source = await commands.readFile(`dist/${entry}`); expect(source.length).toBeLessThan(MAX_FACADE_BYTES); // A facade re-exports one hashed chunk and does nothing else. Anything // more -- a second import, or one reached through another file -- is the // card being split across the name that Home Assistant loads, which is // what the whole arrangement exists to prevent. expect(graph[entry].dynamicImports).toEqual([]); expect(graph[entry].staticImports).toHaveLength(1); const imported = getFileName(graph[entry].staticImports[0]); expect(files).toContain(imported); expect(imported).toMatch(/-[A-Za-z0-9_-]{8}\.js$/); facadeImports.add(imported); } // Both public entries should re-export the same chunk. expect(facadeImports.size).toBe(1); }); 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 not fetch lazily loaded code before the card runs', async () => { const graph = await commands.getDistImportGraph(); // What the browser fetches before the card runs: the entry and everything // reachable from it without a dynamic import. Each file found is appended // and then followed in turn, until nothing new turns up. const fetchedAtStartup = [PUBLIC_ENTRY]; for (let index = 0; index < fetchedAtStartup.length; index++) { for (const specifier of graph[fetchedAtStartup[index]]?.staticImports ?? []) { const imported = getFileName(specifier); if (!fetchedAtStartup.includes(imported)) { fetchedAtStartup.push(imported); } } } // Verify lazy loading still works. Use the editor as a sample as it is one // of the largest things the card can load, so it is the clearest signal // that chunking has stopped working correctly. const editor = Object.keys(graph).filter((file) => file.startsWith('editor-')); expect(editor).toHaveLength(1); expect(fetchedAtStartup).not.toContain(editor[0]); }); 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(createGenericCameraHASS({ language: 'de' })); await mounted.events.waitForFirst('advanced-camera-card:media:loaded'); expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]); }); });