Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export class VitestExecutor implements TestExecutor {
explicitBrowser: [],
explicitServer: [],
};
private readonly logLevel: number;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we switch from numbers to an enum?

Using numbers increases the cognitive load when parsing the file, as one has to mentally map numbers like 1 or 2 to their true meanings and an enum would make the logic self-documenting


// 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
Expand All @@ -54,27 +55,56 @@ export class VitestExecutor implements TestExecutor {
testEntryPointMappings: Map<string, string> | 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<BuilderOutput> {
this.log(1, `Executing test run (kind: ${buildResult.kind}).`);
this.normalizePath ??= (await import('vite')).normalizePath;

if (buildResult.kind === ResultKind.Full) {
this.buildResultFiles.clear();
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));
}
Expand All @@ -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
Expand All @@ -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)),
Expand All @@ -120,28 +160,38 @@ 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.');
}
}

// Check if all the tests pass to calculate the result
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<void> {
this.log(1, 'Disposing VitestExecutor: Closing Vitest instance.');
await this.vitest?.close();
this.log(1, 'Vitest instance closed.');
}

private prepareSetupFiles(): string[] {
Expand All @@ -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<Vitest> {
this.log(1, 'Initializing Vitest.');
const {
coverage,
reporters,
Expand All @@ -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;
}
Expand All @@ -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'));
}

Expand All @@ -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',
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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);
}
}