Skip to content

Commit 5ab4efe

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 6c64560 commit 5ab4efe

1 file changed

Lines changed: 120 additions & 67 deletions

File tree

  • packages/angular/build/src/builders/unit-test

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

Lines changed: 120 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema';
2222

2323
export type { UnitTestBuilderOptions };
2424

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

45140
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}".`);
141+
const runner = await loadTestRunner(normalizedOptions.runnerName);
59142

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

66145
if (runner.isStandalone) {
@@ -73,68 +152,42 @@ export async function* execute(
73152
}
74153

75154
// 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;
155+
let buildTargetOptions: ApplicationBuilderInternalOptions;
156+
try {
157+
buildTargetOptions = (await context.validateOptions(
158+
await context.getTargetOptions(normalizedOptions.buildTarget),
159+
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
160+
)) as unknown as ApplicationBuilderInternalOptions;
161+
} catch (e) {
162+
assertIsError(e);
163+
context.logger.error(
164+
`Could not load build target options for "${normalizedOptions.buildTarget.project}:${normalizedOptions.buildTarget.target}".\n` +
165+
`Please check your 'angular.json' configuration.\n` +
166+
`Error: ${e.message}`,
167+
);
168+
169+
return;
170+
}
80171

81172
// Get runner-specific build options from the hook
82173
const { buildOptions: runnerBuildOptions, virtualFiles } = await runner.getBuildOptions(
83174
normalizedOptions,
84175
buildTargetOptions,
85176
);
86177

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;
178+
const finalExtensions = prepareBuildExtensions(
179+
virtualFiles,
180+
normalizedOptions.projectSourceRoot,
181+
extensions,
182+
);
107183

108184
// Prepare and run the application build
109185
const applicationBuildOptions = {
110-
// Base options
111186
...buildTargetOptions,
112-
watch,
113-
tsConfig,
114-
// Runner specific
115187
...runnerBuildOptions,
188+
watch: normalizedOptions.watch,
189+
tsConfig: normalizedOptions.tsConfig,
116190
} satisfies ApplicationBuilderInternalOptions;
117191

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-
}
192+
yield* runBuildAndTest(executor, applicationBuildOptions, context, finalExtensions);
140193
}

0 commit comments

Comments
 (0)