From bae8febb13d2b7eac1d1fb30ef1d94463dfc49b4 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:46:13 -0500 Subject: [PATCH] refactor(@angular/build): add debug logging to Vitest executor This commit introduces a multi-level debug logging system within the Vitest test runner's executor. This logging is intended to aid in troubleshooting internal issues and provide deeper insights into the test execution process. Logging is controlled by the `NG_TEST_LOG` environment variable, which accepts numeric levels (0 for off, 1 for debug, 2 for verbose). Crucial lifecycle events and state changes in the `VitestExecutor` are now logged, such as constructor instantiation, build result processing (full and incremental), test re-run specifications, Vitest initialization details (including browser configuration, external configuration path, and included files), and executor disposal. These logs are guarded by the `logLevel` to prevent any performance penalties during normal execution. Furthermore, specific areas like incremental run file mappings and external dependency metadata are logged at a verbose level. --- .../unit-test/runners/vitest/executor.ts | 135 +++++++++++++----- 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index 5ff9ac1b443f..0fbd83083e42 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -40,6 +40,7 @@ export class VitestExecutor implements TestExecutor { explicitBrowser: [], explicitServer: [], }; + private readonly logLevel: number; // This is a reverse map of the entry points created in `build-options.ts`. // It is used by the in-memory provider plugin to map the requested test file @@ -54,19 +55,38 @@ export class VitestExecutor implements TestExecutor { testEntryPointMappings: Map | undefined, logger: BuilderContext['logger'], ) { + const level = parseInt(process.env['NG_TEST_LOG'] ?? '0', 10); + this.logLevel = isNaN(level) ? 0 : level; + this.projectName = projectName; this.options = options; this.logger = logger; + this.log(1, 'VitestExecutor instantiated.'); + this.log(2, 'NormalizedUnitTestBuilderOptions:', options); + if (testEntryPointMappings) { for (const [entryPoint, testFile] of testEntryPointMappings) { this.testFileToEntryPoint.set(testFile, entryPoint); this.entryPointToTestFile.set(entryPoint + '.js', testFile); } + this.log(2, 'Test entry point mappings:', Object.fromEntries(testEntryPointMappings)); + } + } + + private log(level: number, message: string, data?: object) { + if (this.logLevel < level) { + return; } + + const formattedMessage = `[VitestExecutor:L${level}] ${message}`; + // Custom formatting for data object to ensure it's readable + const logData = data ? JSON.stringify(data, null, 2) : ''; + this.logger.info(`${formattedMessage}${logData ? `\n${logData}` : ''}`); } async *execute(buildResult: FullResult | IncrementalResult): AsyncIterable { + this.log(1, `Executing test run (kind: ${buildResult.kind}).`); this.normalizePath ??= (await import('vite')).normalizePath; if (buildResult.kind === ResultKind.Full) { @@ -74,7 +94,17 @@ export class VitestExecutor implements TestExecutor { for (const [path, file] of Object.entries(buildResult.files)) { this.buildResultFiles.set(this.normalizePath(path), file); } + this.log(1, `Full build results received. Total files: ${this.buildResultFiles.size}.`); } else { + this.log( + 1, + `Incremental build results received.` + + `Added: ${buildResult.added.length}, Modified: ${buildResult.modified.length}, Removed: ${buildResult.removed.length}.`, + ); + this.log(2, 'Added files:', buildResult.added); + this.log(2, 'Modified files:', buildResult.modified); + this.log(2, 'Removed files:', buildResult.removed); + for (const file of buildResult.removed) { this.buildResultFiles.delete(this.normalizePath(file.path)); } @@ -84,6 +114,7 @@ export class VitestExecutor implements TestExecutor { } updateExternalMetadata(buildResult, this.externalMetadata, undefined, true); + this.log(2, 'Updated external metadata:', this.externalMetadata); // Reset the exit code to allow for a clean state. // This is necessary because Vitest may set the exit code on failure, which can @@ -103,7 +134,16 @@ export class VitestExecutor implements TestExecutor { // We need to find the original source file path to pass to Vitest. const source = this.entryPointToTestFile.get(modifiedFile); if (source) { + this.log( + 2, + `Mapped output file '${modifiedFile}' to source file '${source}' for re-run.`, + ); modifiedSourceFiles.add(source); + } else { + this.log( + 2, + `Could not map output file '${modifiedFile}' to a source file. It may not be a test file.`, + ); } vitest.invalidateFile( this.normalizePath(path.join(this.options.workspaceRoot, modifiedFile)), @@ -120,7 +160,11 @@ export class VitestExecutor implements TestExecutor { } if (specsToRerun.length > 0) { + this.log(1, `Re-running ${specsToRerun.length} test specifications.`); + this.log(2, 'Specs to rerun:', specsToRerun); testResults = await vitest.rerunTestSpecifications(specsToRerun); + } else { + this.log(1, 'No test specifications to rerun.'); } } @@ -128,20 +172,26 @@ export class VitestExecutor implements TestExecutor { const testModules = testResults?.testModules ?? this.vitest.state.getTestModules(); let success = testModules.every((testModule) => testModule.ok()); + let finalResultReason = 'All tests passed.'; + // Vitest does not return a failure result when coverage thresholds are not met. // Instead, it sets the process exit code to 1. // We check this exit code to determine if the test run should be considered a failure. if (success && process.exitCode === 1) { success = false; + finalResultReason = 'Test run failed due to unmet coverage thresholds.'; // Reset the exit code to prevent it from carrying over to subsequent runs/builds process.exitCode = 0; } + this.log(1, `Test run finished with success: ${success}. Reason: ${finalResultReason}`); yield { success }; } async [Symbol.asyncDispose](): Promise { + this.log(1, 'Disposing VitestExecutor: Closing Vitest instance.'); await this.vitest?.close(); + this.log(1, 'Vitest instance closed.'); } private prepareSetupFiles(): string[] { @@ -154,10 +204,13 @@ export class VitestExecutor implements TestExecutor { testSetupFiles.unshift('polyfills.js'); } + this.log(1, 'Prepared setup files:', testSetupFiles); + return testSetupFiles; } private async initializeVitest(): Promise { + this.log(1, 'Initializing Vitest.'); const { coverage, reporters, @@ -180,6 +233,7 @@ export class VitestExecutor implements TestExecutor { vitestNodeModule = await import('vitest/node'); } catch (error: unknown) { assertIsError(error); + this.log(1, `Failed to import 'vitest/node'. Error code: ${error.code}`); if (error.code !== 'ERR_MODULE_NOT_FOUND') { throw error; } @@ -199,6 +253,7 @@ export class VitestExecutor implements TestExecutor { browserViewport, ); if (browserOptions.errors?.length) { + this.log(1, 'Browser configuration errors found.', { errors: browserOptions.errors }); throw new Error(browserOptions.errors.join('\n')); } @@ -207,7 +262,9 @@ export class VitestExecutor implements TestExecutor { this.logger.info(message); } } + this.log(1, 'Browser configuration complete.', { config: browserOptions.browser }); + this.log(1, `Verifying build results. File count: ${this.buildResultFiles.size}.`); assert( this.buildResultFiles.size > 0, 'buildResult must be available before initializing vitest', @@ -235,6 +292,8 @@ export class VitestExecutor implements TestExecutor { ? await findVitestBaseConfig([projectRoot, workspaceRoot]) : runnerConfig; + this.log(1, 'External Vitest configuration path:', { externalConfigPath }); + let project = projectName; if (debug && browserOptions.browser?.instances) { if (browserOptions.browser.instances.length > 1) { @@ -246,6 +305,7 @@ export class VitestExecutor implements TestExecutor { // When running browser tests, Vitest appends the browser name to the project identifier. // The project name must match this augmented name to ensure the correct project is targeted. project = `${projectName} (${browserOptions.browser.instances[0].browser})`; + this.log(1, 'Adjusted project name for debugging with browser:', { project }); } // Filter internal entries and setup files from the include list @@ -256,43 +316,46 @@ export class VitestExecutor implements TestExecutor { !internalEntries.some((internal) => entry.startsWith(internal)) && !setupFileSet.has(entry) ); }); + this.log(2, 'Included test files (after filtering):', include); - return startVitest( - 'test', - undefined, - { - config: externalConfigPath, - root: workspaceRoot, - project, - outputFile, - cache: cacheOptions.enabled ? undefined : false, - testNamePattern: this.options.filter, - watch, - ...(typeof ui === 'boolean' ? { ui } : {}), - ...debugOptions, - }, - { - // Note `.vitest` is auto appended to the path. - cacheDir: cacheOptions.path, - server: { - // Disable the actual file watcher. The boolean watch option above should still - // be enabled as it controls other internal behavior related to rerunning tests. - watch: null, - }, - plugins: [ - await createVitestConfigPlugin({ - browser: browserOptions.browser, - coverage, - projectName, - projectSourceRoot, - optimizeDepsInclude: this.externalMetadata.implicitBrowser, - reporters, - setupFiles: testSetupFiles, - projectPlugins, - include, - }), - ], + const vitestConfig = { + config: externalConfigPath, + root: workspaceRoot, + project, + outputFile, + cache: cacheOptions.enabled ? undefined : (false as const), + testNamePattern: this.options.filter, + watch, + ...(typeof ui === 'boolean' ? { ui } : {}), + ...debugOptions, + }; + const vitestServerConfig = { + // Note `.vitest` is auto appended to the path. + cacheDir: cacheOptions.path, + server: { + // Disable the actual file watcher. The boolean watch option above should still + // be enabled as it controls other internal behavior related to rerunning tests. + watch: null, }, - ); + plugins: [ + await createVitestConfigPlugin({ + browser: browserOptions.browser, + coverage, + projectName, + projectSourceRoot, + optimizeDepsInclude: this.externalMetadata.implicitBrowser, + reporters, + setupFiles: testSetupFiles, + projectPlugins, + include, + }), + ], + }; + + this.log(1, 'Calling startVitest with final configuration.'); + this.log(2, 'Vitest config:', vitestConfig); + this.log(2, 'Vitest server config:', vitestServerConfig); + + return startVitest('test', undefined, vitestConfig, vitestServerConfig); } }