Skip to content

Commit 841ea3a

Browse files
committed
fix(ng-update): properly handle update from different working directory
In some situations, developers will run `ng update` from a sub directory of the project. This currently results in a noop migration as file system paths were computed incorrectly. This is because ng-update always assumed that the CWD is the workspace root in the real file system. This is not the case and therefore threw off path resolution. We fix this by fully relying on the virtual file system now. Previously we mixed virtual and real file system as TypeScript relied on the real file system for parsing configuration, matching and reading files. This commit adds a new compiler host for config parsing and program creation that works fully with a virtual file system. This is something we had planned a long time ago as schematics should never deal with the real file system. We can re-use this logic in other repositories too (such as Angular framework, potentially the CLI, and universal schematics). Ideally we will share this as part of better tooling for migrations. Fixes #19779.
1 parent 0012121 commit 841ea3a

25 files changed

+262
-182
lines changed

src/cdk/schematics/ng-update/devkit-file-system.ts

+24-16
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,26 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {normalize, Path, relative} from '@angular-devkit/core';
9+
import {normalize, Path, PathIsDirectoryException} from '@angular-devkit/core';
1010
import {Tree, UpdateRecorder} from '@angular-devkit/schematics';
11+
import {DirectoryEntry, FileSystem} from '../update-tool/file-system';
1112
import * as path from 'path';
12-
import {FileSystem} from '../update-tool/file-system';
1313

1414
/**
1515
* File system that leverages the virtual tree from the CLI devkit. This file
1616
* system is commonly used by `ng update` migrations that run as part of the
1717
* Angular CLI.
1818
*/
19-
export class DevkitFileSystem extends FileSystem<Path> {
19+
export class DevkitFileSystem extends FileSystem {
2020
private _updateRecorderCache = new Map<string, UpdateRecorder>();
21-
private _workspaceFsPath: Path;
2221

23-
constructor(private _tree: Tree, workspaceFsPath: string) {
22+
constructor(private _tree: Tree) {
2423
super();
25-
this._workspaceFsPath = normalize(workspaceFsPath);
2624
}
2725

2826
resolve(...segments: string[]): Path {
2927
// Note: We use `posix.resolve` as the devkit paths are using posix separators.
30-
const resolvedPath = normalize(path.posix.resolve(...segments.map(normalize)));
31-
// If the resolved path points to the workspace root, then this is an absolute disk
32-
// path and we need to compute a devkit tree relative path.
33-
if (resolvedPath.startsWith(this._workspaceFsPath)) {
34-
return relative(this._workspaceFsPath, resolvedPath);
35-
}
36-
// Otherwise we know that the path is absolute (due to the resolve), and that it
37-
// refers to an absolute devkit tree path (like `/angular.json`). We keep those
38-
// unmodified as they are already resolved workspace paths.
39-
return resolvedPath;
28+
return normalize(path.posix.resolve('/', ...segments.map(normalize)));
4029
}
4130

4231
edit(filePath: Path) {
@@ -73,4 +62,23 @@ export class DevkitFileSystem extends FileSystem<Path> {
7362
const buffer = this._tree.read(filePath);
7463
return buffer !== null ? buffer.toString() : null;
7564
}
65+
66+
existsDirectory(dirPath: Path) {
67+
// The devkit tree does not expose an API for checking whether a given
68+
// directory exists. It throws a specific error though if a directory
69+
// is being read as a file. We use that to check if a directory exists.
70+
try {
71+
this._tree.get(dirPath);
72+
} catch (e) {
73+
if (e instanceof PathIsDirectoryException) {
74+
return true;
75+
}
76+
}
77+
return false;
78+
}
79+
80+
readDirectory(dirPath: Path): DirectoryEntry {
81+
const {subdirs: directories, subfiles: files} = this._tree.getDir(dirPath);
82+
return {directories: directories, files};
83+
}
7684
}

src/cdk/schematics/ng-update/devkit-migration-rule.ts

+7-17
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,16 @@
99
import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics';
1010
import {NodePackageInstallTask} from '@angular-devkit/schematics/tasks';
1111
import {WorkspaceProject} from '@schematics/angular/utility/workspace-models';
12-
import {sync as globSync} from 'glob';
13-
import {join} from 'path';
1412

1513
import {UpdateProject} from '../update-tool';
14+
import {WorkspacePath} from '../update-tool/file-system';
1615
import {MigrationCtor} from '../update-tool/migration';
1716
import {TargetVersion} from '../update-tool/target-version';
18-
import {WorkspacePath} from '../update-tool/file-system';
1917
import {getTargetTsconfigPath, getWorkspaceConfigGracefully} from '../utils/project-tsconfig-paths';
2018

2119
import {DevkitFileSystem} from './devkit-file-system';
2220
import {DevkitContext, DevkitMigration, DevkitMigrationCtor} from './devkit-migration';
21+
import {findStylesheetFiles} from './find-stylesheets';
2322
import {AttributeSelectorsMigration} from './migrations/attribute-selectors';
2423
import {ClassInheritanceMigration} from './migrations/class-inheritance';
2524
import {ClassNamesMigration} from './migrations/class-names';
@@ -74,9 +73,7 @@ export function createMigrationSchematicRule(
7473
// necessary because multiple TypeScript projects can contain the same source file and
7574
// we don't want to check these again, as this would result in duplicated failure messages.
7675
const analyzedFiles = new Set<WorkspacePath>();
77-
// The CLI uses the working directory as the base directory for the virtual file system tree.
78-
const workspaceFsPath = process.cwd();
79-
const fileSystem = new DevkitFileSystem(tree, workspaceFsPath);
76+
const fileSystem = new DevkitFileSystem(tree);
8077
const projectNames = Object.keys(workspace.projects);
8178
const migrations: NullableDevkitMigration[] = [...cdkMigrations, ...extraMigrations];
8279
let hasFailures = false;
@@ -123,12 +120,9 @@ export function createMigrationSchematicRule(
123120

124121
/** Runs the migrations for the specified workspace project. */
125122
function runMigrations(project: WorkspaceProject, projectName: string,
126-
tsconfigPath: string, isTestTarget: boolean) {
127-
const projectRootFsPath = join(workspaceFsPath, project.root);
128-
const tsconfigFsPath = join(workspaceFsPath, tsconfigPath);
129-
const program = UpdateProject.createProgramFromTsconfig(tsconfigFsPath, fileSystem);
123+
tsconfigPath: WorkspacePath, isTestTarget: boolean) {
124+
const program = UpdateProject.createProgramFromTsconfig(tsconfigPath, fileSystem);
130125
const updateContext: DevkitContext = {
131-
workspaceFsPath,
132126
isTestTarget,
133127
projectName,
134128
project,
@@ -145,13 +139,9 @@ export function createMigrationSchematicRule(
145139

146140
// In some applications, developers will have global stylesheets which are not
147141
// specified in any Angular component. Therefore we glob up all CSS and SCSS files
148-
// outside of node_modules and dist. The files will be read by the individual
149-
// stylesheet rules and checked.
142+
// in the project and migrate them if needed.
150143
// TODO: rework this to collect global stylesheets from the workspace config. COMP-280.
151-
const additionalStylesheets = globSync(
152-
'!(node_modules|dist)/**/*.+(css|scss)',
153-
{absolute: true, cwd: projectRootFsPath, nodir: true});
154-
144+
const additionalStylesheets = findStylesheetFiles(tree, project.root);
155145
const result =
156146
updateProject.migrate(migrations, targetVersion, upgradeData, additionalStylesheets);
157147

src/cdk/schematics/ng-update/devkit-migration.ts

-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ export type DevkitContext = {
1717
projectName: string;
1818
/** Workspace project the migrations run against. */
1919
project: WorkspaceProject,
20-
/** Absolute file system path to the workspace */
21-
workspaceFsPath: string,
2220
/** Whether the migrations run for a test target. */
2321
isTestTarget: boolean,
2422
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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.io/license
7+
*/
8+
9+
import {Path} from '@angular-devkit/core';
10+
import {Tree} from '@angular-devkit/schematics';
11+
12+
/** Regular expression that matches stylesheet paths */
13+
const STYLESHEET_REGEX = /.*\.(css|scss)/;
14+
15+
/** Finds stylesheets in the given directory from within the specified tree. */
16+
export function findStylesheetFiles(tree: Tree, baseDir: string): string[] {
17+
const result: string[] = [];
18+
tree.getDir(baseDir).visit((filePath: Path) => {
19+
if (STYLESHEET_REGEX.test(filePath)) {
20+
result.push(filePath);
21+
}
22+
});
23+
return result;
24+
}

src/cdk/schematics/ng-update/migrations/attribute-selectors.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,12 @@ export class AttributeSelectorsMigration extends Migration<UpgradeData> {
5959
}
6060

6161
const literalText = literal.getText();
62-
const filePath = literal.getSourceFile().fileName;
62+
const filePath = this.fileSystem.resolve(literal.getSourceFile().fileName);
6363

6464
this.data.forEach(selector => {
6565
findAllSubstringIndices(literalText, selector.replace)
6666
.map(offset => literal.getStart() + offset)
67-
.forEach(start => this._replaceSelector(
68-
this.fileSystem.resolve(filePath), start, selector));
67+
.forEach(start => this._replaceSelector(filePath, start, selector));
6968
});
7069
}
7170

src/cdk/schematics/ng-update/migrations/css-selectors.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class CssSelectorsMigration extends Migration<UpgradeData> {
6161
}
6262

6363
const textContent = node.getText();
64-
const filePath = node.getSourceFile().fileName;
64+
const filePath = this.fileSystem.resolve(node.getSourceFile().fileName);
6565

6666
this.data.forEach(data => {
6767
if (data.whitelist && !data.whitelist.strings) {
@@ -70,7 +70,7 @@ export class CssSelectorsMigration extends Migration<UpgradeData> {
7070

7171
findAllSubstringIndices(textContent, data.replace)
7272
.map(offset => node.getStart() + offset)
73-
.forEach(start => this._replaceSelector(this.fileSystem.resolve(filePath), start, data));
73+
.forEach(start => this._replaceSelector(filePath, start, data));
7474
});
7575
}
7676

src/cdk/schematics/ng-update/migrations/element-selectors.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,12 @@ export class ElementSelectorsMigration extends Migration<UpgradeData> {
5353
}
5454

5555
const textContent = node.getText();
56-
const filePath = node.getSourceFile().fileName;
56+
const filePath = this.fileSystem.resolve(node.getSourceFile().fileName);
5757

5858
this.data.forEach(selector => {
5959
findAllSubstringIndices(textContent, selector.replace)
6060
.map(offset => node.getStart() + offset)
61-
.forEach(start => this._replaceSelector(
62-
this.fileSystem.resolve(filePath), start, selector));
61+
.forEach(start => this._replaceSelector(filePath, start, selector));
6362
});
6463
}
6564

src/cdk/schematics/ng-update/test-cases/misc/external-resource-resolution.spec.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {createTestCaseSetup} from '../../../testing';
55
describe('ng-update external resource resolution', () => {
66

77
it('should properly resolve referenced resources in components', async () => {
8-
const {runFixers, writeFile, removeTempDir, appTree} = await createTestCaseSetup(
8+
const {runFixers, writeFile, appTree} = await createTestCaseSetup(
99
'migration-v6', MIGRATION_PATH,
1010
[resolveBazelPath(__dirname, './external-resource-resolution_input.ts')]);
1111

@@ -24,7 +24,5 @@ describe('ng-update external resource resolution', () => {
2424
.toBe(expected, 'Expected relative paths with parent segments to work.');
2525
expect(appTree.readContent('/projects/cdk-testing/src/test-cases/local.html'))
2626
.toBe(expected, 'Expected relative paths without explicit dot to work.');
27-
28-
removeTempDir();
2927
});
3028
});

src/cdk/schematics/ng-update/test-cases/misc/global-stylesheets.spec.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {createTestCaseSetup} from '../../../testing';
66
describe('global stylesheets migration', () => {
77

88
it('should not check stylesheet twice if referenced in component', async () => {
9-
const {runFixers, writeFile, removeTempDir, appTree} = await createTestCaseSetup(
9+
const {runFixers, writeFile, appTree} = await createTestCaseSetup(
1010
'migration-v6', MIGRATION_PATH,
1111
[resolveBazelPath(__dirname, './global-stylesheets_input.ts')]);
1212

@@ -25,12 +25,10 @@ describe('global stylesheets migration', () => {
2525
// the same replacements were recorded for the same source file.
2626
expect(appTree.readContent(testStylesheetPath))
2727
.toBe(`[cdkPortalOutlet] {\n color: red;\n}\n`);
28-
29-
removeTempDir();
3028
});
3129

3230
it('should not check stylesheets outside of project target', async () => {
33-
const {runFixers, writeFile, removeTempDir, appTree} = await createTestCaseSetup(
31+
const {runFixers, writeFile, appTree} = await createTestCaseSetup(
3432
'migration-v6', MIGRATION_PATH, []);
3533
const subProjectStylesheet = '[cdkPortalHost] {\n color: red;\n}\n';
3634

@@ -43,7 +41,5 @@ describe('global stylesheets migration', () => {
4341
// if the external stylesheet that is not of a project target would have been checked
4442
// by accident, the stylesheet would differ from the original file content.
4543
expect(appTree.readContent('/sub_project/assets/test.css')).toBe(subProjectStylesheet);
46-
47-
removeTempDir();
4844
});
4945
});

src/cdk/schematics/ng-update/test-cases/misc/method-call-checks.spec.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {createTestCaseSetup} from '../../../testing';
44

55
describe('v6 method call checks', () => {
66
it('should properly report invalid method calls', async () => {
7-
const {runFixers, removeTempDir} = await createTestCaseSetup(
7+
const {runFixers} = await createTestCaseSetup(
88
'migration-v6', MIGRATION_PATH,
99
[resolveBazelPath(__dirname, './method-call-checks_input.ts')]);
1010

@@ -14,7 +14,5 @@ describe('v6 method call checks', () => {
1414
/@15:5 - Found call to "FocusMonitor\.monitor".*renderer.*has been removed/);
1515
expect(logOutput).toMatch(
1616
/@16:5 - Found call to "FocusMonitor\.monitor".*renderer.*has been removed/);
17-
18-
removeTempDir();
1917
});
2018
});

src/cdk/schematics/testing/test-case-setup.ts

+6-26
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import {getSystemPath, JsonParseMode, normalize, parseJson, Path} from '@angular-devkit/core';
9-
import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing';
10-
import * as virtualFs from '@angular-devkit/core/src/virtual-fs/host';
8+
import {getSystemPath, JsonParseMode, parseJson, Path} from '@angular-devkit/core';
119
import {HostTree, Tree} from '@angular-devkit/schematics';
1210
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
13-
import {readFileSync, removeSync} from 'fs-extra';
11+
import {readFileSync} from 'fs-extra';
1412
import {sync as globSync} from 'glob';
1513
import {basename, extname, join, relative, sep} from 'path';
1614
import {EMPTY} from 'rxjs';
@@ -38,28 +36,22 @@ export function readFileContent(filePath: string): string {
3836
* schematic tree. This would allow us to fully take advantage of the virtual file system.
3937
*/
4038
export async function createFileSystemTestApp(runner: SchematicTestRunner) {
41-
const tempFileSystemHost = new TempScopedNodeJsSyncHost();
42-
const hostTree = new HostTree(tempFileSystemHost);
39+
const hostTree = new HostTree();
4340
const appTree: UnitTestTree = await createTestApp(runner, {name: 'cdk-testing'}, hostTree);
44-
const tempPath = getSystemPath(tempFileSystemHost.root);
4541

4642
// Since the TypeScript compiler API expects all files to be present on the real file system, we
4743
// map every file in the app tree to a temporary location on the file system.
4844
appTree.files.forEach(f => writeFile(f, appTree.readContent(f)));
4945

5046
return {
5147
appTree,
52-
tempFileSystemHost,
53-
tempPath,
5448
writeFile,
55-
removeTempDir: () => removeSync(tempPath),
5649
};
5750

5851
function writeFile(filePath: string, content: string) {
5952
// Update the temp file system host to reflect the changes in the real file system.
6053
// This is still necessary since we depend on the real file system for parsing the
6154
// TypeScript project.
62-
tempFileSystemHost.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(content));
6355
if (hostTree.exists(filePath)) {
6456
hostTree.overwrite(filePath, content);
6557
} else {
@@ -72,12 +64,11 @@ export async function createTestCaseSetup(migrationName: string, collectionPath:
7264
inputFiles: string[]) {
7365

7466
const runner = new SchematicTestRunner('schematics', collectionPath);
75-
const initialWorkingDir = process.cwd();
7667

7768
let logOutput = '';
7869
runner.logger.subscribe(entry => logOutput += `${entry.message}\n`);
7970

80-
const {appTree, tempPath, writeFile, removeTempDir} =
71+
const {appTree, writeFile} =
8172
await createFileSystemTestApp(runner);
8273

8374
_patchTypeScriptDefaultLib(appTree);
@@ -105,10 +96,6 @@ export async function createTestCaseSetup(migrationName: string, collectionPath:
10596
writeFile(testAppTsconfigPath, JSON.stringify(testAppTsconfig, null, 2));
10697

10798
const runFixers = async function() {
108-
// Switch to the new temporary directory to simulate that "ng update" is ran
109-
// from within the project.
110-
process.chdir(tempPath);
111-
11299
// Patch "executePostTasks" to do nothing. This is necessary since
113100
// we cannot run the node install task in unit tests. Rather we just
114101
// assert that certain async post tasks are scheduled.
@@ -117,13 +104,10 @@ export async function createTestCaseSetup(migrationName: string, collectionPath:
117104

118105
await runner.runSchematicAsync(migrationName, {}, appTree).toPromise();
119106

120-
// Switch back to the initial working directory.
121-
process.chdir(initialWorkingDir);
122-
123107
return {logOutput};
124108
};
125109

126-
return {runner, appTree, writeFile, tempPath, removeTempDir, runFixers};
110+
return {runner, appTree, writeFile, runFixers};
127111
}
128112

129113
/**
@@ -192,21 +176,17 @@ export function defineJasmineTestCases(versionName: string, collectionFile: stri
192176

193177
let appTree: UnitTestTree;
194178
let testCasesOutputPath: string;
195-
let cleanupTestApp: () => void;
196179

197180
beforeAll(async () => {
198-
const {appTree: _tree, runFixers, removeTempDir} =
181+
const {appTree: _tree, runFixers} =
199182
await createTestCaseSetup(`migration-${versionName}`, collectionFile, inputFiles);
200183

201184
await runFixers();
202185

203186
appTree = _tree;
204187
testCasesOutputPath = '/projects/cdk-testing/src/test-cases/';
205-
cleanupTestApp = removeTempDir;
206188
});
207189

208-
afterAll(() => cleanupTestApp());
209-
210190
// Iterates through every test case directory and generates a jasmine test block that will
211191
// verify that the update schematics properly updated the test input to the expected output.
212192
inputFiles.forEach(inputFile => {

0 commit comments

Comments
 (0)