UNPKG

8.55 kBPlain TextView Raw
1import { EOL } from 'os';
2
3import { I, requireResolve } from '@stryker-mutator/util';
4import { Logger } from '@stryker-mutator/api/logging';
5import { commonTokens, tokens, Injector } from '@stryker-mutator/api/plugin';
6import { StrykerOptions, Mutant } from '@stryker-mutator/api/core';
7import { DryRunCompletedEvent, RunTiming } from '@stryker-mutator/api/report';
8import {
9 DryRunResult,
10 TestRunner,
11 DryRunStatus,
12 CompleteDryRunResult,
13 TestStatus,
14 TestResult,
15 FailedTestResult,
16 ErrorDryRunResult,
17} from '@stryker-mutator/api/test-runner';
18import { lastValueFrom, of } from 'rxjs';
19
20import { coreTokens } from '../di/index.js';
21import { Sandbox } from '../sandbox/sandbox.js';
22import { Timer } from '../utils/timer.js';
23import { createTestRunnerFactory } from '../test-runner/index.js';
24import { MutationTestReportHelper } from '../reporters/mutation-test-report-helper.js';
25import { ConfigError } from '../errors.js';
26import { ConcurrencyTokenProvider, Pool, createTestRunnerPool } from '../concurrent/index.js';
27import { FileMatcher } from '../config/index.js';
28import { IncrementalDiffer, MutantTestPlanner, TestCoverage } from '../mutants/index.js';
29import { CheckerFacade } from '../checker/index.js';
30import { StrictReporter } from '../reporters/index.js';
31import { objectUtils } from '../utils/object-utils.js';
32
33import { IdGenerator } from '../child-proxy/id-generator.js';
34
35import { MutationTestContext } from './4-mutation-test-executor.js';
36import { MutantInstrumenterContext } from './2-mutant-instrumenter-executor.js';
37
38const INITIAL_TEST_RUN_MARKER = 'Initial test run';
39
40export interface DryRunContext extends MutantInstrumenterContext {
41 [coreTokens.sandbox]: I<Sandbox>;
42 [coreTokens.mutants]: readonly Mutant[];
43 [coreTokens.checkerPool]: I<Pool<I<CheckerFacade>>>;
44 [coreTokens.concurrencyTokenProvider]: I<ConcurrencyTokenProvider>;
45}
46
47function isFailedTest(testResult: TestResult): testResult is FailedTestResult {
48 return testResult.status === TestStatus.Failed;
49}
50
51export class DryRunExecutor {
52 public static readonly inject = tokens(
53 commonTokens.injector,
54 commonTokens.logger,
55 commonTokens.options,
56 coreTokens.timer,
57 coreTokens.concurrencyTokenProvider,
58 coreTokens.sandbox,
59 coreTokens.reporter
60 );
61
62 constructor(
63 private readonly injector: Injector<DryRunContext>,
64 private readonly log: Logger,
65 private readonly options: StrykerOptions,
66 private readonly timer: I<Timer>,
67 private readonly concurrencyTokenProvider: I<ConcurrencyTokenProvider>,
68 private readonly sandbox: I<Sandbox>,
69 private readonly reporter: StrictReporter
70 ) {}
71
72 public async execute(): Promise<Injector<MutationTestContext>> {
73 const testRunnerInjector = this.injector
74 .provideClass(coreTokens.workerIdGenerator, IdGenerator)
75 .provideFactory(coreTokens.testRunnerFactory, createTestRunnerFactory)
76 .provideValue(coreTokens.testRunnerConcurrencyTokens, this.concurrencyTokenProvider.testRunnerToken$)
77 .provideFactory(coreTokens.testRunnerPool, createTestRunnerPool);
78 const testRunnerPool = testRunnerInjector.resolve(coreTokens.testRunnerPool);
79 const { result, timing } = await lastValueFrom(testRunnerPool.schedule(of(0), (testRunner) => this.executeDryRun(testRunner)));
80
81 this.logInitialTestRunSucceeded(result.tests, timing);
82 if (!result.tests.length) {
83 throw new ConfigError('No tests were executed. Stryker will exit prematurely. Please check your configuration.');
84 }
85
86 return testRunnerInjector
87 .provideValue(coreTokens.timeOverheadMS, timing.overhead)
88 .provideValue(coreTokens.dryRunResult, result)
89 .provideValue(coreTokens.requireFromCwd, requireResolve)
90 .provideFactory(coreTokens.testCoverage, TestCoverage.from)
91 .provideClass(coreTokens.incrementalDiffer, IncrementalDiffer)
92 .provideClass(coreTokens.mutantTestPlanner, MutantTestPlanner)
93 .provideClass(coreTokens.mutationTestReportHelper, MutationTestReportHelper)
94 .provideClass(coreTokens.workerIdGenerator, IdGenerator);
95 }
96
97 private validateResultCompleted(runResult: DryRunResult): asserts runResult is CompleteDryRunResult {
98 switch (runResult.status) {
99 case DryRunStatus.Complete:
100 const failedTests = runResult.tests.filter(isFailedTest);
101 if (failedTests.length) {
102 this.logFailedTestsInInitialRun(failedTests);
103 throw new ConfigError('There were failed tests in the initial test run.');
104 }
105 return;
106 case DryRunStatus.Error:
107 this.logErrorsInInitialRun(runResult);
108 break;
109 case DryRunStatus.Timeout:
110 this.logTimeoutInitialRun();
111 break;
112 }
113 throw new Error('Something went wrong in the initial test run');
114 }
115
116 private async executeDryRun(testRunner: TestRunner): Promise<DryRunCompletedEvent> {
117 if (this.options.dryRunOnly) {
118 this.log.info('Note: running the dry-run only. No mutations will be tested.');
119 }
120
121 const dryRunTimeout = this.options.dryRunTimeoutMinutes * 1000 * 60;
122 const project = this.injector.resolve(coreTokens.project);
123 const dryRunFiles = objectUtils.map(project.filesToMutate, (_, name) => this.sandbox.sandboxFileFor(name));
124 this.timer.mark(INITIAL_TEST_RUN_MARKER);
125 this.log.info(
126 `Starting initial test run (${this.options.testRunner} test runner with "${this.options.coverageAnalysis}" coverage analysis). This may take a while.`
127 );
128 this.log.debug(`Using timeout of ${dryRunTimeout} ms.`);
129 const result = await testRunner.dryRun({
130 timeout: dryRunTimeout,
131 coverageAnalysis: this.options.coverageAnalysis,
132 disableBail: this.options.disableBail,
133 files: dryRunFiles,
134 });
135 const grossTimeMS = this.timer.elapsedMs(INITIAL_TEST_RUN_MARKER);
136 this.validateResultCompleted(result);
137
138 this.remapSandboxFilesToOriginalFiles(result);
139 const timing = this.calculateTiming(grossTimeMS, result.tests);
140 const dryRunCompleted = { result, timing };
141 this.reporter.onDryRunCompleted(dryRunCompleted);
142 return dryRunCompleted;
143 }
144
145 /**
146 * Remaps test files to their respective original names outside the sandbox.
147 * @param dryRunResult the completed result
148 */
149 private remapSandboxFilesToOriginalFiles(dryRunResult: CompleteDryRunResult) {
150 const disableTypeCheckingFileMatcher = new FileMatcher(this.options.disableTypeChecks);
151 dryRunResult.tests.forEach((test) => {
152 if (test.fileName) {
153 test.fileName = this.sandbox.originalFileFor(test.fileName);
154
155 // HACK line numbers of the tests can be offset by 1 because the disable type checks preprocessor could have added a `// @ts-nocheck` line.
156 // We correct for that here if needed
157 // If we do more complex stuff in sandbox preprocessing in the future, we might want to add a robust remapping logic
158 if (test.startPosition && disableTypeCheckingFileMatcher.matches(test.fileName)) {
159 test.startPosition.line--;
160 }
161 }
162 });
163 }
164
165 private logInitialTestRunSucceeded(tests: TestResult[], timing: RunTiming) {
166 this.log.info(
167 'Initial test run succeeded. Ran %s tests in %s (net %s ms, overhead %s ms).',
168 tests.length,
169 this.timer.humanReadableElapsed(INITIAL_TEST_RUN_MARKER),
170 timing.net,
171 timing.overhead
172 );
173 }
174
175 /**
176 * Calculates the timing variables for the test run.
177 * grossTime = NetTime + overheadTime
178 *
179 * The overhead time is used to calculate exact timeout values during mutation testing.
180 * See timeoutMS setting in README for more information on this calculation
181 */
182 private calculateTiming(grossTimeMS: number, tests: readonly TestResult[]): RunTiming {
183 const netTimeMS = tests.reduce((total, test) => total + test.timeSpentMs, 0);
184 const overheadTimeMS = grossTimeMS - netTimeMS;
185 return {
186 net: netTimeMS,
187 overhead: overheadTimeMS < 0 ? 0 : overheadTimeMS,
188 };
189 }
190
191 private logFailedTestsInInitialRun(failedTests: FailedTestResult[]): void {
192 let message = 'One or more tests failed in the initial test run:';
193 failedTests.forEach((test) => {
194 message += `${EOL}\t${test.name}`;
195 message += `${EOL}\t\t${test.failureMessage}`;
196 });
197 this.log.error(message);
198 }
199 private logErrorsInInitialRun(runResult: ErrorDryRunResult) {
200 const message = `One or more tests resulted in an error:${EOL}\t${runResult.errorMessage}`;
201 this.log.error(message);
202 }
203
204 private logTimeoutInitialRun() {
205 this.log.error('Initial test run timed out!');
206 }
207}