@stryker-mutator/vitest-runner
Version:
A plugin to use the vitest test runner and framework in Stryker, the JavaScript mutation testing framework
340 lines (314 loc) • 10.7 kB
text/typescript
import { fileURLToPath } from 'url';
import path from 'path';
import semver from 'semver';
import fs from 'fs';
import {
CoverageData,
INSTRUMENTER_CONSTANTS,
MutantCoverage,
StrykerOptions,
} from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
import {
commonTokens,
Injector,
PluginContext,
tokens,
} from '@stryker-mutator/api/plugin';
import {
TestRunner,
DryRunResult,
MutantRunOptions,
MutantRunResult,
TestRunnerCapabilities,
DryRunStatus,
toMutantRunResult,
determineHitLimitReached,
TestStatus,
DryRunOptions,
} from '@stryker-mutator/api/test-runner';
import {
errorToString,
escapeRegExp,
normalizeFileName,
notEmpty,
testFilesProvided,
} from '@stryker-mutator/util';
import { vitestWrapper, Vitest } from './vitest-wrapper.js';
import {
convertTestToTestResult,
fromTestId,
collectTestsFromSuite,
normalizeCoverage,
isErrorCodeError,
VITEST_ERROR_CODES,
} from './vitest-helpers.js';
import { VitestRunnerOptionsWithStrykerOptions } from './vitest-runner-options-with-stryker-options.js';
type StrykerNamespace = '__stryker__' | '__stryker2__';
const STRYKER_SETUP = fileURLToPath(
new URL('./stryker-setup.js', import.meta.url),
);
interface RunFilter {
/**
* Run only tests with the specified IDs
*/
testIds?: string[];
/**
* Run only tests that cover a list of source files
* @see https://vitest.dev/guide/cli.html#vitest-related
*/
relatedFiles?: string[];
/**
* Run only tests from the specified test files (absolute paths)
*/
testFiles?: string[];
}
export class VitestTestRunner implements TestRunner {
public static inject = [
commonTokens.options,
commonTokens.logger,
'globalNamespace',
] as const;
private ctx?: Vitest;
private readonly options: VitestRunnerOptionsWithStrykerOptions;
private localSetupFile = path.resolve(
`./stryker-setup-${process.env.STRYKER_MUTATOR_WORKER ?? 0}.js`,
);
constructor(
options: StrykerOptions,
private readonly log: Logger,
private globalNamespace: StrykerNamespace,
) {
this.options = options as VitestRunnerOptionsWithStrykerOptions;
}
public capabilities(): TestRunnerCapabilities {
return { reloadEnvironment: true };
}
public async init(): Promise<void> {
this.setEnv();
await fs.promises.copyFile(STRYKER_SETUP, this.localSetupFile);
this.ctx = await vitestWrapper.createVitest('test', {
config: this.options.vitest?.configFile,
// @ts-expect-error threads got renamed to "pool: threads" in vitest 1.0.0
threads: true,
pool: 'threads',
coverage: { enabled: false },
poolOptions: {
// Since vitest 1.0.0
threads: {
maxThreads: 1,
minThreads: 1,
},
},
maxWorkers: 1,
singleThread: false,
maxConcurrency: 1,
watch: false,
dir: this.options.vitest.dir,
bail: this.options.disableBail ? 0 : 1,
onConsoleLog: () => false,
});
this.ctx.provide('globalNamespace', this.globalNamespace);
this.ctx.provide(
'isGreaterThanVitest4Point1',
semver.satisfies(vitestWrapper.version, '>=4.1.0'),
);
this.ctx.config.browser.screenshotFailures = false;
this.ctx.projects.forEach((project) => {
project.config.setupFiles = [
this.localSetupFile,
...project.config.setupFiles,
];
project.config.browser.screenshotFailures = false;
});
if (this.log.isDebugEnabled()) {
this.log.debug(
`vitest final config: ${JSON.stringify(this.ctx.config, null, 2)}`,
);
}
}
public async dryRun(options: DryRunOptions): Promise<DryRunResult> {
this.ctx!.provide('mode', 'dry-run');
// If testFilter is provided, use those files directly instead of relying on related files
// We still need to pass relatedFiles for vitest to properly resolve the test files
const testResult = testFilesProvided(options)
? await this.run({
testFiles: options.testFiles,
relatedFiles: options.files,
})
: await this.run({ relatedFiles: options.files });
if (
testResult.status === DryRunStatus.Complete &&
testResult.tests.length === 0 &&
this.options.vitest.related &&
!options.testFiles
) {
this.log.warn(
'Vitest failed to find test files related to mutated files. Either disable `vitest.related` or import your source files directly from your test files. See https://stryker-mutator.io/docs/stryker-js/troubleshooting/#vitest-failed-to-find-test-files-related-to-mutated-files',
);
}
const mutantCoverage = this.readMutantCoverage();
if (testResult.status === DryRunStatus.Complete) {
return {
status: testResult.status,
tests: testResult.tests,
mutantCoverage,
};
}
return testResult;
}
public async mutantRun(options: MutantRunOptions): Promise<MutantRunResult> {
this.ctx!.provide('mode', 'mutant');
this.ctx!.provide('hitLimit', options.hitLimit);
this.ctx!.provide('mutantActivation', options.mutantActivation);
this.ctx!.provide('activeMutant', options.activeMutant.id);
const dryRunResult = await this.run({
testIds: options.testFilter,
relatedFiles: [options.sandboxFileName],
});
const hitCount = this.readHitCount();
const timeOut = determineHitLimitReached(hitCount, options.hitLimit);
return toMutantRunResult(timeOut ?? dryRunResult);
}
private async run({
testIds = [],
relatedFiles,
testFiles: explicitTestFiles,
}: RunFilter = {}): Promise<DryRunResult> {
this.resetContext();
this.ctx!.config.related =
this.options.vitest.related && relatedFiles
? relatedFiles.map(normalizeFileName)
: undefined;
let testFilesToRun: string[] | undefined = explicitTestFiles;
if (testIds.length > 0) {
const parsedTests = testIds.map(fromTestId);
const regexTestNameFilter = parsedTests
.map(({ test: name }) => escapeRegExp(name))
.join('|');
const regex = new RegExp(regexTestNameFilter);
testFilesToRun = parsedTests.map(({ file }) => file);
this.ctx!.projects.forEach((project) => {
project.config.testNamePattern = regex;
});
} else {
this.ctx!.projects.forEach((project) => {
project.config.testNamePattern = undefined;
});
}
try {
await this.ctx!.start(testFilesToRun);
} catch (error) {
if (
// No tests found, this isn't a problem, we can continue
!isErrorCodeError(error) ||
VITEST_ERROR_CODES.FILES_NOT_FOUND !== error.code
) {
throw error;
}
}
const tests = this.ctx!.state.getFiles()
.flatMap((file) => collectTestsFromSuite(file))
.filter((test) => test.result); // if no result: it was skipped because of bail
let failure = false;
const testResults = tests.map((test) => {
const testResult = convertTestToTestResult(test);
failure ||= testResult.status === TestStatus.Failed;
return testResult;
});
if (!failure && this.ctx!.state.errorsSet.size > 0) {
const errorText = [...this.ctx!.state.errorsSet]
.map(errorToString)
.join('\n');
return {
status: DryRunStatus.Error,
errorMessage: `An error occurred outside of a test run: ${errorText}`,
};
}
return { tests: testResults, status: DryRunStatus.Complete };
}
private setEnv() {
// Set node environment for issues like these: https://github.com/stryker-mutator/stryker-js/issues/4289
process.env.NODE_ENV = 'test';
// Set vitest environment to signal that we are running in vitest
// as some plugins only initiate when this is set: https://github.com/testing-library/svelte-testing-library/blob/6096f05e805cf55474f52f303562f4013785d25f/src/vite.js#L20
process.env.VITEST = '1';
}
private resetContext() {
// Clear the state from the previous run
// Note that this is kind of a hack, see https://github.com/vitest-dev/vitest/discussions/3017#discussioncomment-5901751
this.ctx!.state.filesMap.clear();
}
private readHitCount() {
const hitCounters: number[] = this.ctx!.state.getFiles()
.map((file) => (file.meta as { hitCount?: number }).hitCount)
.filter(notEmpty);
return hitCounters.reduce((acc, hitCount) => acc + hitCount, 0);
}
private readMutantCoverage(): MutantCoverage {
// Read coverage from all projects
const coverages: MutantCoverage[] = [
...new Map(
this.ctx!.state.getFiles().map(
(file) => [`${file.projectName}-${file.name}`, file] as const,
),
).entries(),
]
.map(
([, file]) =>
(file.meta as { mutantCoverage?: MutantCoverage }).mutantCoverage,
)
.filter(notEmpty)
.map(normalizeCoverage);
if (coverages.length > 1) {
return coverages.reduce((acc, projectCoverage) => {
// perTest contains the coverage per test id
Object.entries(projectCoverage.perTest).forEach(
([testId, testCoverage]) => {
if (testId in acc.perTest) {
// Keys are mutant ids, the numbers are the amount of times it was hit.
mergeCoverage(acc.perTest[testId], testCoverage);
} else {
acc.perTest[testId] = testCoverage;
}
},
);
mergeCoverage(acc.static, projectCoverage.static);
return acc;
});
}
return coverages[0];
function mergeCoverage(to: CoverageData, from: CoverageData) {
Object.entries(from).forEach(([mutantId, hitCount]) => {
if (mutantId in to) {
to[mutantId] += hitCount;
} else {
to[mutantId] = hitCount;
}
});
}
}
public async dispose(): Promise<void> {
this.ctx?.onClose(async () => {
await fs.promises.rm(this.localSetupFile, { force: true });
});
await this.ctx?.close();
}
}
export const vitestTestRunnerFactory = createVitestTestRunnerFactory();
export function createVitestTestRunnerFactory(
namespace:
| typeof INSTRUMENTER_CONSTANTS.NAMESPACE
| '__stryker2__' = INSTRUMENTER_CONSTANTS.NAMESPACE,
): {
(injector: Injector<PluginContext>): VitestTestRunner;
inject: ['$injector'];
} {
createVitestTestRunner.inject = tokens(commonTokens.injector);
function createVitestTestRunner(injector: Injector<PluginContext>) {
return injector
.provideValue('globalNamespace', namespace)
.injectClass(VitestTestRunner);
}
return createVitestTestRunner;
}