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; } /** 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.'); +}