feat(testing): add getJestProjectsAsync to support inferred targets (#21897)

This commit is contained in:
Leosvel Pérez Espinosa 2024-02-23 15:03:46 +01:00 committed by GitHub
parent dd2c7d2601
commit 77a01ca94c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 565 additions and 45 deletions

View File

@ -13,5 +13,6 @@ export { jestConfigObjectAst } from './src/utils/config/functions';
export { jestInitGenerator } from './src/generators/init/init';
export {
getJestProjects,
getJestProjectsAsync,
getNestedJestProjects,
} from './src/utils/config/get-jest-projects';

View File

@ -36,6 +36,8 @@
"dependencies": {
"@jest/reporters": "^29.4.1",
"@jest/test-result": "^29.4.1",
"@nx/devkit": "file:../devkit",
"@nx/js": "file:../js",
"@phenomnomnominal/tsquery": "~5.0.1",
"chalk": "^4.1.0",
"identity-obj-proxy": "3.0.0",
@ -45,8 +47,7 @@
"minimatch": "9.0.3",
"resolve.exports": "1.1.0",
"tslib": "^2.3.0",
"@nx/devkit": "file:../devkit",
"@nx/js": "file:../js"
"yargs-parser": "21.1.1"
},
"publishConfig": {
"access": "public"

View File

@ -1,11 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createJestConfig should generate files 1`] = `
"import { getJestProjects } from '@nx/jest';
"import { getJestProjectsAsync } from '@nx/jest';
export default {
projects: getJestProjects()
};"
export default async () => ({
projects: await getJestProjectsAsync()
});"
`;
exports[`createJestConfig should generate files 2`] = `
@ -15,11 +15,11 @@ module.exports = { ...nxPreset }"
`;
exports[`createJestConfig should generate files with --js flag 1`] = `
"const { getJestProjects } = require('@nx/jest');
"const { getJestProjectsAsync } = require('@nx/jest');
module.exports = {
projects: getJestProjects()
};"
module.exports = async () => ({
projects: await getJestProjectsAsync()
});"
`;
exports[`createJestConfig should generate files with --js flag 2`] = `

View File

@ -162,11 +162,11 @@ export default {
"
`);
expect(tree.read('jest.config.ts', 'utf-8'))
.toEqual(`import { getJestProjects } from '@nx/jest';
.toEqual(`import { getJestProjectsAsync } from '@nx/jest';
export default {
projects: getJestProjects()
};`);
export default async () => ({
projects: await getJestProjectsAsync()
});`);
expect(readProjectConfiguration(tree, 'my-project').targets.test)
.toMatchInlineSnapshot(`
{
@ -214,11 +214,11 @@ module.exports = {
expect(tree.exists('jest.config.app.js')).toBeTruthy();
expect(tree.read('jest.config.js', 'utf-8'))
.toEqual(`const { getJestProjects } = require('@nx/jest');
.toEqual(`const { getJestProjectsAsync } = require('@nx/jest');
module.exports = {
projects: getJestProjects()
};`);
module.exports = async () => ({
projects: await getJestProjectsAsync()
});`);
});
});
});

View File

@ -129,16 +129,16 @@ export async function createJestConfig(
function generateGlobalConfig(tree: Tree, isJS: boolean) {
const contents = isJS
? stripIndents`
const { getJestProjects } = require('@nx/jest');
const { getJestProjectsAsync } = require('@nx/jest');
module.exports = {
projects: getJestProjects()
};`
module.exports = async () => ({
projects: await getJestProjectsAsync()
});`
: stripIndents`
import { getJestProjects } from '@nx/jest';
import { getJestProjectsAsync } from '@nx/jest';
export default {
projects: getJestProjects()
};`;
export default async () => ({
projects: await getJestProjectsAsync()
});`;
tree.write(`jest.config.${isJS ? 'js' : 'ts'}`, contents);
}

View File

@ -5,8 +5,15 @@ import { readProjectConfiguration, Tree } from '@nx/devkit';
function isUsingUtilityFunction(host: Tree) {
const rootConfig = findRootJestConfig(host);
if (!rootConfig) {
return false;
}
const rootConfigContent = host.read(rootConfig, 'utf-8');
return (
rootConfig && host.read(rootConfig).toString().includes('getJestProjects()')
rootConfigContent.includes('getJestProjects()') ||
rootConfigContent.includes('getJestProjectsAsync()')
);
}

View File

@ -11,6 +11,7 @@ describe('@nx/jest/plugin', () => {
beforeEach(async () => {
tempFs = new TempFs('test');
process.chdir(tempFs.tempDir);
context = {
nxJsonConfiguration: {
namedInputs: {

View File

@ -12,7 +12,7 @@ import { dirname, join, relative, resolve } from 'path';
import { readTargetDefaultsForTarget } from 'nx/src/project-graph/utils/project-configuration-utils';
import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs';
import { existsSync, readdirSync } from 'fs';
import { existsSync, readdirSync, readFileSync } from 'fs';
import { readConfig } from 'jest-config';
import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory';
import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes';
@ -82,6 +82,17 @@ export const createNodes: CreateNodes<JestPluginOptions> = [
}
}
const jestConfigContent = readFileSync(
resolve(context.workspaceRoot, configFilePath),
'utf-8'
);
if (jestConfigContent.includes('getJestProjectsAsync()')) {
// The `getJestProjectsAsync` function uses the project graph, which leads to a
// circular dependency. We can skip this since it's no intended to be used for
// an Nx project.
return {};
}
options = normalizeOptions(options);
const hash = calculateHashForCreateNodes(projectRoot, options, context);

View File

@ -1,6 +1,11 @@
import { getJestProjects } from './get-jest-projects';
import type {
ProjectConfiguration,
ProjectGraph,
WorkspaceJsonConfiguration,
} from '@nx/devkit';
import * as devkit from '@nx/devkit';
import * as Workspace from 'nx/src/project-graph/file-utils';
import type { WorkspaceJsonConfiguration } from '@nx/devkit';
import { getJestProjects, getJestProjectsAsync } from './get-jest-projects';
describe('getJestProjects', () => {
test('single project', () => {
@ -142,7 +147,7 @@ describe('getJestProjects', () => {
expect(getJestProjects()).toEqual(expectedResults);
});
test('other projects and targets that do not use the nrwl jest test runner', () => {
test('other projects and targets that do not use the nx jest test runner', () => {
const mockedWorkspaceConfig: WorkspaceJsonConfiguration = {
projects: {
otherTarget: {
@ -180,3 +185,315 @@ describe('getJestProjects', () => {
expect(getJestProjects()).toEqual(expectedResults);
});
});
describe('getJestProjectsAsync', () => {
let projectGraph: ProjectGraph;
function addProject(name: string, config: ProjectConfiguration): void {
projectGraph.nodes[name] = { name, type: 'app', data: config };
}
beforeEach(() => {
projectGraph = { nodes: {}, dependencies: {} };
jest
.spyOn(devkit, 'createProjectGraphAsync')
.mockReturnValue(Promise.resolve(projectGraph));
});
test('single project', async () => {
addProject('test-1', {
root: 'blah',
targets: {
test: {
executor: '@nx/jest:jest',
options: {
jestConfig: 'test/jest/config/location/jest.config.js',
},
},
},
});
const expectedResults = [
'<rootDir>/test/jest/config/location/jest.config.js',
];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test('custom target name', async () => {
addProject('test-1', {
root: 'blah',
targets: {
'test-with-jest': {
executor: '@nx/jest:jest',
options: {
jestConfig: 'test/jest/config/location/jest.config.js',
},
},
},
});
const expectedResults = [
'<rootDir>/test/jest/config/location/jest.config.js',
];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test('root project', async () => {
addProject('test-1', {
root: '.',
targets: {
test: {
executor: '@nx/jest:jest',
options: {
jestConfig: 'jest.config.app.js',
},
},
},
});
const expectedResults = ['<rootDir>/jest.config.app.js'];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test('configuration set with unique jestConfig', async () => {
addProject('test-1', {
root: 'blah',
targets: {
'test-with-jest': {
executor: '@nx/jest:jest',
options: {
jestConfig: 'test/jest/config/location/jest.config.js',
},
configurations: {
prod: {
jestConfig: 'configuration-specific/jest.config.js',
},
},
},
},
});
const expectedResults = [
'<rootDir>/test/jest/config/location/jest.config.js',
'<rootDir>/configuration-specific/jest.config.js',
];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test('configuration, set with same jestConfig on configuration', async () => {
addProject('test', {
root: 'blah',
targets: {
'test-with-jest': {
executor: '@nx/jest:jest',
options: {
jestConfig: 'test/jest/config/location/jest.config.js',
},
configurations: {
prod: {
jestConfig: 'test/jest/config/location/jest.config.js',
},
},
},
},
});
const expectedResults = [
'<rootDir>/test/jest/config/location/jest.config.js',
];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test('other projects and targets that do not use the nx jest test runner', async () => {
addProject('otherTarget', {
root: 'test',
targets: {
test: {
executor: 'something else',
options: {},
},
},
});
addProject('test', {
root: 'blah',
targets: {
'test-with-jest': {
executor: 'something else',
options: {
jestConfig: 'something random',
},
configurations: {
prod: {
jestConfig: 'configuration-specific/jest.config.js',
},
},
},
},
});
const expectedResults = [];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test.each`
command
${'jest'}
${'npx jest'}
${'yarn jest'}
${'pnpm jest'}
${'pnpm dlx jest'}
${'echo "foo" && jest'}
${'echo "foo" && npx jest'}
${'jest && echo "foo"'}
${'npx jest && echo "foo"'}
${'echo "foo" && jest && echo "bar"'}
${'echo "foo" && npx jest && echo "bar"'}
`(
'targets with nx:run-commands executor running "$command"',
async ({ command }) => {
addProject('test-1', {
root: 'projects/test-1',
targets: {
test: {
executor: 'nx:run-commands',
options: { command },
},
},
});
const expectedResults = ['<rootDir>/projects/test-1'];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
}
);
test.each`
command
${'jest'}
${'npx jest'}
${'yarn jest'}
${'pnpm jest'}
${'pnpm dlx jest'}
${'echo "foo" && jest'}
${'echo "foo" && npx jest'}
${'jest && echo "foo"'}
${'npx jest && echo "foo"'}
${'echo "foo" && jest && echo "bar"'}
${'echo "foo" && npx jest && echo "bar"'}
`(
'targets with nx:run-commands executor using "commands" option and running "$command"',
async ({ command }) => {
addProject('test-1', {
root: 'projects/test-1',
targets: {
test: {
executor: 'nx:run-commands',
options: { commands: [command] },
},
},
});
const expectedResults = ['<rootDir>/projects/test-1'];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
}
);
test.each`
command
${'jest'}
${'npx jest'}
${'yarn jest'}
${'pnpm jest'}
${'pnpm dlx jest'}
${'echo "foo" && jest'}
${'echo "foo" && npx jest'}
${'jest && echo "foo"'}
${'npx jest && echo "foo"'}
${'echo "foo" && jest && echo "bar"'}
${'echo "foo" && npx jest && echo "bar"'}
`(
'targets with nx:run-commands executor using "commands" option using the object notation and running "$command"',
async ({ command }) => {
addProject('test-1', {
root: 'projects/test-1',
targets: {
test: {
executor: 'nx:run-commands',
options: { commands: [{ command }] },
},
},
});
const expectedResults = ['<rootDir>/projects/test-1'];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
}
);
test.each`
command | cwd
${'jest --config projects/test-1/jest.config.ts'} | ${'.'}
${'npx jest --config projects/test-1/jest.config.ts'} | ${'.'}
${'jest --config jest.config.ts'} | ${undefined}
${'npx jest --config jest.config.ts'} | ${undefined}
${'jest --config jest.config.ts'} | ${'projects/test-1'}
${'npx jest --config jest.config.ts'} | ${'projects/test-1'}
${'echo "foo" && jest --config jest.config.ts'} | ${undefined}
${'echo "foo" && npx jest --config jest.config.ts'} | ${undefined}
${'jest --config jest.config.ts && echo "foo"'} | ${undefined}
${'npx jest --config jest.config.ts && echo "foo"'} | ${undefined}
${'echo "foo" && jest --config jest.config.ts && echo "bar"'} | ${undefined}
${'echo "foo" && npx jest --config jest.config.ts && echo "bar"'} | ${undefined}
`(
'targets with nx:run-commands executor running "$command" at "$cwd"',
async ({ command, cwd }) => {
addProject('test-1', {
root: 'projects/test-1',
targets: {
test: {
executor: 'nx:run-commands',
options: { command, cwd },
},
},
});
const expectedResults = ['<rootDir>/projects/test-1/jest.config.ts'];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
}
);
test('targets with nx:run-commands executor with a command with multiple "jest" runs', async () => {
addProject('test-1', {
root: 'projects/test-1',
targets: {
test: {
executor: 'nx:run-commands',
options: {
command: 'jest && jest --config jest1.config.ts',
},
},
},
});
const expectedResults = [
'<rootDir>/projects/test-1',
'<rootDir>/projects/test-1/jest1.config.ts',
];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
test('projects with targets using both executors', async () => {
addProject('test-1', {
root: 'projects/test-1',
targets: {
test: {
executor: '@nx/jest:jest',
options: {
jestConfig: 'projects/test-1/jest.config.js',
},
},
},
});
addProject('test-2', {
root: 'projects/test-2',
targets: {
test: {
executor: 'nx:run-commands',
options: { command: 'jest' },
},
},
});
const expectedResults = [
'<rootDir>/projects/test-1/jest.config.js',
'<rootDir>/projects/test-2',
];
expect(await getJestProjectsAsync()).toEqual(expectedResults);
});
});

View File

@ -1,6 +1,11 @@
import { join } from 'path';
import type { ProjectsConfigurations } from '@nx/devkit';
import {
createProjectGraphAsync,
type ProjectsConfigurations,
type TargetConfiguration,
} from '@nx/devkit';
import { readWorkspaceConfig } from 'nx/src/project-graph/file-utils';
import { join } from 'path';
import * as yargs from 'yargs-parser';
function getJestConfigProjectPath(projectJestConfigPath: string): string {
return join('<rootDir>', projectJestConfigPath);
@ -10,7 +15,8 @@ function getJestConfigProjectPath(projectJestConfigPath: string): string {
* Get a list of paths to all the jest config files
* using the Nx Jest executor.
*
* This is used to configure Jest multi-project support.
* This is used to configure Jest multi-project support. To support projects
* using inferred targets @see getJestProjectsAsync
*
* To add a project not using the Nx Jest executor:
* export default {
@ -68,3 +74,146 @@ export function getNestedJestProjects() {
const allProjects = getJestProjects();
return ['/node_modules/'];
}
/**
* Get a list of paths to all the jest config files
* using the Nx Jest executor and `@nx/run:commands`
* running `jest`.
*
* This is used to configure Jest multi-project support.
*
* To add a project not using the Nx Jest executor:
* export default async () => ({
* projects: [...(await getJestProjectsAsync()), '<rootDir>/path/to/jest.config.ts'];
* });
*
**/
export async function getJestProjectsAsync() {
const graph = await createProjectGraphAsync({
exitOnError: false,
resetDaemonClient: true,
});
const jestConfigurations = new Set<string>();
for (const node of Object.values(graph.nodes)) {
const projectConfig = node.data;
if (!projectConfig.targets) {
continue;
}
for (const targetConfiguration of Object.values(projectConfig.targets)) {
if (
targetConfiguration.executor === '@nx/jest:jest' ||
targetConfiguration.executor === '@nrwl/jest:jest'
) {
collectJestConfigFromJestExecutor(
targetConfiguration,
jestConfigurations
);
} else if (targetConfiguration.executor === 'nx:run-commands') {
collectJestConfigFromRunCommandsExecutor(
targetConfiguration,
projectConfig.root,
jestConfigurations
);
}
}
}
return Array.from(jestConfigurations);
}
function collectJestConfigFromJestExecutor(
targetConfiguration: TargetConfiguration,
jestConfigurations: Set<string>
): void {
if (targetConfiguration.options?.jestConfig) {
jestConfigurations.add(
getJestConfigProjectPath(targetConfiguration.options.jestConfig)
);
}
if (targetConfiguration.configurations) {
for (const configurationObject of Object.values(
targetConfiguration.configurations
)) {
if (configurationObject.jestConfig) {
jestConfigurations.add(
getJestConfigProjectPath(configurationObject.jestConfig)
);
}
}
}
}
function collectJestConfigFromRunCommandsExecutor(
targetConfiguration: TargetConfiguration,
projectRoot: string,
jestConfigurations: Set<string>
): void {
if (targetConfiguration.options?.command) {
collectJestConfigFromCommand(
targetConfiguration.options.command,
targetConfiguration.options.cwd ?? projectRoot,
jestConfigurations
);
} else if (targetConfiguration.options?.commands) {
for (const command of targetConfiguration.options.commands) {
const commandScript =
typeof command === 'string' ? command : command.command;
collectJestConfigFromCommand(
commandScript,
targetConfiguration.options.cwd ?? projectRoot,
jestConfigurations
);
}
}
if (targetConfiguration.configurations) {
for (const configurationObject of Object.values(
targetConfiguration.configurations
)) {
if (configurationObject.command) {
collectJestConfigFromCommand(
configurationObject.command,
configurationObject.cwd ?? projectRoot,
jestConfigurations
);
} else if (configurationObject.commands) {
for (const command of configurationObject.commands) {
const commandScript =
typeof command === 'string' ? command : command.command;
collectJestConfigFromCommand(
commandScript,
configurationObject.cwd ?? projectRoot,
jestConfigurations
);
}
}
}
}
}
function collectJestConfigFromCommand(
command: string,
cwd: string,
jestConfigurations: Set<string>
) {
const jestCommandRegex =
/(?<=^|&)(?:[^&\r\n\s]* )*jest(?: [^&\r\n\s]*)*(?=$|&)/g;
const matches = command.match(jestCommandRegex);
if (!matches) {
return;
}
for (const match of matches) {
const parsed = yargs(match, {
configuration: { 'strip-dashed': true },
string: ['config'],
});
if (parsed.config) {
jestConfigurations.add(
getJestConfigProjectPath(join(cwd, parsed.config))
);
} else {
jestConfigurations.add(getJestConfigProjectPath(cwd));
}
}
}

View File

@ -66,7 +66,7 @@ describe('updateJestConfig', () => {
expect(jestConfigAfter).toContain(
`coverageDirectory: '../coverage/my-destination'`
);
expect(rootJestConfigAfter).toContain('getJestProjects()');
expect(rootJestConfigAfter).toContain('getJestProjectsAsync()');
});
it('should update the name and dir correctly when moving to a nested dir', async () => {
@ -142,7 +142,7 @@ describe('updateJestConfig', () => {
expect(jestConfigAfter).toContain(
`coverageDirectory: '../coverage/other/test/dir/my-destination'`
);
expect(rootJestConfigAfter).toContain('getJestProjects()');
expect(rootJestConfigAfter).toContain('getJestProjectsAsync()');
});
it('updates the root config if not using `getJestProjects()`', async () => {

View File

@ -1,6 +1,7 @@
import { ProjectConfiguration, Tree } from '@nx/devkit';
import * as path from 'path';
import { NormalizedSchema } from '../schema';
import { findRootJestConfig } from '../../utils/jest-config';
/**
* Updates the project name and coverage folder in the jest.config.js if it exists
@ -58,9 +59,9 @@ export function updateJestConfig(
}
// update root jest.config.ts
const rootJestConfigPath = '/jest.config.ts';
const rootJestConfigPath = findRootJestConfig(tree);
if (!tree.exists(rootJestConfigPath)) {
if (!rootJestConfigPath || !tree.exists(rootJestConfigPath)) {
return;
}
@ -68,7 +69,8 @@ export function updateJestConfig(
const oldRootJestConfigContent = tree.read(rootJestConfigPath, 'utf-8');
const usingJestProjects =
oldRootJestConfigContent.includes('getJestProjects()');
oldRootJestConfigContent.includes('getJestProjects()') ||
oldRootJestConfigContent.includes('getJestProjectsAsync()');
const newRootJestConfigContent = oldRootJestConfigContent.replace(
findProject,

View File

@ -14,11 +14,22 @@ import type {
} from 'typescript';
import { join } from 'path';
import { ensureTypescript } from '../../../utilities/typescript';
import { findRootJestConfig } from '../../utils/jest-config';
let tsModule: typeof import('typescript');
function isUsingUtilityFunction(host: Tree) {
return host.read('jest.config.ts').toString().includes('getJestProjects()');
const rootConfigPath = findRootJestConfig(host);
if (!rootConfigPath) {
return false;
}
const rootConfig = host.read(rootConfigPath, 'utf-8');
return (
rootConfig.includes('getJestProjects()') ||
rootConfig.includes('getJestProjectsAsync()')
);
}
/**
@ -27,7 +38,12 @@ function isUsingUtilityFunction(host: Tree) {
* in that case we do not need to edit it to remove it
**/
function isMonorepoConfig(tree: Tree) {
return tree.read('jest.config.ts', 'utf-8').includes('projects:');
const rootConfigPath = findRootJestConfig(tree);
if (!rootConfigPath) {
return false;
}
return tree.read(rootConfigPath, 'utf-8').includes('projects:');
}
/**
@ -50,8 +66,10 @@ export function updateJestConfig(
} = tsModule;
const projectToRemove = schema.projectName;
const rootConfigPath = findRootJestConfig(tree);
if (
!tree.exists('jest.config.ts') ||
!tree.exists(rootConfigPath) ||
!tree.exists(join(projectConfig.root, 'jest.config.ts')) ||
isUsingUtilityFunction(tree) ||
!isMonorepoConfig(tree)
@ -59,9 +77,9 @@ export function updateJestConfig(
return;
}
const contents = tree.read('jest.config.ts', 'utf-8');
const contents = tree.read(rootConfigPath, 'utf-8');
const sourceFile = createSourceFile(
'jest.config.ts',
rootConfigPath,
contents,
ScriptTarget.Latest
);
@ -104,7 +122,7 @@ export function updateJestConfig(
: project.getStart(sourceFile);
tree.write(
'jest.config.ts',
rootConfigPath,
applyChangesToString(contents, [
{
type: ChangeType.Delete,

View File

@ -0,0 +1,13 @@
import type { Tree } from '@nx/devkit';
export function findRootJestConfig(tree: Tree): string | null {
if (tree.exists('jest.config.js')) {
return 'jest.config.js';
}
if (tree.exists('jest.config.ts')) {
return 'jest.config.ts';
}
return null;
}