diff --git a/.eslintrc b/.eslintrc index 3e99a43dbc..e10c9cf6c9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,6 +6,9 @@ "sourceType": "module", "project": "./tsconfig.json" }, + "env": { + "node": true + }, "ignorePatterns": ["**/*"], "plugins": ["@typescript-eslint", "@nrwl/nx"], "extends": [ diff --git a/docs/angular/api-jest/builders/jest.md b/docs/angular/api-jest/builders/jest.md index dbeb23a6ae..9e2f5c3f88 100644 --- a/docs/angular/api-jest/builders/jest.md +++ b/docs/angular/api-jest/builders/jest.md @@ -136,7 +136,7 @@ Run all tests serially in the current process (rather than creating a worker poo Type: `string` -The name of a setup file used by Jest. (https://jestjs.io/docs/en/configuration#setupfilesafterenv-array) +[Deprecated] The name of a setup file used by Jest. (use Jest config file https://jestjs.io/docs/en/configuration#setupfilesafterenv-array) ### showConfig @@ -186,7 +186,7 @@ Node module that implements a custom results processor. (https://jestjs.io/docs/ Type: `string` -The name of the Typescript configuration file. +[Deprecated] The name of the Typescript configuration file. Set the tsconfig option in the jest config file. ### updateSnapshot diff --git a/docs/react/api-jest/builders/jest.md b/docs/react/api-jest/builders/jest.md index e39cf35215..d4b3ce80a5 100644 --- a/docs/react/api-jest/builders/jest.md +++ b/docs/react/api-jest/builders/jest.md @@ -137,7 +137,7 @@ Run all tests serially in the current process (rather than creating a worker poo Type: `string` -The name of a setup file used by Jest. (https://jestjs.io/docs/en/configuration#setupfilesafterenv-array) +[Deprecated] The name of a setup file used by Jest. (use Jest config file https://jestjs.io/docs/en/configuration#setupfilesafterenv-array) ### showConfig @@ -187,7 +187,7 @@ Node module that implements a custom results processor. (https://jestjs.io/docs/ Type: `string` -The name of the Typescript configuration file. +[Deprecated] The name of the Typescript configuration file. Set the tsconfig option in the jest config file. ### updateSnapshot diff --git a/e2e/node/src/node.test.ts b/e2e/node/src/node.test.ts index cc0267ef22..59d3dc6fb8 100644 --- a/e2e/node/src/node.test.ts +++ b/e2e/node/src/node.test.ts @@ -337,6 +337,11 @@ forEachCli((currentCLIName) => { stripIndents`module.exports = { name: '${nestlib}', preset: '../../jest.config.js', + globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + }, + }, testEnvironment: 'node', transform: { '^.+\\.[tj]sx?$': 'ts-jest', diff --git a/packages/jest/index.ts b/packages/jest/index.ts index e69de29bb2..a2f8ef738b 100644 --- a/packages/jest/index.ts +++ b/packages/jest/index.ts @@ -0,0 +1,8 @@ +export { + addPropertyToJestConfig, + removePropertyFromJestConfig, +} from './src/utils/config/update-config'; +export { + jestConfigObjectAst, + jestConfigObject, +} from './src/utils/config/functions'; diff --git a/packages/jest/migrations.json b/packages/jest/migrations.json index 6147027802..e3395a515e 100644 --- a/packages/jest/migrations.json +++ b/packages/jest/migrations.json @@ -24,6 +24,11 @@ "version": "9.2.0-beta.3", "description": "Update jest to v25", "factory": "./src/migrations/update-9-2-0/update-9-2-0" + }, + "update-10.0.0": { + "version": "10.0.0-beta.2", + "description": "update jest configs to include setup env files", + "factory": "./src/migrations/update-10-0-0/update-jest-configs" } }, "packageJsonUpdates": { diff --git a/packages/jest/src/builders/jest/jest.impl.spec.ts b/packages/jest/src/builders/jest/jest.impl.spec.ts index d704957dc6..2a54695609 100644 --- a/packages/jest/src/builders/jest/jest.impl.spec.ts +++ b/packages/jest/src/builders/jest/jest.impl.spec.ts @@ -61,16 +61,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: [], - globals: JSON.stringify({ - 'ts-jest': { - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), testPathPattern: [], watch: false, }, @@ -103,16 +93,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: ['lib.spec.ts'], - globals: JSON.stringify({ - 'ts-jest': { - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), coverage: false, runInBand: true, testNamePattern: 'should load', @@ -147,16 +127,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: ['file1.ts', 'file2.ts'], - globals: JSON.stringify({ - 'ts-jest': { - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), coverage: false, findRelatedTests: true, runInBand: true, @@ -206,16 +176,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: [], - globals: JSON.stringify({ - 'ts-jest': { - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), coverage: true, bail: 1, color: false, @@ -260,16 +220,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: [], - globals: JSON.stringify({ - 'ts-jest': { - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), maxWorkers: '50%', testPathPattern: [], }, @@ -292,16 +242,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: [], - globals: JSON.stringify({ - 'ts-jest': { - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), setupFilesAfterEnv: ['/root/test-setup.ts'], testPathPattern: [], watch: false, @@ -350,18 +290,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: [], - globals: JSON.stringify({ - hereToStay: true, - 'ts-jest': { - diagnostics: false, - tsConfig: '/root/tsconfig.test.json', - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }, - }), setupFilesAfterEnv: ['/root/test-setup.ts'], testPathPattern: [], watch: false, @@ -400,7 +328,6 @@ describe('Jest Builder', () => { expect(runCLI).toHaveBeenCalledWith( { _: [], - globals: '{}', testPathPattern: [], watch: false, }, diff --git a/packages/jest/src/builders/jest/jest.impl.ts b/packages/jest/src/builders/jest/jest.impl.ts index d6027e58d3..8e2d745890 100644 --- a/packages/jest/src/builders/jest/jest.impl.ts +++ b/packages/jest/src/builders/jest/jest.impl.ts @@ -11,14 +11,14 @@ import { JestBuilderOptions } from './schema'; try { require('dotenv').config(); -} catch (e) {} +} catch (e) { + // noop +} if (process.env.NODE_ENV == null || process.env.NODE_ENV == undefined) { (process.env as any).NODE_ENV = 'test'; } -export default createBuilder(run); - function run( options: JestBuilderOptions, context: BuilderContext @@ -28,9 +28,11 @@ function run( const jestConfig: { transform: any; globals: any; + setupFilesAfterEnv: any; + // eslint-disable-next-line @typescript-eslint/no-var-requires } = require(options.jestConfig); - let transformers = Object.values(jestConfig.transform || {}); + const transformers = Object.values(jestConfig.transform || {}); if (transformers.includes('babel-jest') && transformers.includes('ts-jest')) { throw new Error( 'Using babel-jest and ts-jest together is not supported.\n' + @@ -38,35 +40,6 @@ function run( ); } - // use ts-jest by default - const globals = jestConfig.globals || {}; - if (!transformers.includes('babel-jest')) { - const tsJestConfig = { - tsConfig: path.resolve(context.workspaceRoot, options.tsConfig), - }; - - // TODO: This is hacky, We should probably just configure it in the user's workspace - // If jest-preset-angular is installed, apply settings - try { - require.resolve('jest-preset-angular'); - Object.assign(tsJestConfig, { - stringifyContentPathRegex: '\\.(html|svg)$', - astTransformers: [ - 'jest-preset-angular/build/InlineFilesTransformer', - 'jest-preset-angular/build/StripStylesTransformer', - ], - }); - } catch (e) {} - - // merge the jestConfig globals with our 'ts-jest' override - Object.assign(globals, { - 'ts-jest': { - ...(globals['ts-jest'] || {}), - ...tsJestConfig, - }, - }); - } - const config: any = { _: [], config: options.config, @@ -95,13 +68,15 @@ function run( useStderr: options.useStderr, watch: options.watch, watchAll: options.watchAll, - globals: JSON.stringify(globals), }; + // for backwards compatibility if (options.setupFile) { - config.setupFilesAfterEnv = [ + const setupFilesAfterEnvSet = new Set([ + ...(jestConfig.setupFilesAfterEnv ?? []), path.resolve(context.workspaceRoot, options.setupFile), - ]; + ]); + config.setupFilesAfterEnv = Array.from(setupFilesAfterEnvSet); } if (options.testFile) { @@ -132,3 +107,5 @@ function run( }) ); } + +export default createBuilder(run); diff --git a/packages/jest/src/builders/jest/schema.json b/packages/jest/src/builders/jest/schema.json index b981100c8c..b9bde6484a 100644 --- a/packages/jest/src/builders/jest/schema.json +++ b/packages/jest/src/builders/jest/schema.json @@ -29,12 +29,14 @@ "type": "string" }, "tsConfig": { - "description": "The name of the Typescript configuration file.", - "type": "string" + "description": "[Deprecated] The name of the Typescript configuration file. Set the tsconfig option in the jest config file. ", + "type": "string", + "x-deprecated": true }, "setupFile": { - "description": "The name of a setup file used by Jest. (https://jestjs.io/docs/en/configuration#setupfilesafterenv-array)", - "type": "string" + "description": "[Deprecated] The name of a setup file used by Jest. (use Jest config file https://jestjs.io/docs/en/configuration#setupfilesafterenv-array)", + "type": "string", + "x-deprecated": true }, "bail": { "alias": "b", @@ -150,5 +152,5 @@ "type": "boolean" } }, - "required": ["jestConfig", "tsConfig"] + "required": ["jestConfig"] } diff --git a/packages/jest/src/migrations/update-10-0-0/update-jest-configs.spec.ts b/packages/jest/src/migrations/update-10-0-0/update-jest-configs.spec.ts new file mode 100644 index 0000000000..cf5451bbab --- /dev/null +++ b/packages/jest/src/migrations/update-10-0-0/update-jest-configs.spec.ts @@ -0,0 +1,156 @@ +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { createEmptyWorkspace } from '@nrwl/workspace/testing'; +import * as path from 'path'; +import { serializeJson } from '@nrwl/workspace'; +import { jestConfigObject } from '../../..'; + +describe('update 10.0.0', () => { + let initialTree: Tree; + let schematicRunner: SchematicTestRunner; + + const jestConfig = String.raw` + module.exports = { + name: 'test-jest', + preset: '../../jest.config.js', + coverageDirectory: '../../coverage/libs/test-jest', + globals: { + "existing-global": "test" + }, + snapshotSerializers: [ + 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', + 'jest-preset-angular/build/AngularSnapshotSerializer.js', + 'jest-preset-angular/build/HTMLCommentSerializer.js' + ] + } + `; + + const jestConfigReact = String.raw` + module.exports = { + name: 'my-react-app', + preset: '../../jest.config.js', + transform: { + '^(?!.*\\\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest', + '^.+\\\\.[tj]sx?$': [ + 'babel-jest', + { cwd: __dirname, configFile: './babel-jest.config.json' } + ] + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'], + coverageDirectory: '../../coverage/apps/my-react-app' + } + `; + + beforeEach(() => { + initialTree = createEmptyWorkspace(Tree.empty()); + + initialTree.create('apps/products/jest.config.js', jestConfig); + initialTree.create( + 'apps/products/src/test-setup.ts', + `import 'jest-preset-angular'` + ); + initialTree.create('apps/cart/jest.config.js', jestConfigReact); + initialTree.overwrite( + 'workspace.json', + serializeJson({ + version: 1, + projects: { + products: { + root: 'apps/products', + sourceRoot: 'apps/products/src', + architect: { + build: { + builder: '@angular-devkit/build-angular:browser', + }, + test: { + builder: '@nrwl/jest:jest', + options: { + jestConfig: 'apps/products/jest.config.js', + tsConfig: 'apps/products/tsconfig.spec.json', + setupFile: 'apps/products/src/test-setup.ts', + passWithNoTests: true, + }, + }, + }, + }, + cart: { + root: 'apps/cart', + sourceRoot: 'apps/cart/src', + architect: { + build: { + builder: '@nrwl/web:build', + }, + test: { + builder: '@nrwl/jest:jest', + options: { + jestConfig: 'apps/cart/jest.config.js', + passWithNoTests: true, + }, + }, + }, + }, + }, + }) + ); + schematicRunner = new SchematicTestRunner( + '@nrwl/jest', + path.join(__dirname, '../../../migrations.json') + ); + }); + + it('should remove setupFile and tsconfig in test architect from workspace.json', async (done) => { + const result = await schematicRunner + .runSchematicAsync('update-10.0.0', {}, initialTree) + .toPromise(); + + const updatedWorkspace = JSON.parse(result.readContent('workspace.json')); + expect(updatedWorkspace.projects.products.architect.test.options).toEqual({ + jestConfig: expect.anything(), + passWithNoTests: expect.anything(), + }); + expect(updatedWorkspace.projects.cart.architect.test.options).toEqual({ + jestConfig: expect.anything(), + passWithNoTests: expect.anything(), + }); + done(); + }); + + it('should update the jest.config files', async (done) => { + await schematicRunner + .runSchematicAsync('update-10.0.0', {}, initialTree) + .toPromise(); + + const jestObject = jestConfigObject( + initialTree, + 'apps/products/jest.config.js' + ); + + const angularSetupFiles = jestObject.setupFilesAfterEnv; + const angularGlobals = jestObject.globals; + + expect(angularSetupFiles).toEqual(['/src/test-setup.ts']); + expect(angularGlobals).toEqual({ + 'existing-global': 'test', + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + astTransformers: [ + 'jest-preset-angular/build/InlineFilesTransformer', + 'jest-preset-angular/build/StripStylesTransformer', + ], + }, + }); + + const reactJestObject = jestConfigObject( + initialTree, + 'apps/cart/jest.config.js' + ); + + const reactSetupFiles = reactJestObject.setupFilesAfterEnv; + const reactGlobals = reactJestObject.globals; + expect(reactSetupFiles).toBeUndefined(); + expect(reactGlobals).toBeUndefined(); + + done(); + }); +}); diff --git a/packages/jest/src/migrations/update-10-0-0/update-jest-configs.ts b/packages/jest/src/migrations/update-10-0-0/update-jest-configs.ts new file mode 100644 index 0000000000..11e4c93527 --- /dev/null +++ b/packages/jest/src/migrations/update-10-0-0/update-jest-configs.ts @@ -0,0 +1,173 @@ +import { + chain, + Rule, + SchematicContext, + Tree, +} from '@angular-devkit/schematics'; +import { + formatFiles, + getWorkspace, + getWorkspacePath, + serializeJson, + updateWorkspace, +} from '@nrwl/workspace'; +import { addPropertyToJestConfig, jestConfigObject } from '../../..'; + +function checkJestPropertyObject(object: unknown): object is object { + return object !== null && object !== undefined; +} + +function modifyJestConfig( + host: Tree, + context: SchematicContext, + project: string, + setupFile: string, + jestConfig: string, + tsConfig: string, + isAngular: boolean +) { + if (setupFile === '') { + return; + } + + let globalTsJest: any = { + tsConfig, + }; + + if (isAngular) { + globalTsJest = { + ...globalTsJest, + stringifyContentPathRegex: '\\.(html|svg)$', + astTransformers: [ + 'jest-preset-angular/build/InlineFilesTransformer', + 'jest-preset-angular/build/StripStylesTransformer', + ], + }; + } + + try { + const jestObject = jestConfigObject(host, jestConfig); + + // add set up env file + // setupFilesAfterEnv + const existingSetupFiles = jestObject.setupFilesAfterEnv; + + let setupFilesAfterEnv: string | string[] = [setupFile]; + if (Array.isArray(existingSetupFiles)) { + setupFilesAfterEnv = setupFile; + } + + addPropertyToJestConfig( + host, + jestConfig, + 'setupFilesAfterEnv', + setupFilesAfterEnv + ); + + // check if jest config has babel transform + const transformProperty = jestObject.transform; + + let hasBabelTransform = false; + if (transformProperty) { + for (const prop in transformProperty) { + const transformPropValue = transformProperty[prop]; + if (Array.isArray(transformPropValue)) { + hasBabelTransform = transformPropValue.some( + (value) => typeof value === 'string' && value.includes('babel') + ); + } else if (typeof transformPropValue === 'string') { + transformPropValue.includes('babel'); + } + } + } + + if (hasBabelTransform) { + return; + } + + // Add ts-jest configurations + const existingGlobals = jestObject.globals; + if (!existingGlobals) { + addPropertyToJestConfig(host, jestConfig, 'globals', { + 'ts-jest': globalTsJest, + }); + } else { + const existingGlobalTsJest = existingGlobals['ts-jest']; + if (!checkJestPropertyObject(existingGlobalTsJest)) { + addPropertyToJestConfig( + host, + jestConfig, + 'globals.ts-jest', + globalTsJest + ); + } + } + } catch { + context.logger.warn(` + Cannot update jest config for the ${project} project. + This is most likely caused because the jest config at ${jestConfig} it not in a expected configuration format (ie. module.exports = {}). + + Since this migration could not be ran on this project, please make sure to modify the Jest config file to have the following configured: + * setupFilesAfterEnv with: "${setupFile}" + * globals.ts-jest with: + "${serializeJson(globalTsJest)}" + `); + } +} + +function updateJestConfigForProjects() { + return async (host: Tree, context: SchematicContext) => { + const workspace = await getWorkspace(host, getWorkspacePath(host)); + + for (const [projectName, projectDefinition] of workspace.projects) { + for (const [, testTarget] of projectDefinition.targets) { + if (testTarget.builder !== '@nrwl/jest:jest') { + continue; + } + + const setupfile = testTarget.options?.setupFile; + const jestConfig = (testTarget.options?.jestConfig as string) ?? ''; + const tsConfig = (testTarget.options?.tsConfig as string) ?? ''; + const tsConfigWithRootDir = tsConfig.replace( + projectDefinition.root, + '' + ); + + let isAngular = false; + let setupFileWithRootDir = ''; + if (typeof setupfile === 'string') { + isAngular = host + .read(setupfile) + ?.toString() + .includes('jest-preset-angular'); + setupFileWithRootDir = setupfile.replace( + projectDefinition.root, + '' + ); + } + + modifyJestConfig( + host, + context, + projectName, + setupFileWithRootDir, + jestConfig, + tsConfigWithRootDir, + isAngular + ); + + const updatedOptions = { ...testTarget.options }; + delete updatedOptions.setupFile; + delete updatedOptions.tsConfig; + + testTarget.options = updatedOptions; + } + } + + return updateWorkspace(workspace); + }; +} + +export default function update(): Rule { + return chain([updateJestConfigForProjects(), formatFiles()]); +} diff --git a/packages/jest/src/schematics/jest-project/files/jest.config.js__tmpl__ b/packages/jest/src/schematics/jest-project/files/jest.config.js__tmpl__ index 1fd8af59d4..b34eafb79e 100644 --- a/packages/jest/src/schematics/jest-project/files/jest.config.js__tmpl__ +++ b/packages/jest/src/schematics/jest-project/files/jest.config.js__tmpl__ @@ -1,6 +1,18 @@ module.exports = { name: '<%= project %>', - preset: '<%= offsetFromRoot %>jest.config.js',<% if(testEnvironment) { %> + preset: '<%= offsetFromRoot %>jest.config.js',<% if(setupFile !== 'none') { %> + setupFilesAfterEnv: ['/src/test-setup.ts'], + <% } %><% if (transformer === 'ts-jest') { %> + globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json',<%if (setupFile === 'angular') { %> + stringifyContentPathRegex: '\\.(html|svg)$', + astTransformers: [ + 'jest-preset-angular/build/InlineFilesTransformer', + 'jest-preset-angular/build/StripStylesTransformer' + ],<% } %> + } + },<% } %><% if(testEnvironment) { %> testEnvironment: '<%= testEnvironment %>',<% } %><% if (supportTsx) { %> transform: { '^.+\\.[tj]sx?$': <% if (transformer == 'babel-jest') { %>[ 'babel-jest', @@ -13,4 +25,4 @@ module.exports = { 'jest-preset-angular/build/AngularSnapshotSerializer.js', 'jest-preset-angular/build/HTMLCommentSerializer.js' ]<% } %> -}; +}; \ No newline at end of file diff --git a/packages/jest/src/schematics/jest-project/jest-project.spec.ts b/packages/jest/src/schematics/jest-project/jest-project.spec.ts index db01adc70a..850851f540 100644 --- a/packages/jest/src/schematics/jest-project/jest-project.spec.ts +++ b/packages/jest/src/schematics/jest-project/jest-project.spec.ts @@ -2,6 +2,7 @@ import { Tree } from '@angular-devkit/schematics'; import { readJsonInTree, updateJsonInTree } from '@nrwl/workspace'; import { createEmptyWorkspace } from '@nrwl/workspace/testing'; import { callRule, runSchematic } from '../../utils/testing'; +import { stripIndents } from '@angular-devkit/core/src/utils/literals'; describe('jestProject', () => { let appTree: Tree; @@ -84,10 +85,15 @@ describe('jestProject', () => { }, appTree ); - expect(resultTree.readContent('libs/lib1/jest.config.js')) - .toBe(`module.exports = { + expect(stripIndents`${resultTree.readContent('libs/lib1/jest.config.js')}`) + .toBe(stripIndents`module.exports = { name: 'lib1', preset: '../../jest.config.js', + globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + } + }, coverageDirectory: '../../coverage/libs/lib1', snapshotSerializers: [ 'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', @@ -145,6 +151,47 @@ describe('jestProject', () => { appTree ); expect(resultTree.exists('src/test-setup.ts')).toBeFalsy(); + expect(resultTree.readContent('libs/lib1/jest.config.js')).not.toContain( + `setupFilesAfterEnv: ['/src/test-setup.ts'],` + ); + }); + + it('should have setupFilesAfterEnv in the jest.config when generated for web-components', async () => { + const resultTree = await runSchematic( + 'jest-project', + { + project: 'lib1', + setupFile: 'web-components', + }, + appTree + ); + expect(resultTree.readContent('libs/lib1/jest.config.js')).toContain( + `setupFilesAfterEnv: ['/src/test-setup.ts'],` + ); + }); + + it('should have setupFilesAfterEnv and globals.ts-ject in the jest.config when generated for angular', async () => { + const resultTree = await runSchematic( + 'jest-project', + { + project: 'lib1', + setupFile: 'angular', + }, + appTree + ); + + const jestConfig = resultTree.readContent('libs/lib1/jest.config.js'); + expect(jestConfig).toContain( + `setupFilesAfterEnv: ['/src/test-setup.ts'],` + ); + expect(stripIndents`${jestConfig}`).toContain(stripIndents`globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + astTransformers: [ + 'jest-preset-angular/build/InlineFilesTransformer', + 'jest-preset-angular/build/StripStylesTransformer' + ],`); }); it('should not list the setup file in workspace.json', async () => { @@ -261,4 +308,50 @@ describe('jestProject', () => { ); }); }); + + describe('--babelJest', () => { + it('should have globals.ts-jest configured when babelJest is false', async () => { + const resultTree = await runSchematic( + 'jest-project', + { + project: 'lib1', + babelJest: false, + setupFile: 'none', + }, + appTree + ); + const jestConfig = stripIndents`${resultTree.readContent( + 'libs/lib1/jest.config.js' + )}`; + expect(jestConfig).toContain( + stripIndents`globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + } + }` + ); + }); + + it('should have NOT have globals.ts-jest configured when babelJest is true', async () => { + const resultTree = await runSchematic( + 'jest-project', + { + project: 'lib1', + babelJest: true, + setupFile: 'none', + }, + appTree + ); + const jestConfig = stripIndents`${resultTree.readContent( + 'libs/lib1/jest.config.js' + )}`; + expect(jestConfig).not.toContain( + stripIndents`globals: { + 'ts-jest': { + tsConfig: '/tsconfig.spec.json', + } + }` + ); + }); + }); }); diff --git a/packages/jest/src/utils/config/functions.ts b/packages/jest/src/utils/config/functions.ts new file mode 100644 index 0000000000..1babb3cf9a --- /dev/null +++ b/packages/jest/src/utils/config/functions.ts @@ -0,0 +1,209 @@ +import * as ts from 'typescript'; +import { findNodes, InsertChange, ReplaceChange } from '@nrwl/workspace'; +import { Tree } from '@angular-devkit/schematics'; +import * as stripJsonComments from 'strip-json-comments'; +import { Config } from '@jest/types'; + +function trailingCommaNeeded(needed: boolean) { + return needed ? ',' : ''; +} + +function createInsertChange( + path: string, + value: unknown, + position: number, + commaNeeded: boolean +) { + return new InsertChange( + path, + position, + `${trailingCommaNeeded(!commaNeeded)}${value}${trailingCommaNeeded( + commaNeeded + )}` + ); +} + +function findPropertyAssignment( + object: ts.ObjectLiteralExpression, + propertyName: string +) { + return object.properties.find((prop) => { + const propNameText = prop.name.getText(); + if (propNameText.match(/^["'].+["']$/g)) { + return JSON.parse(propNameText.replace(/'/g, '"')) === propertyName; + } + + return propNameText === propertyName; + }) as ts.PropertyAssignment | undefined; +} + +export function getJsonObject(object: string) { + const value = stripJsonComments(object); + // react babel-jest has __dirname in the config. + // Put a temp variable in the anon function so that it doesnt fail. + // Migration script has a catch handler to give instructions on how to update the jest config if this fails. + return Function(` + "use strict"; + let __dirname = ''; + return (${value}); + `)(); +} + +export function addOrUpdateProperty( + object: ts.ObjectLiteralExpression, + properties: string[], + value: unknown, + path: string +) { + const propertyName = properties.shift(); + const propertyAssignment = findPropertyAssignment(object, propertyName); + + if (propertyAssignment) { + if ( + propertyAssignment.initializer.kind === ts.SyntaxKind.StringLiteral || + propertyAssignment.initializer.kind === ts.SyntaxKind.NumericLiteral || + propertyAssignment.initializer.kind === ts.SyntaxKind.FalseKeyword || + propertyAssignment.initializer.kind === ts.SyntaxKind.TrueKeyword + ) { + return [ + new ReplaceChange( + path, + propertyAssignment.initializer.pos, + propertyAssignment.initializer.getFullText(), + value as string + ), + ]; + } + + if ( + propertyAssignment.initializer.kind === + ts.SyntaxKind.ArrayLiteralExpression + ) { + const arrayLiteral = propertyAssignment.initializer as ts.ArrayLiteralExpression; + + if ( + arrayLiteral.elements.some((element) => { + return element.getText().replace(/'/g, '"') === value; + }) + ) { + return []; + } + + return [ + createInsertChange( + path, + value, + arrayLiteral.elements.end, + arrayLiteral.elements.hasTrailingComma + ), + ]; + } else if ( + propertyAssignment.initializer.kind === + ts.SyntaxKind.ObjectLiteralExpression + ) { + return addOrUpdateProperty( + propertyAssignment.initializer as ts.ObjectLiteralExpression, + properties, + value, + path + ); + } + } else { + if (propertyName === undefined) { + throw new Error( + `Please use dot delimited paths to update an existing object. Eg. object.property ` + ); + } + return [ + createInsertChange( + path, + `${JSON.stringify(propertyName)}: ${value}`, + object.properties.end, + object.properties.hasTrailingComma + ), + ]; + } +} + +export function removeProperty( + object: ts.ObjectLiteralExpression, + properties: string[] +): ts.PropertyAssignment | null { + const propertyName = properties.shift(); + const propertyAssignment = findPropertyAssignment(object, propertyName); + + if (propertyAssignment) { + if ( + properties.length > 0 && + propertyAssignment.initializer.kind === + ts.SyntaxKind.ObjectLiteralExpression + ) { + return removeProperty( + propertyAssignment.initializer as ts.ObjectLiteralExpression, + properties + ); + } + return propertyAssignment; + } else { + return null; + } +} + +/** + * Should be used to get the jest config object. + * + * @param host + * @param path + */ +export function jestConfigObjectAst( + host: Tree, + path: string +): ts.ObjectLiteralExpression { + if (!host.exists(path)) { + throw new Error(`Cannot find '${path}' in your workspace.`); + } + + const fileContent = host.read(path).toString('utf-8'); + + const sourceFile = ts.createSourceFile( + 'jest.config.js', + fileContent, + ts.ScriptTarget.Latest, + true + ); + + const expressions = findNodes( + sourceFile, + ts.SyntaxKind.BinaryExpression + ) as ts.BinaryExpression[]; + + const moduleExports = expressions.find( + (node) => + node.left.getText() === 'module.exports' && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isObjectLiteralExpression(node.right) + ); + + if (!moduleExports) { + throw new Error( + ` + The provided jest config file does not have the expected 'module.exports' expression. + See https://jestjs.io/docs/en/configuration for more details.` + ); + } + + return moduleExports.right as ts.ObjectLiteralExpression; +} + +/** + * Returns the jest config object + * @param host + * @param path + */ +export function jestConfigObject( + host: Tree, + path: string +): Partial & { [index: string]: any } { + const jestConfigAst = jestConfigObjectAst(host, path); + return getJsonObject(jestConfigAst.getText()); +} diff --git a/packages/jest/src/utils/config/update-config.spec.ts b/packages/jest/src/utils/config/update-config.spec.ts new file mode 100644 index 0000000000..c61156b5d2 --- /dev/null +++ b/packages/jest/src/utils/config/update-config.spec.ts @@ -0,0 +1,235 @@ +import { Tree } from '@angular-devkit/schematics'; +import { + addPropertyToJestConfig, + removePropertyFromJestConfig, +} from './update-config'; +import { jestConfigObject } from './functions'; + +describe('Update jest.config.js', () => { + let host: Tree; + + beforeEach(() => { + host = Tree.empty(); + // create + host.create( + 'jest.config.js', + String.raw` + module.exports = { + name: 'test', + boolean: false, + preset: 'nrwl-preset', + "update-me": "hello", + alreadyExistingArray: ['something'], + alreadyExistingObject: { + nestedProperty: { + primitive: 'string', + childArray: ['value1', 'value2'] + }, + 'nested-object': { + childArray: ['value1', 'value2'] + } + }, + numeric: 0 + } + ` + ); + }); + + describe('inserting or updating an existing property', () => { + it('should be able to update an existing property with a primitive value ', () => { + addPropertyToJestConfig(host, 'jest.config.js', 'name', 'test-case'); + + let json = jestConfigObject(host, 'jest.config.js'); + expect(json.name).toBe('test-case'); + + addPropertyToJestConfig(host, 'jest.config.js', 'boolean', true); + json = jestConfigObject(host, 'jest.config.js'); + expect(json.boolean).toBe(true); + + addPropertyToJestConfig(host, 'jest.config.js', 'numeric', 1); + json = jestConfigObject(host, 'jest.config.js'); + expect(json.numeric).toBe(1); + }); + + it('should be able to insert a new property with a primitive value', () => { + addPropertyToJestConfig(host, 'jest.config.js', 'bail', 0); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.bail).toBe(0); + }); + + it('it should be able to insert a new property with an array value', () => { + const arrayValue = ['value', 'value2']; + addPropertyToJestConfig(host, 'jest.config.js', 'myArrayProperty', [ + 'value', + 'value2', + ]); + + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.myArrayProperty).toEqual(arrayValue); + }); + + it('should be able to insert a new property with an object value', () => { + const objectValue = { + 'some-property': { config1: '1', config2: ['value1', 'value2'] }, + }; + addPropertyToJestConfig( + host, + 'jest.config.js', + 'myObjectProperty', + objectValue + ); + + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.myObjectProperty).toEqual(objectValue); + }); + + it('should be able to update an existing array', () => { + addPropertyToJestConfig( + host, + 'jest.config.js', + 'alreadyExistingArray', + 'something new' + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.alreadyExistingArray).toEqual(['something', 'something new']); + }); + + it('should not add duplicate values in an existing array', () => { + addPropertyToJestConfig( + host, + 'jest.config.js', + 'alreadyExistingArray', + 'something' + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.alreadyExistingArray).toEqual(['something']); + }); + + it('should be able to update an existing object', () => { + const newPropertyValue = ['my new object']; + addPropertyToJestConfig( + host, + 'jest.config.js', + 'alreadyExistingObject.something-new', + newPropertyValue + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.alreadyExistingObject['something-new']).toEqual( + newPropertyValue + ); + }); + + it('should be able to update an existing array in a nested object', () => { + const newPropertyValue = 'new value'; + addPropertyToJestConfig( + host, + 'jest.config.js', + 'alreadyExistingObject.nestedProperty.childArray', + newPropertyValue + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.alreadyExistingObject.nestedProperty.childArray).toEqual([ + 'value1', + 'value2', + newPropertyValue, + ]); + }); + + it('should be able to update an existing value in a nested object', () => { + const newPropertyValue = false; + addPropertyToJestConfig( + host, + 'jest.config.js', + 'alreadyExistingObject.nestedProperty.primitive', + newPropertyValue + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json.alreadyExistingObject.nestedProperty.primitive).toEqual( + newPropertyValue + ); + }); + + it('should be able to modify an object with a string identifier', () => { + addPropertyToJestConfig( + host, + 'jest.config.js', + 'something-here', + 'newPropertyValue' + ); + let json = jestConfigObject(host, 'jest.config.js'); + expect(json['something-here']).toEqual('newPropertyValue'); + + addPropertyToJestConfig(host, 'jest.config.js', 'update-me', 'goodbye'); + json = jestConfigObject(host, 'jest.config.js'); + expect(json['update-me']).toEqual('goodbye'); + }); + + describe('errors', () => { + it('should throw an error when trying to add a value to an already existing object without being dot delimited', () => { + expect(() => { + addPropertyToJestConfig( + host, + 'jest.config.js', + 'alreadyExistingObject', + 'should fail' + ); + }).toThrow(); + }); + + it('should throw an error if the jest.config doesnt match module.exports = {} style', () => { + host.create( + 'jest.unconventional.js', + String.raw` + jestObject = { + stuffhere: true + } + + module.exports = jestObject; + ` + ); + expect(() => { + addPropertyToJestConfig( + host, + 'jest.unconventional.js', + 'stuffhere', + 'should fail' + ); + }).toThrow(); + }); + + it('should throw if the provided config does not exist in the tree', () => { + expect(() => { + addPropertyToJestConfig(host, 'jest.doesnotexist.js', '', ''); + }).toThrow(); + }); + }); + }); + + describe('removing values', () => { + it('should remove single nested properties in the jest config, ', () => { + removePropertyFromJestConfig( + host, + 'jest.config.js', + 'alreadyExistingObject.nested-object.childArray' + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect( + json['alreadyExistingObject']['nested-object']['childArray'] + ).toEqual(undefined); + }); + it('should remove single properties', () => { + removePropertyFromJestConfig(host, 'jest.config.js', 'update-me'); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json['update-me']).toEqual(undefined); + }); + it('should remove a whole object', () => { + removePropertyFromJestConfig( + host, + 'jest.config.js', + 'alreadyExistingObject' + ); + const json = jestConfigObject(host, 'jest.config.js'); + expect(json['alreadyExistingObject']).toEqual(undefined); + }); + }); +}); diff --git a/packages/jest/src/utils/config/update-config.ts b/packages/jest/src/utils/config/update-config.ts new file mode 100644 index 0000000000..5687b99239 --- /dev/null +++ b/packages/jest/src/utils/config/update-config.ts @@ -0,0 +1,61 @@ +import { Tree } from '@angular-devkit/schematics'; +import { insert, RemoveChange } from '@nrwl/workspace'; +import { + addOrUpdateProperty, + jestConfigObjectAst, + removeProperty, +} from './functions'; + +/** + * Add a property to the jest config + * @param host + * @param path - path to the jest config file + * @param propertyName - Property to update. Can be dot delimited to access deeply nested properties + * @param value + */ +export function addPropertyToJestConfig( + host: Tree, + path: string, + propertyName: string, + value: unknown +) { + const configObject = jestConfigObjectAst(host, path); + const properties = propertyName.split('.'); + const changes = addOrUpdateProperty( + configObject, + properties, + JSON.stringify(value), + path + ); + insert(host, path, changes); +} + +/** + * Remove a property value from the jest config + * @param host + * @param path + * @param propertyName - Property to remove. Can be dot delimited to access deeply nested properties + */ +export function removePropertyFromJestConfig( + host: Tree, + path: string, + propertyName: string +) { + const configObject = jestConfigObjectAst(host, path); + const propertyAssignment = removeProperty( + configObject, + propertyName.split('.') + ); + + if (propertyAssignment) { + const file = host.read(path).toString('utf-8'); + const commaNeeded = file[propertyAssignment.end] === ','; + insert(host, path, [ + new RemoveChange( + path, + propertyAssignment.getStart(), + `${propertyAssignment.getText()}${commaNeeded ? ',' : ''}` + ), + ]); + } +} diff --git a/packages/workspace/index.ts b/packages/workspace/index.ts index fde2a4b6e8..a959c6f540 100644 --- a/packages/workspace/index.ts +++ b/packages/workspace/index.ts @@ -48,6 +48,9 @@ export { updateNxJsonInTree, addProjectToNxJsonInTree, readNxJsonInTree, + InsertChange, + ReplaceChange, + RemoveChange, } from './src/utils/ast-utils'; export { diff --git a/packages/workspace/src/utils/ast-utils.ts b/packages/workspace/src/utils/ast-utils.ts index 4b85602987..3e9577bec8 100644 --- a/packages/workspace/src/utils/ast-utils.ts +++ b/packages/workspace/src/utils/ast-utils.ts @@ -163,8 +163,8 @@ export class RemoveChange implements Change { constructor( public path: string, - private pos: number, - private toRemove: string + public pos: number, + public toRemove: string ) { if (pos < 0) { throw new Error('Negative positions are invalid'); @@ -189,9 +189,9 @@ export class ReplaceChange implements Change { constructor( public path: string, - private pos: number, - private oldText: string, - private newText: string + public pos: number, + public oldText: string, + public newText: string ) { if (pos < 0) { throw new Error('Negative positions are invalid'); @@ -350,22 +350,25 @@ export function addGlobal( } } -export function insert(host: Tree, modulePath: string, changes: any[]) { +export function insert(host: Tree, modulePath: string, changes: Change[]) { if (changes.length < 1) { return; } + + // sort changes so that the highest pos goes first + const orderedChanges = changes.sort((a, b) => b.order - a.order); + const recorder = host.beginUpdate(modulePath); - for (const change of changes) { - if (change.type === 'insert') { + for (const change of orderedChanges) { + if (change instanceof InsertChange) { recorder.insertLeft(change.pos, change.toAdd); - } else if (change.type === 'remove') { - recorder.remove((change).pos - 1, (change).toRemove.length + 1); + } else if (change instanceof RemoveChange) { + recorder.remove(change.pos - 1, change.toRemove.length + 1); + } else if (change instanceof ReplaceChange) { + recorder.remove(change.pos, change.oldText.length); + recorder.insertLeft(change.pos, change.newText); } else if (change.type === 'noop') { // do nothing - } else if (change.type === 'replace') { - const action = change; - recorder.remove(action.pos, action.oldText.length); - recorder.insertLeft(action.pos, action.newText); } else { throw new Error(`Unexpected Change '${change.constructor.name}'`); }