From c39604fcbc953801c490f4890291f58a27770bfb Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 6 Nov 2025 14:08:07 -0500 Subject: [PATCH 1/2] test(@angular/build): add an E2E test for absolute include paths for unit tests An additional E2E test has been added that tests the usage of an absolute path for the `unit-test` command line `--include` option. --- .../legacy-cli/e2e/tests/vitest/absolute-include.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/legacy-cli/e2e/tests/vitest/absolute-include.ts diff --git a/tests/legacy-cli/e2e/tests/vitest/absolute-include.ts b/tests/legacy-cli/e2e/tests/vitest/absolute-include.ts new file mode 100644 index 000000000000..787fe942edbd --- /dev/null +++ b/tests/legacy-cli/e2e/tests/vitest/absolute-include.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { applyVitestBuilder } from '../../utils/vitest'; +import { ng } from '../../utils/process'; +import path from 'node:path'; + +export default async function (): Promise { + await applyVitestBuilder(); + + const { stdout } = await ng('test', '--include', path.resolve('src/app/app.spec.ts')); + + assert.match(stdout, /1 passed/, 'Expected 1 test to pass.'); +} From 832305ee1dc88d621e980395687a3b016110c4a8 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 6 Nov 2025 15:30:54 -0500 Subject: [PATCH 2/2] fix(@angular/build): correctly handle absolute paths and casing in test discovery The `findTests` function's path normalization logic had two flaws that affected cross-platform compatibility and user experience: 1. Absolute paths provided in the `include` patterns were incorrectly processed. The leading slash was removed, causing the globber to fail to find the specified test files. 2. The prefix removal logic was case-sensitive, which would fail to normalize workspace-relative paths on case-insensitive filesystems (like Windows and macOS) if the user's input casing for the project path differed from the actual directory casing. This commit corrects both issues: - The `normalizePattern` function now uses `isAbsolute` to detect and preserve absolute paths, ensuring they are passed to the globber unmodified. - The `removePrefix` helper function is now conditionally case-insensitive based on the host operating system (`win32`, `darwin`), aligning its behavior with the underlying filesystem and making path normalization more robust. - Minor code style refactoring and JSDoc comments have been added for clarity. --- .../build/src/builders/karma/find-tests.ts | 23 +++++++- .../src/builders/unit-test/test-discovery.ts | 57 +++++++++++++------ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/packages/angular/build/src/builders/karma/find-tests.ts b/packages/angular/build/src/builders/karma/find-tests.ts index 62bcd563d455..00468146df5f 100644 --- a/packages/angular/build/src/builders/karma/find-tests.ts +++ b/packages/angular/build/src/builders/karma/find-tests.ts @@ -6,6 +6,27 @@ * found in the LICENSE file at https://angular.dev/license */ +import { findTests as findTestsBase } from '../unit-test/test-discovery'; + // This file is a compatibility layer that re-exports the test discovery logic from its new location. // This is necessary to avoid breaking the Karma builder, which still depends on this file. -export { findTests, getTestEntrypoints } from '../unit-test/test-discovery'; +export { getTestEntrypoints } from '../unit-test/test-discovery'; + +const removeLeadingSlash = (path: string): string => { + return path.startsWith('/') ? path.substring(1) : path; +}; + +export async function findTests( + include: string[], + exclude: string[], + workspaceRoot: string, + projectSourceRoot: string, +): Promise { + // Karma has legacy support for workspace "root-relative" file paths + return findTestsBase( + include.map(removeLeadingSlash), + exclude.map(removeLeadingSlash), + workspaceRoot, + projectSourceRoot, + ); +} diff --git a/packages/angular/build/src/builders/unit-test/test-discovery.ts b/packages/angular/build/src/builders/unit-test/test-discovery.ts index 987b55e39a81..89d38dbbe787 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -7,6 +7,7 @@ */ import { type PathLike, constants, promises as fs } from 'node:fs'; +import os from 'node:os'; import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path'; import { glob, isDynamicPattern } from 'tinyglobby'; import { toPosixPath } from '../../utils/path'; @@ -157,15 +158,32 @@ function generateNameFromPath( return result; } -/** Removes a leading slash from a path. */ -const removeLeadingSlash = (path: string): string => { - return path.startsWith('/') ? path.substring(1) : path; -}; +/** + * Whether the current operating system's filesystem is case-insensitive. + */ +const isCaseInsensitiveFilesystem = os.platform() === 'win32' || os.platform() === 'darwin'; -/** Removes a prefix from the beginning of a string. */ -const removePrefix = (str: string, prefix: string): string => { - return str.startsWith(prefix) ? str.substring(prefix.length) : str; -}; +/** + * Removes a prefix from the beginning of a string, with conditional case-insensitivity + * based on the operating system's filesystem characteristics. + * + * @param text The string to remove the prefix from. + * @param prefix The prefix to remove. + * @returns The string with the prefix removed, or the original string if the prefix was not found. + */ +function removePrefix(text: string, prefix: string): string { + if (isCaseInsensitiveFilesystem) { + if (text.toLowerCase().startsWith(prefix.toLowerCase())) { + return text.substring(prefix.length); + } + } else { + if (text.startsWith(prefix)) { + return text.substring(prefix.length); + } + } + + return text; +} /** * Removes potential root paths from a file path, returning a relative path. @@ -177,8 +195,10 @@ const removePrefix = (str: string, prefix: string): string => { */ function removeRoots(path: string, roots: string[]): string { for (const root of roots) { - if (path.startsWith(root)) { - return path.substring(root.length); + const result = removePrefix(path, root); + // If the prefix was removed, the result will be a different string. + if (result !== path) { + return result; } } @@ -194,15 +214,18 @@ function removeRoots(path: string, roots: string[]): string { * @returns A normalized glob pattern. */ function normalizePattern(pattern: string, projectRootPrefix: string): string { - let normalizedPattern = toPosixPath(pattern); - normalizedPattern = removeLeadingSlash(normalizedPattern); + const posixPattern = toPosixPath(pattern); + + // Do not modify absolute paths. The globber will handle them correctly. + if (isAbsolute(posixPattern)) { + return posixPattern; + } - // Some IDEs and tools may provide patterns relative to the workspace root. - // To ensure the glob operates correctly within the project's source root, - // we remove the project's relative path from the front of the pattern. - normalizedPattern = removePrefix(normalizedPattern, projectRootPrefix); + // For relative paths, ensure they are correctly relative to the project source root. + // This involves removing the project root prefix if the user provided a workspace-relative path. + const normalizedRelative = removePrefix(posixPattern, projectRootPrefix); - return normalizedPattern; + return normalizedRelative; } /**