Skip to content

Commit d1dd3a8

Browse files
committed
refactor(@angular/build): improve error handling for unit-test builder setup
Error handling has been enhanced to provide more actionable feedback for common misconfigurations, such as an invalid `buildTarget` or a malformed test runner package.
1 parent b720554 commit d1dd3a8

File tree

1 file changed

+125
-68
lines changed
  • packages/angular/build/src/builders/unit-test

1 file changed

+125
-68
lines changed

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

Lines changed: 125 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
9+
import {
10+
type BuilderContext,
11+
type BuilderOutput,
12+
targetStringFromTarget,
13+
} from '@angular-devkit/architect';
1014
import assert from 'node:assert';
1115
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
1216
import { assertIsError } from '../../utils/error';
@@ -22,6 +26,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema';
2226

2327
export type { UnitTestBuilderOptions };
2428

29+
async function loadTestRunner(runnerName: string): Promise<TestRunner> {
30+
// Harden against directory traversal
31+
if (!/^[a-zA-Z0-9-]+$/.test(runnerName)) {
32+
throw new Error(
33+
`Invalid runner name "${runnerName}". Runner names can only contain alphanumeric characters and hyphens.`,
34+
);
35+
}
36+
37+
let runnerModule;
38+
try {
39+
runnerModule = await import(`./runners/${runnerName}/index`);
40+
} catch (e) {
41+
assertIsError(e);
42+
if (e.code === 'ERR_MODULE_NOT_FOUND') {
43+
throw new Error(`Unknown test runner "${runnerName}".`);
44+
}
45+
throw new Error(
46+
`Failed to load the '${runnerName}' test runner. The package may be corrupted or improperly installed.\n` +
47+
`Error: ${e.message}`,
48+
);
49+
}
50+
51+
const runner = runnerModule.default;
52+
if (
53+
!runner ||
54+
typeof runner.getBuildOptions !== 'function' ||
55+
typeof runner.createExecutor !== 'function'
56+
) {
57+
throw new Error(
58+
`The loaded test runner '${runnerName}' does not appear to be a valid TestRunner implementation.`,
59+
);
60+
}
61+
62+
return runner;
63+
}
64+
65+
function prepareBuildExtensions(
66+
virtualFiles: Record<string, string> | undefined,
67+
projectSourceRoot: string,
68+
extensions?: ApplicationBuilderExtensions,
69+
): ApplicationBuilderExtensions | undefined {
70+
if (!virtualFiles) {
71+
return extensions;
72+
}
73+
74+
extensions ??= {};
75+
extensions.codePlugins ??= [];
76+
for (const [namespace, contents] of Object.entries(virtualFiles)) {
77+
extensions.codePlugins.push(
78+
createVirtualModulePlugin({
79+
namespace,
80+
loadContent: () => {
81+
return {
82+
contents,
83+
loader: 'js',
84+
resolveDir: projectSourceRoot,
85+
};
86+
},
87+
}),
88+
);
89+
}
90+
91+
return extensions;
92+
}
93+
94+
async function* runBuildAndTest(
95+
executor: import('./runners/api').TestExecutor,
96+
applicationBuildOptions: ApplicationBuilderInternalOptions,
97+
context: BuilderContext,
98+
extensions: ApplicationBuilderExtensions | undefined,
99+
): AsyncIterable<BuilderOutput> {
100+
for await (const buildResult of buildApplicationInternal(
101+
applicationBuildOptions,
102+
context,
103+
extensions,
104+
)) {
105+
if (buildResult.kind === ResultKind.Failure) {
106+
yield { success: false };
107+
continue;
108+
} else if (
109+
buildResult.kind !== ResultKind.Full &&
110+
buildResult.kind !== ResultKind.Incremental
111+
) {
112+
assert.fail(
113+
'A full and/or incremental build result is required from the application builder.',
114+
);
115+
}
116+
117+
assert(buildResult.files, 'Builder did not provide result files.');
118+
119+
// Pass the build artifacts to the executor
120+
yield* executor.execute(buildResult);
121+
}
122+
}
123+
25124
/**
26125
* @experimental Direct usage of this function is considered experimental.
27126
*/
@@ -43,24 +142,8 @@ export async function* execute(
43142
);
44143

45144
const normalizedOptions = await normalizeOptions(context, projectName, options);
46-
const { runnerName, projectSourceRoot } = normalizedOptions;
47-
48-
// Dynamically load the requested runner
49-
let runner: TestRunner;
50-
try {
51-
const { default: runnerModule } = await import(`./runners/${runnerName}/index`);
52-
runner = runnerModule;
53-
} catch (e) {
54-
assertIsError(e);
55-
if (e.code !== 'ERR_MODULE_NOT_FOUND') {
56-
throw e;
57-
}
58-
context.logger.error(`Unknown test runner "${runnerName}".`);
145+
const runner = await loadTestRunner(normalizedOptions.runnerName);
59146

60-
return;
61-
}
62-
63-
// Create the stateful executor once
64147
await using executor = await runner.createExecutor(context, normalizedOptions);
65148

66149
if (runner.isStandalone) {
@@ -73,68 +156,42 @@ export async function* execute(
73156
}
74157

75158
// Get base build options from the buildTarget
76-
const buildTargetOptions = (await context.validateOptions(
77-
await context.getTargetOptions(normalizedOptions.buildTarget),
78-
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
79-
)) as unknown as ApplicationBuilderInternalOptions;
159+
let buildTargetOptions: ApplicationBuilderInternalOptions;
160+
try {
161+
buildTargetOptions = (await context.validateOptions(
162+
await context.getTargetOptions(normalizedOptions.buildTarget),
163+
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
164+
)) as unknown as ApplicationBuilderInternalOptions;
165+
} catch (e) {
166+
assertIsError(e);
167+
context.logger.error(
168+
`Could not load build target options for "${targetStringFromTarget(normalizedOptions.buildTarget)}".\n` +
169+
`Please check your 'angular.json' configuration.\n` +
170+
`Error: ${e.message}`,
171+
);
172+
173+
return;
174+
}
80175

81176
// Get runner-specific build options from the hook
82177
const { buildOptions: runnerBuildOptions, virtualFiles } = await runner.getBuildOptions(
83178
normalizedOptions,
84179
buildTargetOptions,
85180
);
86181

87-
if (virtualFiles) {
88-
extensions ??= {};
89-
extensions.codePlugins ??= [];
90-
for (const [namespace, contents] of Object.entries(virtualFiles)) {
91-
extensions.codePlugins.push(
92-
createVirtualModulePlugin({
93-
namespace,
94-
loadContent: () => {
95-
return {
96-
contents,
97-
loader: 'js',
98-
resolveDir: projectSourceRoot,
99-
};
100-
},
101-
}),
102-
);
103-
}
104-
}
105-
106-
const { watch, tsConfig } = normalizedOptions;
182+
const finalExtensions = prepareBuildExtensions(
183+
virtualFiles,
184+
normalizedOptions.projectSourceRoot,
185+
extensions,
186+
);
107187

108188
// Prepare and run the application build
109189
const applicationBuildOptions = {
110-
// Base options
111190
...buildTargetOptions,
112-
watch,
113-
tsConfig,
114-
// Runner specific
115191
...runnerBuildOptions,
192+
watch: normalizedOptions.watch,
193+
tsConfig: normalizedOptions.tsConfig,
116194
} satisfies ApplicationBuilderInternalOptions;
117195

118-
for await (const buildResult of buildApplicationInternal(
119-
applicationBuildOptions,
120-
context,
121-
extensions,
122-
)) {
123-
if (buildResult.kind === ResultKind.Failure) {
124-
yield { success: false };
125-
continue;
126-
} else if (
127-
buildResult.kind !== ResultKind.Full &&
128-
buildResult.kind !== ResultKind.Incremental
129-
) {
130-
assert.fail(
131-
'A full and/or incremental build result is required from the application builder.',
132-
);
133-
}
134-
135-
assert(buildResult.files, 'Builder did not provide result files.');
136-
137-
// Pass the build artifacts to the executor
138-
yield* executor.execute(buildResult);
139-
}
196+
yield* runBuildAndTest(executor, applicationBuildOptions, context, finalExtensions);
140197
}

0 commit comments

Comments
 (0)