From 7c4c67641340d561cccde8e79d5b8d31220d2821 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 21 May 2019 15:23:24 +0100 Subject: [PATCH] feat(ivy): customize ngcc via configuration files (#30591) There are scenarios where it is not possible for ngcc to guess the format or configuration of an entry-point just from the files on disk. Such scenarios include: 1) Unwanted entry-points: A spurious package.json makes ngcc think there is an entry-point when there should not be one. 2) Deep-import entry-points: some packages allow deep-imports but do not provide package.json files to indicate to ngcc that the imported path is actually an entry-point to be processed. 3) Invalid/missing package.json properties: For example, an entry-point that does not provide a valid property to a required format. The configuration is provided by one or more `ngcc.config.js` files: * If placed at the root of the project, this file can provide configuration for named packages (and their entry-points) that have been npm installed into the project. * If published as part of a package, the file can provide configuration for entry-points of the package. The configured of a package at the project level will override any configuration provided by the package itself. PR Close #30591 --- packages/compiler-cli/ngcc/src/main.ts | 10 +- .../ngcc/src/packages/build_marker.ts | 5 +- .../ngcc/src/packages/configuration.ts | 124 ++++++++++ .../ngcc/src/packages/entry_point.ts | 57 ++++- .../ngcc/src/packages/entry_point_finder.ts | 50 ++-- .../ngcc/test/integration/ngcc_spec.ts | 86 +++++++ .../ngcc/test/packages/configuration_spec.ts | 168 ++++++++++++++ .../test/packages/entry_point_finder_spec.ts | 4 +- .../ngcc/test/packages/entry_point_spec.ts | 214 ++++++++++++++---- .../new_entry_point_file_writer_spec.ts | 10 +- 10 files changed, 655 insertions(+), 73 deletions(-) create mode 100644 packages/compiler-cli/ngcc/src/packages/configuration.ts create mode 100644 packages/compiler-cli/ngcc/test/packages/configuration_spec.ts diff --git a/packages/compiler-cli/ngcc/src/main.ts b/packages/compiler-cli/ngcc/src/main.ts index 32c4614b1b..3cc632309f 100644 --- a/packages/compiler-cli/ngcc/src/main.ts +++ b/packages/compiler-cli/ngcc/src/main.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem, resolve} from '../../src/ngtsc/file_system'; +import {AbsoluteFsPath, FileSystem, absoluteFrom, dirname, getFileSystem, resolve} from '../../src/ngtsc/file_system'; import {CommonJsDependencyHost} from './dependencies/commonjs_dependency_host'; import {DependencyResolver} from './dependencies/dependency_resolver'; import {EsmDependencyHost} from './dependencies/esm_dependency_host'; @@ -14,6 +14,7 @@ import {UmdDependencyHost} from './dependencies/umd_dependency_host'; import {ConsoleLogger, LogLevel} from './logging/console_logger'; import {Logger} from './logging/logger'; import {hasBeenProcessed, markAsProcessed} from './packages/build_marker'; +import {NgccConfiguration} from './packages/configuration'; import {EntryPointFormat, EntryPointJsonProperty, SUPPORTED_FORMAT_PROPERTIES, getEntryPointFormat} from './packages/entry_point'; import {makeEntryPointBundle} from './packages/entry_point_bundle'; import {EntryPointFinder} from './packages/entry_point_finder'; @@ -92,7 +93,8 @@ export function mainNgcc( umd: umdDependencyHost, commonjs: commonJsDependencyHost }); - const finder = new EntryPointFinder(fileSystem, logger, resolver); + const config = new NgccConfiguration(fileSystem, dirname(absoluteFrom(basePath))); + const finder = new EntryPointFinder(fileSystem, config, logger, resolver); const fileWriter = getFileWriter(fileSystem, createNewEntryPointFormats); const absoluteTargetEntryPointPath = @@ -192,6 +194,10 @@ function hasProcessedTargetEntryPoint( fs: FileSystem, targetPath: AbsoluteFsPath, propertiesToConsider: string[], compileAllFormats: boolean) { const packageJsonPath = resolve(targetPath, 'package.json'); + // It might be that this target is configured in which case its package.json might not exist. + if (!fs.exists(packageJsonPath)) { + return false; + } const packageJson = JSON.parse(fs.readFile(packageJsonPath)); for (const property of propertiesToConsider) { diff --git a/packages/compiler-cli/ngcc/src/packages/build_marker.ts b/packages/compiler-cli/ngcc/src/packages/build_marker.ts index 9223a2036b..1daa5ea1e8 100644 --- a/packages/compiler-cli/ngcc/src/packages/build_marker.ts +++ b/packages/compiler-cli/ngcc/src/packages/build_marker.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ -import {AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system'; +import {AbsoluteFsPath, FileSystem, dirname} from '../../../src/ngtsc/file_system'; import {EntryPointJsonProperty, EntryPointPackageJson} from './entry_point'; export const NGCC_VERSION = '0.0.0-PLACEHOLDER'; @@ -49,5 +49,8 @@ export function markAsProcessed( format: EntryPointJsonProperty) { if (!packageJson.__processed_by_ivy_ngcc__) packageJson.__processed_by_ivy_ngcc__ = {}; packageJson.__processed_by_ivy_ngcc__[format] = NGCC_VERSION; + // Just in case this package.json was synthesized due to a custom configuration + // we will ensure that the path to the containing folder exists before we write the file. + fs.ensureDir(dirname(packageJsonPath)); fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)); } diff --git a/packages/compiler-cli/ngcc/src/packages/configuration.ts b/packages/compiler-cli/ngcc/src/packages/configuration.ts new file mode 100644 index 0000000000..3ae30f5591 --- /dev/null +++ b/packages/compiler-cli/ngcc/src/packages/configuration.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as vm from 'vm'; +import {AbsoluteFsPath, FileSystem, dirname, join, resolve} from '../../../src/ngtsc/file_system'; +import {PackageJsonFormatProperties} from './entry_point'; + +/** + * The format of a project level configuration file. + */ +export interface NgccProjectConfig { packages: {[packagePath: string]: NgccPackageConfig}; } + +/** + * The format of a package level configuration file. + */ +export interface NgccPackageConfig { + /** + * The entry-points to configure for this package. + * + * In the config file the keys can be paths relative to the package path; + * but when being read back from the `NgccConfiguration` service, these paths + * will be absolute. + */ + entryPoints: {[entryPointPath: string]: NgccEntryPointConfig;}; +} + +/** + * Configuration options for an entry-point. + * + * The existence of a configuration for a path tells ngcc that this should be considered for + * processing as an entry-point. + */ +export interface NgccEntryPointConfig { + /** Do not process (or even acknowledge the existence of) this entry-point, if true. */ + ignore?: boolean; + /** + * This property, if provided, holds values that will override equivalent properties in an + * entry-point's package.json file. + */ + override?: PackageJsonFormatProperties; +} + +const NGCC_CONFIG_FILENAME = 'ngcc.config.js'; + +export class NgccConfiguration { + // TODO: change string => ModuleSpecifier when we tighten the path types in #30556 + private cache = new Map(); + + constructor(private fs: FileSystem, baseDir: AbsoluteFsPath) { + const projectConfig = this.loadProjectConfig(baseDir); + for (const packagePath in projectConfig.packages) { + const absPackagePath = resolve(baseDir, 'node_modules', packagePath); + const packageConfig = projectConfig.packages[packagePath]; + packageConfig.entryPoints = + this.processEntryPoints(absPackagePath, packageConfig.entryPoints); + this.cache.set(absPackagePath, packageConfig); + } + } + + getConfig(packagePath: AbsoluteFsPath): NgccPackageConfig { + if (this.cache.has(packagePath)) { + return this.cache.get(packagePath) !; + } + + const packageConfig = this.loadPackageConfig(packagePath); + packageConfig.entryPoints = this.processEntryPoints(packagePath, packageConfig.entryPoints); + this.cache.set(packagePath, packageConfig); + return packageConfig; + } + + private loadProjectConfig(baseDir: AbsoluteFsPath): NgccProjectConfig { + const configFilePath = join(baseDir, NGCC_CONFIG_FILENAME); + if (this.fs.exists(configFilePath)) { + try { + return this.evalSrcFile(configFilePath); + } catch (e) { + throw new Error(`Invalid project configuration file at "${configFilePath}": ` + e.message); + } + } else { + return {packages: {}}; + } + } + + private loadPackageConfig(packagePath: AbsoluteFsPath): NgccPackageConfig { + const configFilePath = join(packagePath, NGCC_CONFIG_FILENAME); + if (this.fs.exists(configFilePath)) { + try { + return this.evalSrcFile(configFilePath); + } catch (e) { + throw new Error(`Invalid package configuration file at "${configFilePath}": ` + e.message); + } + } else { + return {entryPoints: {}}; + } + } + + private evalSrcFile(srcPath: AbsoluteFsPath): any { + const src = this.fs.readFile(srcPath); + const theExports = {}; + const sandbox = { + module: {exports: theExports}, + exports: theExports, require, + __dirname: dirname(srcPath), + __filename: srcPath + }; + vm.runInNewContext(src, sandbox, {filename: srcPath}); + return sandbox.module.exports; + } + + private processEntryPoints( + packagePath: AbsoluteFsPath, entryPoints: {[entryPointPath: string]: NgccEntryPointConfig;}): + {[entryPointPath: string]: NgccEntryPointConfig;} { + const processedEntryPoints: {[entryPointPath: string]: NgccEntryPointConfig;} = {}; + for (const entryPointPath in entryPoints) { + // Change the keys to be absolute paths + processedEntryPoints[resolve(packagePath, entryPointPath)] = entryPoints[entryPointPath]; + } + return processedEntryPoints; + } +} \ No newline at end of file diff --git a/packages/compiler-cli/ngcc/src/packages/entry_point.ts b/packages/compiler-cli/ngcc/src/packages/entry_point.ts index 7d7f063e0d..6bcfff20c5 100644 --- a/packages/compiler-cli/ngcc/src/packages/entry_point.ts +++ b/packages/compiler-cli/ngcc/src/packages/entry_point.ts @@ -5,10 +5,13 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import {relative} from 'canonical-path'; +import {basename} from 'path'; import * as ts from 'typescript'; import {AbsoluteFsPath, FileSystem, join, resolve} from '../../../src/ngtsc/file_system'; import {parseStatementForUmdModule} from '../host/umd_host'; import {Logger} from '../logging/logger'; +import {NgccConfiguration, NgccEntryPointConfig} from './configuration'; /** * The possible values for the format of an entry-point. @@ -34,7 +37,7 @@ export interface EntryPoint { compiledByAngular: boolean; } -interface PackageJsonFormatProperties { +export interface PackageJsonFormatProperties { fesm2015?: string; fesm5?: string; es2015?: string; // if exists then it is actually FESM2015 @@ -67,18 +70,25 @@ export const SUPPORTED_FORMAT_PROPERTIES: EntryPointJsonProperty[] = * @returns An entry-point if it is valid, `null` otherwise. */ export function getEntryPointInfo( - fs: FileSystem, logger: Logger, packagePath: AbsoluteFsPath, + fs: FileSystem, config: NgccConfiguration, logger: Logger, packagePath: AbsoluteFsPath, entryPointPath: AbsoluteFsPath): EntryPoint|null { const packageJsonPath = resolve(entryPointPath, 'package.json'); - if (!fs.exists(packageJsonPath)) { + const entryPointConfig = config.getConfig(packagePath).entryPoints[entryPointPath]; + if (entryPointConfig === undefined && !fs.exists(packageJsonPath)) { return null; } - const entryPointPackageJson = loadEntryPointPackage(fs, logger, packageJsonPath); - if (!entryPointPackageJson) { + if (entryPointConfig !== undefined && entryPointConfig.ignore === true) { return null; } + const loadedEntryPointPackageJson = + loadEntryPointPackage(fs, logger, packageJsonPath, entryPointConfig !== undefined); + const entryPointPackageJson = mergeConfigAndPackageJson( + loadedEntryPointPackageJson, entryPointConfig, packagePath, entryPointPath); + if (entryPointPackageJson === null) { + return null; + } // We must have a typings property const typings = entryPointPackageJson.typings || entryPointPackageJson.types; @@ -86,16 +96,18 @@ export function getEntryPointInfo( return null; } - // Also there must exist a `metadata.json` file next to the typings entry-point. + // An entry-point is assumed to be compiled by Angular if there is either: + // * a `metadata.json` file next to the typings entry-point + // * a custom config for this entry-point const metadataPath = resolve(entryPointPath, typings.replace(/\.d\.ts$/, '') + '.metadata.json'); + const compiledByAngular = entryPointConfig !== undefined || fs.exists(metadataPath); const entryPointInfo: EntryPoint = { name: entryPointPackageJson.name, packageJson: entryPointPackageJson, package: packagePath, path: entryPointPath, - typings: resolve(entryPointPath, typings), - compiledByAngular: fs.exists(metadataPath), + typings: resolve(entryPointPath, typings), compiledByAngular, }; return entryPointInfo; @@ -140,12 +152,15 @@ export function getEntryPointFormat( * @returns JSON from the package.json file if it is valid, `null` otherwise. */ function loadEntryPointPackage( - fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath): EntryPointPackageJson|null { + fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath, + hasConfig: boolean): EntryPointPackageJson|null { try { return JSON.parse(fs.readFile(packageJsonPath)); } catch (e) { - // We may have run into a package.json with unexpected symbols - logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`); + if (!hasConfig) { + // We may have run into a package.json with unexpected symbols + logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`); + } return null; } } @@ -156,3 +171,23 @@ function isUmdModule(fs: FileSystem, sourceFilePath: AbsoluteFsPath): boolean { return sourceFile.statements.length > 0 && parseStatementForUmdModule(sourceFile.statements[0]) !== null; } + +function mergeConfigAndPackageJson( + entryPointPackageJson: EntryPointPackageJson | null, + entryPointConfig: NgccEntryPointConfig | undefined, packagePath: AbsoluteFsPath, + entryPointPath: AbsoluteFsPath): EntryPointPackageJson|null { + if (entryPointPackageJson !== null) { + if (entryPointConfig === undefined) { + return entryPointPackageJson; + } else { + return {...entryPointPackageJson, ...entryPointConfig.override}; + } + } else { + if (entryPointConfig === undefined) { + return null; + } else { + const name = `${basename(packagePath)}/${relative(packagePath, entryPointPath)}`; + return {name, ...entryPointConfig.override}; + } + } +} diff --git a/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts b/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts index ef3abc79de..9cb618f83b 100644 --- a/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts +++ b/packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts @@ -9,12 +9,13 @@ import {AbsoluteFsPath, FileSystem, join, resolve} from '../../../src/ngtsc/file import {DependencyResolver, SortedEntryPointsInfo} from '../dependencies/dependency_resolver'; import {Logger} from '../logging/logger'; import {PathMappings} from '../utils'; - +import {NgccConfiguration} from './configuration'; import {EntryPoint, getEntryPointInfo} from './entry_point'; export class EntryPointFinder { constructor( - private fs: FileSystem, private logger: Logger, private resolver: DependencyResolver) {} + private fs: FileSystem, private config: NgccConfiguration, private logger: Logger, + private resolver: DependencyResolver) {} /** * Search the given directory, and sub-directories, for Angular package entry points. * @param sourceDirectory An absolute path to the directory to search for entry points. @@ -111,7 +112,8 @@ export class EntryPointFinder { const entryPoints: EntryPoint[] = []; // Try to get an entry point from the top level package directory - const topLevelEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, packagePath); + const topLevelEntryPoint = + getEntryPointInfo(this.fs, this.config, this.logger, packagePath, packagePath); // If there is no primary entry-point then exit if (topLevelEntryPoint === null) { @@ -120,8 +122,11 @@ export class EntryPointFinder { // Otherwise store it and search for secondary entry-points entryPoints.push(topLevelEntryPoint); - this.walkDirectory(packagePath, subdir => { - const subEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, subdir); + this.walkDirectory(packagePath, packagePath, (path, isDirectory) => { + // If the path is a JS file then strip its extension and see if we can match an entry-point. + const possibleEntryPointPath = isDirectory ? path : stripJsExtension(path); + const subEntryPoint = + getEntryPointInfo(this.fs, this.config, this.logger, packagePath, possibleEntryPointPath); if (subEntryPoint !== null) { entryPoints.push(subEntryPoint); } @@ -136,22 +141,29 @@ export class EntryPointFinder { * @param dir the directory to recursively walk. * @param fn the function to apply to each directory. */ - private walkDirectory(dir: AbsoluteFsPath, fn: (dir: AbsoluteFsPath) => void) { + private walkDirectory( + packagePath: AbsoluteFsPath, dir: AbsoluteFsPath, + fn: (path: AbsoluteFsPath, isDirectory: boolean) => void) { return this.fs .readdir(dir) // Not interested in hidden files - .filter(p => !p.startsWith('.')) + .filter(path => !path.startsWith('.')) // Ignore node_modules - .filter(p => p !== 'node_modules') - // Only interested in directories (and only those that are not symlinks) - .filter(p => { - const stat = this.fs.lstat(resolve(dir, p)); - return stat.isDirectory() && !stat.isSymbolicLink(); - }) - .forEach(subDir => { - const resolvedSubDir = resolve(dir, subDir); - fn(resolvedSubDir); - this.walkDirectory(resolvedSubDir, fn); + .filter(path => path !== 'node_modules') + .map(path => resolve(dir, path)) + .forEach(path => { + const stat = this.fs.lstat(path); + + if (stat.isSymbolicLink()) { + // We are not interested in symbolic links + return; + } + + fn(path, stat.isDirectory()); + + if (stat.isDirectory()) { + this.walkDirectory(packagePath, path, fn); + } }); } } @@ -187,3 +199,7 @@ function removeDeeperPaths(value: AbsoluteFsPath, index: number, array: Absolute function values(obj: {[key: string]: T}): T[] { return Object.keys(obj).map(key => obj[key]); } + +function stripJsExtension(filePath: T): T { + return filePath.replace(/\.js$/, '') as T; +} diff --git a/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts b/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts index e26a5cc197..2b168bc674 100644 --- a/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts +++ b/packages/compiler-cli/ngcc/test/integration/ngcc_spec.ts @@ -351,6 +351,92 @@ runInEachFileSystem(() => { }); }); + describe('with configuration files', () => { + it('should process a configured deep-import as an entry-point', () => { + loadTestFiles([ + { + name: _('/ngcc.config.js'), + contents: `module.exports = { packages: { + 'deep_import': { + entryPoints: { + './entry_point': { override: { typings: '../entry_point.d.ts', es2015: '../entry_point.js' } } + } + } + }};`, + }, + { + name: _('/node_modules/deep_import/package.json'), + contents: '{"name": "deep-import", "es2015": "./index.js", "typings": "./index.d.ts"}', + }, + { + name: _('/node_modules/deep_import/entry_point.js'), + contents: ` + import {Component} from '@angular/core'; + @Component({selector: 'entry-point'}) + export class EntryPoint {} + `, + }, + { + name: _('/node_modules/deep_import/entry_point.d.ts'), + contents: ` + import {Component} from '@angular/core'; + @Component({selector: 'entry-point'}) + export class EntryPoint {} + `, + }, + ]); + mainNgcc({ + basePath: '/node_modules', + targetEntryPointPath: 'deep_import/entry_point', + propertiesToConsider: ['es2015'] + }); + // The containing package is not processed + expect(loadPackage('deep_import').__processed_by_ivy_ngcc__).toBeUndefined(); + // But the configured entry-point and its dependency (@angular/core) are processed. + expect(loadPackage('deep_import/entry_point').__processed_by_ivy_ngcc__).toEqual({ + es2015: '0.0.0-PLACEHOLDER', + typings: '0.0.0-PLACEHOLDER', + }); + expect(loadPackage('@angular/core').__processed_by_ivy_ngcc__).toEqual({ + es2015: '0.0.0-PLACEHOLDER', + typings: '0.0.0-PLACEHOLDER', + }); + }); + + it('should not process ignored entry-points', () => { + loadTestFiles([ + { + name: _('/ngcc.config.js'), + contents: `module.exports = { packages: { + '@angular/core': { + entryPoints: { + './testing': {ignore: true} + }, + }, + '@angular/common': { + entryPoints: { + '.': {ignore: true} + }, + } + }};`, + }, + ]); + mainNgcc({basePath: '/node_modules', propertiesToConsider: ['es2015']}); + // We process core but not core/testing. + expect(loadPackage('@angular/core').__processed_by_ivy_ngcc__).toEqual({ + es2015: '0.0.0-PLACEHOLDER', + typings: '0.0.0-PLACEHOLDER', + }); + expect(loadPackage('@angular/core/testing').__processed_by_ivy_ngcc__).toBeUndefined(); + // We do not compile common but we do compile its sub-entry-points. + expect(loadPackage('@angular/common').__processed_by_ivy_ngcc__).toBeUndefined(); + expect(loadPackage('@angular/common/http').__processed_by_ivy_ngcc__).toEqual({ + es2015: '0.0.0-PLACEHOLDER', + typings: '0.0.0-PLACEHOLDER', + }); + }); + }); + function loadPackage( packageName: string, basePath: AbsoluteFsPath = _('/node_modules')): EntryPointPackageJson { return JSON.parse(fs.readFile(fs.resolve(basePath, packageName, 'package.json'))); diff --git a/packages/compiler-cli/ngcc/test/packages/configuration_spec.ts b/packages/compiler-cli/ngcc/test/packages/configuration_spec.ts new file mode 100644 index 0000000000..7c1fe97852 --- /dev/null +++ b/packages/compiler-cli/ngcc/test/packages/configuration_spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {FileSystem, absoluteFrom, getFileSystem} from '../../../src/ngtsc/file_system'; +import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; +import {loadTestFiles} from '../../../test/helpers'; +import {NgccConfiguration} from '../../src/packages/configuration'; + + +runInEachFileSystem(() => { + let _Abs: typeof absoluteFrom; + let fs: FileSystem; + + beforeEach(() => { + _Abs = absoluteFrom; + fs = getFileSystem(); + }); + + describe('NgccConfiguration', () => { + describe('constructor', () => { + it('should error if a project level config file is badly formatted', () => { + loadTestFiles([{name: _Abs('/project-1/ngcc.config.js'), contents: `bad js code`}]); + expect(() => new NgccConfiguration(fs, _Abs('/project-1'))) + .toThrowError( + `Invalid project configuration file at "${_Abs('/project-1/ngcc.config.js')}": Unexpected identifier`); + }); + }); + + describe('getConfig()', () => { + it('should return configuration for a package found in a package level file', () => { + loadTestFiles([{ + name: _Abs('/project-1/node_modules/package-1/ngcc.config.js'), + contents: `module.exports = {entryPoints: { './entry-point-1': {}}}` + }]); + const readFileSpy = spyOn(fs, 'readFile').and.callThrough(); + const configuration = new NgccConfiguration(fs, _Abs('/project-1')); + const config = configuration.getConfig(_Abs('/project-1/node_modules/package-1')); + + expect(config).toEqual( + {entryPoints: {[_Abs('/project-1/node_modules/package-1/entry-point-1')]: {}}}); + expect(readFileSpy) + .toHaveBeenCalledWith(_Abs('/project-1/node_modules/package-1/ngcc.config.js')); + }); + + it('should cache configuration for a package found in a package level file', () => { + loadTestFiles([{ + name: _Abs('/project-1/node_modules/package-1/ngcc.config.js'), + contents: ` + module.exports = { + entryPoints: { + './entry-point-1': {} + }, + };` + }]); + const configuration = new NgccConfiguration(fs, _Abs('/project-1')); + + // Populate the cache + configuration.getConfig(_Abs('/project-1/node_modules/package-1')); + + const readFileSpy = spyOn(fs, 'readFile').and.callThrough(); + const config = configuration.getConfig(_Abs('/project-1/node_modules/package-1')); + + expect(config).toEqual( + {entryPoints: {[_Abs('/project-1/node_modules/package-1/entry-point-1')]: {}}}); + expect(readFileSpy).not.toHaveBeenCalled(); + }); + + it('should return an empty configuration object if there is no matching config file', () => { + const configuration = new NgccConfiguration(fs, _Abs('/project-1')); + const config = configuration.getConfig(_Abs('/project-1/node_modules/package-1')); + expect(config).toEqual({entryPoints: {}}); + }); + + it('should error if a package level config file is badly formatted', () => { + loadTestFiles([{ + name: _Abs('/project-1/node_modules/package-1/ngcc.config.js'), + contents: `bad js code` + }]); + const configuration = new NgccConfiguration(fs, _Abs('/project-1')); + expect(() => configuration.getConfig(_Abs('/project-1/node_modules/package-1'))) + .toThrowError( + `Invalid package configuration file at "${_Abs('/project-1/node_modules/package-1/ngcc.config.js')}": Unexpected identifier`); + }); + + it('should return configuration for a package found in a project level file', () => { + loadTestFiles([{ + name: _Abs('/project-1/ngcc.config.js'), + contents: ` + module.exports = { + packages: { + 'package-1': { + entryPoints: { + './entry-point-1': {} + }, + }, + }, + };` + }]); + const readFileSpy = spyOn(fs, 'readFile').and.callThrough(); + const configuration = new NgccConfiguration(fs, _Abs('/project-1')); + expect(readFileSpy).toHaveBeenCalledWith(_Abs('/project-1/ngcc.config.js')); + + const config = configuration.getConfig(_Abs('/project-1/node_modules/package-1')); + expect(config).toEqual( + {entryPoints: {[_Abs('/project-1/node_modules/package-1/entry-point-1')]: {}}}); + }); + + it('should override package level config with project level config per package', () => { + loadTestFiles([ + { + name: _Abs('/project-1/ngcc.config.js'), + contents: ` + module.exports = { + packages: { + 'package-2': { + entryPoints: { + './project-setting-entry-point': {} + }, + }, + }, + };`, + }, + { + name: _Abs('/project-1/node_modules/package-1/ngcc.config.js'), + contents: ` + module.exports = { + entryPoints: { + './package-setting-entry-point': {} + }, + };`, + }, + { + name: _Abs('/project-1/node_modules/package-2/ngcc.config.js'), + contents: ` + module.exports = { + entryPoints: { + './package-setting-entry-point': {} + }, + };`, + } + ]); + const readFileSpy = spyOn(fs, 'readFile').and.callThrough(); + const configuration = new NgccConfiguration(fs, _Abs('/project-1')); + expect(readFileSpy).toHaveBeenCalledWith(_Abs('/project-1/ngcc.config.js')); + + const package1Config = configuration.getConfig(_Abs('/project-1/node_modules/package-1')); + expect(package1Config).toEqual({ + entryPoints: + {[_Abs('/project-1/node_modules/package-1/package-setting-entry-point')]: {}} + }); + expect(readFileSpy) + .toHaveBeenCalledWith(_Abs('/project-1/node_modules/package-1/ngcc.config.js')); + + const package2Config = configuration.getConfig(_Abs('/project-1/node_modules/package-2')); + expect(package2Config).toEqual({ + entryPoints: + {[_Abs('/project-1/node_modules/package-2/project-setting-entry-point')]: {}} + }); + expect(readFileSpy) + .not.toHaveBeenCalledWith(_Abs('/project-1/node_modules/package-2/ngcc.config.js')); + }); + }); + }); +}); diff --git a/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts b/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts index 527c630ea8..ca86e41d8e 100644 --- a/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/entry_point_finder_spec.ts @@ -11,6 +11,7 @@ import {loadTestFiles} from '../../../test/helpers'; import {DependencyResolver} from '../../src/dependencies/dependency_resolver'; import {EsmDependencyHost} from '../../src/dependencies/esm_dependency_host'; import {ModuleResolver} from '../../src/dependencies/module_resolver'; +import {NgccConfiguration} from '../../src/packages/configuration'; import {EntryPoint} from '../../src/packages/entry_point'; import {EntryPointFinder} from '../../src/packages/entry_point_finder'; import {MockLogger} from '../helpers/mock_logger'; @@ -31,7 +32,8 @@ runInEachFileSystem(() => { spyOn(resolver, 'sortEntryPointsByDependency').and.callFake((entryPoints: EntryPoint[]) => { return {entryPoints, ignoredEntryPoints: [], ignoredDependencies: []}; }); - finder = new EntryPointFinder(fs, new MockLogger(), resolver); + finder = + new EntryPointFinder(fs, new NgccConfiguration(fs, _('/')), new MockLogger(), resolver); }); it('should find sub-entry-points within a package', () => { diff --git a/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts b/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts index 871c656f9e..c8735f68c2 100644 --- a/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts +++ b/packages/compiler-cli/ngcc/test/packages/entry_point_spec.ts @@ -9,6 +9,7 @@ import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; import {loadTestFiles} from '../../../test/helpers'; +import {NgccConfiguration} from '../../src/packages/configuration'; import {getEntryPointInfo} from '../../src/packages/entry_point'; import {MockLogger} from '../helpers/mock_logger'; @@ -19,8 +20,8 @@ runInEachFileSystem(() => { let fs: FileSystem; beforeEach(() => { - SOME_PACKAGE = absoluteFrom('/some_package'); _ = absoluteFrom; + SOME_PACKAGE = _('/project/node_modules/some_package'); fs = getFileSystem(); }); @@ -28,51 +29,152 @@ runInEachFileSystem(() => { () => { loadTestFiles([ { - name: _('/some_package/valid_entry_point/package.json'), + name: _('/project/node_modules/some_package/valid_entry_point/package.json'), contents: createPackageJson('valid_entry_point') }, { - name: _('/some_package/valid_entry_point/valid_entry_point.metadata.json'), + name: _( + '/project/node_modules/some_package/valid_entry_point/valid_entry_point.metadata.json'), contents: 'some meta data' }, ]); + const config = new NgccConfiguration(fs, _('/project')); const entryPoint = getEntryPointInfo( - fs, new MockLogger(), SOME_PACKAGE, _('/some_package/valid_entry_point')); + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/valid_entry_point')); expect(entryPoint).toEqual({ - name: 'some-package/valid_entry_point', + name: 'some_package/valid_entry_point', package: SOME_PACKAGE, - path: _('/some_package/valid_entry_point'), - typings: _(`/some_package/valid_entry_point/valid_entry_point.d.ts`), - packageJson: loadPackageJson(fs, '/some_package/valid_entry_point'), + path: _('/project/node_modules/some_package/valid_entry_point'), + typings: + _(`/project/node_modules/some_package/valid_entry_point/valid_entry_point.d.ts`), + packageJson: loadPackageJson(fs, '/project/node_modules/some_package/valid_entry_point'), compiledByAngular: true, }); }); + it('should return null if configured to ignore the specified entry-point', () => { + loadTestFiles([ + { + name: _('/project/node_modules/some_package/valid_entry_point/package.json'), + contents: createPackageJson('valid_entry_point'), + }, + { + name: _( + '/project/node_modules/some_package/valid_entry_point/valid_entry_point.metadata.json'), + contents: 'some meta data', + }, + ]); + const config = new NgccConfiguration(fs, _('/project')); + spyOn(config, 'getConfig').and.returnValue({ + entryPoints: + {[_('/project/node_modules/some_package/valid_entry_point')]: {ignore: true}} + }); + const entryPoint = getEntryPointInfo( + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/valid_entry_point')); + expect(entryPoint).toBe(null); + }); + + it('should override the properties on package.json if the entry-point is configured', () => { + loadTestFiles([ + { + name: _('/project/node_modules/some_package/valid_entry_point/package.json'), + contents: createPackageJson('valid_entry_point'), + }, + { + name: _( + '/project/node_modules/some_package/valid_entry_point/valid_entry_point.metadata.json'), + contents: 'some meta data', + }, + ]); + const config = new NgccConfiguration(fs, _('/project')); + const override = { + typings: './some_other.d.ts', + esm2015: './some_other.js', + }; + spyOn(config, 'getConfig').and.returnValue({ + entryPoints: {[_('/project/node_modules/some_package/valid_entry_point')]: {override}} + }); + const entryPoint = getEntryPointInfo( + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/valid_entry_point')); + const overriddenPackageJson = { + ...loadPackageJson(fs, '/project/node_modules/some_package/valid_entry_point'), + ...override}; + expect(entryPoint).toEqual({ + name: 'some_package/valid_entry_point', + package: SOME_PACKAGE, + path: _('/project/node_modules/some_package/valid_entry_point'), + typings: _('/project/node_modules/some_package/valid_entry_point/some_other.d.ts'), + packageJson: overriddenPackageJson, + compiledByAngular: true, + }); + }); + it('should return null if there is no package.json at the entry-point path', () => { loadTestFiles([ { - name: _('/some_package/missing_package_json/missing_package_json.metadata.json'), + name: _( + '/project/node_modules/some_package/missing_package_json/missing_package_json.metadata.json'), contents: 'some meta data' }, ]); + const config = new NgccConfiguration(fs, _('/project')); const entryPoint = getEntryPointInfo( - fs, new MockLogger(), SOME_PACKAGE, _('/some_package/missing_package_json')); + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/missing_package_json')); expect(entryPoint).toBe(null); }); + it('should return a configured entry-point if there is no package.json at the entry-point path', + () => { + loadTestFiles([ + // no package.json! + { + name: _( + '/project/node_modules/some_package/missing_package_json/missing_package_json.metadata.json'), + contents: 'some meta data', + }, + ]); + const config = new NgccConfiguration(fs, _('/project')); + const override = + JSON.parse(createPackageJson('missing_package_json', {excludes: ['name']})); + spyOn(config, 'getConfig').and.returnValue({ + entryPoints: + {[_('/project/node_modules/some_package/missing_package_json')]: {override}} + }); + const entryPoint = getEntryPointInfo( + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/missing_package_json')); + expect(entryPoint).toEqual({ + name: 'some_package/missing_package_json', + package: SOME_PACKAGE, + path: _('/project/node_modules/some_package/missing_package_json'), + typings: _( + '/project/node_modules/some_package/missing_package_json/missing_package_json.d.ts'), + packageJson: {name: 'some_package/missing_package_json', ...override}, + compiledByAngular: true, + }); + }); + + it('should return null if there is no typings or types field in the package.json', () => { loadTestFiles([ { - name: _('/some_package/missing_typings/package.json'), + name: _('/project/node_modules/some_package/missing_typings/package.json'), contents: createPackageJson('missing_typings', {excludes: ['typings']}) }, { - name: _('/some_package/missing_typings/missing_typings.metadata.json'), + name: + _('/project/node_modules/some_package/missing_typings/missing_typings.metadata.json'), contents: 'some meta data' }, ]); - const entryPoint = - getEntryPointInfo(fs, new MockLogger(), SOME_PACKAGE, _('/some_package/missing_typings')); + const config = new NgccConfiguration(fs, _('/project')); + const entryPoint = getEntryPointInfo( + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/missing_typings')); expect(entryPoint).toBe(null); }); @@ -80,42 +182,74 @@ runInEachFileSystem(() => { () => { loadTestFiles([ { - name: _('/some_package/missing_metadata/package.json'), + name: _('/project/node_modules/some_package/missing_metadata/package.json'), contents: createPackageJson('missing_metadata') }, ]); + const config = new NgccConfiguration(fs, _('/project')); const entryPoint = getEntryPointInfo( - fs, new MockLogger(), SOME_PACKAGE, _('/some_package/missing_metadata')); + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/missing_metadata')); expect(entryPoint).toEqual({ - name: 'some-package/missing_metadata', + name: 'some_package/missing_metadata', package: SOME_PACKAGE, - path: _('/some_package/missing_metadata'), - typings: _(`/some_package/missing_metadata/missing_metadata.d.ts`), - packageJson: loadPackageJson(fs, '/some_package/missing_metadata'), + path: _('/project/node_modules/some_package/missing_metadata'), + typings: _(`/project/node_modules/some_package/missing_metadata/missing_metadata.d.ts`), + packageJson: loadPackageJson(fs, '/project/node_modules/some_package/missing_metadata'), compiledByAngular: false, }); }); + it('should return an object with `compiledByAngular` set to true if there is no metadata.json file but the entry-point has a configuration', + () => { + loadTestFiles([ + { + name: _('/project/node_modules/some_package/missing_metadata/package.json'), + contents: createPackageJson('missing_metadata'), + }, + // no metadata.json! + ]); + const config = new NgccConfiguration(fs, _('/project')); + spyOn(config, 'getConfig').and.returnValue({ + entryPoints: {[_('/project/node_modules/some_package/missing_metadata')]: {}} + }); + const entryPoint = getEntryPointInfo( + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/missing_metadata')); + expect(entryPoint).toEqual({ + name: 'some_package/missing_metadata', + package: SOME_PACKAGE, + path: _('/project/node_modules/some_package/missing_metadata'), + typings: _('/project/node_modules/some_package/missing_metadata/missing_metadata.d.ts'), + packageJson: loadPackageJson(fs, '/project/node_modules/some_package/missing_metadata'), + compiledByAngular: true, + }); + }); + it('should work if the typings field is named `types', () => { loadTestFiles([ { - name: _('/some_package/types_rather_than_typings/package.json'), + name: _('/project/node_modules/some_package/types_rather_than_typings/package.json'), contents: createPackageJson('types_rather_than_typings', {}, 'types') }, { - name: - _('/some_package/types_rather_than_typings/types_rather_than_typings.metadata.json'), + name: _( + '/project/node_modules/some_package/types_rather_than_typings/types_rather_than_typings.metadata.json'), contents: 'some meta data' }, ]); + const config = new NgccConfiguration(fs, _('/project')); const entryPoint = getEntryPointInfo( - fs, new MockLogger(), SOME_PACKAGE, _('/some_package/types_rather_than_typings')); + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/types_rather_than_typings')); expect(entryPoint).toEqual({ - name: 'some-package/types_rather_than_typings', + name: 'some_package/types_rather_than_typings', package: SOME_PACKAGE, - path: _('/some_package/types_rather_than_typings'), - typings: _(`/some_package/types_rather_than_typings/types_rather_than_typings.d.ts`), - packageJson: loadPackageJson(fs, '/some_package/types_rather_than_typings'), + path: _('/project/node_modules/some_package/types_rather_than_typings'), + typings: _( + `/project/node_modules/some_package/types_rather_than_typings/types_rather_than_typings.d.ts`), + packageJson: + loadPackageJson(fs, '/project/node_modules/some_package/types_rather_than_typings'), compiledByAngular: true, }); }); @@ -123,7 +257,7 @@ runInEachFileSystem(() => { it('should work with Angular Material style package.json', () => { loadTestFiles([ { - name: _('/some_package/material_style/package.json'), + name: _('/project/node_modules/some_package/material_style/package.json'), contents: `{ "name": "some_package/material_style", "typings": "./material_style.d.ts", @@ -133,18 +267,20 @@ runInEachFileSystem(() => { }` }, { - name: _('/some_package/material_style/material_style.metadata.json'), + name: _('/project/node_modules/some_package/material_style/material_style.metadata.json'), contents: 'some meta data' }, ]); - const entryPoint = - getEntryPointInfo(fs, new MockLogger(), SOME_PACKAGE, _('/some_package/material_style')); + const config = new NgccConfiguration(fs, _('/project')); + const entryPoint = getEntryPointInfo( + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/material_style')); expect(entryPoint).toEqual({ name: 'some_package/material_style', package: SOME_PACKAGE, - path: _('/some_package/material_style'), - typings: _(`/some_package/material_style/material_style.d.ts`), - packageJson: loadPackageJson(fs, '/some_package/material_style'), + path: _('/project/node_modules/some_package/material_style'), + typings: _(`/project/node_modules/some_package/material_style/material_style.d.ts`), + packageJson: loadPackageJson(fs, '/project/node_modules/some_package/material_style'), compiledByAngular: true, }); }); @@ -155,12 +291,14 @@ runInEachFileSystem(() => { // for example, @schematics/angular contains a package.json blueprint // with unexpected symbols { - name: _('/some_package/unexpected_symbols/package.json'), + name: _('/project/node_modules/some_package/unexpected_symbols/package.json'), contents: '{"devDependencies": {<% if (!minimal) { %>"@types/jasmine": "~2.8.8" <% } %>}}' }, ]); + const config = new NgccConfiguration(fs, _('/project')); const entryPoint = getEntryPointInfo( - fs, new MockLogger(), SOME_PACKAGE, _('/some_package/unexpected_symbols')); + fs, config, new MockLogger(), SOME_PACKAGE, + _('/project/node_modules/some_package/unexpected_symbols')); expect(entryPoint).toBe(null); }); }); @@ -169,7 +307,7 @@ runInEachFileSystem(() => { packageName: string, {excludes}: {excludes?: string[]} = {}, typingsProp: string = 'typings'): string { const packageJson: any = { - name: `some-package/${packageName}`, + name: `some_package/${packageName}`, [typingsProp]: `./${packageName}.d.ts`, fesm2015: `./fesm2015/${packageName}.js`, esm2015: `./esm2015/${packageName}.js`, diff --git a/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts b/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts index 1ece970a1f..caca11c624 100644 --- a/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts +++ b/packages/compiler-cli/ngcc/test/writing/new_entry_point_file_writer_spec.ts @@ -8,6 +8,7 @@ import {FileSystem, absoluteFrom, getFileSystem} from '../../../src/ngtsc/file_system'; import {runInEachFileSystem} from '../../../src/ngtsc/file_system/testing'; import {loadTestFiles} from '../../../test/helpers'; +import {NgccConfiguration} from '../../src/packages/configuration'; import {EntryPoint, EntryPointFormat, EntryPointJsonProperty, getEntryPointInfo} from '../../src/packages/entry_point'; import {EntryPointBundle, makeEntryPointBundle} from '../../src/packages/entry_point_bundle'; import {FileWriter} from '../../src/writing/file_writer'; @@ -86,8 +87,9 @@ runInEachFileSystem(() => { beforeEach(() => { fs = getFileSystem(); fileWriter = new NewEntryPointFileWriter(fs); + const config = new NgccConfiguration(fs, _('/')); entryPoint = getEntryPointInfo( - fs, new MockLogger(), _('/node_modules/test'), _('/node_modules/test')) !; + fs, config, new MockLogger(), _('/node_modules/test'), _('/node_modules/test')) !; esm5bundle = makeTestBundle(fs, entryPoint, 'module', 'esm5'); esm2015bundle = makeTestBundle(fs, entryPoint, 'es2015', 'esm2015'); }); @@ -174,8 +176,9 @@ runInEachFileSystem(() => { beforeEach(() => { fs = getFileSystem(); fileWriter = new NewEntryPointFileWriter(fs); + const config = new NgccConfiguration(fs, _('/')); entryPoint = getEntryPointInfo( - fs, new MockLogger(), _('/node_modules/test'), _('/node_modules/test/a')) !; + fs, config, new MockLogger(), _('/node_modules/test'), _('/node_modules/test/a')) !; esm5bundle = makeTestBundle(fs, entryPoint, 'module', 'esm5'); esm2015bundle = makeTestBundle(fs, entryPoint, 'es2015', 'esm2015'); }); @@ -251,8 +254,9 @@ runInEachFileSystem(() => { beforeEach(() => { fs = getFileSystem(); fileWriter = new NewEntryPointFileWriter(fs); + const config = new NgccConfiguration(fs, _('/')); entryPoint = getEntryPointInfo( - fs, new MockLogger(), _('/node_modules/test'), _('/node_modules/test/b')) !; + fs, config, new MockLogger(), _('/node_modules/test'), _('/node_modules/test/b')) !; esm5bundle = makeTestBundle(fs, entryPoint, 'module', 'esm5'); esm2015bundle = makeTestBundle(fs, entryPoint, 'es2015', 'esm2015'); });