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:
Dermot Duffy
2026-08-03 21:50:21 -07:00
committed by GitHub
parent 18ea725564
commit 2d124fe3cd
18 changed files with 483 additions and 50 deletions
+27
View File
@@ -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];
};
+56
View File
@@ -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;
},
};