Skip to content

Commit f8a1939

Browse files
clydinalan-agius4
authored andcommitted
fix(@angular/build): add filename truncation to test discovery
Adds truncation logic to the `generateNameFromPath` function in the unit test discovery process. This prevents issues with filesystems that have filename length limits. When a generated test entrypoint name exceeds 128 characters, it is truncated. The truncated name consists of a prefix of the original name, an 8-character SHA256 hash of the full path, and a suffix of the original name, ensuring uniqueness while keeping the name readable. Unit tests are included to verify the truncation behavior for long filenames. (cherry picked from commit 63c3e3f)
1 parent dc6d946 commit f8a1939

File tree

2 files changed

+119
-2
lines changed

2 files changed

+119
-2
lines changed

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

Lines changed: 28 additions & 2 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 { createHash } from 'node:crypto';
910
import { type PathLike, constants, promises as fs } from 'node:fs';
1011
import os from 'node:os';
1112
import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path';
@@ -18,6 +19,9 @@ import { toPosixPath } from '../../utils/path';
1819
*/
1920
const TEST_FILE_INFIXES = ['.spec', '.test'];
2021

22+
/** Maximum length for a generated test entrypoint name. */
23+
const MAX_FILENAME_LENGTH = 128;
24+
2125
/**
2226
* Finds all test files in the project. This function implements a special handling
2327
* for static paths (non-globs) to improve developer experience. For example, if a
@@ -120,7 +124,7 @@ export function getTestEntrypoints(
120124
* @param removeTestExtension Whether to remove the test file infix and extension from the result.
121125
* @returns A dash-cased name derived from the relative path of the test file.
122126
*/
123-
function generateNameFromPath(
127+
export function generateNameFromPath(
124128
testFile: string,
125129
roots: string[],
126130
removeTestExtension: boolean,
@@ -155,7 +159,29 @@ function generateNameFromPath(
155159
result += char === '/' || char === '\\' ? '-' : char;
156160
}
157161

158-
return result;
162+
return truncateName(result, relativePath);
163+
}
164+
165+
/**
166+
* Truncates a generated name if it exceeds the maximum allowed filename length.
167+
* If truncation occurs, the name will be shortened by replacing a middle segment
168+
* with an 8-character SHA256 hash of the original full path to maintain uniqueness.
169+
*
170+
* @param name The generated name to potentially truncate.
171+
* @param originalPath The original full path from which the name was derived. Used for hashing.
172+
* @returns The original name if within limits, or a truncated name with a hash.
173+
*/
174+
function truncateName(name: string, originalPath: string): string {
175+
if (name.length <= MAX_FILENAME_LENGTH) {
176+
return name;
177+
}
178+
179+
const hash = createHash('sha256').update(originalPath).digest('hex').substring(0, 8);
180+
const availableLength = MAX_FILENAME_LENGTH - hash.length - 2; // 2 for '-' separators
181+
const prefixLength = Math.floor(availableLength / 2);
182+
const suffixLength = availableLength - prefixLength;
183+
184+
return `${name.substring(0, prefixLength)}-${hash}-${name.substring(name.length - suffixLength)}`;
159185
}
160186

161187
/**
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { generateNameFromPath } from './test-discovery';
10+
11+
describe('generateNameFromPath', () => {
12+
const roots = ['/project/src/', '/project/'];
13+
14+
it('should generate a dash-cased name from a simple path', () => {
15+
const testFile = '/project/src/app/components/my-component.spec.ts';
16+
const result = generateNameFromPath(testFile, roots, true);
17+
expect(result).toBe('app-components-my-component');
18+
});
19+
20+
it('should handle Windows-style paths', () => {
21+
const testFile = 'C:\\project\\src\\app\\components\\my-component.spec.ts';
22+
const result = generateNameFromPath(testFile, ['C:\\project\\src\\'], true);
23+
expect(result).toBe('app-components-my-component');
24+
});
25+
26+
it('should remove test extensions when removeTestExtension is true', () => {
27+
const testFile = '/project/src/app/utils/helpers.test.ts';
28+
const result = generateNameFromPath(testFile, roots, true);
29+
expect(result).toBe('app-utils-helpers');
30+
});
31+
32+
it('should not remove test extensions when removeTestExtension is false', () => {
33+
const testFile = '/project/src/app/utils/helpers.test.ts';
34+
const result = generateNameFromPath(testFile, roots, false);
35+
expect(result).toBe('app-utils-helpers.test');
36+
});
37+
38+
it('should handle paths with leading dots and slashes', () => {
39+
const testFile = '/project/src/./app/services/api.service.spec.ts';
40+
const result = generateNameFromPath(testFile, roots, true);
41+
expect(result).toBe('app-services-api.service');
42+
});
43+
44+
it('should return the basename if no root matches', () => {
45+
const testFile = '/unrelated/path/to/some/file.spec.ts';
46+
const result = generateNameFromPath(testFile, roots, true);
47+
expect(result).toBe('file');
48+
});
49+
50+
it('should truncate a long file name', () => {
51+
const longPath =
52+
'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
53+
const testFile = `/project/src/${longPath}`;
54+
const result = generateNameFromPath(testFile, roots, true);
55+
56+
expect(result.length).toBeLessThanOrEqual(128);
57+
expect(result).toBe(
58+
'a-very-long-path-that-definitely-exceeds-the-maximum-allowe-9cf40291-me-in-order-to-trigger-the-truncation-logic-in-the-function',
59+
); // eslint-disable-line max-len
60+
});
61+
62+
it('should generate different hashes for different paths with similar truncated names', () => {
63+
const longPath1 =
64+
'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
65+
const longPath2 =
66+
'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
67+
68+
const testFile1 = `/project/src/${longPath1}`;
69+
const testFile2 = `/project/src/${longPath2}`;
70+
71+
const result1 = generateNameFromPath(testFile1, roots, true);
72+
const result2 = generateNameFromPath(testFile2, roots, true);
73+
74+
expect(result1).not.toBe(result2);
75+
// The hash is always 8 characters long and is surrounded by hyphens.
76+
const hashRegex = /-[a-f0-9]{8}-/;
77+
const hash1 = result1.match(hashRegex)?.[0];
78+
const hash2 = result2.match(hashRegex)?.[0];
79+
80+
expect(hash1).toBeDefined();
81+
expect(hash2).toBeDefined();
82+
expect(hash1).not.toBe(hash2);
83+
});
84+
85+
it('should not truncate a filename that is exactly the max length', () => {
86+
const name = 'a'.repeat(128);
87+
const testFile = `/project/src/${name}.spec.ts`;
88+
const result = generateNameFromPath(testFile, roots, true);
89+
expect(result).toBe(name);
90+
});
91+
});

0 commit comments

Comments
 (0)