@stryker-mutator/core
Version:
The extendable JavaScript mutation testing framework
215 lines (191 loc) • 8.76 kB
text/typescript
import { EOL } from 'os';
import { I, requireResolve } from '@stryker-mutator/util';
import { Logger } from '@stryker-mutator/api/logging';
import { commonTokens, tokens, Injector } from '@stryker-mutator/api/plugin';
import { StrykerOptions, Mutant } from '@stryker-mutator/api/core';
import { DryRunCompletedEvent, RunTiming } from '@stryker-mutator/api/report';
import {
DryRunResult,
TestRunner,
DryRunStatus,
CompleteDryRunResult,
TestStatus,
TestResult,
FailedTestResult,
ErrorDryRunResult,
} from '@stryker-mutator/api/test-runner';
import { lastValueFrom, of } from 'rxjs';
import { coreTokens } from '../di/index.js';
import { Sandbox } from '../sandbox/sandbox.js';
import { Timer } from '../utils/timer.js';
import { createTestRunnerFactory } from '../test-runner/index.js';
import { MutationTestReportHelper } from '../reporters/mutation-test-report-helper.js';
import { ConfigError } from '../errors.js';
import { ConcurrencyTokenProvider, Pool, createTestRunnerPool } from '../concurrent/index.js';
import { FileMatcher } from '../config/index.js';
import { IncrementalDiffer, MutantTestPlanner, TestCoverage } from '../mutants/index.js';
import { CheckerFacade } from '../checker/index.js';
import { StrictReporter } from '../reporters/index.js';
import { objectUtils } from '../utils/object-utils.js';
import { IdGenerator } from '../child-proxy/id-generator.js';
import { MutationTestContext } from './4-mutation-test-executor.js';
import { MutantInstrumenterContext } from './2-mutant-instrumenter-executor.js';
const INITIAL_TEST_RUN_MARKER = 'Initial test run';
export interface DryRunContext extends MutantInstrumenterContext {
[coreTokens.sandbox]: I<Sandbox>;
[coreTokens.mutants]: readonly Mutant[];
[coreTokens.checkerPool]: I<Pool<I<CheckerFacade>>>;
[coreTokens.concurrencyTokenProvider]: I<ConcurrencyTokenProvider>;
}
function isFailedTest(testResult: TestResult): testResult is FailedTestResult {
return testResult.status === TestStatus.Failed;
}
export class DryRunExecutor {
public static readonly inject = tokens(
commonTokens.injector,
commonTokens.logger,
commonTokens.options,
coreTokens.timer,
coreTokens.concurrencyTokenProvider,
coreTokens.sandbox,
coreTokens.reporter,
);
constructor(
private readonly injector: Injector<DryRunContext>,
private readonly log: Logger,
private readonly options: StrykerOptions,
private readonly timer: I<Timer>,
private readonly concurrencyTokenProvider: I<ConcurrencyTokenProvider>,
private readonly sandbox: I<Sandbox>,
private readonly reporter: StrictReporter,
) {}
public async execute(): Promise<Injector<MutationTestContext>> {
const testRunnerInjector = this.injector
.provideClass(coreTokens.workerIdGenerator, IdGenerator)
.provideFactory(coreTokens.testRunnerFactory, createTestRunnerFactory)
.provideValue(coreTokens.testRunnerConcurrencyTokens, this.concurrencyTokenProvider.testRunnerToken$)
.provideFactory(coreTokens.testRunnerPool, createTestRunnerPool);
const testRunnerPool = testRunnerInjector.resolve(coreTokens.testRunnerPool);
const { result, timing } = await lastValueFrom(testRunnerPool.schedule(of(0), (testRunner) => this.executeDryRun(testRunner)));
this.logInitialTestRunSucceeded(result.tests, timing);
if (!result.tests.length && !this.options.allowEmpty) {
throw new ConfigError('No tests were executed. Stryker will exit prematurely. Please check your configuration.');
}
return testRunnerInjector
.provideValue(coreTokens.timeOverheadMS, timing.overhead)
.provideValue(coreTokens.dryRunResult, result)
.provideValue(coreTokens.requireFromCwd, requireResolve)
.provideFactory(coreTokens.testCoverage, TestCoverage.from)
.provideClass(coreTokens.incrementalDiffer, IncrementalDiffer)
.provideClass(coreTokens.mutantTestPlanner, MutantTestPlanner)
.provideClass(coreTokens.mutationTestReportHelper, MutationTestReportHelper)
.provideClass(coreTokens.workerIdGenerator, IdGenerator);
}
private validateResultCompleted(runResult: DryRunResult): asserts runResult is CompleteDryRunResult {
switch (runResult.status) {
case DryRunStatus.Complete: {
const failedTests = runResult.tests.filter(isFailedTest);
if (failedTests.length) {
this.logFailedTestsInInitialRun(failedTests);
throw new ConfigError('There were failed tests in the initial test run.');
}
return;
}
case DryRunStatus.Error:
this.logErrorsInInitialRun(runResult);
break;
case DryRunStatus.Timeout:
this.logTimeoutInitialRun();
break;
}
throw new Error('Something went wrong in the initial test run');
}
private async executeDryRun(testRunner: TestRunner): Promise<DryRunCompletedEvent> {
if (this.options.dryRunOnly) {
this.log.info('Note: running the dry-run only. No mutations will be tested.');
}
const dryRunTimeout = this.options.dryRunTimeoutMinutes * 1000 * 60;
const project = this.injector.resolve(coreTokens.project);
const dryRunFiles = objectUtils.map(project.filesToMutate, (_, name) => this.sandbox.sandboxFileFor(name));
this.timer.mark(INITIAL_TEST_RUN_MARKER);
this.log.info(
`Starting initial test run (${this.options.testRunner} test runner with "${this.options.coverageAnalysis}" coverage analysis). This may take a while.`,
);
this.log.debug(`Using timeout of ${dryRunTimeout} ms.`);
const result = await testRunner.dryRun({
timeout: dryRunTimeout,
coverageAnalysis: this.options.coverageAnalysis,
disableBail: this.options.disableBail,
files: dryRunFiles,
});
const grossTimeMS = this.timer.elapsedMs(INITIAL_TEST_RUN_MARKER);
const capabilities = await testRunner.capabilities();
this.validateResultCompleted(result);
this.remapSandboxFilesToOriginalFiles(result);
const timing = this.calculateTiming(grossTimeMS, result.tests);
const dryRunCompleted = { result, timing, capabilities };
this.reporter.onDryRunCompleted(dryRunCompleted);
return dryRunCompleted;
}
/**
* Remaps test files to their respective original names outside the sandbox.
* @param dryRunResult the completed result
*/
private remapSandboxFilesToOriginalFiles(dryRunResult: CompleteDryRunResult) {
const disableTypeCheckingFileMatcher = new FileMatcher(this.options.disableTypeChecks);
dryRunResult.tests.forEach((test) => {
if (test.fileName) {
test.fileName = this.sandbox.originalFileFor(test.fileName);
// HACK line numbers of the tests can be offset by 1 because the disable type checks preprocessor could have added a `// @ts-nocheck` line.
// We correct for that here if needed
// If we do more complex stuff in sandbox preprocessing in the future, we might want to add a robust remapping logic
if (test.startPosition && disableTypeCheckingFileMatcher.matches(test.fileName)) {
test.startPosition.line--;
}
}
});
}
private logInitialTestRunSucceeded(tests: TestResult[], timing: RunTiming) {
if (!tests.length) {
this.log.info('No tests were found');
return;
}
this.log.info(
'Initial test run succeeded. Ran %s tests in %s (net %s ms, overhead %s ms).',
tests.length,
this.timer.humanReadableElapsed(INITIAL_TEST_RUN_MARKER),
timing.net,
timing.overhead,
);
}
/**
* Calculates the timing variables for the test run.
* grossTime = NetTime + overheadTime
*
* The overhead time is used to calculate exact timeout values during mutation testing.
* See timeoutMS setting in README for more information on this calculation
*/
private calculateTiming(grossTimeMS: number, tests: readonly TestResult[]): RunTiming {
const netTimeMS = tests.reduce((total, test) => total + test.timeSpentMs, 0);
const overheadTimeMS = grossTimeMS - netTimeMS;
return {
net: netTimeMS,
overhead: overheadTimeMS < 0 ? 0 : overheadTimeMS,
};
}
private logFailedTestsInInitialRun(failedTests: FailedTestResult[]): void {
let message = 'One or more tests failed in the initial test run:';
failedTests.forEach((test) => {
message += `${EOL}\t${test.name}`;
message += `${EOL}\t\t${test.failureMessage}`;
});
this.log.error(message);
}
private logErrorsInInitialRun(runResult: ErrorDryRunResult) {
const message = `One or more tests resulted in an error:${EOL}\t${runResult.errorMessage}`;
this.log.error(message);
}
private logTimeoutInitialRun() {
this.log.error('Initial test run timed out!');
}
}