UNPKG

@stryker-mutator/vitest-runner

Version:

A plugin to use the vitest test runner and framework in Stryker, the JavaScript mutation testing framework

233 lines 10.1 kB
import { fileURLToPath } from 'url'; import path from 'path'; import semver from 'semver'; import fs from 'fs'; import { INSTRUMENTER_CONSTANTS, } from '@stryker-mutator/api/core'; import { commonTokens, tokens, } from '@stryker-mutator/api/plugin'; import { DryRunStatus, toMutantRunResult, determineHitLimitReached, TestStatus, } from '@stryker-mutator/api/test-runner'; import { errorToString, escapeRegExp, normalizeFileName, notEmpty, testFilesProvided, } from '@stryker-mutator/util'; import { vitestWrapper } from './vitest-wrapper.js'; import { convertTestToTestResult, fromTestId, collectTestsFromSuite, normalizeCoverage, isErrorCodeError, VITEST_ERROR_CODES, } from './vitest-helpers.js'; const STRYKER_SETUP = fileURLToPath(new URL('./stryker-setup.js', import.meta.url)); export class VitestTestRunner { log; globalNamespace; static inject = [ commonTokens.options, commonTokens.logger, 'globalNamespace', ]; ctx; options; localSetupFile = path.resolve(`./stryker-setup-${process.env.STRYKER_MUTATOR_WORKER ?? 0}.js`); constructor(options, log, globalNamespace) { this.log = log; this.globalNamespace = globalNamespace; this.options = options; } capabilities() { return { reloadEnvironment: true }; } async init() { 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)}`); } } async dryRun(options) { 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; } async mutantRun(options) { 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); } async run({ testIds = [], relatedFiles, testFiles: explicitTestFiles, } = {}) { this.resetContext(); this.ctx.config.related = this.options.vitest.related && relatedFiles ? relatedFiles.map(normalizeFileName) : undefined; let testFilesToRun = 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 }; } 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'; } 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(); } readHitCount() { const hitCounters = this.ctx.state.getFiles() .map((file) => file.meta.hitCount) .filter(notEmpty); return hitCounters.reduce((acc, hitCount) => acc + hitCount, 0); } readMutantCoverage() { // Read coverage from all projects const coverages = [ ...new Map(this.ctx.state.getFiles().map((file) => [`${file.projectName}-${file.name}`, file])).entries(), ] .map(([, file]) => file.meta.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, from) { Object.entries(from).forEach(([mutantId, hitCount]) => { if (mutantId in to) { to[mutantId] += hitCount; } else { to[mutantId] = hitCount; } }); } } async dispose() { this.ctx?.onClose(async () => { await fs.promises.rm(this.localSetupFile, { force: true }); }); await this.ctx?.close(); } } export const vitestTestRunnerFactory = createVitestTestRunnerFactory(); export function createVitestTestRunnerFactory(namespace = INSTRUMENTER_CONSTANTS.NAMESPACE) { createVitestTestRunner.inject = tokens(commonTokens.injector); function createVitestTestRunner(injector) { return injector .provideValue('globalNamespace', namespace) .injectClass(VitestTestRunner); } return createVitestTestRunner; } //# sourceMappingURL=vitest-test-runner.js.map