1 | import path from 'path';
|
2 | import { createRequire } from 'module';
|
3 |
|
4 | import { StrykerOptions, INSTRUMENTER_CONSTANTS, CoverageAnalysis } from '@stryker-mutator/api/core';
|
5 | import { Logger } from '@stryker-mutator/api/logging';
|
6 | import { commonTokens, Injector, PluginContext, tokens } from '@stryker-mutator/api/plugin';
|
7 | import {
|
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';
|
21 | import { escapeRegExp, notEmpty, requireResolve } from '@stryker-mutator/util';
|
22 | import type * as jest from '@jest/types';
|
23 | import type * as jestTestResult from '@jest/test-result';
|
24 |
|
25 | import { JestOptions } from '../src-generated/jest-runner-options.js';
|
26 |
|
27 | import { jestTestAdapterFactory } from './jest-test-adapters/index.js';
|
28 | import { JestTestAdapter, RunSettings } from './jest-test-adapters/jest-test-adapter.js';
|
29 | import { JestConfigLoader } from './config-loaders/jest-config-loader.js';
|
30 | import { withCoverageAnalysis, withHitLimit } from './jest-plugins/index.js';
|
31 | import { pluginTokens } from './plugin-di.js';
|
32 | import { configLoaderFactory } from './config-loaders/index.js';
|
33 | import { JestRunnerOptionsWithStrykerOptions } from './jest-runner-options-with-stryker-options.js';
|
34 | import { JEST_OVERRIDE_OPTIONS } from './jest-override-options.js';
|
35 | import { determineResolveFromDirectory, JestConfigWrapper, JestWrapper, verifyAllTestFilesHaveCoverage } from './utils/index.js';
|
36 | import { state } from './jest-plugins/messaging.cjs';
|
37 |
|
38 | export 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 |
|
59 | export const jestTestRunnerFactory = createJestTestRunnerFactory();
|
60 |
|
61 | export 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 |
|
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 |
|
138 |
|
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 |
|
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 |