feat(testing): support root project generation for jest (#13353)

Co-authored-by: Miroslav Jonas <missing.manual@gmail.com>
This commit is contained in:
Caleb Ukle 2022-11-29 16:03:19 -06:00 committed by GitHub
parent 8f2fb24605
commit 74bd0bb00c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 414 additions and 49 deletions

View File

@ -41,6 +41,12 @@
"type": "boolean",
"default": false,
"description": "Use JavaScript instead of TypeScript for config files"
},
"rootProject": {
"description": "initialize Jest for an application at the root of the workspace",
"type": "boolean",
"default": false,
"hidden": true
}
},
"required": [],
@ -123,6 +129,12 @@
"type": "boolean",
"default": false,
"description": "Use JavaScript instead of TypeScript for config files"
},
"rootProject": {
"description": "Add Jest to an application at the root of the workspace",
"type": "boolean",
"default": false,
"hidden": true
}
},
"required": [],

View File

@ -0,0 +1,73 @@
import { newProject, runCLI, uniq, runCLIAsync } from '@nrwl/e2e/utils';
describe('Jest root projects', () => {
const myapp = uniq('myapp');
const mylib = uniq('mylib');
describe('angular', () => {
beforeAll(() => {
newProject();
});
it('should test root level app projects', async () => {
runCLI(`generate @nrwl/angular:app ${myapp} --rootProject=true`);
const rootProjectTestResults = await runCLIAsync(`test ${myapp}`);
expect(rootProjectTestResults.combinedOutput).toContain(
'Test Suites: 1 passed, 1 total'
);
}, 300_000);
it('should add lib project and tests should still work', async () => {
runCLI(`generate @nrwl/angular:lib ${mylib}`);
runCLI(
`generate @nrwl/angular:component ${mylib} --export --standalone --project=${mylib} --no-interactive`
);
const libProjectTestResults = await runCLIAsync(`test ${mylib}`);
expect(libProjectTestResults.combinedOutput).toContain(
'Test Suites: 1 passed, 1 total'
);
const rootProjectTestResults = await runCLIAsync(`test ${myapp}`);
expect(rootProjectTestResults.combinedOutput).toContain(
'Test Suites: 1 passed, 1 total'
);
}, 300_000);
});
describe('react', () => {
beforeAll(() => {
newProject();
});
it('should test root level app projects', async () => {
runCLI(`generate @nrwl/react:app ${myapp} --rootProject=true`);
const rootProjectTestResults = await runCLIAsync(`test ${myapp}`);
expect(rootProjectTestResults.combinedOutput).toContain(
'Test Suites: 1 passed, 1 total'
);
}, 300_000);
it('should add lib project and tests should still work', async () => {
runCLI(`generate @nrwl/react:lib ${mylib}`);
const libProjectTestResults = await runCLIAsync(`test ${mylib}`);
expect(libProjectTestResults.combinedOutput).toContain(
'Test Suites: 1 passed, 1 total'
);
const rootProjectTestResults = await runCLIAsync(`test ${myapp}`);
expect(rootProjectTestResults.combinedOutput).toContain(
'Test Suites: 1 passed, 1 total'
);
}, 300_000);
});
});

View File

@ -4,9 +4,12 @@ import {
runCLI,
runCLIAsync,
uniq,
readJson,
updateFile,
expectJestTestsToPass,
cleanupProject,
readFile,
checkFilesExist,
} from '@nrwl/e2e/utils';
describe('Jest', () => {

View File

@ -473,9 +473,6 @@ export function tslibC(): string {
'plugin:@nrwl/nx/javascript',
]);
console.log(JSON.stringify(rootEslint, null, 2));
console.log(JSON.stringify(e2eEslint, null, 2));
runCLI(`generate @nrwl/react:lib ${mylib}`);
// should add new tslint
expect(() => checkFilesExist(`.eslintrc.base.json`)).not.toThrow();

View File

@ -14,6 +14,7 @@ export async function addUnitTestRunner(host: Tree, options: NormalizedSchema) {
supportTsx: false,
skipSerializers: false,
skipPackageJson: options.skipPackageJson,
rootProject: options.rootProject,
});
} else if (options.unitTestRunner === UnitTestRunner.Karma) {
await karmaProjectGenerator(host, {

View File

@ -5,4 +5,7 @@ export {
export { jestConfigObjectAst } from './src/utils/config/functions';
export { jestProjectGenerator } from './src/generators/jest-project/jest-project';
export { jestInitGenerator } from './src/generators/init/init';
export { getJestProjects } from './src/utils/config/get-jest-projects';
export {
getJestProjects,
getNestedJestProjects,
} from './src/utils/config/get-jest-projects';

View File

@ -1,6 +1,8 @@
import {
addProjectConfiguration,
NxJsonConfiguration,
readJson,
readProjectConfiguration,
stripIndents,
Tree,
updateJson,
@ -41,9 +43,29 @@ describe('jest', () => {
});
it('should not override existing files', async () => {
tree.write('jest.config.ts', `test`);
addProjectConfiguration(tree, 'my-project', {
root: 'apps/my-app',
name: 'my-app',
sourceRoot: 'apps/my-app/src',
targets: {
test: {
executor: '@nrwl/jest:jest',
options: {
jestConfig: 'apps/my-app/jest.config.ts',
},
},
},
});
const expected = stripIndents`
import { getJestProjects } from '@nrwl/jest';
export default {
projects: getJestProjects(),
extraThing: "Goes Here"
}
`;
tree.write('jest.config.ts', expected);
jestInitGenerator(tree, {});
expect(tree.read('jest.config.ts', 'utf-8')).toEqual('test');
expect(tree.read('jest.config.ts', 'utf-8')).toEqual(expected);
});
it('should add target defaults for test', async () => {
@ -144,6 +166,102 @@ describe('jest', () => {
});
});
describe('root project', () => {
it('should not add a monorepo jest.config.ts to the project', () => {
jestInitGenerator(tree, { rootProject: true });
expect(tree.exists('jest.config.ts')).toBeFalsy();
});
it('should rename the project jest.config.ts to project jest config', () => {
addProjectConfiguration(tree, 'my-project', {
root: '.',
name: 'my-project',
sourceRoot: 'src',
targets: {
test: {
executor: '@nrwl/jest:jest',
options: {
jestConfig: 'jest.config.ts',
},
},
},
});
tree.write(
'jest.config.ts',
`
/* eslint-disable */
export default {
transform: {
'^.+\\.[tj]sx?$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'],
globals: { 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } },
displayName: 'my-project',
testEnvironment: 'node',
preset: './jest.preset.js',
};
`
);
jestInitGenerator(tree, { rootProject: false });
expect(tree.exists('jest.config.app.ts')).toBeTruthy();
expect(tree.read('jest.config.ts', 'utf-8'))
.toEqual(`import { getJestProjects } from '@nrwl/jest';
export default {
projects: getJestProjects()
};`);
expect(readProjectConfiguration(tree, 'my-project').targets.test)
.toMatchInlineSnapshot(`
Object {
"executor": "@nrwl/jest:jest",
"options": Object {
"jestConfig": "jest.config.app.ts",
},
}
`);
});
it('should work with --js', () => {
addProjectConfiguration(tree, 'my-project', {
root: '.',
name: 'my-project',
sourceRoot: 'src',
targets: {
test: {
executor: '@nrwl/jest:jest',
options: {
jestConfig: 'jest.config.js',
},
},
},
});
tree.write(
'jest.config.js',
`
/* eslint-disable */
module.exports = {
transform: {
'^.+\\.[tj]sx?$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'],
globals: { 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } },
displayName: 'my-project',
testEnvironment: 'node',
preset: './jest.preset.js',
};
`
);
jestInitGenerator(tree, { js: true, rootProject: false });
expect(tree.exists('jest.config.app.js')).toBeTruthy();
expect(tree.read('jest.config.js', 'utf-8'))
.toEqual(`const { getJestProjects } = require('@nrwl/jest');
module.exports = {
projects: getJestProjects()
};`);
});
});
describe('adds jest extension', () => {
beforeEach(async () => {
writeJson(tree, '.vscode/extensions.json', {

View File

@ -8,7 +8,10 @@ import {
Tree,
updateJson,
updateWorkspaceConfiguration,
updateProjectConfiguration,
getProjects,
} from '@nrwl/devkit';
import { findRootJestConfig } from '../../utils/config/find-root-jest-files';
import {
babelJestVersion,
jestTypesVersion,
@ -18,6 +21,7 @@ import {
tsJestVersion,
tslibVersion,
tsNodeVersion,
typesNodeVersion,
} from '../../utils/versions';
import { JestInitSchema } from './schema';
@ -26,39 +30,81 @@ interface NormalizedSchema extends ReturnType<typeof normalizeOptions> {}
const schemaDefaults = {
compiler: 'tsc',
js: false,
rootProject: false,
} as const;
function createJestConfig(tree: Tree, js: boolean = false) {
// if the root ts config already exists then don't make a js one or vice versa
if (!tree.exists('jest.config.ts') && !tree.exists('jest.config.js')) {
const contents = js
? stripIndents`
const { getJestProjects } = require('@nrwl/jest');
function generateGlobalConfig(tree: Tree, isJS: boolean) {
const contents = isJS
? stripIndents`
const { getJestProjects } = require('@nrwl/jest');
module.exports = {
projects: getJestProjects()
};`
: stripIndents`
import { getJestProjects } from '@nrwl/jest';
module.exports = {
projects: getJestProjects()
};`
: stripIndents`
import { getJestProjects } from '@nrwl/jest';
export default {
projects: getJestProjects()
};`;
tree.write(`jest.config.${js ? 'js' : 'ts'}`, contents);
}
export default {
projects: getJestProjects()
};`;
tree.write(`jest.config.${isJS ? 'js' : 'ts'}`, contents);
}
function createJestConfig(tree: Tree, options: NormalizedSchema) {
if (!tree.exists('jest.preset.js')) {
// preset is always js file.
tree.write(
`jest.preset.js`,
`
const nxPreset = require('@nrwl/jest/preset').default;
module.exports = { ...nxPreset }`
);
addTestInputs(tree);
}
if (options.rootProject) {
// we don't want any config to be made because the `jestProjectGenerator`
// will copy the template config file
return;
}
const rootJestPath = findRootJestConfig(tree);
if (!rootJestPath) {
// if there's not root jest config, we will create one and return
// this can happen when:
// - root jest config was renamed => in which case there is migration needed
// - root project didn't have jest setup => again, no migration is needed
generateGlobalConfig(tree, options.js);
return;
}
if (tree.exists(rootJestPath)) {
// moving from root project config to monorepo-style config
const projects = getProjects(tree);
const projectNames = Array.from(projects.keys());
const rootProject = projectNames.find(
(projectName) => projects.get(projectName)?.root === '.'
);
// root project might have been removed,
// if it's missing there's nothing to migrate
if (rootProject) {
const rootProjectConfig = projects.get(rootProject);
const jestTarget = Object.values(rootProjectConfig.targets || {}).find(
(t) => t?.executor === '@nrwl/jest:jest'
);
const isProjectConfig = jestTarget?.options?.jestConfig === rootJestPath;
// if root project doesn't have jest target, there's nothing to migrate
if (isProjectConfig) {
const jestAppConfig = `jest.config.app.${options.js ? 'js' : 'ts'}`;
tree.rename(rootJestPath, jestAppConfig);
jestTarget.options.jestConfig = jestAppConfig;
updateProjectConfiguration(tree, rootProject, rootProjectConfig);
}
// generate new global config as it was move to project config or is missing
generateGlobalConfig(tree, options.js);
}
}
}
function addTestInputs(tree: Tree) {
@ -113,7 +159,7 @@ function updateDependencies(tree: Tree, options: NormalizedSchema) {
if (!options.js) {
devDeps['ts-node'] = tsNodeVersion;
devDeps['@types/jest'] = jestTypesVersion;
devDeps['@types/node'] = '16.11.7';
devDeps['@types/node'] = typesNodeVersion;
}
if (options.compiler === 'babel' || options.babelJest) {
@ -144,7 +190,7 @@ function updateExtensions(host: Tree) {
export function jestInitGenerator(tree: Tree, schema: JestInitSchema) {
const options = normalizeOptions(schema);
createJestConfig(tree, options.js);
createJestConfig(tree, options);
let installTask: GeneratorCallback = () => {};
if (!options.skipPackageJson) {

View File

@ -6,4 +6,5 @@ export interface JestInitSchema {
* @deprecated
*/
babelJest?: boolean;
rootProject?: boolean;
}

View File

@ -21,6 +21,12 @@
"type": "boolean",
"default": false,
"description": "Use JavaScript instead of TypeScript for config files"
},
"rootProject": {
"description": "initialize Jest for an application at the root of the workspace",
"type": "boolean",
"default": false,
"hidden": true
}
},
"required": []

View File

@ -19,5 +19,9 @@
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
]<% } %>
]<% } %><% if(rootProject){ %>,
testMatch: [
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
'<rootDir>/src/**/?(*.)+(spec|test).[jt]s?(x)',
],<% } %>
};

View File

@ -13,5 +13,9 @@
<% if (supportTsx){ %>'^.+\\.[tj]sx?$'<% } else { %>'^.+\\.[tj]s$'<% } %>: <% if (supportTsx && transformer === '@swc/jest') { %>['<%= transformer %>', { jsc: { transform: { react: { runtime: 'automatic' } } } }]<% } else { %>'<%= transformer %>'<% } %>
},
<% if (supportTsx) { %>moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],<% } else { %>moduleFileExtensions: ['ts', 'js', 'html'],<% } %><% } %>
coverageDirectory: '<%= offsetFromRoot %>coverage/<%= projectRoot %>'
coverageDirectory: '<%= offsetFromRoot %>coverage/<%= projectRoot %>'<% if(rootProject){ %>,
testMatch: [
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
'<rootDir>/src/**/?(*.)+(spec|test).[jt]s?(x)',
],<% } %>
};

View File

@ -362,4 +362,81 @@ describe('jestProject', () => {
expect(tree.read('libs/lib1/jest.config.ts', 'utf-8')).toMatchSnapshot();
});
});
describe('root project', () => {
it('root jest.config.ts should be project config', async () => {
writeJson(tree, 'tsconfig.json', {
files: [],
include: [],
references: [],
});
addProjectConfiguration(tree, 'my-project', {
root: '',
sourceRoot: 'src',
name: 'my-project',
targets: {},
});
await jestProjectGenerator(tree, {
...defaultOptions,
project: 'my-project',
rootProject: true,
});
expect(tree.read('jest.config.ts', 'utf-8')).toMatchInlineSnapshot(`
"/* eslint-disable */
export default {
displayName: 'my-project',
preset: '../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
}
},
coverageDirectory: '../coverage/my-project',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
'<rootDir>/src/**/?(*.)+(spec|test).[jt]s?(x)',
],
};
"
`);
});
it('root jest.config.js should be project config', async () => {
writeJson(tree, 'tsconfig.json', {
files: [],
include: [],
references: [],
});
addProjectConfiguration(tree, 'my-project', {
root: '',
sourceRoot: 'src',
name: 'my-project',
targets: {},
});
await jestProjectGenerator(tree, {
...defaultOptions,
project: 'my-project',
rootProject: true,
js: true,
});
expect(tree.read('jest.config.js', 'utf-8')).toMatchInlineSnapshot(`
"/* eslint-disable */
module.exports = {
displayName: 'my-project',
preset: '../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
}
},
coverageDirectory: '../coverage/my-project',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.[jt]s?(x)',
'<rootDir>/src/**/?(*.)+(spec|test).[jt]s?(x)',
],
};
"
`);
});
});
});

View File

@ -13,6 +13,7 @@ const schemaDefaults = {
supportTsx: false,
skipSetupFile: false,
skipSerializers: false,
rootProject: false,
} as const;
function normalizeOptions(options: JestProjectSchema) {
@ -42,7 +43,6 @@ function normalizeOptions(options: JestProjectSchema) {
// setupFile is always 'none'
options.setupFile = schemaDefaults.setupFile;
return {
...schemaDefaults,
...options,
@ -55,11 +55,12 @@ export async function jestProjectGenerator(
) {
const options = normalizeOptions(schema);
const installTask = init(tree, options);
checkForTestTarget(tree, options);
createFiles(tree, options);
updateTsConfig(tree, options);
updateWorkspace(tree, options);
updateJestConfig(tree, options);
if (!schema.skipFormat) {
await formatFiles(tree);
}

View File

@ -3,7 +3,7 @@ import { JestProjectSchema } from '../schema';
export function checkForTestTarget(tree: Tree, options: JestProjectSchema) {
const projectConfig = readProjectConfiguration(tree, options.project);
if (projectConfig.targets.test) {
throw new Error(`${options.project}: already has a test architect option.`);
if (projectConfig?.targets?.test) {
throw new Error(`${options.project}: already has a test target set.`);
}
}

View File

@ -27,7 +27,8 @@ export function createFiles(tree: Tree, options: JestProjectSchema) {
...options,
transformer,
js: !!options.js,
projectRoot: projectConfig.root,
rootProject: options.rootProject,
projectRoot: options.rootProject ? options.project : projectConfig.root,
offsetFromRoot: offsetFromRoot(projectConfig.root),
});

View File

@ -4,10 +4,10 @@ import { addPropertyToJestConfig } from '../../../utils/config/update-config';
import { readProjectConfiguration, Tree } from '@nrwl/devkit';
function isUsingUtilityFunction(host: Tree) {
return host
.read(findRootJestConfig(host))
.toString()
.includes('getJestProjects()');
const rootConfig = findRootJestConfig(host);
return (
rootConfig && host.read(rootConfig).toString().includes('getJestProjects()')
);
}
export function updateJestConfig(host: Tree, options: JestProjectSchema) {
@ -15,10 +15,13 @@ export function updateJestConfig(host: Tree, options: JestProjectSchema) {
return;
}
const project = readProjectConfiguration(host, options.project);
addPropertyToJestConfig(
host,
findRootJestConfig(host),
'projects',
`<rootDir>/${project.root}`
);
const rootConfig = findRootJestConfig(host);
if (rootConfig) {
addPropertyToJestConfig(
host,
findRootJestConfig(host),
'projects',
`<rootDir>/${project.root}`
);
}
}

View File

@ -16,4 +16,5 @@ export interface JestProjectSchema {
compiler?: 'tsc' | 'babel' | 'swc';
skipPackageJson?: boolean;
js?: boolean;
rootProject?: boolean;
}

View File

@ -68,6 +68,12 @@
"type": "boolean",
"default": false,
"description": "Use JavaScript instead of TypeScript for config files"
},
"rootProject": {
"description": "Add Jest to an application at the root of the workspace",
"type": "boolean",
"default": false,
"hidden": true
}
},
"required": []

View File

@ -41,3 +41,17 @@ export function getJestProjects() {
}
return Array.from(jestConfigurationSet);
}
/**
* a list of nested projects that have jest configured
* to be used in the testPathIgnorePatterns property of a given jest config
* https://jestjs.io/docs/configuration#testpathignorepatterns-arraystring
* */
export function getNestedJestProjects() {
// TODO(caleb): get current project path and list of all projects and their rootDir
// return a list of all projects that are nested in the current projects path
// always include node_modules as that's the default
const allProjects = getJestProjects();
return ['/node_modules/'];
}

View File

@ -14,5 +14,6 @@ export async function addJest(host: Tree, options: NormalizedSchema) {
skipSerializers: true,
setupFile: 'none',
compiler: options.compiler,
rootProject: options.rootProject,
});
}

View File

@ -8,7 +8,6 @@ import {
Tree,
updateWorkspaceConfiguration,
} from '@nrwl/devkit';
import { jestInitGenerator } from '@nrwl/jest';
import { webInitGenerator } from '@nrwl/web';
import { runTasksInSerial } from '@nrwl/workspace/src/utilities/run-tasks-in-serial';
import {
@ -71,10 +70,6 @@ export async function reactInitGenerator(host: Tree, schema: InitSchema) {
setDefault(host);
if (!schema.unitTestRunner || schema.unitTestRunner === 'jest') {
const jestTask = jestInitGenerator(host, schema);
tasks.push(jestTask);
}
if (!schema.e2eTestRunner || schema.e2eTestRunner === 'cypress') {
const cypressTask = cypressInitGenerator(host, {});
tasks.push(cypressTask);

View File

@ -44,7 +44,6 @@ async function createPreset(tree: Tree, options: Schema) {
name: options.name,
style: options.style,
linter: options.linter,
unitTestRunner: 'none',
standaloneConfig: options.standaloneConfig,
rootProject: true,
});
@ -68,7 +67,6 @@ async function createPreset(tree: Tree, options: Schema) {
name: options.name,
style: options.style,
linter: options.linter,
unitTestRunner: 'none',
standaloneConfig: options.standaloneConfig,
rootProject: true,
bundler: 'vite',