1 | import { EOL } from 'os';
|
2 |
|
3 | import { I, requireResolve } from '@stryker-mutator/util';
|
4 | import { Logger } from '@stryker-mutator/api/logging';
|
5 | import { commonTokens, tokens, Injector } from '@stryker-mutator/api/plugin';
|
6 | import { StrykerOptions, Mutant } from '@stryker-mutator/api/core';
|
7 | import { DryRunCompletedEvent, RunTiming } from '@stryker-mutator/api/report';
|
8 | import {
|
9 | DryRunResult,
|
10 | TestRunner,
|
11 | DryRunStatus,
|
12 | CompleteDryRunResult,
|
13 | TestStatus,
|
14 | TestResult,
|
15 | FailedTestResult,
|
16 | ErrorDryRunResult,
|
17 | } from '@stryker-mutator/api/test-runner';
|
18 | import { lastValueFrom, of } from 'rxjs';
|
19 |
|
20 | import { coreTokens } from '../di/index.js';
|
21 | import { Sandbox } from '../sandbox/sandbox.js';
|
22 | import { Timer } from '../utils/timer.js';
|
23 | import { createTestRunnerFactory } from '../test-runner/index.js';
|
24 | import { MutationTestReportHelper } from '../reporters/mutation-test-report-helper.js';
|
25 | import { ConfigError } from '../errors.js';
|
26 | import { ConcurrencyTokenProvider, Pool, createTestRunnerPool } from '../concurrent/index.js';
|
27 | import { FileMatcher } from '../config/index.js';
|
28 | import { IncrementalDiffer, MutantTestPlanner, TestCoverage } from '../mutants/index.js';
|
29 | import { CheckerFacade } from '../checker/index.js';
|
30 | import { StrictReporter } from '../reporters/index.js';
|
31 | import { objectUtils } from '../utils/object-utils.js';
|
32 |
|
33 | import { IdGenerator } from '../child-proxy/id-generator.js';
|
34 |
|
35 | import { MutationTestContext } from './4-mutation-test-executor.js';
|
36 | import { MutantInstrumenterContext } from './2-mutant-instrumenter-executor.js';
|
37 |
|
38 | const INITIAL_TEST_RUN_MARKER = 'Initial test run';
|
39 |
|
40 | export 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 |
|
47 | function isFailedTest(testResult: TestResult): testResult is FailedTestResult {
|
48 | return testResult.status === TestStatus.Failed;
|
49 | }
|
50 |
|
51 | export 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 |
|
147 |
|
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 |
|
156 |
|
157 |
|
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 |
|
177 |
|
178 |
|
179 |
|
180 |
|
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 | }
|