Skip to content

Commit 48e85ef

Browse files
committed
refactor(@angular/build): add option to dump unit test build output
Debugging the `unit-test` builder can be challenging, particularly when using runners like Vitest that rely on virtual or in-memory files for test setup and execution. It is difficult to inspect the final build artifacts that are passed to the test runner. This commit introduces a new hidden boolean option, `dumpVirtualFiles`, to the `unit-test` builder's schema. When this option is enabled, the builder will write the full build output to a directory within the project's cache (`.angular/cache/<project-name>/unit-test/output-files/`). This provides a crucial debugging mechanism, allowing developers to inspect the exact contents of the files being used in the test execution, which helps in troubleshooting complex configuration and build-related problems.
1 parent d0631c5 commit 48e85ef

File tree

8 files changed

+90
-37
lines changed

8 files changed

+90
-37
lines changed

goldens/public-api/angular/build/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export type UnitTestBuilderOptions = {
221221
codeCoverageExclude?: string[];
222222
codeCoverageReporters?: SchemaCodeCoverageReporter[];
223223
debug?: boolean;
224+
dumpVirtualFiles?: boolean;
224225
exclude?: string[];
225226
filter?: string;
226227
include?: string[];

packages/angular/build/src/builders/karma/application_builder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as fs from 'node:fs/promises';
1313
import path from 'node:path';
1414
import { ReadableStream } from 'node:stream/web';
1515
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
16+
import { writeTestFiles } from '../../utils/test-files';
1617
import { buildApplicationInternal } from '../application/index';
1718
import { ApplicationBuilderInternalOptions } from '../application/options';
1819
import { Result, ResultKind } from '../application/results';
@@ -30,7 +31,6 @@ import {
3031
getProjectSourceRoot,
3132
hasChunkOrWorkerFiles,
3233
normalizePolyfills,
33-
writeTestFiles,
3434
} from './utils';
3535
import type { KarmaBuilderTransformsOptions } from './index';
3636

packages/angular/build/src/builders/karma/progress-reporter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
import type { BuilderOutput } from '@angular-devkit/architect';
1010
import type { Config, ConfigOptions } from 'karma';
1111
import type { ReadableStreamController } from 'node:stream/web';
12+
import { writeTestFiles } from '../../utils/test-files';
1213
import type { ApplicationBuilderInternalOptions } from '../application/options';
1314
import type { Result } from '../application/results';
1415
import { ResultKind } from '../application/results';
1516
import type { LatestBuildFiles } from './assets-middleware';
16-
import { writeTestFiles } from './utils';
1717

1818
const LATEST_BUILD_FILES_TOKEN = 'angularLatestBuildFiles';
1919

packages/angular/build/src/builders/karma/utils.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,9 @@
77
*/
88

99
import type { BuilderContext } from '@angular-devkit/architect';
10-
import * as fs from 'node:fs/promises';
1110
import { createRequire } from 'node:module';
12-
import path from 'node:path';
1311
import { BuildOutputFileType } from '../../tools/esbuild/bundler-context';
14-
import { emitFilesToDisk } from '../../tools/esbuild/utils';
1512
import { getProjectRootPaths } from '../../utils/project-metadata';
16-
import { ResultFile } from '../application/results';
1713
import { findTests, getTestEntrypoints } from './find-tests';
1814
import type { NormalizedKarmaBuilderOptions } from './options';
1915

@@ -72,36 +68,6 @@ export function hasChunkOrWorkerFiles(files: Record<string, unknown>): boolean {
7268
});
7369
}
7470

75-
export async function writeTestFiles(
76-
files: Record<string, ResultFile>,
77-
testDir: string,
78-
): Promise<void> {
79-
const directoryExists = new Set<string>();
80-
// Writes the test related output files to disk and ensures the containing directories are present
81-
await emitFilesToDisk(Object.entries(files), async ([filePath, file]) => {
82-
if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) {
83-
return;
84-
}
85-
86-
const fullFilePath = path.join(testDir, filePath);
87-
88-
// Ensure output subdirectories exist
89-
const fileBasePath = path.dirname(fullFilePath);
90-
if (fileBasePath && !directoryExists.has(fileBasePath)) {
91-
await fs.mkdir(fileBasePath, { recursive: true });
92-
directoryExists.add(fileBasePath);
93-
}
94-
95-
if (file.origin === 'memory') {
96-
// Write file contents
97-
await fs.writeFile(fullFilePath, file.contents);
98-
} else {
99-
// Copy file contents
100-
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
101-
}
102-
});
103-
}
104-
10571
/** Returns the first item yielded by the given generator and cancels the execution. */
10672
export async function first<T>(
10773
generator: AsyncIterable<T>,

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import {
1212
targetStringFromTarget,
1313
} from '@angular-devkit/architect';
1414
import assert from 'node:assert';
15+
import { rm } from 'node:fs/promises';
16+
import path from 'node:path';
1517
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
1618
import { assertIsError } from '../../utils/error';
19+
import { writeTestFiles } from '../../utils/test-files';
1720
import { buildApplicationInternal } from '../application';
1821
import type {
1922
ApplicationBuilderExtensions,
@@ -96,6 +99,7 @@ async function* runBuildAndTest(
9699
executor: import('./runners/api').TestExecutor,
97100
applicationBuildOptions: ApplicationBuilderInternalOptions,
98101
context: BuilderContext,
102+
dumpDirectory: string | undefined,
99103
extensions: ApplicationBuilderExtensions | undefined,
100104
): AsyncIterable<BuilderOutput> {
101105
let consecutiveErrorCount = 0;
@@ -118,6 +122,20 @@ async function* runBuildAndTest(
118122

119123
assert(buildResult.files, 'Builder did not provide result files.');
120124

125+
if (dumpDirectory) {
126+
if (buildResult.kind === ResultKind.Full) {
127+
// Full build, so clean the directory
128+
await rm(dumpDirectory, { recursive: true, force: true });
129+
} else {
130+
// Incremental build, so delete removed files
131+
for (const file of buildResult.removed) {
132+
await rm(path.join(dumpDirectory, file.path), { force: true });
133+
}
134+
}
135+
await writeTestFiles(buildResult.files, dumpDirectory);
136+
context.logger.info(`Build output files successfully dumped to '${dumpDirectory}'.`);
137+
}
138+
121139
// Pass the build artifacts to the executor
122140
try {
123141
yield* executor.execute(buildResult);
@@ -263,7 +281,17 @@ export async function* execute(
263281
progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress,
264282
} satisfies ApplicationBuilderInternalOptions;
265283

266-
yield* runBuildAndTest(executor, applicationBuildOptions, context, finalExtensions);
284+
const dumpDirectory = normalizedOptions.dumpVirtualFiles
285+
? path.join(normalizedOptions.cacheOptions.path, 'unit-test', 'output-files')
286+
: undefined;
287+
288+
yield* runBuildAndTest(
289+
executor,
290+
applicationBuildOptions,
291+
context,
292+
dumpDirectory,
293+
finalExtensions,
294+
);
267295
} catch (e) {
268296
assertIsError(e);
269297
context.logger.error(

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export async function normalizeOptions(
7474
setupFiles: options.setupFiles
7575
? options.setupFiles.map((setupFile) => path.join(workspaceRoot, setupFile))
7676
: [],
77+
dumpVirtualFiles: options.dumpVirtualFiles,
7778
};
7879
}
7980

packages/angular/build/src/builders/unit-test/schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@
147147
"progress": {
148148
"type": "boolean",
149149
"description": "Shows build progress information in the console. Defaults to the `progress` setting of the specified `buildTarget`."
150+
},
151+
"dumpVirtualFiles": {
152+
"type": "boolean",
153+
"description": "Dumps build output files to the `.angular/cache` directory for debugging purposes.",
154+
"default": false,
155+
"visible": false
150156
}
151157
},
152158
"additionalProperties": false,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 * as fs from 'node:fs/promises';
10+
import path from 'node:path';
11+
import { ResultFile } from '../builders/application/results';
12+
import { BuildOutputFileType } from '../tools/esbuild/bundler-context';
13+
import { emitFilesToDisk } from '../tools/esbuild/utils';
14+
15+
/**
16+
* Writes a collection of build result files to a specified directory.
17+
* This function handles both in-memory and on-disk files, creating subdirectories
18+
* as needed.
19+
*
20+
* @param files A map of file paths to `ResultFile` objects, representing the build output.
21+
* @param testDir The absolute path to the directory where the files should be written.
22+
*/
23+
export async function writeTestFiles(
24+
files: Record<string, ResultFile>,
25+
testDir: string,
26+
): Promise<void> {
27+
const directoryExists = new Set<string>();
28+
// Writes the test related output files to disk and ensures the containing directories are present
29+
await emitFilesToDisk(Object.entries(files), async ([filePath, file]) => {
30+
if (file.type !== BuildOutputFileType.Browser && file.type !== BuildOutputFileType.Media) {
31+
return;
32+
}
33+
34+
const fullFilePath = path.join(testDir, filePath);
35+
36+
// Ensure output subdirectories exist
37+
const fileBasePath = path.dirname(fullFilePath);
38+
if (fileBasePath && !directoryExists.has(fileBasePath)) {
39+
await fs.mkdir(fileBasePath, { recursive: true });
40+
directoryExists.add(fileBasePath);
41+
}
42+
43+
if (file.origin === 'memory') {
44+
// Write file contents
45+
await fs.writeFile(fullFilePath, file.contents);
46+
} else {
47+
// Copy file contents
48+
await fs.copyFile(file.inputPath, fullFilePath, fs.constants.COPYFILE_FICLONE);
49+
}
50+
});
51+
}

0 commit comments

Comments
 (0)