From c8434bb6391956282b86b38fcf4eb88e8eb5f1e0 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 2 May 2022 12:07:39 -0400 Subject: [PATCH] feat(@schematics/angular): introduce `addDependency` rule to utilities An `addDependency` schematics rule has been added to the newly introduced `utility` subpath export for the `@schematics/angular` package. This new rule allows for adding a package as a dependency to a `package.json`. By default the `package.json` located at the schematic's root will be used. The `packageJsonPath` option can be used to explicitly specify a `package.json` in a different location. The type of the dependency can also be specified instead of the default of the `dependencies` section by using the `type` option for either development or peer dependencies. --- packages/schematics/angular/BUILD.bazel | 1 + .../schematics/angular/utility/dependency.ts | 97 ++++++ .../angular/utility/dependency_spec.ts | 277 ++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 packages/schematics/angular/utility/dependency.ts create mode 100644 packages/schematics/angular/utility/dependency_spec.ts diff --git a/packages/schematics/angular/BUILD.bazel b/packages/schematics/angular/BUILD.bazel index c83b9ceb3bd3..249cae0907ce 100644 --- a/packages/schematics/angular/BUILD.bazel +++ b/packages/schematics/angular/BUILD.bazel @@ -112,6 +112,7 @@ ts_library( "//packages/angular_devkit/core", "//packages/angular_devkit/core/node/testing", "//packages/angular_devkit/schematics", + "//packages/angular_devkit/schematics/tasks", "//packages/angular_devkit/schematics/testing", "//packages/schematics/angular/third_party/github.com/Microsoft/TypeScript", "@npm//@types/browserslist", diff --git a/packages/schematics/angular/utility/dependency.ts b/packages/schematics/angular/utility/dependency.ts new file mode 100644 index 000000000000..b4acdd6cbc5b --- /dev/null +++ b/packages/schematics/angular/utility/dependency.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC 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 { Rule, SchematicContext } from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import * as path from 'path'; + +const installTasks = new WeakMap>(); + +interface MinimalPackageManifest { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; +} + +/** + * An enum used to specify the type of a dependency found within a package manifest + * file (`package.json`). + */ +export enum DependencyType { + Default = 'dependencies', + Dev = 'devDependencies', + Peer = 'peerDependencies', +} + +/** + * Adds a package as a dependency to a `package.json`. By default the `package.json` located + * at the schematic's root will be used. The `manifestPath` option can be used to explicitly specify + * a `package.json` in different location. The type of the dependency can also be specified instead + * of the default of the `dependencies` section by using the `type` option for either `devDependencies` + * or `peerDependencies`. + * + * When using this rule, {@link NodePackageInstallTask} does not need to be included directly by + * a schematic. A package manager install task will be automatically scheduled as needed. + * + * @param name The name of the package to add. + * @param specifier The package specifier for the package to add. Typically a SemVer range. + * @param options An optional object that can contain the `type` of the dependency + * and/or a path (`packageJsonPath`) of a manifest file (`package.json`) to modify. + * @returns A Schematics {@link Rule} + */ +export function addDependency( + name: string, + specifier: string, + options: { + /** + * The type of the dependency determines the section of the `package.json` to which the + * dependency will be added. Defaults to {@link DependencyType.Default} (`dependencies`). + */ + type?: DependencyType; + /** + * The path of the package manifest file (`package.json`) that will be modified. + * Defaults to `/package.json`. + */ + packageJsonPath?: string; + } = {}, +): Rule { + const { type = DependencyType.Default, packageJsonPath = '/package.json' } = options; + + return (tree, context) => { + const manifest = tree.readJson(packageJsonPath) as MinimalPackageManifest; + const dependencySection = manifest[type]; + + if (!dependencySection) { + // Section is not present. The dependency can be added to a new object literal for the section. + manifest[type] = { [name]: specifier }; + } else if (dependencySection[name] === specifier) { + // Already present with same specifier + return; + } else if (dependencySection[name]) { + // Already present but different specifier + throw new Error(`Package dependency "${name}" already exists with a different specifier.`); + } else { + // Add new dependency in alphabetical order + const entries = Object.entries(dependencySection); + entries.push([name, specifier]); + entries.sort((a, b) => a[0].localeCompare(b[0])); + manifest[type] = Object.fromEntries(entries); + } + + tree.overwrite(packageJsonPath, JSON.stringify(manifest, null, 2)); + + const installPaths = installTasks.get(context) ?? new Set(); + if (!installPaths.has(packageJsonPath)) { + context.addTask( + new NodePackageInstallTask({ workingDirectory: path.dirname(packageJsonPath) }), + ); + installPaths.add(packageJsonPath); + installTasks.set(context, installPaths); + } + }; +} diff --git a/packages/schematics/angular/utility/dependency_spec.ts b/packages/schematics/angular/utility/dependency_spec.ts new file mode 100644 index 000000000000..67a96ea3d2ba --- /dev/null +++ b/packages/schematics/angular/utility/dependency_spec.ts @@ -0,0 +1,277 @@ +/** + * @license + * Copyright Google LLC 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 { + EmptyTree, + Rule, + SchematicContext, + TaskConfigurationGenerator, + Tree, + callRule, + chain, +} from '@angular-devkit/schematics'; +import { DependencyType, addDependency } from './dependency'; + +async function testRule(rule: Rule, tree: Tree): Promise { + const tasks: TaskConfigurationGenerator[] = []; + const context = { + addTask(task: TaskConfigurationGenerator) { + tasks.push(task); + }, + }; + + await callRule(rule, tree, context as unknown as SchematicContext).toPromise(); + + return tasks; +} + +describe('addDependency', () => { + it('adds a package to "dependencies" by default', async () => { + const tree = new EmptyTree(); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: {}, + }), + ); + + const rule = addDependency('@angular/core', '^14.0.0'); + + await testRule(rule, tree); + + expect(tree.readJson('/package.json')).toEqual({ + dependencies: { '@angular/core': '^14.0.0' }, + }); + }); + + it('throws if a package is already present with a different specifier', async () => { + const tree = new EmptyTree(); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: { '@angular/core': '^13.0.0' }, + }), + ); + + const rule = addDependency('@angular/core', '^14.0.0'); + + await expectAsync(testRule(rule, tree)).toBeRejectedWithError( + undefined, + 'Package dependency "@angular/core" already exists with a different specifier.', + ); + }); + + it('adds a package version with other packages in alphabetical order', async () => { + const tree = new EmptyTree(); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: { '@angular/common': '^14.0.0', '@angular/router': '^14.0.0' }, + }), + ); + + const rule = addDependency('@angular/core', '^14.0.0'); + + await testRule(rule, tree); + + expect( + Object.entries((tree.readJson('/package.json') as { dependencies: {} }).dependencies), + ).toEqual([ + ['@angular/common', '^14.0.0'], + ['@angular/core', '^14.0.0'], + ['@angular/router', '^14.0.0'], + ]); + }); + + it('adds a dependency section if not present', async () => { + const tree = new EmptyTree(); + tree.create('/package.json', JSON.stringify({})); + + const rule = addDependency('@angular/core', '^14.0.0'); + + await testRule(rule, tree); + + expect(tree.readJson('/package.json')).toEqual({ + dependencies: { '@angular/core': '^14.0.0' }, + }); + }); + + it('adds a package to "devDependencies" when "type" is "dev"', async () => { + const tree = new EmptyTree(); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: {}, + devDependencies: {}, + }), + ); + + const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Dev }); + + await testRule(rule, tree); + + expect(tree.readJson('/package.json')).toEqual({ + dependencies: {}, + devDependencies: { '@angular/core': '^14.0.0' }, + }); + }); + + it('adds a package to "peerDependencies" when "type" is "peer"', async () => { + const tree = new EmptyTree(); + tree.create( + '/package.json', + JSON.stringify({ + devDependencies: {}, + peerDependencies: {}, + }), + ); + + const rule = addDependency('@angular/core', '^14.0.0', { type: DependencyType.Peer }); + + await testRule(rule, tree); + + expect(tree.readJson('/package.json')).toEqual({ + devDependencies: {}, + peerDependencies: { '@angular/core': '^14.0.0' }, + }); + }); + + it('uses specified manifest when provided via "manifestPath" option', async () => { + const tree = new EmptyTree(); + tree.create('/package.json', JSON.stringify({})); + tree.create('/abc/package.json', JSON.stringify({})); + + const rule = addDependency('@angular/core', '^14.0.0', { + packageJsonPath: '/abc/package.json', + }); + + await testRule(rule, tree); + + expect(tree.readJson('/package.json')).toEqual({}); + expect(tree.readJson('/abc/package.json')).toEqual({ + dependencies: { '@angular/core': '^14.0.0' }, + }); + }); + + it('schedules a package install task', async () => { + const tree = new EmptyTree(); + tree.create('/package.json', JSON.stringify({})); + + const rule = addDependency('@angular/core', '^14.0.0'); + + const tasks = await testRule(rule, tree); + + expect(tasks.map((task) => task.toConfiguration())).toEqual([ + { + name: 'node-package', + options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }), + }, + ]); + }); + + it('schedules a package install task with working directory when "packageJsonPath" is used', async () => { + const tree = new EmptyTree(); + tree.create('/abc/package.json', JSON.stringify({})); + + const rule = addDependency('@angular/core', '^14.0.0', { + packageJsonPath: '/abc/package.json', + }); + + const tasks = await testRule(rule, tree); + + expect(tasks.map((task) => task.toConfiguration())).toEqual([ + { + name: 'node-package', + options: jasmine.objectContaining({ command: 'install', workingDirectory: '/abc' }), + }, + ]); + }); + + it('does not schedule a package install task if version is the same', async () => { + const tree = new EmptyTree(); + tree.create( + '/package.json', + JSON.stringify({ + dependencies: { '@angular/core': '^14.0.0' }, + }), + ); + + const rule = addDependency('@angular/core', '^14.0.0'); + + const tasks = await testRule(rule, tree); + + expect(tasks).toEqual([]); + }); + + it('only schedules one package install task for the same manifest path', async () => { + const tree = new EmptyTree(); + tree.create('/package.json', JSON.stringify({})); + + const rule = chain([ + addDependency('@angular/core', '^14.0.0'), + addDependency('@angular/common', '^14.0.0'), + ]); + + const tasks = await testRule(rule, tree); + + expect(tasks.map((task) => task.toConfiguration())).toEqual([ + { + name: 'node-package', + options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }), + }, + ]); + }); + + it('schedules a package install task for each manifest path present', async () => { + const tree = new EmptyTree(); + tree.create('/package.json', JSON.stringify({})); + tree.create('/abc/package.json', JSON.stringify({})); + + const rule = chain([ + addDependency('@angular/core', '^14.0.0'), + addDependency('@angular/common', '^14.0.0', { packageJsonPath: '/abc/package.json' }), + ]); + + const tasks = await testRule(rule, tree); + + expect(tasks.map((task) => task.toConfiguration())).toEqual([ + { + name: 'node-package', + options: jasmine.objectContaining({ command: 'install', workingDirectory: '/' }), + }, + { + name: 'node-package', + options: jasmine.objectContaining({ command: 'install', workingDirectory: '/abc' }), + }, + ]); + }); + + it('throws an error when the default manifest path does not exist', async () => { + const tree = new EmptyTree(); + + const rule = addDependency('@angular/core', '^14.0.0'); + + await expectAsync(testRule(rule, tree)).toBeRejectedWithError( + undefined, + `Path "/package.json" does not exist.`, + ); + }); + + it('throws an error when the specified manifest path does not exist', async () => { + const tree = new EmptyTree(); + + const rule = addDependency('@angular/core', '^14.0.0', { + packageJsonPath: '/abc/package.json', + }); + + await expectAsync(testRule(rule, tree)).toBeRejectedWithError( + undefined, + `Path "/abc/package.json" does not exist.`, + ); + }); +});