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; [coreTokens.mutants]: readonly Mutant[]; [coreTokens.checkerPool]: I>>; [coreTokens.concurrencyTokenProvider]: I; } 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, private readonly log: Logger, private readonly options: StrykerOptions, private readonly timer: I, private readonly concurrencyTokenProvider: I, private readonly sandbox: I, private readonly reporter: StrictReporter ) {} public async execute(): Promise> { 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) { 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 { 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); this.validateResultCompleted(result); this.remapSandboxFilesToOriginalFiles(result); const timing = this.calculateTiming(grossTimeMS, result.tests); const dryRunCompleted = { result, timing }; 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) { 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!'); } }