Skip to content

Commit d60c883

Browse files
committed
fix(@angular/build): canonicalize drive letter casing for workspace root on Windows
On Windows, process.cwd() casing can inherit the casing from the shell where the command was executed (e.g. lowercase c:\projects\my-app). When running builds or unit tests, the builders pass this workspaceRoot to underlying tools. Since Node's ESM loader and other tools resolve real file paths using canonical on-disk casing (which has an uppercase drive letter, C:\projects\my-app), the casing mismatches cause duplicate module imports (such as loading the vitest package twice) or path lookup mismatches. This introduces a shared canonicalizePath helper in utils/path.ts which normalizes the drive-letter casing of the workspace root to uppercase on Windows, and resolves symbolic links unless preserveSymlinks is enabled. It refactors the application and unit-test builders to use this shared helper.
1 parent b0b3a47 commit d60c883

4 files changed

Lines changed: 40 additions & 19 deletions

File tree

packages/angular/build/src/builders/application/options.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import type { BuilderContext } from '@angular-devkit/architect';
1010
import type { Plugin } from 'esbuild';
11-
import { realpathSync } from 'node:fs';
1211
import { access, constants, readFile } from 'node:fs/promises';
1312
import { createRequire } from 'node:module';
1413
import path from 'node:path';
@@ -18,6 +17,7 @@ import { useJSONBuildLogs, usePartialSsrBuild } from '../../utils/environment-op
1817
import { I18nOptions, createI18nOptions } from '../../utils/i18n-options';
1918
import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator';
2019
import { normalizeCacheOptions } from '../../utils/normalize-cache';
20+
import { canonicalizePath } from '../../utils/path';
2121
import {
2222
SearchDirectory,
2323
findTailwindConfiguration,
@@ -160,12 +160,7 @@ export async function normalizeOptions(
160160
options.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks');
161161

162162
// Setup base paths based on workspace root and project information
163-
const workspaceRoot = preserveSymlinks
164-
? context.workspaceRoot
165-
: // NOTE: promises.realpath should not be used here since it uses realpath.native which
166-
// can cause case conversion and other undesirable behavior on Windows systems.
167-
// ref: https://github.com/nodejs/node/issues/7726
168-
realpathSync(context.workspaceRoot);
163+
const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks);
169164
const projectMetadata = await context.getProjectMetadata(projectName);
170165
const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata);
171166

@@ -213,8 +208,7 @@ export async function normalizeOptions(
213208
}
214209

215210
let loaderExtensions:
216-
| Record<string, 'text' | 'binary' | 'file' | 'dataurl' | 'base64'>
217-
| undefined;
211+
Record<string, 'text' | 'binary' | 'file' | 'dataurl' | 'base64'> | undefined;
218212
if (options.loader) {
219213
for (const [extension, value] of Object.entries(options.loader)) {
220214
if (extension[0] !== '.' || /\.[cm]?[jt]sx?$/.test(extension)) {

packages/angular/build/src/builders/unit-test/builder.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,6 @@ export async function* execute(
286286
return;
287287
}
288288

289-
// Resolve final preserveSymlinks option
290-
normalizedOptions.preserveSymlinks =
291-
buildTargetOptions.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks');
292-
293289
// Get runner-specific build options
294290
let runnerBuildOptions;
295291
let virtualFiles;

packages/angular/build/src/builders/unit-test/options.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { type BuilderContext, targetFromTargetString } from '@angular-devkit/architect';
1010
import { constants, promises as fs } from 'node:fs';
1111
import path from 'node:path';
12+
import { canonicalizePath } from '../../utils/path';
1213
import { normalizeCacheOptions } from '../../utils/normalize-cache';
1314
import { getProjectRootPaths } from '../../utils/project-metadata';
1415
import { isTTY } from '../../utils/tty';
@@ -41,19 +42,31 @@ export async function normalizeOptions(
4142
projectName: string,
4243
options: UnitTestBuilderOptions,
4344
) {
45+
// Target specifier defaults to the current project's build target using a development configuration
46+
const buildTargetSpecifier = options.buildTarget ?? `::development`;
47+
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
48+
49+
// Load target options to determine preserveSymlinks setting
50+
let buildTargetOptions: Record<string, unknown> | undefined;
51+
try {
52+
buildTargetOptions = await context.getTargetOptions(buildTarget);
53+
} catch {}
54+
55+
const preserveSymlinks =
56+
typeof buildTargetOptions?.preserveSymlinks === 'boolean'
57+
? buildTargetOptions.preserveSymlinks
58+
: process.execArgv.includes('--preserve-symlinks');
59+
4460
// Setup base paths based on workspace root and project information
45-
const workspaceRoot = context.workspaceRoot;
61+
const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks);
62+
4663
const projectMetadata = await context.getProjectMetadata(projectName);
4764
const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata);
4865

4966
// Gather persistent caching option and provide a project specific cache location
5067
const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot);
5168
cacheOptions.path = path.join(cacheOptions.path, projectName);
5269

53-
// Target specifier defaults to the current project's build target using a development configuration
54-
const buildTargetSpecifier = options.buildTarget ?? `::development`;
55-
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
56-
5770
const { runner, browsers, progress, filter, browserViewport, ui, runnerConfig, isolate } =
5871
options;
5972

@@ -134,7 +147,7 @@ export async function normalizeOptions(
134147
: [],
135148
dumpVirtualFiles: options.dumpVirtualFiles,
136149
listTests: options.listTests,
137-
preserveSymlinks: undefined as boolean | undefined,
150+
preserveSymlinks,
138151
runnerConfig:
139152
typeof runnerConfig === 'string'
140153
? runnerConfig.length === 0

packages/angular/build/src/utils/path.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import { realpathSync } from 'node:fs';
910
import { isAbsolute, posix, relative, resolve } from 'node:path';
1011
import { platform } from 'node:process';
1112

@@ -50,3 +51,20 @@ export function isSubDirectory(parent: string, child: string): boolean {
5051

5152
return relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath);
5253
}
54+
55+
/**
56+
* Canonicalizes a file path by normalising Windows drive-letter casing to uppercase
57+
* and optionally resolving symbolic links.
58+
*
59+
* @param pathString - The file path to canonicalize.
60+
* @param preserveSymlinks - If true, symbolic links will not be resolved.
61+
* @returns The canonicalized file path.
62+
*/
63+
export function canonicalizePath(pathString: string, preserveSymlinks = false): string {
64+
let normalized = pathString;
65+
if (platform === 'win32' && /^[a-zA-Z]:/.test(normalized)) {
66+
normalized = normalized[0].toUpperCase() + normalized.slice(1);
67+
}
68+
69+
return preserveSymlinks ? normalized : realpathSync(normalized);
70+
}

0 commit comments

Comments
 (0)