diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index ab5f7de7dfae..4ec810ff3c07 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -8,7 +8,6 @@ import type { BuilderContext } from '@angular-devkit/architect'; import type { Plugin } from 'esbuild'; -import { realpathSync } from 'node:fs'; import { access, constants, readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; @@ -18,6 +17,7 @@ import { useJSONBuildLogs, usePartialSsrBuild } from '../../utils/environment-op import { I18nOptions, createI18nOptions } from '../../utils/i18n-options'; import { IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { canonicalizePath } from '../../utils/path'; import { SearchDirectory, findTailwindConfiguration, @@ -160,12 +160,7 @@ export async function normalizeOptions( options.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'); // Setup base paths based on workspace root and project information - const workspaceRoot = preserveSymlinks - ? context.workspaceRoot - : // NOTE: promises.realpath should not be used here since it uses realpath.native which - // can cause case conversion and other undesirable behavior on Windows systems. - // ref: https://github.com/nodejs/node/issues/7726 - realpathSync(context.workspaceRoot); + const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); @@ -213,8 +208,7 @@ export async function normalizeOptions( } let loaderExtensions: - | Record - | undefined; + Record | undefined; if (options.loader) { for (const [extension, value] of Object.entries(options.loader)) { if (extension[0] !== '.' || /\.[cm]?[jt]sx?$/.test(extension)) { diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index c01b7b69d75e..146ce6654bd0 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -286,10 +286,6 @@ export async function* execute( return; } - // Resolve final preserveSymlinks option - normalizedOptions.preserveSymlinks = - buildTargetOptions.preserveSymlinks ?? process.execArgv.includes('--preserve-symlinks'); - // Get runner-specific build options let runnerBuildOptions; let virtualFiles; diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 7ff4d3fa753f..d3402b090e0f 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -10,6 +10,7 @@ import { type BuilderContext, targetFromTargetString } from '@angular-devkit/arc import { constants, promises as fs } from 'node:fs'; import path from 'node:path'; import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { canonicalizePath } from '../../utils/path'; import { getProjectRootPaths } from '../../utils/project-metadata'; import { isTTY } from '../../utils/tty'; import { Runner, type Schema as UnitTestBuilderOptions } from './schema'; @@ -41,8 +42,24 @@ export async function normalizeOptions( projectName: string, options: UnitTestBuilderOptions, ) { + // Target specifier defaults to the current project's build target using a development configuration + const buildTargetSpecifier = options.buildTarget ?? `::development`; + const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); + + // Load target options to determine preserveSymlinks setting + let buildTargetOptions: Record | undefined; + try { + buildTargetOptions = await context.getTargetOptions(buildTarget); + } catch {} + + const preserveSymlinks = + typeof buildTargetOptions?.preserveSymlinks === 'boolean' + ? buildTargetOptions.preserveSymlinks + : process.execArgv.includes('--preserve-symlinks'); + // Setup base paths based on workspace root and project information - const workspaceRoot = context.workspaceRoot; + const workspaceRoot = canonicalizePath(context.workspaceRoot, preserveSymlinks); + const projectMetadata = await context.getProjectMetadata(projectName); const { projectRoot, projectSourceRoot } = getProjectRootPaths(workspaceRoot, projectMetadata); @@ -50,10 +67,6 @@ export async function normalizeOptions( const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); cacheOptions.path = path.join(cacheOptions.path, projectName); - // Target specifier defaults to the current project's build target using a development configuration - const buildTargetSpecifier = options.buildTarget ?? `::development`; - const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); - const { runner, browsers, progress, filter, browserViewport, ui, runnerConfig, isolate } = options; @@ -134,7 +147,7 @@ export async function normalizeOptions( : [], dumpVirtualFiles: options.dumpVirtualFiles, listTests: options.listTests, - preserveSymlinks: undefined as boolean | undefined, + preserveSymlinks, runnerConfig: typeof runnerConfig === 'string' ? runnerConfig.length === 0 diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts index e969d420e429..5b881f1a4206 100644 --- a/packages/angular/build/src/utils/path.ts +++ b/packages/angular/build/src/utils/path.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { realpathSync } from 'node:fs'; import { isAbsolute, posix, relative, resolve } from 'node:path'; import { platform } from 'node:process'; @@ -50,3 +51,20 @@ export function isSubDirectory(parent: string, child: string): boolean { return relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath); } + +/** + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase + * and optionally resolving symbolic links. + * + * @param pathString - The file path to canonicalize. + * @param preserveSymlinks - If true, symbolic links will not be resolved. + * @returns The canonicalized file path. + */ +export function canonicalizePath(pathString: string, preserveSymlinks = false): string { + const resolved = preserveSymlinks ? pathString : realpathSync(pathString); + if (platform === 'win32' && /^[a-zA-Z]:/.test(resolved)) { + return resolved[0].toUpperCase() + resolved.slice(1); + } + + return resolved; +}