Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions packages/angular/build/src/builders/unit-test/test-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { createHash } from 'node:crypto';
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';
Expand All @@ -18,6 +19,9 @@ import { toPosixPath } from '../../utils/path';
*/
const TEST_FILE_INFIXES = ['.spec', '.test'];

/** Maximum length for a generated test entrypoint name. */
const MAX_FILENAME_LENGTH = 128;

/**
* Finds all test files in the project. This function implements a special handling
* for static paths (non-globs) to improve developer experience. For example, if a
Expand Down Expand Up @@ -120,7 +124,7 @@ export function getTestEntrypoints(
* @param removeTestExtension Whether to remove the test file infix and extension from the result.
* @returns A dash-cased name derived from the relative path of the test file.
*/
function generateNameFromPath(
export function generateNameFromPath(
testFile: string,
roots: string[],
removeTestExtension: boolean,
Expand Down Expand Up @@ -155,7 +159,29 @@ function generateNameFromPath(
result += char === '/' || char === '\\' ? '-' : char;
}

return result;
return truncateName(result, relativePath);
}

/**
* Truncates a generated name if it exceeds the maximum allowed filename length.
* If truncation occurs, the name will be shortened by replacing a middle segment
* with an 8-character SHA256 hash of the original full path to maintain uniqueness.
*
* @param name The generated name to potentially truncate.
* @param originalPath The original full path from which the name was derived. Used for hashing.
* @returns The original name if within limits, or a truncated name with a hash.
*/
function truncateName(name: string, originalPath: string): string {
if (name.length <= MAX_FILENAME_LENGTH) {
return name;
}

const hash = createHash('sha256').update(originalPath).digest('hex').substring(0, 8);
const availableLength = MAX_FILENAME_LENGTH - hash.length - 2; // 2 for '-' separators
const prefixLength = Math.floor(availableLength / 2);
const suffixLength = availableLength - prefixLength;

return `${name.substring(0, prefixLength)}-${hash}-${name.substring(name.length - suffixLength)}`;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { generateNameFromPath } from './test-discovery';

describe('generateNameFromPath', () => {
const roots = ['/project/src/', '/project/'];

it('should generate a dash-cased name from a simple path', () => {
const testFile = '/project/src/app/components/my-component.spec.ts';
const result = generateNameFromPath(testFile, roots, true);
expect(result).toBe('app-components-my-component');
});

it('should handle Windows-style paths', () => {
const testFile = 'C:\\project\\src\\app\\components\\my-component.spec.ts';
const result = generateNameFromPath(testFile, ['C:\\project\\src\\'], true);
expect(result).toBe('app-components-my-component');
});

it('should remove test extensions when removeTestExtension is true', () => {
const testFile = '/project/src/app/utils/helpers.test.ts';
const result = generateNameFromPath(testFile, roots, true);
expect(result).toBe('app-utils-helpers');
});

it('should not remove test extensions when removeTestExtension is false', () => {
const testFile = '/project/src/app/utils/helpers.test.ts';
const result = generateNameFromPath(testFile, roots, false);
expect(result).toBe('app-utils-helpers.test');
});

it('should handle paths with leading dots and slashes', () => {
const testFile = '/project/src/./app/services/api.service.spec.ts';
const result = generateNameFromPath(testFile, roots, true);
expect(result).toBe('app-services-api.service');
});

it('should return the basename if no root matches', () => {
const testFile = '/unrelated/path/to/some/file.spec.ts';
const result = generateNameFromPath(testFile, roots, true);
expect(result).toBe('file');
});

it('should truncate a long file name', () => {
const longPath =
'a/very/long/path/that/definitely/exceeds/the/maximum/allowed/length/for/a/filename/in/order/to/trigger/the/truncation/logic/in/the/function.spec.ts'; // eslint-disable-line max-len
const testFile = `/project/src/${longPath}`;
const result = generateNameFromPath(testFile, roots, true);

expect(result.length).toBeLessThanOrEqual(128);
expect(result).toBe(
'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-9cf40291-me-in-order-to-trigger-the-truncation-logic-in-the-function',
); // eslint-disable-line max-len
});

it('should generate different hashes for different paths with similar truncated names', () => {
const longPath1 =
'a/very/long/path/that/definitely/exceeds/the/maximum/allowed/length/for/a/filename/in/order/to/trigger/the/truncation/logic/variant-a.spec.ts'; // eslint-disable-line max-len
const longPath2 =
'a/very/long/path/that/definitely/exceeds/the/maximum/allowed/length/for/a/filename/in/order/to/trigger/the/truncation/logic/variant-b.spec.ts'; // eslint-disable-line max-len

const testFile1 = `/project/src/${longPath1}`;
const testFile2 = `/project/src/${longPath2}`;

const result1 = generateNameFromPath(testFile1, roots, true);
const result2 = generateNameFromPath(testFile2, roots, true);

expect(result1).not.toBe(result2);
// The hash is always 8 characters long and is surrounded by hyphens.
const hashRegex = /-[a-f0-9]{8}-/;
const hash1 = result1.match(hashRegex)?.[0];
const hash2 = result2.match(hashRegex)?.[0];

expect(hash1).toBeDefined();
expect(hash2).toBeDefined();
expect(hash1).not.toBe(hash2);
});

it('should not truncate a filename that is exactly the max length', () => {
const name = 'a'.repeat(128);
const testFile = `/project/src/${name}.spec.ts`;
const result = generateNameFromPath(testFile, roots, true);
expect(result).toBe(name);
});
});