From a89c360f46323d04ed8e54b1fbc125b3314b5fc7 Mon Sep 17 00:00:00 2001 From: James Henry Date: Wed, 18 May 2022 13:02:59 +0400 Subject: [PATCH] chore(core): refactor explicit-project-dependencies unit tests (#10317) --- .../explicit-project-dependencies.spec.ts | 841 +++++++++++++----- 1 file changed, 637 insertions(+), 204 deletions(-) diff --git a/packages/nx/src/project-graph/build-dependencies/explicit-project-dependencies.spec.ts b/packages/nx/src/project-graph/build-dependencies/explicit-project-dependencies.spec.ts index ff2c777d53..67b6c2e798 100644 --- a/packages/nx/src/project-graph/build-dependencies/explicit-project-dependencies.spec.ts +++ b/packages/nx/src/project-graph/build-dependencies/explicit-project-dependencies.spec.ts @@ -1,223 +1,656 @@ +import { vol } from 'memfs'; +import type { Workspace } from '../../config/workspace-json-project-json'; +import { defaultFileHasher } from '../../hasher/file-hasher'; import { createProjectFileMap } from '../file-map-utils'; +import { ProjectGraphBuilder } from '../project-graph-builder'; +import { buildExplicitTypeScriptDependencies } from './explicit-project-dependencies'; jest.mock('fs', () => require('memfs').fs); jest.mock('nx/src/utils/app-root', () => ({ workspaceRoot: '/root', })); -import { vol } from 'memfs'; -import { buildExplicitTypeScriptDependencies } from './explicit-project-dependencies'; -import { defaultFileHasher } from '../../hasher/file-hasher'; -import { - ProjectGraphProcessorContext, - ProjectGraphProjectNode, -} from '../../config/project-graph'; -import { ProjectGraphBuilder } from '../project-graph-builder'; +// projectName => tsconfig import path +const dependencyProjectNamesToImportPaths = { + proj2: '@proj/my-second-proj', + proj3a: '@proj/project-3', + proj4ab: '@proj/proj4ab', +}; describe('explicit project dependencies', () => { - let ctx: ProjectGraphProcessorContext; - let projects: Record; - let fsJson; beforeEach(() => { - const workspaceJson = { - projects: { - proj: { - root: 'libs/proj', - }, - proj2: { - root: 'libs/proj2', - }, - proj3a: { - root: 'libs/proj3a', - }, - proj123: { - root: 'libs/proj123', - }, - proj1234: { - root: 'libs/proj1234', - }, - 'proj1234-child': { - root: 'libs/proj1234-child', - }, - }, - }; - const nxJson = { - npmScope: 'proj', - }; - const tsConfig = { - compilerOptions: { - baseUrl: '.', - paths: { - '@proj/proj': ['libs/proj/index.ts'], - '@proj/my-second-proj': ['libs/proj2/index.ts'], - '@proj/project-3': ['libs/proj3a/index.ts'], - '@proj/proj123': ['libs/proj123/index.ts'], - '@proj/proj1234': ['libs/proj1234/index.ts'], - '@proj/proj1234-child': ['libs/proj1234-child/index.ts'], - '@proj/proj4ab': ['libs/proj4ab/index.ts'], - }, - }, - }; - fsJson = { - './package.json': `{ - "name": "test", - "dependencies": [], - "devDependencies": [] - }`, - './workspace.json': JSON.stringify(workspaceJson), - './nx.json': JSON.stringify(nxJson), - './tsconfig.base.json': JSON.stringify(tsConfig), - './libs/proj/index.ts': `import {a} from '@proj/my-second-proj'; - import('@proj/project-3'); - const a = { loadChildren: '@proj/proj4ab#a' }; - `, - './libs/proj2/index.ts': `export const a = 2;`, - './libs/proj3a/index.ts': `export const a = 3;`, - './libs/proj4ab/index.ts': `export const a = 4;`, - './libs/proj123/index.ts': 'export const a = 5', - './libs/proj1234/index.ts': `export const a = 6 - import { a } from '@proj/proj1234-child' - `, - './libs/proj1234-child/index.ts': 'export const a = 7', - './libs/proj1234/a.b.ts': `// nx-ignore-next-line - import('@proj/proj2') - /* nx-ignore-next-line */ - import {a} from '@proj/proj3a - `, - './libs/proj1234/b.c.ts': `// nx-ignore-next-line - require('@proj/proj4ab#a') - // nx-ignore-next-line - import('@proj/proj2') - /* nx-ignore-next-line */ - import {a} from '@proj/proj3a - const a = { - // nx-ignore-next-line - loadChildren: '@proj/3a' - } - const b = { - // nx-ignore-next-line - loadChildren: '@proj/3a', - children: [{ - // nx-ignore-next-line - loadChildren: '@proj/proj2, - // nx-ignore-next-line - loadChildren: '@proj/proj3a' - }] - } - `, - }; - vol.fromJSON(fsJson, '/root'); + vol.reset(); + }); - defaultFileHasher.init(); + describe('static imports, dynamic imports, and commonjs requires', () => { + it('should build explicit dependencies for static imports, and top-level dynamic imports and commonjs requires', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/index.ts', + content: ` + import {a} from '@proj/my-second-proj'; + await import('@proj/project-3'); + require('@proj/proj4ab'); + import * as npmPackage from 'npm-package'; + `, + }, + ], + }); - ctx = { + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([ + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj2', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj3a', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj4ab', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'npm:npm-package', + }, + ]); + }); + + it('should build explicit dependencies for static exports', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/index.ts', + content: ` + export {a} from '@proj/my-second-proj'; + export * as project3 from '@proj/project-3'; + export * from '@proj/proj4ab'; + `, + }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([ + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj2', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj3a', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj4ab', + }, + ]); + }); + + it(`should build explicit dependencies for TypeScript's import/export require syntax, and side-effectful import`, () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/index.ts', + content: ` + import i = require("@proj/my-second-proj"); + export import i = require("@proj/project-3"); + import '@proj/proj4ab'; + `, + }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([ + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj2', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj3a', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/index.ts', + targetProjectName: 'proj4ab', + }, + ]); + }); + + it('should build explicit dependencies for nested dynamic imports and commonjs requires', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/nested-dynamic-import.ts', + content: ` + async function nestedInAFunction() { + await import('@proj/project-3'); + } + `, + }, + { + path: 'libs/proj/nested-require.ts', + content: ` + function nestedInAFunction() { + require('@proj/proj4ab'); + } + `, + }, + { + path: 'libs/proj/component.tsx', + content: ` + export function App() { + import('@proj/my-second-proj') + return ( + + + + ); + } + `, + }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([ + { + sourceProjectName, + sourceProjectFile: 'libs/proj/component.tsx', + targetProjectName: 'proj2', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/nested-dynamic-import.ts', + targetProjectName: 'proj3a', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/nested-require.ts', + targetProjectName: 'proj4ab', + }, + ]); + }); + + it('should build explicit dependencies when relative paths are used', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/absolute-path.ts', + content: ` + import('../../libs/proj3a/index.ts'); + `, + }, + { + path: 'libs/proj/relative-path.ts', + content: ` + import('../proj4ab/index.ts'); + `, + }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([ + { + sourceProjectName, + sourceProjectFile: 'libs/proj/absolute-path.ts', + targetProjectName: 'proj3a', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/relative-path.ts', + targetProjectName: 'proj4ab', + }, + ]); + }); + + it('should not build explicit dependencies when nx-ignore-next-line comments are present', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/static-import-1.ts', + content: ` + // nx-ignore-next-line + import {a} from '@proj/my-second-proj'; + `, + }, + { + path: 'libs/proj/static-import-2.ts', + content: ` + /* nx-ignore-next-line */ + import {a} from '@proj/my-second-proj'; + `, + }, + { + path: 'libs/proj/dynamic-import-1.ts', + content: ` + // nx-ignore-next-line + await import('@proj/project-3'); + `, + }, + { + path: 'libs/proj/dynamic-import-2.ts', + content: ` + /* nx-ignore-next-line */ + await import('@proj/project-3'); + `, + }, + { + path: 'libs/proj/dynamic-import-3.ts', + content: ` + async function nestedInAFunction() { + /* nx-ignore-next-line */ + await import('@proj/project-3'); + } + `, + }, + { + path: 'libs/proj/require-1.ts', + content: ` + // nx-ignore-next-line + require('@proj/proj4ab'); + `, + }, + { + path: 'libs/proj/require-2.ts', + content: ` + /* nx-ignore-next-line */ + require('@proj/proj4ab'); + `, + }, + { + path: 'libs/proj/require-3.ts', + content: ` + function nestedInAFunction() { + /* nx-ignore-next-line */ + require('@proj/proj4ab'); + } + `, + }, + { + path: 'libs/proj/comments-with-excess-whitespace.ts', + content: ` + /* + nx-ignore-next-line + + */ + require('@proj/proj4ab'); + // nx-ignore-next-line + import('@proj/proj4ab'); + /* + + nx-ignore-next-line */ + import { foo } from '@proj/proj4ab'; + `, + }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([]); + }); + + it('should not build explicit dependencies for stringified or templatized import/require statements', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/stringified-imports-and-require.ts', + content: ` + "import {a} from '@proj/my-second-proj';"; + 'await import("@proj/my-second-proj");'; + \`require('@proj/my-second-proj');\` + `, + }, + /** + * TODO: Refactor usage of Scanner to fix these. + * + * The use of a template literal before a templatized import/require + * currently causes Nx to interpret the import/require as if they were not templatized and were declarared directly + * in the source code. + */ + // Also reported here: https://github.com/nrwl/nx/issues/8938 + // { + // path: 'libs/proj/file-1.ts', + // content: ` + // const npmScope = 'myorg'; + // console.log(\`@\${npmScope}\`); + + // console.log(\`import {foo} from '@proj/my-second-proj'\`) + // `, + // }, + // { + // path: 'libs/proj/file-2.ts', + // content: ` + // const v = \`\${val} + // \${val} + // \${val} \${val} + + // \${val} \${val} + + // \`; + // tree.write('/path/to/file.ts', \`import something from "@proj/project-3";\`); + // `, + // }, + // { + // path: 'libs/proj/file-3.ts', + // content: ` + // \`\${Tree}\`; + // \`\`; + // \`import { A } from '@proj/my-second-proj'\`; + // \`import { B, C, D } from '@proj/project-3'\`; + // \`require('@proj/proj4ab')\`; + // `, + // }, + // { + // path: 'libs/proj/file-4.ts', + // // Ensure unterminated template literal does not break project graph creation + // content: ` + // \`\${Tree\`; + // \`\`; + // \`import { A } from '@proj/my-second-proj'\`; + // \`import { B, C, D } from '@proj/project-3'\`; + // \`require('@proj/proj4ab')\`; + // `, + // }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([]); + }); + }); + + /** + * In version 8, Angular deprecated the loadChildren string syntax in favor of using dynamic imports, but it is still + * fully supported by the framework: + * + * https://angular.io/guide/deprecations#loadchildren-string-syntax + */ + describe('legacy Angular loadChildren string syntax', () => { + it('should build explicit dependencies for legacy Angular loadChildren string syntax', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/file-1.ts', + content: ` + const a = { loadChildren: '@proj/proj4ab#a' }; + `, + }, + { + path: 'libs/proj/file-2.ts', + content: ` + const routes: Routes = [{ + path: 'lazy', + loadChildren: '@proj/project-3#LazyModule', + }]; + `, + }, + /** + * TODO: This case, where a no subsitution template literal is used, is not working + */ + // { + // path: 'libs/proj/no-substitution-template-literal.ts', + // content: ` + // const a = { + // loadChildren: \`@proj/my-second-proj\` + // }; + // `, + // }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([ + { + sourceProjectName, + sourceProjectFile: 'libs/proj/file-1.ts', + targetProjectName: 'proj4ab', + }, + { + sourceProjectName, + sourceProjectFile: 'libs/proj/file-2.ts', + targetProjectName: 'proj3a', + }, + ]); + }); + + it('should not build explicit dependencies when nx-ignore-next-line comments are present', () => { + const sourceProjectName = 'proj'; + const { ctx, builder } = createVirtualWorkspace({ + sourceProjectName, + sourceProjectFiles: [ + { + path: 'libs/proj/file-1.ts', + content: ` + const a = { + // nx-ignore-next-line + loadChildren: '@proj/proj4ab#a' + }; + `, + }, + /** + * TODO: This case, where a multi-line comment is used, is not working + */ + // { + // path: 'libs/proj/file-2.ts', + // content: ` + // const a = { + // /* nx-ignore-next-line */ + // loadChildren: '@proj/proj4ab#a' + // }; + // `, + // }, + /** + * TODO: These cases, where loadChildren is on the same line as the variable declaration, are not working + */ + // { + // path: 'libs/proj/file-3.ts', + // content: ` + // // nx-ignore-next-line + // const a = { loadChildren: '@proj/proj4ab#a' }; + // `, + // }, + // { + // path: 'libs/proj/file-4.ts', + // content: ` + // /* nx-ignore-next-line */ + // const a = { loadChildren: '@proj/proj4ab#a' }; + // `, + // }, + ], + }); + + const res = buildExplicitTypeScriptDependencies( + ctx.workspace, + builder.graph, + ctx.filesToProcess + ); + + expect(res).toEqual([]); + }); + }); +}); + +interface VirtualWorkspaceConfig { + sourceProjectName: string; + sourceProjectFiles: { + path: string; + content: string; + }[]; +} + +/** + * Prepares a minimal workspace and virtual file-system for the given files and dependency + * projects in order to be able to execute `buildExplicitTypeScriptDependencies()` in the tests. + */ +function createVirtualWorkspace(config: VirtualWorkspaceConfig) { + const nxJson = { + npmScope: 'proj', + }; + const workspaceJson = { + projects: { + [config.sourceProjectName]: { + root: `libs/${config.sourceProjectName}`, + }, + }, + }; + const fsJson = { + './package.json': `{ + "name": "test", + "dependencies": { + "npm-package": "1.0.0" + }, + "devDependencies": [] + }`, + './workspace.json': JSON.stringify(workspaceJson), + './nx.json': JSON.stringify(nxJson), + ...config.sourceProjectFiles.reduce( + (acc, file) => ({ + ...acc, + [file.path]: file.content, + }), + {} + ), + }; + const tsConfig = { + compilerOptions: { + baseUrl: '.', + paths: { + [`@proj/${config.sourceProjectName}`]: [ + `libs/${config.sourceProjectName}/index.ts`, + ], + }, + }, + }; + + const builder = new ProjectGraphBuilder(); + builder.addNode({ + name: config.sourceProjectName, + type: 'lib', + data: { + root: `libs/${config.sourceProjectName}`, + files: config.sourceProjectFiles.map(({ path }) => ({ + file: path, + })), + }, + }); + builder.addExternalNode({ + name: 'npm:npm-package', + type: 'npm', + data: { + version: '1.0.0', + packageName: 'npm-package', + }, + }); + + for (const [projectName, tsconfigPath] of Object.entries( + dependencyProjectNamesToImportPaths + )) { + fsJson[`libs/${projectName}/index.ts`] = ``; + workspaceJson.projects[projectName] = { + root: `libs/${projectName}`, + }; + tsConfig.compilerOptions.paths[tsconfigPath] = [ + `libs/${projectName}/index.ts`, + ]; + builder.addNode({ + name: projectName, + type: 'lib', + data: { + root: `libs/${projectName}`, + files: [{ file: `libs/${projectName}/index.ts` }], + }, + }); + } + + fsJson['./tsconfig.base.json'] = JSON.stringify(tsConfig); + + vol.fromJSON(fsJson, '/root'); + + defaultFileHasher.init(); + + return { + ctx: { workspace: { ...workspaceJson, ...nxJson, - } as any, + } as Workspace, filesToProcess: createProjectFileMap( workspaceJson, defaultFileHasher.allFileData() ).projectFileMap, - } as any; - - projects = { - proj3a: { - name: 'proj3a', - type: 'lib', - data: { - root: 'libs/proj3a', - files: [{ file: 'libs/proj3a/index.ts' }], - }, - }, - proj2: { - name: 'proj2', - type: 'lib', - data: { - root: 'libs/proj2', - files: [{ file: 'libs/proj2/index.ts' }], - }, - }, - proj: { - name: 'proj', - type: 'lib', - data: { - root: 'libs/proj', - files: [{ file: 'libs/proj/index.ts' }], - }, - }, - proj1234: { - name: 'proj1234', - type: 'lib', - data: { - root: 'libs/proj1234', - files: [ - { file: 'libs/proj1234/index.ts' }, - { file: 'libs/proj1234/a.b.ts' }, - { file: 'libs/proj1234/b.c.ts' }, - ], - }, - }, - proj123: { - name: 'proj123', - type: 'lib', - data: { - root: 'libs/proj123', - files: [{ file: 'libs/proj123/index.ts' }], - }, - }, - proj4ab: { - name: 'proj4ab', - type: 'lib', - data: { - root: 'libs/proj4ab', - files: [{ file: 'libs/proj4ab/index.ts' }], - }, - }, - 'proj1234-child': { - name: 'proj1234-child', - type: 'lib', - data: { - root: 'libs/proj1234-child', - files: [{ file: 'libs/proj1234-child/index.ts' }], - }, - }, - }; - }); - - it(`should add dependencies for projects based on file imports`, () => { - const builder = new ProjectGraphBuilder(); - Object.values(projects).forEach((p) => { - builder.addNode(p); - }); - - const res = buildExplicitTypeScriptDependencies( - ctx.workspace, - builder.graph, - ctx.filesToProcess - ); - - expect(res).toEqual([ - { - sourceProjectFile: 'libs/proj/index.ts', - sourceProjectName: 'proj', - targetProjectName: 'proj2', - }, - { - sourceProjectFile: 'libs/proj/index.ts', - sourceProjectName: 'proj', - targetProjectName: 'proj3a', - }, - { - sourceProjectFile: 'libs/proj/index.ts', - sourceProjectName: 'proj', - targetProjectName: 'proj4ab', - }, - { - sourceProjectFile: 'libs/proj1234/index.ts', - sourceProjectName: 'proj1234', - targetProjectName: 'proj1234-child', - }, - ]); - }); -}); + }, + builder, + }; +}