import type { Options } from 'tinyexec' import type { FSWatcher, UserConfig as ViteUserConfig } from 'vite' import type { ParsedStack, SerializedConfig, TestContext, WorkerGlobalState } from 'vitest' import type { TestProjectConfiguration } from 'vitest/config' import type { TestCase, CliOptions as TestCliOptions, TestCollection, TestModule, TestSpecification, TestSuite, TestUserConfig, Vitest, } from 'vitest/node' import { webcrypto as crypto } from 'node:crypto' import fs from 'node:fs' import { Readable, Writable } from 'node:stream' import { fileURLToPath, pathToFileURL } from 'node:url' import { inspect, stripVTControlCharacters } from 'node:util' import { dirname, relative, resolve } from 'pathe' import { x } from 'tinyexec' import { afterEach, onTestFinished, TestRunner } from 'vitest' import { disableDefaultColors, Logger, PluginHarness, resolveConfig, startVitest, } from 'vitest/node' import { Cli } from './cli' // Vitest bundles its own `tinyrainbow` instance (see rollup `manualChunks`), which // is a different object than the one imported above. Disable colors on it too so // reporter output captured in tests is deterministic regardless of `CI`/`FORCE_COLOR`. disableDefaultColors() export interface VitestRunnerCLIOptions { std?: 'inherit' fails?: boolean printExitCode?: boolean preserveAnsi?: boolean tty?: boolean } export interface RunVitestConfig extends TestUserConfig { $viteConfig?: Omit $cliOptions?: TestCliOptions $cliFilters?: string[] } const process_ = process // Polling interval of the file watcher in spawned watch-mode instances. The // watcher-ready probe derives its timing from it: each probe state must // outlive a full poll cycle, or the poller can keep sampling the state that // matches its baseline and never observe a difference. const WATCH_POLL_INTERVAL = 100 export function createConsole({ tty, std }: { tty?: boolean; std?: 'inherit' } = {}) { const stdout = new Writable({ write(chunk, __, callback) { if (std === 'inherit') { process.stdout.write(chunk.toString()) } callback() }, }) if (tty) { ;(stdout as typeof process.stdout).isTTY = true } const stderr = new Writable({ write(chunk, __, callback) { if (std === 'inherit') { process.stderr.write(chunk.toString()) } callback() }, }) // "node:tty".ReadStream doesn't work on Github Windows CI, let's simulate it const stdin = new Readable({ read: () => '' }) as NodeJS.ReadStream stdin.isTTY = true stdin.setRawMode = () => stdin return { stdin, stdout, stderr, } } export async function resolveTestConfig( config: RunVitestConfig, runnerOptions: VitestRunnerCLIOptions = {}, ) { const { stdout, stderr, stdin } = createConsole(runnerOptions) const cli = new Cli({ stdin, stdout, stderr, preserveAnsi: runnerOptions.preserveAnsi }) const { $cliOptions, $viteConfig, ...restConfig } = config const harness = new PluginHarness(new Logger(stdout, stderr)) const resolved = await resolveConfig( { config: false, ...$cliOptions }, { ...$viteConfig, test: restConfig }, harness, ) return { config: resolved, vitest: cli, stdout: cli.stdout, stderr: cli.stderr, } } /** * The config is assumed to be the config on the file system, not CLI options * (Note that CLI only options like "standalone" are passed as CLI options, not config options) * - To pass options as CLI, provide `$cliOptions` in the config object. * - To pass other Vite config properties, provide `$viteConfig` in the config object. * * **WARNING** * If the fixture in `root` has a config file, its options **WILL TAKE PRIORITY** over the ones provided here, * except for the ones provided in `$cliOptions`. */ export async function runVitest( config: RunVitestConfig, cliFilters: string[] = config.$cliFilters || [], runnerOptions: VitestRunnerCLIOptions = {}, ) { // Reset possible previous runs process.exitCode = 0 let exitCode = process.exitCode if (runnerOptions.printExitCode) { globalThis.process = new Proxy(process_, { set(target, p, newValue, receiver) { if (p === 'exitCode') { // oxlint-disable-next-line no-console console.trace('exitCode was set to', newValue) } return Reflect.set(target, p, newValue, receiver) }, }) } // Prevent possible process.exit() calls, e.g. from --browser const exit = process.exit process.exit = (() => {}) as never const { stdout, stderr, stdin } = createConsole(runnerOptions) const cli = new Cli({ stdin, stdout, stderr, preserveAnsi: runnerOptions.preserveAnsi }) // @ts-expect-error not typed global const currentConfig: SerializedConfig = __vitest_worker__.ctx.config let ctx: Vitest | undefined let thrown = false const { reporters, root, watch, maxWorkers, // #region cli-only options config: configFile, standalone, dom, related, mode, changed, shard, project, cliExclude, clearScreen, mergeReports, clearCache, // #endregion $cliOptions: cliOptions, $viteConfig: viteConfig = {}, ...rest } = config if ((viteConfig as any).test) { throw new Error( `Don't pass down "viteConfig" with "test" property. Use the rest of the first argument.`, ) } // Don't let unrelated package.json / config edits made by other tests running // in parallel force-rerun this test's watcher. Applied as a default here so a // fixture config or an explicit `forceRerunTriggers` still takes precedence. rest.forceRerunTriggers ??= [] ;(viteConfig as any).test = rest try { ctx = await startVitest( cliFilters, removeUndefinedValues({ root, config: configFile, standalone, dom, related, mode, changed, shard, project, cliExclude, clearScreen, mergeReports, clearCache, cache: 'cache' in config ? config.cache : false, // Test cases are already run with multiple forks/threads maxWorkers: maxWorkers ?? 1, watch: watch ?? false, // "none" can be used to disable passing "reporter" option so that default value is used (it's not same as reporters: ["default"]) ...(reporters === 'none' ? {} : reporters ? { reporters } : { reporters: ['verbose'] }), ...cliOptions, env: { NO_COLOR: 'true', FORCE_COLOR: undefined, AI_AGENT: '', ...rest.env, ...cliOptions?.env, }, // override cache config with the one that was used to run `vitest` from the CLI fsModuleCache: rest.fsModuleCache ?? currentConfig.fsModuleCache, experimental: { // keep performance hints out of captured test output unless a test opts in diagnostics: rest.experimental?.diagnostics ?? false, ...cliOptions?.experimental, }, }), { ...viteConfig, plugins: [ ...(viteConfig.plugins ?? []), // Spawning the worker is the dominant cost of these meta-tests (each `runVitest` // boots a fresh Vitest). Default the spawned run to `threads`, which is cheaper // to start than `forks`, especially on Windows. A `config` hook only fills the // gap when nothing else set a pool, so an explicit `pool` (from `config` or the // fixture's own config file) always wins. Browser runs are left alone since they // don't execute in a node pool. { name: 'vitest:test-utils:default-pool', config(config) { if (config.test?.pool == null && !config.test?.browser?.enabled) { return { test: { pool: 'threads' } } } }, }, ], server: { watch: { // During tests we edit the files too fast and sometimes chokidar // misses change events, so enforce polling for consistency // https://github.com/vitejs/vite/blob/b723a753ced0667470e72b4853ecda27b17f546a/playground/vitestSetup.ts#L211 usePolling: true, interval: WATCH_POLL_INTERVAL, ...viteConfig.server?.watch, }, ...viteConfig?.server, }, }, { stdin, stdout, stderr, }, ) } catch (e: any) { if (runnerOptions.fails !== true) { console.error(e) } thrown = true cli.stderr += inspect(e) } finally { if (runnerOptions.printExitCode) { globalThis.process = process_ } exitCode = process.exitCode process.exitCode = 0 // tests emulating CLI shortcuts (`q`, double CTRL+C) trigger `vitest.exit()`, // which arms an unref'd force-exit watchdog; it must be disarmed before the // real `process.exit` is restored, or it would kill this worker // `teardownTimeout` later, in the middle of a subsequent test file if (TestRunner.getCurrentTest()) { onTestFinished(async () => { clearTimeout(ctx?._exitTimeout) await ctx?.close() process.exit = exit }) } else { afterEach(async () => { clearTimeout(ctx?._exitTimeout) await ctx?.close() process.exit = exit }) } } // In watch mode, `startVitest` can resolve before the file watcher finished // its initial scan (especially in standalone mode, which doesn't run any // tests on startup). A test file created right after would be folded into the // watcher's baseline and ignored, so newly added files wouldn't trigger a // rerun. Wait for the watcher to be ready before handing control back. if (watch && ctx) { await waitForWatcherReady(ctx) } return { thrown, ctx, exitCode, vitest: cli, stdout: cli.stdout, stderr: cli.stderr, get results() { return ctx?.state.getTestModules() || [] }, errorTree(options?: BuildErrorTreeOptions & { project?: boolean }) { const modules = ctx?.state.getTestModules() || [] const tree = options?.project ? buildErrorProjectTree(modules, options) : buildErrorTree(modules, options) const errors = ctx?.state.getUnhandledErrors() const root = ctx?.config.root if (errors && errors.length > 0 && root) { tree.__unhandled_errors__ = errors.map((e: any) => replaceRoot(e.message, root)) } return tree }, testTree() { return buildTestTree(ctx?.state.getTestModules() || []) }, buildTree(onResult: (testResult: TestCase) => any) { return buildTestTree(ctx?.state.getTestModules() || [], onResult) }, waitForClose: async () => { await new Promise((resolve) => ctx!.onClose(resolve)) return ctx?.closingPromise }, } } // In watch mode `startVitest` can resolve before the file watcher has finished // establishing itself. chokidar's `ready`/`_readyEmitted` fires after the // initial scan, but with polling a file only gets a per-file stat poller some // time after it is discovered, and an edit made before the poller exists is // folded into its baseline stat and never emits an event. No amount of chokidar // bookkeeping (`_readyEmitted`, `getWatched()`) proves the pollers are live, so // verify end-to-end: keep cycling a probe file in the root until the watcher // reports it. The probe is recreated rather than rewritten because a creation // folded into the initial scan of the file AND its directory leaves nothing to // rescan; alternating present/absent guarantees the current state differs from // whichever state the poller's baseline captured, and any probe event (`add`, // `change` or `unlink`) proves delivery. Each state is held for multiple poll // intervals: on a loaded runner, a cycle as fast as the poll interval aliases // with it — every rescan can land when the probe is back in the state matching // the poller's baseline, and no event ever fires. // // The scan can also surface writes made by the PREVIOUS test (an `afterEach` // restoring a fixture right before this instance started) as fresh change // events, triggering a rerun the test never asked for. While waiting, neuter // every rerun attempt by clearing the watcher state (the rerun debounce // returns silently when `changedTests` is empty) and only hand control back // after the event backlog has been quiet for a couple of poll intervals. // // Only instances with an explicit small root (a fixture or an inline-test dir) // get the probe: tests that assert on watch reruns always use one. Instances // rooted at the whole test package (config-introspection tests passing // `watch: true` without a root) never wait for file events, and polling their // huge tree can take longer than any reasonable deadline, so for them only the // cheap bookkeeping check runs, as before. async function waitForWatcherReady(ctx: Vitest): Promise { const watcher: FSWatcher = ctx.vite.watcher const root = ctx.config.root const slash = (p: string) => p.replace(/\\/g, '/') const parent = slash(dirname(root)) const bookkeepingDeadline = Date.now() + 2000 while (Date.now() < bookkeepingDeadline) { const isReady = (watcher as { _readyEmitted?: boolean })._readyEmitted const watchesParent = Object.keys(watcher.getWatched()).some((dir) => slash(dir) === parent) if (isReady && watchesParent) { break } await new Promise((resolve) => setTimeout(resolve, 10)) } if (slash(root) === slash(process.cwd())) { return } const deadline = Date.now() + 10_000 const probe = resolve(root, '.vitest-watcher-ready-probe') const suppressRerun = () => { ctx.watcher.changedTests.clear() ctx.watcher.invalidates.clear() } let probeSeen = false let lastEventAt = Date.now() const onWatcherEvent = (_event: string, file: string) => { suppressRerun() if (slash(file) === probe) { probeSeen = true } else { lastEventAt = Date.now() } } watcher.on('all', onWatcherEvent) try { const holdProbeState = async () => { const stateEnd = Date.now() + WATCH_POLL_INTERVAL * 2.5 while (Date.now() < stateEnd) { if (probeSeen) { return } await new Promise((resolve) => setTimeout(resolve, 25)) } } while (true) { if (probeSeen) { break } if (Date.now() > deadline) { throw new Error( `The watcher of ${root} did not report the probe file within 10s. Watched files:\n${JSON.stringify( watcher.getWatched(), null, 2, )}`, ) } fs.writeFileSync(probe, `${Date.now()}`, 'utf-8') await holdProbeState() if (probeSeen) { break } fs.rmSync(probe, { force: true }) await holdProbeState() } while (Date.now() - lastEventAt < 200 && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)) } } finally { watcher.off('all', onWatcherEvent) fs.rmSync(probe, { force: true }) suppressRerun() } } interface CliOptions extends Partial { earlyReturn?: boolean preserveAnsi?: boolean } async function runCli(command: 'vitest', _options?: CliOptions | string, ...args: string[]) { let options = _options if (typeof _options === 'string') { args.unshift(_options) options = undefined } if (command === 'vitest') { args.push('--maxWorkers=1') } const subprocess = x(command, args, { ...(options as Options), nodeOptions: { ...(options as Options)?.nodeOptions, env: { ...process.env, AI_AGENT: '', ...(options as Options)?.nodeOptions?.env }, }, }).process! const cli = new Cli({ stdin: subprocess.stdin!, stdout: subprocess.stdout!, stderr: subprocess.stderr!, preserveAnsi: typeof _options !== 'string' ? _options?.preserveAnsi : false, }) let setDone: (value?: unknown) => void const isDone = new Promise((resolve) => (setDone = resolve)) subprocess.on('exit', () => setDone()) function output() { return { vitest: cli, exitCode: subprocess.exitCode, stdout: cli.stdout || '', stderr: cli.stderr || '', waitForClose: () => isDone, } } // Manually stop the processes so that each test don't have to do this themselves onTestFinished(async () => { if (subprocess.exitCode === null) { subprocess.kill() } await isDone }) if ( (options as CliOptions)?.earlyReturn || args.includes('--inspect') || args.includes('--inspect-brk') ) { return output() } if (args[0] === 'init') { return output() } if (args[0] !== 'list' && (args.includes('--watch') || args[0] === 'watch')) { if (command === 'vitest') { // Waiting for either success or failure await Promise.race([ cli.waitForStdout('Waiting for file changes'), cli.waitForStdout('Tests failed. Watching for file changes'), ]) } // make sure watcher is ready await cli.waitForStdout('[debug] watcher is ready') cli.stdout = cli.stdout.replace('[debug] watcher is ready\n', '') } else { await isDone } return output() } export async function runVitestCli(_options?: CliOptions | string, ...args: string[]) { process.env.VITE_TEST_WATCHER_DEBUG = 'true' return runCli('vitest', _options, ...args) } export function getInternalState(): WorkerGlobalState { // @ts-expect-error untyped global return globalThis.__vitest_worker__ } const originalFiles = new Map() const originalFileStats = new Map() export function createFile(file: string, content: string) { fs.mkdirSync(dirname(file), { recursive: true }) fs.writeFileSync(file, content, 'utf-8') onTestFinished(() => { if (fs.existsSync(file)) { fs.unlinkSync(file) } }) } export function editFile(file: string, callback: (content: string) => string) { const content = fs.readFileSync(file, 'utf-8') if (!originalFiles.has(file)) { originalFiles.set(file, content) originalFileStats.set(file, fs.statSync(file)) } fs.writeFileSync(file, callback(content), 'utf-8') onTestFinished(() => { const original = originalFiles.get(file) if (original !== undefined) { restoreFile(file, original, originalFileStats.get(file)) originalFiles.delete(file) originalFileStats.delete(file) } }) } // Restore the original mtime along with the content: a restore with a fresh // mtime is indistinguishable from a real edit to any watcher whose stat // baseline predates it, and the phantom change event then reruns tests in the // NEXT test's instance. With content, size and mtime all matching the // pre-test state, no stat comparison can report the file as changed. export function restoreFile(file: string, content: string, stat?: fs.Stats) { fs.writeFileSync(file, content, 'utf-8') if (stat) { fs.utimesSync(file, stat.atime, stat.mtime) } } export function resolvePath(baseUrl: string, path: string) { const filename = fileURLToPath(baseUrl) return resolve(dirname(filename), path) } export type TestFsStructure = Record< string, | string | ViteUserConfig | TestProjectConfiguration[] | ((...args: any[]) => unknown) | [(...args: any[]) => unknown, { exports?: string[]; imports?: Record }] > export function stripIndent(str: string): string { const normalized = str.replace(/\t/g, ' ') const match = normalized.match(/^[ \t]*(?=\S)/gm) if (!match) { return normalized } const indent = match .filter((m) => !!m) .reduce((min, line) => Math.min(min, line.length), Infinity) if (indent === 0) { return normalized } return normalized.replace(new RegExp(`^[ ]{${indent}}`, 'gm'), '') } function getGeneratedFileContent(content: TestFsStructure[string]) { if (typeof content === 'string') { return content } if (typeof content === 'function') { const code = `await (${stripIndent(String(content))})()` return code } if ( Array.isArray(content) && typeof content[1] === 'object' && ('exports' in content[1] || 'imports' in content[1]) ) { const imports = Object.entries(content[1].imports || []) const code = ` ${imports.map(([path, is]) => `import { ${is.join(', ')} } from '${path}'`)} const results = await (${stripIndent(String(content[0]))})({ ${imports.flatMap(([_, is]) => is).join(', ')} }) ${(content[1].exports || []).map((e) => `export const ${e} = results["${e}"]`)} ` return code } if ( 'test' in content && content.test?.browser?.enabled && content.test?.browser?.provider?.name ) { const name = content.test.browser.provider.name return ` import { ${name} } from '@vitest/browser-${name}' const config = ${JSON.stringify(content)} config.test.browser.provider = ${name}(${JSON.stringify(content.test.browser.provider.options || {})}) export default config ` } return `export default ${JSON.stringify(content)}` } export function useTmpFS( structure: T, ensureConfig = true, task?: TestContext['task'], ) { const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`) return useFS(root, structure, ensureConfig, task) } export function useFS( root: string, structure: T, ensureConfig = true, task?: TestContext['task'], ) { const files = new Set() const hasConfig = Object.keys(structure).some((file) => file.includes('.config.')) if (ensureConfig && !hasConfig) { ;(structure as any)['./vitest.config.js'] = {} } for (const file in structure) { const filepath = resolve(root, file) files.add(filepath) const content = getGeneratedFileContent(structure[file]) fs.mkdirSync(dirname(filepath), { recursive: true }) fs.writeFileSync(filepath, String(content), 'utf-8') } ;(task?.context.onTestFinished ?? onTestFinished)(() => { if (process.env.VITEST_FS_CLEANUP !== 'false') { fs.rmSync(root, { recursive: true, force: true }) } }) task?.context.signal.addEventListener('abort', () => { if (process.env.VITEST_FS_CLEANUP !== 'false') { fs.rmSync(root, { recursive: true, force: true }) } }) return { root, readdir: (file: string): string[] => { return fs.readdirSync(resolve(root, file)) }, readFile: (file: string): string => { const filepath = resolve(root, file) if (relative(root, filepath).startsWith('..')) { throw new Error(`file ${file} is outside of the test file system`) } return fs.readFileSync(filepath, 'utf-8') }, editFile: (file: string, callback: (content: string) => string) => { const filepath = resolve(root, file) if (!files.has(filepath)) { throw new Error(`file ${file} is outside of the test file system`) } const content = fs.readFileSync(filepath, 'utf-8') fs.writeFileSync(filepath, callback(content)) }, createFile: (file: string, content: string) => { if (file.startsWith('..')) { throw new Error(`file ${file} is outside of the test file system`) } const filepath = resolve(root, file) if (files.has(filepath)) { throw new Error(`file ${file} already exists in the test file system`) } files.add(filepath) createFile(filepath, content) }, statFile: (file: string): fs.Stats => { const filepath = resolve(root, file) if (relative(root, filepath).startsWith('..')) { throw new Error(`file ${file} is outside of the test file system`) } return fs.statSync(filepath) }, resolveFile: (file: string): string => { return resolve(root, file) }, renameFile: (oldFile: string, newFile: string) => { const oldFilepath = resolve(root, oldFile) const newFilepath = resolve(root, newFile) return fs.renameSync(oldFilepath, newFilepath) }, } } export async function runInlineTests( structure: TestFsStructure, config?: RunVitestConfig, options?: VitestRunnerCLIOptions, task?: TestContext['task'], ) { const fs = useTmpFS(structure, undefined, task ?? TestRunner.getCurrentTest()) const vitest = await runVitest( { root: fs.root, ...config, }, config?.$cliFilters ?? [], options, ) return { fs, root: fs.root, ...vitest, get results() { return vitest.ctx?.state.getTestModules() || [] }, testTree() { return buildTestTree(vitest.ctx?.state.getTestModules() || []) }, buildTree(onResult: (testResult: TestCase) => any) { return buildTestTree(vitest.ctx?.state.getTestModules() || [], onResult) }, } } const isWindows = process.platform === 'win32' export function replaceRoot(string: string, root: string) { const schemaRoot = root.startsWith('file://') ? root : pathToFileURL(root).toString() if (!root.endsWith('/') && !isWindows) { root += '?/' } if (!isWindows) { return string .replace(new RegExp(schemaRoot, 'g'), '') .replace(new RegExp(root, 'g'), '/') } let unixRoot = root.replace(/\\/g, '/') let win32Root = root.replaceAll('/', '\\\\') if (!root.endsWith('/') && !root.endsWith('\\')) { unixRoot += '?/' win32Root += '?\\\\' } return string .replace(new RegExp(schemaRoot, 'gi'), '') .replace(new RegExp(unixRoot, 'gi'), '/') .replace(new RegExp(win32Root, 'gi'), '/') } export const ts = String.raw export class StableTestFileOrderSorter { sort(files: TestSpecification[]) { return files.sort((a, b) => a.moduleId.localeCompare(b.moduleId)) } shard(files: TestSpecification[]) { return files } } export interface BuildErrorTreeOptions { stackTrace?: boolean diff?: boolean fileLabel?: boolean } export function buildErrorTree(testModules: TestModule[], options?: BuildErrorTreeOptions) { const root = testModules[0]?.project.config.root function mapError(e: { message: string; diff?: string; stacks?: ParsedStack[] }) { let message = stripVTControlCharacters(e.message) if (root) { message = replaceRoot(message, root) } if (options?.diff && e.diff) { message = [message, stripVTControlCharacters(e.diff)].join('\n') } if (options?.stackTrace) { const stacks = (e.stacks || []).map((s) => { const loc = `${relative(root, s.file)}:${s.line}:${s.column}` return s.method ? ` at ${s.method} (${loc})` : ` at ${loc}` }) message = [message, ...stacks].join('\n') } return message } return buildTestTree( testModules, (testCase) => { const result = testCase.result() if (result.state === 'failed') { return result.errors.map((e) => mapError(e)) } return result.state }, (testSuite, suiteChildren) => { const errors = testSuite.errors() if (errors.length > 0) { return { ...suiteChildren, __suite_errors__: errors.map((e) => mapError(e)), } } return suiteChildren }, (testModule, moduleChildren) => { const errors = testModule.errors() if (errors.length > 0) { return { ...moduleChildren, __module_errors__: errors.map((e) => mapError(e)), } } return moduleChildren }, options?.fileLabel ? (module) => `${module.relativeModuleId} (${module.meta().__vitest_label__})` : undefined, ) } export function buildTestTree( testModules: TestModule[], onTestCase?: (result: TestCase) => unknown, onTestSuite?: (testSuite: TestSuite, suiteChildren: Record) => unknown, onTestModule?: (testModule: TestModule, moduleChildren: Record) => unknown, onTestModuleKey?: (testModule: TestModule) => string, ) { type TestTree = Record function walkCollection(collection: TestCollection): TestTree { const node: TestTree = {} for (const child of collection) { if (child.type === 'suite') { // Recursively walk suite children const suiteChildren = walkCollection(child.children) node[child.name] = onTestSuite ? onTestSuite(child, suiteChildren) : suiteChildren } else if (child.type === 'test') { const result = child.result() if (onTestCase) { node[child.name] = onTestCase(child) } else { node[child.name] = result.state } } } return node } const tree: TestTree = {} for (const module of testModules) { // Use relative module ID for cleaner output const key = onTestModuleKey ? onTestModuleKey(module) : module.relativeModuleId const moduleChildren = walkCollection(module.children) tree[key] = onTestModule ? onTestModule(module, moduleChildren) : moduleChildren } return tree } export function buildTestProjectTree( testModules: TestModule[], onTestCase?: (result: TestCase) => unknown, ) { const projectTree: Record> = {} for (const testModule of testModules) { const projectName = testModule.project.name projectTree[projectName] = { ...projectTree[projectName], ...buildTestTree([testModule], onTestCase), } } return projectTree } export function buildErrorProjectTree(testModules: TestModule[], options?: BuildErrorTreeOptions) { const projectTree: Record> = {} for (const testModule of testModules) { const projectName = testModule.project.name projectTree[projectName] = { ...projectTree[projectName], ...buildErrorTree([testModule], options), } } return projectTree } function removeUndefinedValues>(obj: T): T { for (const key of Object.keys(obj)) { if (obj[key] === undefined) { delete obj[key] } } return obj }