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:
Vendored
+62
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Vendored
+43
-21
@@ -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();
|
||||
|
||||
|
||||
Vendored
+30
@@ -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));
|
||||
Reference in New Issue
Block a user