1 | import path from 'path';
|
2 | import { createRequire } from 'module';
|
3 | import { INSTRUMENTER_CONSTANTS } from '@stryker-mutator/api/core';
|
4 | import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
|
5 | import { toMutantRunResult, DryRunStatus, TestStatus, determineHitLimitReached, } from '@stryker-mutator/api/test-runner';
|
6 | import { escapeRegExp, notEmpty, requireResolve } from '@stryker-mutator/util';
|
7 | import { jestTestAdapterFactory } from './jest-test-adapters/index.js';
|
8 | import { withCoverageAnalysis, withHitLimit } from './jest-plugins/index.js';
|
9 | import { pluginTokens } from './plugin-di.js';
|
10 | import { configLoaderFactory } from './config-loaders/index.js';
|
11 | import { JEST_OVERRIDE_OPTIONS } from './jest-override-options.js';
|
12 | import { determineResolveFromDirectory, JestConfigWrapper, JestWrapper, verifyAllTestFilesHaveCoverage } from './utils/index.js';
|
13 | import { state } from './jest-plugins/messaging.cjs';
|
14 | export function createJestTestRunnerFactory(namespace = INSTRUMENTER_CONSTANTS.NAMESPACE) {
|
15 | jestTestRunnerFactory.inject = tokens(commonTokens.injector);
|
16 | function jestTestRunnerFactory(injector) {
|
17 | return injector
|
18 | .provideValue(pluginTokens.resolve, createRequire(process.cwd()).resolve)
|
19 | .provideFactory(pluginTokens.resolveFromDirectory, determineResolveFromDirectory)
|
20 | .provideValue(pluginTokens.requireFromCwd, requireResolve)
|
21 | .provideValue(pluginTokens.processEnv, process.env)
|
22 | .provideClass(pluginTokens.jestWrapper, JestWrapper)
|
23 | .provideClass(pluginTokens.jestConfigWrapper, JestConfigWrapper)
|
24 | .provideFactory(pluginTokens.jestTestAdapter, jestTestAdapterFactory)
|
25 | .provideFactory(pluginTokens.configLoader, configLoaderFactory)
|
26 | .provideValue(pluginTokens.globalNamespace, namespace)
|
27 | .injectClass(JestTestRunner);
|
28 | }
|
29 | return jestTestRunnerFactory;
|
30 | }
|
31 | export const jestTestRunnerFactory = createJestTestRunnerFactory();
|
32 | export class JestTestRunner {
|
33 | log;
|
34 | jestTestAdapter;
|
35 | configLoader;
|
36 | jestWrapper;
|
37 | globalNamespace;
|
38 | jestConfig;
|
39 | jestOptions;
|
40 | enableFindRelatedTests;
|
41 | static inject = tokens(commonTokens.logger, commonTokens.options, pluginTokens.jestTestAdapter, pluginTokens.configLoader, pluginTokens.jestWrapper, pluginTokens.globalNamespace);
|
42 | constructor(log, options, jestTestAdapter, configLoader, jestWrapper, globalNamespace) {
|
43 | this.log = log;
|
44 | this.jestTestAdapter = jestTestAdapter;
|
45 | this.configLoader = configLoader;
|
46 | this.jestWrapper = jestWrapper;
|
47 | this.globalNamespace = globalNamespace;
|
48 | this.jestOptions = options.jest;
|
49 |
|
50 | this.enableFindRelatedTests = this.jestOptions.enableFindRelatedTests;
|
51 | if (this.enableFindRelatedTests) {
|
52 | this.log.debug('Running jest with --findRelatedTests flag. Set jest.enableFindRelatedTests to false to run all tests on every mutant.');
|
53 | }
|
54 | else {
|
55 | this.log.debug('Running jest without --findRelatedTests flag. Set jest.enableFindRelatedTests to true to run only relevant tests on every mutant.');
|
56 | }
|
57 | }
|
58 | async init() {
|
59 | const configFromFile = await this.configLoader.loadConfig();
|
60 | this.jestConfig = this.mergeConfigSettings(configFromFile, this.jestOptions || {});
|
61 | }
|
62 | capabilities() {
|
63 | return { reloadEnvironment: true };
|
64 | }
|
65 | async dryRun({ coverageAnalysis, files }) {
|
66 | state.coverageAnalysis = coverageAnalysis;
|
67 | const fileNamesUnderTest = this.enableFindRelatedTests ? files : undefined;
|
68 | const { dryRunResult, jestResult } = await this.run({
|
69 | fileNamesUnderTest,
|
70 | jestConfig: this.configForDryRun(fileNamesUnderTest, coverageAnalysis, this.jestWrapper),
|
71 | testLocationInResults: true,
|
72 | });
|
73 | if (dryRunResult.status === DryRunStatus.Complete && coverageAnalysis !== 'off') {
|
74 | const errorMessage = verifyAllTestFilesHaveCoverage(jestResult, state.testFilesWithStrykerEnvironment);
|
75 | if (errorMessage) {
|
76 | return {
|
77 | status: DryRunStatus.Error,
|
78 | errorMessage,
|
79 | };
|
80 | }
|
81 | else {
|
82 | dryRunResult.mutantCoverage = state.instrumenterContext.mutantCoverage;
|
83 | }
|
84 | }
|
85 | return dryRunResult;
|
86 | }
|
87 | async mutantRun({ activeMutant, sandboxFileName, testFilter, disableBail, hitLimit }) {
|
88 | const fileNameUnderTest = this.enableFindRelatedTests ? sandboxFileName : undefined;
|
89 | state.coverageAnalysis = 'off';
|
90 | let testNamePattern;
|
91 | if (testFilter) {
|
92 | testNamePattern = testFilter.map((testId) => `(${escapeRegExp(testId)})`).join('|');
|
93 | }
|
94 | state.instrumenterContext.hitLimit = hitLimit;
|
95 | state.instrumenterContext.hitCount = hitLimit ? 0 : undefined;
|
96 | try {
|
97 |
|
98 |
|
99 | process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE] = activeMutant.id.toString();
|
100 | const { dryRunResult } = await this.run({
|
101 | fileNamesUnderTest: fileNameUnderTest ? [fileNameUnderTest] : undefined,
|
102 | jestConfig: this.configForMutantRun(fileNameUnderTest, hitLimit, this.jestWrapper),
|
103 | testNamePattern,
|
104 | });
|
105 | return toMutantRunResult(dryRunResult, disableBail);
|
106 | }
|
107 | finally {
|
108 | delete process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE];
|
109 | delete state.instrumenterContext.activeMutant;
|
110 | }
|
111 | }
|
112 | configForDryRun(fileNamesUnderTest, coverageAnalysis, jestWrapper) {
|
113 | return withCoverageAnalysis(this.configWithRoots(fileNamesUnderTest), coverageAnalysis, jestWrapper);
|
114 | }
|
115 | configForMutantRun(fileNameUnderTest, hitLimit, jestWrapper) {
|
116 | return withHitLimit(this.configWithRoots(fileNameUnderTest ? [fileNameUnderTest] : undefined), hitLimit, jestWrapper);
|
117 | }
|
118 | configWithRoots(fileNamesUnderTest) {
|
119 | let config;
|
120 | if (fileNamesUnderTest && this.jestConfig.roots) {
|
121 |
|
122 | config = {
|
123 | ...this.jestConfig,
|
124 | roots: [...this.jestConfig.roots, ...new Set(fileNamesUnderTest.map((file) => path.dirname(file)))],
|
125 | };
|
126 | }
|
127 | else {
|
128 | config = this.jestConfig;
|
129 | }
|
130 | return config;
|
131 | }
|
132 | async run(settings) {
|
133 | this.setEnv();
|
134 | if (this.log.isTraceEnabled()) {
|
135 | this.log.trace('Invoking Jest with config %s', JSON.stringify(settings));
|
136 | }
|
137 | const { results } = await this.jestTestAdapter.run(settings);
|
138 | return { dryRunResult: this.collectRunResult(results), jestResult: results };
|
139 | }
|
140 | collectRunResult(results) {
|
141 | const timeoutResult = determineHitLimitReached(state.instrumenterContext.hitCount, state.instrumenterContext.hitLimit);
|
142 | if (timeoutResult) {
|
143 | return timeoutResult;
|
144 | }
|
145 | if (results.numRuntimeErrorTestSuites) {
|
146 | const errorMessage = results.testResults
|
147 | .map((testSuite) => this.collectSerializableErrorText(testSuite.testExecError))
|
148 | .filter(notEmpty)
|
149 | .join(', ');
|
150 | return {
|
151 | status: DryRunStatus.Error,
|
152 | errorMessage,
|
153 | };
|
154 | }
|
155 | else {
|
156 | return {
|
157 | status: DryRunStatus.Complete,
|
158 | tests: this.processTestResults(results.testResults),
|
159 | };
|
160 | }
|
161 | }
|
162 | collectSerializableErrorText(error) {
|
163 | return error && `${error.code && `${error.code} `}${error.message} ${error.stack}`;
|
164 | }
|
165 | setEnv() {
|
166 |
|
167 | process.env.FORCE_COLOR = '0';
|
168 |
|
169 | process.env.NODE_ENV = 'test';
|
170 | }
|
171 | processTestResults(suiteResults) {
|
172 | const testResults = [];
|
173 | for (const suiteResult of suiteResults) {
|
174 | for (const testResult of suiteResult.testResults) {
|
175 | const result = {
|
176 | id: testResult.fullName,
|
177 | name: testResult.fullName,
|
178 | timeSpentMs: testResult.duration ?? 0,
|
179 | fileName: suiteResult.testFilePath,
|
180 | startPosition: testResult.location
|
181 | ? {
|
182 |
|
183 | line: testResult.location.line - 1,
|
184 | column: testResult.location.column,
|
185 | }
|
186 | : undefined,
|
187 | };
|
188 | switch (testResult.status) {
|
189 | case 'passed':
|
190 | testResults.push({
|
191 | status: TestStatus.Success,
|
192 | ...result,
|
193 | });
|
194 | break;
|
195 | case 'failed':
|
196 | testResults.push({
|
197 | status: TestStatus.Failed,
|
198 | failureMessage: testResult.failureMessages.join(', '),
|
199 | ...result,
|
200 | });
|
201 | break;
|
202 | default:
|
203 | testResults.push({
|
204 | status: TestStatus.Skipped,
|
205 | ...result,
|
206 | });
|
207 | break;
|
208 | }
|
209 | }
|
210 | }
|
211 | return testResults;
|
212 | }
|
213 | mergeConfigSettings(configFromFile, options) {
|
214 | const config = (options.config ?? {});
|
215 | const stringify = (obj) => JSON.stringify(obj, null, 2);
|
216 | this.log.debug(`Merging file-based config ${stringify(configFromFile)}
|
217 | with custom config ${stringify(config)}
|
218 | and default (internal) stryker config ${stringify(JEST_OVERRIDE_OPTIONS)}`);
|
219 | const mergedConfig = {
|
220 | ...configFromFile,
|
221 | ...config,
|
222 | ...JEST_OVERRIDE_OPTIONS,
|
223 | };
|
224 | mergedConfig.globals = {
|
225 | ...mergedConfig.globals,
|
226 | __strykerGlobalNamespace__: this.globalNamespace,
|
227 | };
|
228 | return mergedConfig;
|
229 | }
|
230 | }
|
231 |
|
\ | No newline at end of file |