UNPKG

11.6 kBPlain TextView Raw
1import path from 'path';
2import { createRequire } from 'module';
3
4import { StrykerOptions, INSTRUMENTER_CONSTANTS, CoverageAnalysis } from '@stryker-mutator/api/core';
5import { Logger } from '@stryker-mutator/api/logging';
6import { commonTokens, Injector, PluginContext, tokens } from '@stryker-mutator/api/plugin';
7import {
8 TestRunner,
9 MutantRunOptions,
10 DryRunResult,
11 MutantRunResult,
12 toMutantRunResult,
13 DryRunStatus,
14 TestResult,
15 TestStatus,
16 DryRunOptions,
17 BaseTestResult,
18 TestRunnerCapabilities,
19 determineHitLimitReached,
20} from '@stryker-mutator/api/test-runner';
21import { escapeRegExp, notEmpty, requireResolve } from '@stryker-mutator/util';
22import type * as jest from '@jest/types';
23import type * as jestTestResult from '@jest/test-result';
24
25import { JestOptions } from '../src-generated/jest-runner-options.js';
26
27import { jestTestAdapterFactory } from './jest-test-adapters/index.js';
28import { JestTestAdapter, RunSettings } from './jest-test-adapters/jest-test-adapter.js';
29import { JestConfigLoader } from './config-loaders/jest-config-loader.js';
30import { withCoverageAnalysis, withHitLimit } from './jest-plugins/index.js';
31import { pluginTokens } from './plugin-di.js';
32import { configLoaderFactory } from './config-loaders/index.js';
33import { JestRunnerOptionsWithStrykerOptions } from './jest-runner-options-with-stryker-options.js';
34import { JEST_OVERRIDE_OPTIONS } from './jest-override-options.js';
35import { determineResolveFromDirectory, JestConfigWrapper, JestWrapper, verifyAllTestFilesHaveCoverage } from './utils/index.js';
36import { state } from './jest-plugins/messaging.cjs';
37
38export function createJestTestRunnerFactory(namespace: typeof INSTRUMENTER_CONSTANTS.NAMESPACE | '__stryker2__' = INSTRUMENTER_CONSTANTS.NAMESPACE): {
39 (injector: Injector<PluginContext>): JestTestRunner;
40 inject: ['$injector'];
41} {
42 jestTestRunnerFactory.inject = tokens(commonTokens.injector);
43 function jestTestRunnerFactory(injector: Injector<PluginContext>) {
44 return injector
45 .provideValue(pluginTokens.resolve, createRequire(process.cwd()).resolve)
46 .provideFactory(pluginTokens.resolveFromDirectory, determineResolveFromDirectory)
47 .provideValue(pluginTokens.requireFromCwd, requireResolve)
48 .provideValue(pluginTokens.processEnv, process.env)
49 .provideClass(pluginTokens.jestWrapper, JestWrapper)
50 .provideClass(pluginTokens.jestConfigWrapper, JestConfigWrapper)
51 .provideFactory(pluginTokens.jestTestAdapter, jestTestAdapterFactory)
52 .provideFactory(pluginTokens.configLoader, configLoaderFactory)
53 .provideValue(pluginTokens.globalNamespace, namespace)
54 .injectClass(JestTestRunner);
55 }
56 return jestTestRunnerFactory;
57}
58
59export const jestTestRunnerFactory = createJestTestRunnerFactory();
60
61export class JestTestRunner implements TestRunner {
62 private jestConfig!: jest.Config.InitialOptions;
63 private readonly jestOptions: JestOptions;
64 private readonly enableFindRelatedTests!: boolean;
65
66 public static inject = tokens(
67 commonTokens.logger,
68 commonTokens.options,
69 pluginTokens.jestTestAdapter,
70 pluginTokens.configLoader,
71 pluginTokens.jestWrapper,
72 pluginTokens.globalNamespace,
73 );
74
75 constructor(
76 private readonly log: Logger,
77 options: StrykerOptions,
78 private readonly jestTestAdapter: JestTestAdapter,
79 private readonly configLoader: JestConfigLoader,
80 private readonly jestWrapper: JestWrapper,
81 private readonly globalNamespace: typeof INSTRUMENTER_CONSTANTS.NAMESPACE | '__stryker2__',
82 ) {
83 this.jestOptions = (options as JestRunnerOptionsWithStrykerOptions).jest;
84 // Get enableFindRelatedTests from stryker jest options or default to true
85 this.enableFindRelatedTests = this.jestOptions.enableFindRelatedTests;
86 if (this.enableFindRelatedTests) {
87 this.log.debug('Running jest with --findRelatedTests flag. Set jest.enableFindRelatedTests to false to run all tests on every mutant.');
88 } else {
89 this.log.debug(
90 'Running jest without --findRelatedTests flag. Set jest.enableFindRelatedTests to true to run only relevant tests on every mutant.',
91 );
92 }
93 }
94
95 public async init(): Promise<void> {
96 const configFromFile = await this.configLoader.loadConfig();
97 this.jestConfig = this.mergeConfigSettings(configFromFile, this.jestOptions || {});
98 }
99
100 public capabilities(): TestRunnerCapabilities {
101 return { reloadEnvironment: true };
102 }
103
104 public async dryRun({ coverageAnalysis, files }: Pick<DryRunOptions, 'coverageAnalysis' | 'files'>): Promise<DryRunResult> {
105 state.coverageAnalysis = coverageAnalysis;
106 const fileNamesUnderTest = this.enableFindRelatedTests ? files : undefined;
107 const { dryRunResult, jestResult } = await this.run({
108 fileNamesUnderTest,
109 jestConfig: this.configForDryRun(fileNamesUnderTest, coverageAnalysis, this.jestWrapper),
110 testLocationInResults: true,
111 });
112 if (dryRunResult.status === DryRunStatus.Complete && coverageAnalysis !== 'off') {
113 const errorMessage = verifyAllTestFilesHaveCoverage(jestResult, state.testFilesWithStrykerEnvironment);
114 if (errorMessage) {
115 return {
116 status: DryRunStatus.Error,
117 errorMessage,
118 };
119 } else {
120 dryRunResult.mutantCoverage = state.instrumenterContext.mutantCoverage;
121 }
122 }
123 return dryRunResult;
124 }
125
126 public async mutantRun({ activeMutant, sandboxFileName, testFilter, disableBail, hitLimit }: MutantRunOptions): Promise<MutantRunResult> {
127 const fileNameUnderTest = this.enableFindRelatedTests ? sandboxFileName : undefined;
128 state.coverageAnalysis = 'off';
129 let testNamePattern: string | undefined;
130 if (testFilter) {
131 testNamePattern = testFilter.map((testId) => `(${escapeRegExp(testId)})`).join('|');
132 }
133 state.instrumenterContext.hitLimit = hitLimit;
134 state.instrumenterContext.hitCount = hitLimit ? 0 : undefined;
135
136 try {
137 // Use process.env to set the active mutant.
138 // We could use `state.strykerStatic.activeMutant`, but that only works with the `StrykerEnvironment` mixin, which is optional
139 process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE] = activeMutant.id.toString();
140 const { dryRunResult } = await this.run({
141 fileNamesUnderTest: fileNameUnderTest ? [fileNameUnderTest] : undefined,
142 jestConfig: this.configForMutantRun(fileNameUnderTest, hitLimit, this.jestWrapper),
143 testNamePattern,
144 });
145 return toMutantRunResult(dryRunResult, disableBail);
146 } finally {
147 delete process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE];
148 delete state.instrumenterContext.activeMutant;
149 }
150 }
151
152 private configForDryRun(
153 fileNamesUnderTest: string[] | undefined,
154 coverageAnalysis: CoverageAnalysis,
155 jestWrapper: JestWrapper,
156 ): jest.Config.InitialOptions {
157 return withCoverageAnalysis(this.configWithRoots(fileNamesUnderTest), coverageAnalysis, jestWrapper);
158 }
159
160 private configForMutantRun(
161 fileNameUnderTest: string | undefined,
162 hitLimit: number | undefined,
163 jestWrapper: JestWrapper,
164 ): jest.Config.InitialOptions {
165 return withHitLimit(this.configWithRoots(fileNameUnderTest ? [fileNameUnderTest] : undefined), hitLimit, jestWrapper);
166 }
167
168 private configWithRoots(fileNamesUnderTest: string[] | undefined): jest.Config.InitialOptions {
169 let config: jest.Config.InitialOptions;
170
171 if (fileNamesUnderTest && this.jestConfig.roots) {
172 // Make sure the file under test lives inside one of the roots
173 config = {
174 ...this.jestConfig,
175 roots: [...this.jestConfig.roots, ...new Set(fileNamesUnderTest.map((file) => path.dirname(file)))],
176 };
177 } else {
178 config = this.jestConfig;
179 }
180 return config;
181 }
182
183 private async run(settings: RunSettings): Promise<{ dryRunResult: DryRunResult; jestResult: jestTestResult.AggregatedResult }> {
184 this.setEnv();
185 if (this.log.isTraceEnabled()) {
186 this.log.trace('Invoking Jest with config %s', JSON.stringify(settings));
187 }
188 const { results } = await this.jestTestAdapter.run(settings);
189 return { dryRunResult: this.collectRunResult(results), jestResult: results };
190 }
191
192 private collectRunResult(results: jestTestResult.AggregatedResult): DryRunResult {
193 const timeoutResult = determineHitLimitReached(state.instrumenterContext.hitCount, state.instrumenterContext.hitLimit);
194 if (timeoutResult) {
195 return timeoutResult;
196 }
197 if (results.numRuntimeErrorTestSuites) {
198 const errorMessage = results.testResults
199 .map((testSuite) => this.collectSerializableErrorText(testSuite.testExecError))
200 .filter(notEmpty)
201 .join(', ');
202 return {
203 status: DryRunStatus.Error,
204 errorMessage,
205 };
206 } else {
207 return {
208 status: DryRunStatus.Complete,
209 tests: this.processTestResults(results.testResults),
210 };
211 }
212 }
213
214 private collectSerializableErrorText(error?: jest.TestResult.SerializableError): string | undefined {
215 return error && `${error.code && `${error.code} `}${error.message} ${error.stack}`;
216 }
217
218 private setEnv() {
219 // Force colors off: https://github.com/chalk/supports-color#info
220 process.env.FORCE_COLOR = '0';
221 // Set node environment for issues like these: https://github.com/stryker-mutator/stryker-js/issues/3580
222 process.env.NODE_ENV = 'test';
223 }
224
225 private processTestResults(suiteResults: jestTestResult.TestResult[]): TestResult[] {
226 const testResults: TestResult[] = [];
227
228 for (const suiteResult of suiteResults) {
229 for (const testResult of suiteResult.testResults) {
230 const result: BaseTestResult = {
231 id: testResult.fullName,
232 name: testResult.fullName,
233 timeSpentMs: testResult.duration ?? 0,
234 fileName: suiteResult.testFilePath,
235 startPosition: testResult.location
236 ? {
237 // Stryker works 0-based internally, jest works 1-based: https://jestjs.io/docs/cli#--testlocationinresults
238 line: testResult.location.line - 1,
239 column: testResult.location.column,
240 }
241 : undefined,
242 };
243
244 switch (testResult.status) {
245 case 'passed':
246 testResults.push({
247 status: TestStatus.Success,
248 ...result,
249 });
250 break;
251 case 'failed':
252 testResults.push({
253 status: TestStatus.Failed,
254 failureMessage: testResult.failureMessages.join(', '),
255 ...result,
256 });
257 break;
258 default:
259 testResults.push({
260 status: TestStatus.Skipped,
261 ...result,
262 });
263 break;
264 }
265 }
266 }
267
268 return testResults;
269 }
270
271 private mergeConfigSettings(configFromFile: jest.Config.InitialOptions, options: JestOptions): jest.Config.InitialOptions {
272 const config = (options.config ?? {}) as jest.Config.InitialOptions;
273 const stringify = (obj: unknown) => JSON.stringify(obj, null, 2);
274 this.log.debug(
275 `Merging file-based config ${stringify(configFromFile)}
276 with custom config ${stringify(config)}
277 and default (internal) stryker config ${stringify(JEST_OVERRIDE_OPTIONS)}`,
278 );
279 const mergedConfig: jest.Config.InitialOptions = {
280 ...configFromFile,
281 ...config,
282 ...JEST_OVERRIDE_OPTIONS,
283 };
284 mergedConfig.globals = {
285 ...mergedConfig.globals,
286 __strykerGlobalNamespace__: this.globalNamespace,
287 };
288 return mergedConfig;
289 }
290}
291
\No newline at end of file