Skip to content

Commit 7761180

Browse files
committed
refactor(@angular/build): default tsConfig option in unit-test builder
The unit-test builder is updated to streamline the initial configuration by intelligently defaulting the `tsConfig` option. If the `tsConfig` option is not provided in the project's configuration, the builder now checks for the existence of a `tsconfig.spec.json` file in the project's root. If found, it is used automatically. This removes the need for boilerplate `tsConfig` configuration in `angular.json` for the majority of projects. Additionally, a validation check has been added to ensure that if a `tsConfig` path is specified, the file must exist. An actionable error is thrown if it is not found, preventing downstream build failures. The builder's schema has been updated to reflect these changes, making the `tsConfig` option optional and documenting the new behavior.
1 parent c885652 commit 7761180

File tree

7 files changed

+59
-35
lines changed

7 files changed

+59
-35
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export type UnitTestBuilderOptions = {
232232
reporters?: SchemaReporter[];
233233
runner: Runner;
234234
setupFiles?: string[];
235-
tsConfig: string;
235+
tsConfig?: string;
236236
watch?: boolean;
237237
};
238238

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,10 +296,13 @@ export async function* execute(
296296
...buildTargetOptions,
297297
...runnerBuildOptions,
298298
watch: normalizedOptions.watch,
299-
tsConfig: normalizedOptions.tsConfig,
300299
progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress,
301300
} satisfies ApplicationBuilderInternalOptions;
302301

302+
if (normalizedOptions.tsConfig) {
303+
applicationBuildOptions.tsConfig = normalizedOptions.tsConfig;
304+
}
305+
303306
const dumpDirectory = normalizedOptions.dumpVirtualFiles
304307
? path.join(normalizedOptions.cacheOptions.path, 'unit-test', 'output-files')
305308
: undefined;

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { type BuilderContext, targetFromTargetString } from '@angular-devkit/architect';
10+
import { constants, promises as fs } from 'node:fs';
1011
import path from 'node:path';
1112
import { normalizeCacheOptions } from '../../utils/normalize-cache';
1213
import { getProjectRootPaths } from '../../utils/project-metadata';
@@ -15,6 +16,16 @@ import type { Schema as UnitTestBuilderOptions } from './schema';
1516

1617
export type NormalizedUnitTestBuilderOptions = Awaited<ReturnType<typeof normalizeOptions>>;
1718

19+
async function exists(path: string): Promise<boolean> {
20+
try {
21+
await fs.access(path, constants.F_OK);
22+
23+
return true;
24+
} catch {
25+
return false;
26+
}
27+
}
28+
1829
function normalizeReporterOption(
1930
reporters: unknown[] | undefined,
2031
): [string, Record<string, unknown>][] | undefined {
@@ -43,7 +54,21 @@ export async function normalizeOptions(
4354
const buildTargetSpecifier = options.buildTarget ?? `::development`;
4455
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
4556

46-
const { tsConfig, runner, browsers, progress, filter } = options;
57+
const { runner, browsers, progress, filter } = options;
58+
59+
let tsConfig = options.tsConfig;
60+
if (tsConfig) {
61+
const fullTsConfigPath = path.join(workspaceRoot, tsConfig);
62+
if (!(await exists(fullTsConfigPath))) {
63+
throw new Error(`The specified tsConfig file '${tsConfig}' does not exist.`);
64+
}
65+
} else {
66+
const tsconfigSpecPath = path.join(projectRoot, 'tsconfig.spec.json');
67+
if (await exists(tsconfigSpecPath)) {
68+
// The application builder expects a path relative to the workspace root.
69+
tsConfig = path.relative(workspaceRoot, tsconfigSpecPath);
70+
}
71+
}
4772

4873
return {
4974
// Project/workspace information

packages/angular/build/src/builders/unit-test/runners/karma/executor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class KarmaExecutor implements TestExecutor {
3939
)) as unknown as ApplicationBuilderInternalOptions;
4040

4141
const karmaOptions: KarmaBuilderOptions = {
42-
tsConfig: unitTestOptions.tsConfig,
42+
tsConfig: unitTestOptions.tsConfig ?? buildTargetOptions.tsConfig,
4343
polyfills: buildTargetOptions.polyfills,
4444
assets: buildTargetOptions.assets,
4545
scripts: buildTargetOptions.scripts,

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

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,7 @@ export async function getVitestBuildOptions(
6363
options: NormalizedUnitTestBuilderOptions,
6464
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
6565
): Promise<RunnerOptions> {
66-
const {
67-
workspaceRoot,
68-
projectSourceRoot,
69-
include,
70-
exclude = [],
71-
watch,
72-
tsConfig,
73-
providersFile,
74-
} = options;
66+
const { workspaceRoot, projectSourceRoot, include, exclude = [], watch, providersFile } = options;
7567

7668
// Find test files
7769
const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot);
@@ -108,7 +100,6 @@ export async function getVitestBuildOptions(
108100
sourceMap: { scripts: true, vendor: false, styles: false },
109101
outputHashing: adjustOutputHashing(baseBuildOptions.outputHashing),
110102
optimization: false,
111-
tsConfig,
112103
entryPoints,
113104
externalDependencies: ['vitest', '@vitest/browser/context'],
114105
};

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
},
1212
"tsConfig": {
1313
"type": "string",
14-
"description": "The path to the TypeScript configuration file, relative to the workspace root."
14+
"description": "The path to the TypeScript configuration file, relative to the workspace root. Defaults to `tsconfig.spec.json` in the project root if it exists. If not specified and the default does not exist, the `tsConfig` from the specified `buildTarget` will be used."
1515
},
1616
"runner": {
1717
"type": "string",
@@ -188,5 +188,5 @@
188188
}
189189
},
190190
"additionalProperties": false,
191-
"required": ["buildTarget", "tsConfig", "runner"]
191+
"required": ["buildTarget", "runner"]
192192
}

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

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,48 @@ import {
1212
describeBuilder,
1313
UNIT_TEST_BUILDER_INFO,
1414
setupApplicationTarget,
15+
expectLog,
1516
} from '../setup';
1617

1718
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18-
xdescribe('Option: "tsConfig"', () => {
19+
describe('Option: "tsConfig"', () => {
1920
beforeEach(async () => {
2021
setupApplicationTarget(harness);
2122
});
2223

23-
it('should fail when tsConfig is not provided', async () => {
24+
it('should use "tsconfig.spec.json" by default when it exists', async () => {
2425
const { tsConfig, ...rest } = BASE_OPTIONS;
25-
harness.useTarget('test', rest as any);
26+
harness.useTarget('test', rest);
2627

27-
await expectAsync(harness.executeOnce()).toBeRejectedWithError(/"tsConfig" is required/);
28+
// Create tsconfig.spec.json
29+
await harness.writeFile(
30+
'tsconfig.spec.json',
31+
`{ "extends": "./tsconfig.json", "compilerOptions": { "types": ["jasmine"] }, "include": ["src/**/*.ts"] }`,
32+
);
33+
34+
const { result } = await harness.executeOnce();
35+
expect(result?.success).toBeTrue();
36+
// TODO: Add expectation that the file was used.
2837
});
2938

30-
it('should fail when tsConfig is empty', async () => {
31-
harness.useTarget('test', {
32-
...BASE_OPTIONS,
33-
tsConfig: '',
34-
});
39+
it('should use build target tsConfig when "tsconfig.spec.json" does not exist', async () => {
40+
const { tsConfig, ...rest } = BASE_OPTIONS;
41+
harness.useTarget('test', rest);
3542

36-
await expectAsync(harness.executeOnce()).toBeRejectedWithError(
37-
/must NOT have fewer than 1 characters/,
38-
);
43+
// The build target tsconfig is not setup to build the tests and should fail
44+
const { result } = await harness.executeOnce();
45+
expect(result?.success).toBeFalse();
3946
});
4047

41-
it('should fail when tsConfig does not exist', async () => {
48+
it('should fail when user specified tsConfig does not exist', async () => {
4249
harness.useTarget('test', {
4350
...BASE_OPTIONS,
44-
tsConfig: 'src/tsconfig.spec.json',
51+
tsConfig: 'random/tsconfig.spec.json',
4552
});
4653

47-
const { result, error } = await harness.executeOnce({ outputLogsOnFailure: false });
48-
expect(result).toBeUndefined();
49-
expect(error?.message).toMatch(
50-
`The specified tsConfig file "src/tsconfig.spec.json" does not exist.`,
51-
);
54+
const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false });
55+
expect(result?.success).toBeFalse();
56+
expectLog(logs, `The specified tsConfig file 'random/tsconfig.spec.json' does not exist.`);
5257
});
5358
});
5459
});

0 commit comments

Comments
 (0)