From c4c6058a70c0e6767b8c80aafa697cef0add07f0 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 25 Jul 2026 21:47:13 -0700 Subject: [PATCH] test: Split the test suite into isolation groups for faster runs (#2613) --- package.json | 2 +- vite.config.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9e3bb796..e716c7c6 100644 --- a/package.json +++ b/package.json @@ -188,7 +188,7 @@ "format-check": "prettier --check .", "rollup": "rollup -c", "prune": "knip", - "test": "vitest run", + "test": "VITEST_GROUP=shared vitest run && VITEST_GROUP=isolated vitest run", "coverage": "vitest run --coverage" }, "volta": { diff --git a/vite.config.ts b/vite.config.ts index 9deeeacd..30fb6f2a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,6 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + import { defineConfig } from 'vitest/config'; import { svgPath } from './scripts/svg-path-plugin.js'; @@ -19,7 +22,48 @@ const EXCLUSIONS = [ 'src/patches/**/*.ts', ]; -const INCLUSIONS = ['tests/**/*.test.ts']; +const TEST_DIRECTORY = 'tests'; +const INCLUSIONS = [`${TEST_DIRECTORY}/**/*.test.ts`]; + +// For test performance reasons, tests are split into two groups: +// +// - `shared`: run non-isolated, so they share one loaded copy of the source +// tree instead of each re-importing it. +// - `isolated`: given their own module registry per file, because sharing one +// would change their behaviour. A file calling `vi.mock()` cannot replace a +// module an earlier file already loaded unmocked; a file loading the template +// renderer reads browser globals as the renderer loads; and a file needing a +// DOM leaves modules holding a `window` that is torn down when it finishes, +// which breaks any later file that reaches one of those modules. +// +// Absent this variable every test file runs isolated, which is both the safe +// default and what coverage requires (istanbul only counts a module's top-level +// code the first time it runs). +const GROUP = process.env.VITEST_GROUP; + +const findTestFiles = (directory: string): string[] => { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...findTestFiles(path)); + } else if (entry.name.endsWith('.test.ts')) { + files.push(path); + } + } + return files; +}; + +const REQUIRE_ISOLATION_REGEXP = + /\bvi\.(do)?mock\(|loadRenderer|stubConnectedHomeAssistant|@vitest-environment\s+jsdom/; + +const getGroup = (file: string): string => + REQUIRE_ISOLATION_REGEXP.test(readFileSync(file, 'utf-8')) ? 'isolated' : 'shared'; + +const getInclusions = (): string[] => + GROUP + ? findTestFiles(TEST_DIRECTORY).filter((file) => getGroup(file) === GROUP) + : INCLUSIONS; export default defineConfig({ plugins: [svgPath()], @@ -31,7 +75,13 @@ export default defineConfig({ inline: ['ha-nunjucks', 'ts-py-datetime'], }, }, - include: INCLUSIONS, + include: getInclusions(), + + // Forked child processes start and tear down faster here than worker + // threads, which matters when every test file needs a fresh one. + pool: 'forks', + + isolate: !GROUP || GROUP === 'isolated', // Hide console writing to keep output clean, usual sources of noise: // - Unnecessary Lit dev-mode warnings.