Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/schematics/angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions packages/schematics/angular/utility/dependency.ts
Original file line number Diff line number Diff line change
@@ -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<SchematicContext, Set<string>>();

interface MinimalPackageManifest {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}

/**
* 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<string>();
if (!installPaths.has(packageJsonPath)) {
context.addTask(
new NodePackageInstallTask({ workingDirectory: path.dirname(packageJsonPath) }),
);
installPaths.add(packageJsonPath);
installTasks.set(context, installPaths);
}
};
}
277 changes: 277 additions & 0 deletions packages/schematics/angular/utility/dependency_spec.ts
Original file line number Diff line number Diff line change
@@ -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<TaskConfigurationGenerator[]> {
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.`,
);
});
});