Skip to content

Commit 703b03f

Browse files
committed
fix(@angular/cli): supplement workspace member dependencies in ng update
In npm/pnpm/yarn workspace setups, the package manager's list command runs against the workspace root and may not include packages that are only declared in a workspace member's package.json. This caused ng update to report "Package X is not a dependency" for packages installed in a workspace member even though they were present and installed. The fix reads the Angular project root's package.json directly and resolves any declared dependencies that are resolvable from node_modules but were absent from the package manager's output. This restores the behaviour that was present before the package-manager abstraction was introduced. Closes #32787
1 parent 7fbc715 commit 703b03f

File tree

1 file changed

+150
-0
lines changed

1 file changed

+150
-0
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import * as fs from 'node:fs/promises';
10+
import * as os from 'node:os';
11+
import * as path from 'node:path';
12+
import type { InstalledPackage } from '../../package-managers';
13+
import { supplementWithLocalDependencies } from './cli';
14+
15+
/**
16+
* Creates a minimal on-disk fixture that simulates an npm workspace member:
17+
*
18+
* <projectRoot>/
19+
* package.json ← Angular project manifest (workspace member)
20+
* node_modules/
21+
* <depName>/
22+
* package.json ← installed package manifest
23+
*/
24+
async function createWorkspaceMemberFixture(options: {
25+
projectDeps: Record<string, string>;
26+
installedPackages: Array<{ name: string; version: string }>;
27+
}): Promise<string> {
28+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-update-spec-'));
29+
30+
// Write the Angular project's package.json
31+
await fs.writeFile(
32+
path.join(projectRoot, 'package.json'),
33+
JSON.stringify({
34+
name: 'test-app',
35+
version: '0.0.0',
36+
dependencies: options.projectDeps,
37+
}),
38+
);
39+
40+
// Write each installed package into node_modules
41+
for (const pkg of options.installedPackages) {
42+
// Support scoped packages like @angular/core
43+
const pkgDir = path.join(projectRoot, 'node_modules', ...pkg.name.split('/'));
44+
await fs.mkdir(pkgDir, { recursive: true });
45+
await fs.writeFile(
46+
path.join(pkgDir, 'package.json'),
47+
JSON.stringify({ name: pkg.name, version: pkg.version }),
48+
);
49+
}
50+
51+
return projectRoot;
52+
}
53+
54+
describe('supplementWithLocalDependencies', () => {
55+
let tmpDir: string;
56+
57+
afterEach(async () => {
58+
if (tmpDir) {
59+
await fs.rm(tmpDir, { recursive: true, force: true });
60+
}
61+
});
62+
63+
it('should add packages from the local package.json that are missing from the dependency map', async () => {
64+
// Simulates an npm workspace member where `npm list` (run against the
65+
// workspace root) did not return `@angular/core`, even though it is
66+
// declared in the member's package.json and installed in node_modules.
67+
tmpDir = await createWorkspaceMemberFixture({
68+
projectDeps: { '@angular/core': '^21.0.0' },
69+
installedPackages: [{ name: '@angular/core', version: '21.2.4' }],
70+
});
71+
72+
const deps = new Map<string, InstalledPackage>();
73+
74+
await supplementWithLocalDependencies(deps, tmpDir);
75+
76+
expect(deps.has('@angular/core')).toBeTrue();
77+
expect(deps.get('@angular/core')?.version).toBe('21.2.4');
78+
});
79+
80+
it('should not overwrite a package that is already present in the dependency map', async () => {
81+
tmpDir = await createWorkspaceMemberFixture({
82+
projectDeps: { '@angular/core': '^21.0.0' },
83+
installedPackages: [{ name: '@angular/core', version: '21.2.4' }],
84+
});
85+
86+
// The package manager already returned a version for @angular/core.
87+
const existingEntry: InstalledPackage = { name: '@angular/core', version: '21.0.0' };
88+
const deps = new Map<string, InstalledPackage>([['@angular/core', existingEntry]]);
89+
90+
await supplementWithLocalDependencies(deps, tmpDir);
91+
92+
// The existing entry must not be overwritten.
93+
expect(deps.get('@angular/core')).toBe(existingEntry);
94+
expect(deps.get('@angular/core')?.version).toBe('21.0.0');
95+
});
96+
97+
it('should skip packages that are declared in package.json but not installed in node_modules', async () => {
98+
tmpDir = await createWorkspaceMemberFixture({
99+
projectDeps: { 'not-installed': '^1.0.0' },
100+
installedPackages: [],
101+
});
102+
103+
const deps = new Map<string, InstalledPackage>();
104+
105+
await supplementWithLocalDependencies(deps, tmpDir);
106+
107+
// Package is not installed; should not be added.
108+
expect(deps.has('not-installed')).toBeFalse();
109+
});
110+
111+
it('should handle devDependencies and peerDependencies in addition to dependencies', async () => {
112+
tmpDir = await createWorkspaceMemberFixture({
113+
projectDeps: {},
114+
installedPackages: [
115+
{ name: 'rxjs', version: '7.8.2' },
116+
{ name: 'zone.js', version: '0.15.0' },
117+
],
118+
});
119+
120+
// Write a package.json that uses devDependencies and peerDependencies.
121+
await fs.writeFile(
122+
path.join(tmpDir, 'package.json'),
123+
JSON.stringify({
124+
name: 'test-app',
125+
version: '0.0.0',
126+
devDependencies: { 'zone.js': '~0.15.0' },
127+
peerDependencies: { rxjs: '~7.8.0' },
128+
}),
129+
);
130+
131+
const deps = new Map<string, InstalledPackage>();
132+
133+
await supplementWithLocalDependencies(deps, tmpDir);
134+
135+
expect(deps.has('zone.js')).toBeTrue();
136+
expect(deps.get('zone.js')?.version).toBe('0.15.0');
137+
expect(deps.has('rxjs')).toBeTrue();
138+
expect(deps.get('rxjs')?.version).toBe('7.8.2');
139+
});
140+
141+
it('should do nothing when the project root has no package.json', async () => {
142+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-update-spec-'));
143+
144+
const deps = new Map<string, InstalledPackage>();
145+
146+
// Should resolve without throwing.
147+
await expectAsync(supplementWithLocalDependencies(deps, tmpDir)).toBeResolved();
148+
expect(deps.size).toBe(0);
149+
});
150+
});

0 commit comments

Comments
 (0)