refactor: Replace Rollup with Vite (#2656)

This is a high risk change: every byte the card ships is emitted by a
different bundler, minified by a different minifier, and every
stylesheet is compiled by a different sass. The intent is that the
card's behaviour is unchanged.

**Significant development win**: Build time drops from 24s to 1.1s 🎉 

Also adds a test suite ('dist') that runs against the built bundle.
This commit is contained in:
Dermot Duffy
2026-08-04 15:00:13 -07:00
committed by GitHub
parent 06bcfd58ba
commit 80ec92e3b2
105 changed files with 1273 additions and 1719 deletions
+62
View File
@@ -0,0 +1,62 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { commands } from 'vitest/browser';
import { defineHAElementStubs } from '../browser/ha-element-stubs';
import { flushBrowserWork, loadModule } from './test-utils';
beforeAll(() => {
// The card subclasses Home Assistant's own player elements once those exist.
// Without them the chunks holding the subclasses still load, but never run
// them.
defineHAElementStubs();
});
describe('every chunk the build emits', () => {
it('should load correctly', async () => {
const chunks = (await commands.listDistFiles()).filter((file) =>
file.endsWith('.js'),
);
expect(chunks.length).toBeGreaterThan(0);
const failures: string[] = [];
// What a chunk throws while running is reported to `window` rather than to
// whoever loaded it. The browser names the file it came from.
const recordError = (event: ErrorEvent): void => {
failures.push(`${event.filename}: ${event.message}`);
};
window.addEventListener('error', recordError);
// Part of what a chunk runs can be deferred to a promise it keeps to itself
// (e.g. the player subclasses wait on Home Assistant's own element), which
// is reported separately and without naming a file.
const recordRejection = (event: PromiseRejectionEvent): void => {
failures.push(`${event.reason}`);
};
window.addEventListener('unhandledrejection', recordRejection);
try {
// Most of what the build emits is reached only by a view the user has to
// navigate to, so nothing else here runs it. A chunk that throws the
// moment it is loaded -- a dependency resolved to a build the code using
// it cannot call, say -- reaches a user as a view that renders empty, and
// does so without failing any other test.
for (const chunk of chunks) {
try {
await loadModule(`/${chunk}`);
} catch (error) {
failures.push(`${chunk}: ${error}`);
}
}
// Need to flush browser work to ensure deferrals from the last chunk are
// noticed.
await flushBrowserWork();
} finally {
window.removeEventListener('error', recordError);
window.removeEventListener('unhandledrejection', recordRejection);
}
expect(failures).toEqual([]);
});
});
+43 -21
View File
@@ -1,6 +1,7 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { commands } from 'vitest/browser';
import { PUBLIC_ENTRIES, PUBLIC_ENTRY } from '../../scripts/public-entries.js';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import type { FakeHASS } from '../browser/fake-hass';
import { defineHAElementStubs } from '../browser/ha-element-stubs';
@@ -14,13 +15,7 @@ import {
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];
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.
@@ -90,14 +85,7 @@ class BuildMountedCardFactory extends MountedCardFactory {
// 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}`);
await loadModule(url);
},
config,
hass,
@@ -131,6 +119,7 @@ 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<string>();
for (const entry of PUBLIC_ENTRIES) {
expect(files).toContain(entry);
@@ -138,13 +127,22 @@ describe('the built card', () => {
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));
}
// 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 () => {
@@ -169,6 +167,30 @@ describe('the built card', () => {
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();
+30
View File
@@ -0,0 +1,30 @@
/**
* Loads the module at `url` the same way Home Assistant puts a dashboard
* resource into one: by appending a script element for it. Resolves once the
* browser has fetched and run it.
*
* Rejects only when the file could not be fetched or parsed. A module that
* throws while running has still loaded, and reports itself to `window`
* instead.
*/
export const loadModule = async (url: string): Promise<void> =>
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.type = 'module';
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Could not load ${url}`));
script.src = url;
document.body.appendChild(script);
});
/**
* Waits for the browser to get through the work it already has queued.
*
* Draining promises (as `flushPromises` does) reaches none of that work. An
* uncaught exception or a rejected promise nothing handled, for instance, is
* reported only once the browser has run out of work to do.
*/
export const flushBrowserWork = async (): Promise<void> =>
await new Promise((resolve) => setTimeout(resolve));