Skip to content

Commit

Permalink
fix(jest): support multiple jest installations (#3781)
Browse files Browse the repository at this point in the history
Use the jest instance relative to `react-scripts` when the `projectType` is `"create-react-app"`, instead of simply loading jest from the CWD. This allows users to install a newer version of `jest` right next to `react-scripts` to be used for other tasks.
  • Loading branch information
nicojs authored Oct 10, 2022
1 parent 79c4de0 commit 9f10e20
Show file tree
Hide file tree
Showing 21 changed files with 300 additions and 121 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Config } from '@jest/types';
import type { requireResolve } from '@stryker-mutator/util';

import { JestRunnerOptionsWithStrykerOptions } from '../jest-runner-options-with-stryker-options.js';
import * as pluginTokens from '../plugin-tokens.js';
import { pluginTokens } from '../plugin-di.js';

import { JestConfigLoader } from './jest-config-loader.js';

Expand Down
18 changes: 5 additions & 13 deletions packages/jest-runner/src/config-loaders/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import { createRequire } from 'module';

import { tokens, commonTokens, Injector, PluginContext } from '@stryker-mutator/api/plugin';
import { tokens, commonTokens, Injector } from '@stryker-mutator/api/plugin';
import { StrykerOptions } from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';

import { requireResolve } from '@stryker-mutator/util';

import { JestRunnerOptionsWithStrykerOptions } from '../jest-runner-options-with-stryker-options.js';
import * as pluginTokens from '../plugin-tokens.js';
import { JestPluginContext } from '../plugin-di.js';

import { CustomJestConfigLoader } from './custom-jest-config-loader.js';
import { ReactScriptsJestConfigLoader } from './react-scripts-jest-config-loader.js';

configLoaderFactory.inject = tokens(commonTokens.options, commonTokens.injector, commonTokens.logger);
export function configLoaderFactory(
options: StrykerOptions,
injector: Injector<PluginContext>,
injector: Injector<JestPluginContext>,
log: Logger
): CustomJestConfigLoader | ReactScriptsJestConfigLoader {
const warnAboutConfigFile = (projectType: string, configFile: string | undefined) => {
Expand All @@ -24,16 +20,12 @@ export function configLoaderFactory(
}
};
const optionsWithJest: JestRunnerOptionsWithStrykerOptions = options as JestRunnerOptionsWithStrykerOptions;
const configLoaderInjector = injector
.provideValue(pluginTokens.resolve, createRequire(import.meta.url).resolve)
.provideValue(pluginTokens.requireFromCwd, requireResolve)
.provideValue(pluginTokens.processEnv, process.env);
switch (optionsWithJest.jest.projectType) {
case 'custom':
return configLoaderInjector.injectClass(CustomJestConfigLoader);
return injector.injectClass(CustomJestConfigLoader);
case 'create-react-app':
warnAboutConfigFile(optionsWithJest.jest.projectType, optionsWithJest.jest.configFile);
return configLoaderInjector.injectClass(ReactScriptsJestConfigLoader);
return injector.injectClass(ReactScriptsJestConfigLoader);
default:
throw new Error(`No configLoader available for ${optionsWithJest.jest.projectType}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import { commonTokens, tokens } from '@stryker-mutator/api/plugin';
import { propertyPath, type requireResolve } from '@stryker-mutator/util';
import { Logger } from '@stryker-mutator/api/logging';

import * as pluginTokens from '../plugin-tokens.js';
import { pluginTokens } from '../plugin-di.js';
import { JestRunnerOptionsWithStrykerOptions } from '../jest-runner-options-with-stryker-options.js';
import { state } from '../jest-plugins/cjs/messaging.js';

import { JestConfigLoader } from './jest-config-loader.js';

function isString(maybeString: unknown): maybeString is string {
return typeof maybeString === 'string';
}

export class ReactScriptsJestConfigLoader implements JestConfigLoader {
public static inject = tokens(commonTokens.logger, pluginTokens.resolve, pluginTokens.processEnv, pluginTokens.requireFromCwd);

Expand All @@ -23,11 +28,13 @@ export class ReactScriptsJestConfigLoader implements JestConfigLoader {
public loadConfig(): Config.InitialOptions {
try {
// Create the React configuration for Jest
const jestConfiguration = this.createJestConfig();

const { config, reactScriptsLocation } = this.createJestConfig();
// Make sure that any jest environment plugins (i.e. jest-environment-jsdom) is loaded from the react-script module
state.resolveFromDirectory = reactScriptsLocation;
config.watchPlugins = config.watchPlugins?.filter(isString).map((watchPlugin) => this.resolve(watchPlugin, { paths: [reactScriptsLocation] }));
this.setEnv();

return jestConfiguration;
return config;
} catch (e) {
if (this.isNodeErrnoException(e) && e.code === 'MODULE_NOT_FOUND') {
throw Error(
Expand All @@ -45,14 +52,17 @@ export class ReactScriptsJestConfigLoader implements JestConfigLoader {
return arg.code !== undefined;
}

private createJestConfig(): Config.InitialOptions {
private createJestConfig(): { reactScriptsLocation: string; config: Config.InitialOptions } {
const createReactJestConfig = this.requireFromCwd('react-scripts/scripts/utils/createJestConfig') as (
resolve: (thing: string) => string,
rootDir: string,
isEjecting: boolean
) => Config.InitialOptions;
const reactScriptsLocation = path.join(this.resolve('react-scripts/package.json'), '..');
return createReactJestConfig((relativePath) => path.join(reactScriptsLocation, relativePath), process.cwd(), false);
return {
reactScriptsLocation,
config: createReactJestConfig((relativePath) => path.join(reactScriptsLocation, relativePath), process.cwd(), false),
};
}
private setEnv() {
// Jest CLI will set process.env.NODE_ENV to 'test' when it's null, do the same here
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { JestEnvironment } from '@jest/environment';

import { state } from './messaging.js';

export function loadJestEnvironment(name: string): typeof JestEnvironment {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jestEnvironmentModule = require(require.resolve(name, { paths: [process.cwd()] }));
const jestEnvironmentModule = require(require.resolve(name, { paths: [state.resolveFromDirectory] }));

return jestEnvironmentModule.default ?? jestEnvironmentModule;
}
2 changes: 2 additions & 0 deletions packages/jest-runner/src/jest-plugins/cjs/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class State {
public testFilesWithStrykerEnvironment = new Set<string>();
public coverageAnalysis!: CoverageAnalysis;
public jestEnvironment!: string;
public resolveFromDirectory!: string;

constructor() {
this.clear();
Expand All @@ -15,6 +16,7 @@ class State {
this.instrumenterContext = {};
this.coverageAnalysis = 'off';
this.jestEnvironment = 'jest-environment-node';
this.resolveFromDirectory = process.cwd();
}
}

Expand Down
26 changes: 15 additions & 11 deletions packages/jest-runner/src/jest-plugins/with-coverage-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CoverageAnalysis, StrykerOptions } from '@stryker-mutator/api/core';
import { propertyPath } from '@stryker-mutator/util';
import semver from 'semver';

import { jestWrapper } from '../utils/index.js';
import { JestWrapper } from '../utils/jest-wrapper.js';

import { state } from './cjs/messaging.js';

Expand All @@ -16,7 +16,7 @@ const jestEnvironmentGenericFileName = fileURLToPath(new URL('./cjs/jest-environ
* Jest's defaults.
* @see https://jestjs.io/docs/en/configuration
*/
function getJestDefaults() {
function getJestDefaults(jestWrapper: JestWrapper) {
// New defaults since 27: https://jestjs.io/blog/2021/05/25/jest-27
if (semver.satisfies(jestWrapper.getVersion(), '>=27')) {
return {
Expand All @@ -32,25 +32,29 @@ function getJestDefaults() {
}
}

export function withCoverageAnalysis(jestConfig: Config.InitialOptions, coverageAnalysis: CoverageAnalysis): Config.InitialOptions {
export function withCoverageAnalysis(
jestConfig: Config.InitialOptions,
coverageAnalysis: CoverageAnalysis,
jestWrapper: JestWrapper
): Config.InitialOptions {
// Override with Stryker specific test environment to capture coverage analysis
if (coverageAnalysis === 'off') {
return jestConfig;
} else {
const overrides: Config.InitialOptions = {};
overrideEnvironment(jestConfig, overrides);
overrideEnvironment(jestConfig, overrides, jestWrapper);
if (coverageAnalysis === 'perTest') {
setupFramework(jestConfig, overrides);
setupFramework(jestConfig, overrides, jestWrapper);
}
return { ...jestConfig, ...overrides };
}
}

export function withHitLimit(jestConfig: Config.InitialOptions, hitLimit: number | undefined): Config.InitialOptions {
export function withHitLimit(jestConfig: Config.InitialOptions, hitLimit: number | undefined, jestWrapper: JestWrapper): Config.InitialOptions {
// Override with Stryker specific test environment to capture coverage analysis
if (typeof hitLimit === 'number') {
const overrides: Config.InitialOptions = {};
overrideEnvironment(jestConfig, overrides);
overrideEnvironment(jestConfig, overrides, jestWrapper);
return { ...jestConfig, ...overrides };
} else {
return jestConfig;
Expand All @@ -61,8 +65,8 @@ export function withHitLimit(jestConfig: Config.InitialOptions, hitLimit: number
* Setup the test framework (aka "runner" in jest terms) for "perTest" coverage analysis.
* Will use monkey patching for framework "jest-jasmine2", and will assume the test environment handles events when "jest-circus"
*/
function setupFramework(jestConfig: Config.InitialOptions, overrides: Config.InitialOptions) {
const testRunner = jestConfig.testRunner ?? getJestDefaults().testRunner;
function setupFramework(jestConfig: Config.InitialOptions, overrides: Config.InitialOptions, jestWrapper: JestWrapper) {
const testRunner = jestConfig.testRunner ?? getJestDefaults(jestWrapper).testRunner;
if (testRunner === 'jest-jasmine2') {
overrides.setupFilesAfterEnv = [
path.resolve(path.dirname(fileURLToPath(import.meta.url)), './jasmine2-setup-coverage-analysis.js'),
Expand All @@ -81,8 +85,8 @@ function setupFramework(jestConfig: Config.InitialOptions, overrides: Config.Ini
}
}

function overrideEnvironment(jestConfig: Config.InitialOptions, overrides: Config.InitialOptions): void {
const originalJestEnvironment = jestConfig.testEnvironment ?? getJestDefaults().testEnvironment;
function overrideEnvironment(jestConfig: Config.InitialOptions, overrides: Config.InitialOptions, jestWrapper: JestWrapper): void {
const originalJestEnvironment = jestConfig.testEnvironment ?? getJestDefaults(jestWrapper).testEnvironment;
state.jestEnvironment = nameEnvironment(originalJestEnvironment);
overrides.testEnvironment = jestEnvironmentGenericFileName;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { jestWrapper } from '../utils/index.js';
import { JestRunResult } from '../jest-run-result.js';
import { pluginTokens } from '../plugin-di.js';
import { JestWrapper } from '../utils/jest-wrapper.js';

import { JestTestAdapter, RunSettings } from './jest-test-adapter.js';

export class JestGreaterThan25TestAdapter implements JestTestAdapter {
public static readonly inject = [pluginTokens.jestWrapper] as const;
constructor(private readonly jestWrapper: JestWrapper) {}

public async run({ jestConfig, fileNamesUnderTest, testNamePattern, testLocationInResults }: RunSettings): Promise<JestRunResult> {
const config = JSON.stringify(jestConfig);
const result = await jestWrapper.runCLI(
const result = await this.jestWrapper.runCLI(
{
$0: 'stryker',
_: fileNamesUnderTest ? fileNamesUnderTest : [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { jestWrapper } from '../utils/index.js';
import { JestRunResult } from '../jest-run-result.js';
import { JestWrapper } from '../utils/jest-wrapper.js';
import { pluginTokens } from '../plugin-di.js';

import { RunSettings, JestTestAdapter } from './jest-test-adapter.js';

Expand All @@ -8,9 +9,12 @@ import { RunSettings, JestTestAdapter } from './jest-test-adapter.js';
* It has a lot of `any` typings here, since the installed typings are not in sync.
*/
export class JestLessThan25TestAdapter implements JestTestAdapter {
public static readonly inject = [pluginTokens.jestWrapper] as const;
constructor(private readonly jestWrapper: JestWrapper) {}

public run({ jestConfig, fileNamesUnderTest, testNamePattern, testLocationInResults }: RunSettings): Promise<JestRunResult> {
const config = JSON.stringify(jestConfig);
return jestWrapper.runCLI(
return this.jestWrapper.runCLI(
{
$0: 'stryker',
_: fileNamesUnderTest ? fileNamesUnderTest : [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import { Logger } from '@stryker-mutator/api/logging';
import { StrykerOptions } from '@stryker-mutator/api/core';
import { BaseContext, commonTokens, Injector, tokens } from '@stryker-mutator/api/plugin';
import { commonTokens, Injector, tokens } from '@stryker-mutator/api/plugin';
import semver from 'semver';

import { jestVersion } from '../plugin-tokens.js';
import { JestPluginContext, pluginTokens } from '../plugin-di.js';
import { JestWrapper } from '../utils/jest-wrapper.js';

import { JestLessThan25TestAdapter } from './jest-less-than-25-adapter.js';
import { JestGreaterThan25TestAdapter } from './jest-greater-than-25-adapter.js';

export function jestTestAdapterFactory(
log: Logger,
jest: string,
jestWrapper: JestWrapper,
options: StrykerOptions,
injector: Injector<BaseContext>
injector: Injector<JestPluginContext>
): JestGreaterThan25TestAdapter | JestLessThan25TestAdapter {
log.debug('Detected Jest version %s', jest);
guardJestVersion(jest, options, log);
const version = jestWrapper.getVersion();
log.debug('Detected Jest version %s', version);
guardJestVersion(version, options, log);

if (semver.satisfies(jest, '<25.0.0')) {
if (semver.satisfies(version, '<25.0.0')) {
return injector.injectClass(JestLessThan25TestAdapter);
} else {
return injector.injectClass(JestGreaterThan25TestAdapter);
}
}
jestTestAdapterFactory.inject = tokens(commonTokens.logger, jestVersion, commonTokens.options, commonTokens.injector);
jestTestAdapterFactory.inject = tokens(commonTokens.logger, pluginTokens.jestWrapper, commonTokens.options, commonTokens.injector);

function guardJestVersion(jest: string, options: StrykerOptions, log: Logger) {
if (semver.satisfies(jest, '<22.0.0')) {
Expand Down
35 changes: 25 additions & 10 deletions packages/jest-runner/src/jest-test-runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'path';
import { createRequire } from 'module';

import { StrykerOptions, INSTRUMENTER_CONSTANTS, CoverageAnalysis } from '@stryker-mutator/api/core';
import { Logger } from '@stryker-mutator/api/logging';
Expand All @@ -17,7 +18,7 @@ import {
TestRunnerCapabilities,
determineHitLimitReached,
} from '@stryker-mutator/api/test-runner';
import { escapeRegExp, notEmpty } from '@stryker-mutator/util';
import { escapeRegExp, notEmpty, requireResolve } from '@stryker-mutator/util';
import type * as jest from '@jest/types';
import type * as jestTestResult from '@jest/test-result';

Expand All @@ -27,11 +28,11 @@ import { jestTestAdapterFactory } from './jest-test-adapters/index.js';
import { JestTestAdapter, RunSettings } from './jest-test-adapters/jest-test-adapter.js';
import { JestConfigLoader } from './config-loaders/jest-config-loader.js';
import { withCoverageAnalysis, withHitLimit } from './jest-plugins/index.js';
import * as pluginTokens from './plugin-tokens.js';
import { pluginTokens } from './plugin-di.js';
import { configLoaderFactory } from './config-loaders/index.js';
import { JestRunnerOptionsWithStrykerOptions } from './jest-runner-options-with-stryker-options.js';
import { JEST_OVERRIDE_OPTIONS } from './jest-override-options.js';
import { jestWrapper, verifyAllTestFilesHaveCoverage } from './utils/index.js';
import { determineResolveFromDirectory, JestWrapper, verifyAllTestFilesHaveCoverage } from './utils/index.js';
import { state } from './jest-plugins/cjs/messaging.js';

export function createJestTestRunnerFactory(namespace: typeof INSTRUMENTER_CONSTANTS.NAMESPACE | '__stryker2__' = INSTRUMENTER_CONSTANTS.NAMESPACE): {
Expand All @@ -41,7 +42,11 @@ export function createJestTestRunnerFactory(namespace: typeof INSTRUMENTER_CONST
jestTestRunnerFactory.inject = tokens(commonTokens.injector);
function jestTestRunnerFactory(injector: Injector<PluginContext>) {
return injector
.provideValue(pluginTokens.jestVersion, jestWrapper.getVersion())
.provideValue(pluginTokens.resolve, createRequire(process.cwd()).resolve)
.provideFactory(pluginTokens.resolveFromDirectory, determineResolveFromDirectory)
.provideValue(pluginTokens.requireFromCwd, requireResolve)
.provideValue(pluginTokens.processEnv, process.env)
.provideClass(pluginTokens.jestWrapper, JestWrapper)
.provideFactory(pluginTokens.jestTestAdapter, jestTestAdapterFactory)
.provideFactory(pluginTokens.configLoader, configLoaderFactory)
.provideValue(pluginTokens.globalNamespace, namespace)
Expand All @@ -62,6 +67,7 @@ export class JestTestRunner implements TestRunner {
commonTokens.options,
pluginTokens.jestTestAdapter,
pluginTokens.configLoader,
pluginTokens.jestWrapper,
pluginTokens.globalNamespace
);

Expand All @@ -70,6 +76,7 @@ export class JestTestRunner implements TestRunner {
options: StrykerOptions,
private readonly jestTestAdapter: JestTestAdapter,
configLoader: JestConfigLoader,
private readonly jestWrapper: JestWrapper,
private readonly globalNamespace: typeof INSTRUMENTER_CONSTANTS.NAMESPACE | '__stryker2__'
) {
this.jestOptions = (options as JestRunnerOptionsWithStrykerOptions).jest;
Expand Down Expand Up @@ -98,7 +105,7 @@ export class JestTestRunner implements TestRunner {
const fileNamesUnderTest = this.enableFindRelatedTests ? files : undefined;
const { dryRunResult, jestResult } = await this.run({
fileNamesUnderTest,
jestConfig: this.configForDryRun(fileNamesUnderTest, coverageAnalysis),
jestConfig: this.configForDryRun(fileNamesUnderTest, coverageAnalysis, this.jestWrapper),
testLocationInResults: true,
});
if (dryRunResult.status === DryRunStatus.Complete && coverageAnalysis !== 'off') {
Expand Down Expand Up @@ -131,7 +138,7 @@ export class JestTestRunner implements TestRunner {
process.env[INSTRUMENTER_CONSTANTS.ACTIVE_MUTANT_ENV_VARIABLE] = activeMutant.id.toString();
const { dryRunResult } = await this.run({
fileNamesUnderTest: fileNameUnderTest ? [fileNameUnderTest] : undefined,
jestConfig: this.configForMutantRun(fileNameUnderTest, hitLimit),
jestConfig: this.configForMutantRun(fileNameUnderTest, hitLimit, this.jestWrapper),
testNamePattern,
});
return toMutantRunResult(dryRunResult, disableBail);
Expand All @@ -141,12 +148,20 @@ export class JestTestRunner implements TestRunner {
}
}

private configForDryRun(fileNamesUnderTest: string[] | undefined, coverageAnalysis: CoverageAnalysis): jest.Config.InitialOptions {
return withCoverageAnalysis(this.configWithRoots(fileNamesUnderTest), coverageAnalysis);
private configForDryRun(
fileNamesUnderTest: string[] | undefined,
coverageAnalysis: CoverageAnalysis,
jestWrapper: JestWrapper
): jest.Config.InitialOptions {
return withCoverageAnalysis(this.configWithRoots(fileNamesUnderTest), coverageAnalysis, jestWrapper);
}

private configForMutantRun(fileNameUnderTest: string | undefined, hitLimit: number | undefined): jest.Config.InitialOptions {
return withHitLimit(this.configWithRoots(fileNameUnderTest ? [fileNameUnderTest] : undefined), hitLimit);
private configForMutantRun(
fileNameUnderTest: string | undefined,
hitLimit: number | undefined,
jestWrapper: JestWrapper
): jest.Config.InitialOptions {
return withHitLimit(this.configWithRoots(fileNameUnderTest ? [fileNameUnderTest] : undefined), hitLimit, jestWrapper);
}

private configWithRoots(fileNamesUnderTest: string[] | undefined): jest.Config.InitialOptions {
Expand Down
Loading

0 comments on commit 9f10e20

Please sign in to comment.