Skip to content

Commit 7c4e758

Browse files
author
Robby Rabbitman
committed
fix(@angular/build): respect tsconfig customConditions in unit-test builder
The unit-test builder synthesizes an application-builder build from the configured buildTarget. When the buildTarget is @angular/build:ng-packagr, that builder has no `conditions` option and the synthesized application options never carried custom resolve conditions, so library imports were resolved with the default conditions only — diverging from `ng build`, which honors `compilerOptions.customConditions` natively through ng-packagr. Read `compilerOptions.customConditions` from the test tsconfig (following the `extends` chain) and: - forward them as `conditions` to the application build, but only when the buildTarget did not already set `conditions` (preserves application-builder behavior and explicit `conditions: []`), - append them to Vite's `resolve.conditions` so the Vitest runner's own resolver matches the build-time resolution. No schema or public API changes; the new behavior is opt-in via the existing tsconfig field.
1 parent 60481e9 commit 7c4e758

5 files changed

Lines changed: 153 additions & 1 deletion

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ export async function* execute(
327327
progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress,
328328
quiet: normalizedOptions.quiet,
329329
...(normalizedOptions.tsConfig ? { tsConfig: normalizedOptions.tsConfig } : {}),
330+
// The `@angular/build:ng-packagr` builder has no `conditions` option, so
331+
// its synthesized application options never carry custom resolve
332+
// conditions. Backfill from the test tsconfig's
333+
// `compilerOptions.customConditions` so module resolution matches
334+
// `ng build` with `@angular/build:ng-packagr`. The `@angular/build:application` buildTarget already
335+
// exposes `conditions`; only fill in when it wasn't explicitly set.
336+
...((buildTargetOptions as { conditions?: string[] }).conditions === undefined &&
337+
normalizedOptions.customConditions
338+
? { conditions: normalizedOptions.customConditions }
339+
: {}),
330340
} satisfies ApplicationBuilderInternalOptions;
331341

332342
const dumpDirectory = normalizedOptions.dumpVirtualFiles

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { type BuilderContext, targetFromTargetString } from '@angular-devkit/architect';
1010
import { constants, promises as fs } from 'node:fs';
1111
import path from 'node:path';
12+
import ts from 'typescript';
1213
import { normalizeCacheOptions } from '../../utils/normalize-cache';
1314
import { getProjectRootPaths } from '../../utils/project-metadata';
1415
import { isTTY } from '../../utils/tty';
@@ -26,6 +27,29 @@ async function exists(path: string): Promise<boolean> {
2627
}
2728
}
2829

30+
/**
31+
* Reads the `compilerOptions.customConditions` from a tsconfig file, honoring
32+
* the `extends` chain. Returns `undefined` when no conditions are declared.
33+
*/
34+
function readCustomConditionsFromTsConfig(tsConfigPath: string): string[] | undefined {
35+
const { config, error } = ts.readConfigFile(tsConfigPath, (p) => ts.sys.readFile(p));
36+
if (error || !config) {
37+
return undefined;
38+
}
39+
40+
const parsed = ts.parseJsonConfigFileContent(
41+
config,
42+
ts.sys,
43+
path.dirname(tsConfigPath),
44+
/* existingOptions */ undefined,
45+
tsConfigPath,
46+
);
47+
48+
const conditions = parsed.options.customConditions;
49+
50+
return Array.isArray(conditions) && conditions.length > 0 ? [...conditions] : undefined;
51+
}
52+
2953
function normalizeReporterOption(
3054
reporters: unknown[] | undefined,
3155
): [string, Record<string, unknown>][] | undefined {
@@ -89,6 +113,15 @@ export async function normalizeOptions(
89113
watch = true;
90114
}
91115

116+
// Resolve custom package resolution conditions from the test tsconfig so
117+
// library tests get parity with `ng build` (ng-packagr honors
118+
// `compilerOptions.customConditions` natively, but the unit-test builder
119+
// synthesizes an application-builder build that would otherwise drop them).
120+
// The `extends` chain is followed by the TypeScript config parser.
121+
const customConditions = tsConfig
122+
? readCustomConditionsFromTsConfig(path.join(workspaceRoot, tsConfig))
123+
: undefined;
124+
92125
return {
93126
// Project/workspace information
94127
workspaceRoot,
@@ -140,6 +173,7 @@ export async function normalizeOptions(
140173
? true
141174
: path.resolve(workspaceRoot, runnerConfig)
142175
: runnerConfig,
176+
customConditions,
143177
};
144178
}
145179

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ export class VitestExecutor implements TestExecutor {
379379
include,
380380
watch,
381381
isolate: this.options.isolate,
382+
customConditions: this.options.customConditions,
382383
}),
383384
],
384385
};

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ interface VitestConfigPluginOptions {
5555
optimizeDepsInclude: string[];
5656
watch: boolean;
5757
isolate: boolean;
58+
customConditions: string[] | undefined;
5859
}
5960

6061
async function findTestEnvironment(
@@ -156,6 +157,7 @@ export async function createVitestConfigPlugin(
156157
setupFiles,
157158
projectPlugins,
158159
projectSourceRoot,
160+
customConditions,
159161
} = options;
160162

161163
const { mergeConfig } = await import('vitest/config');
@@ -257,7 +259,13 @@ export async function createVitestConfigPlugin(
257259
},
258260
resolve: {
259261
mainFields: ['es2020', 'module', 'main'],
260-
conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])],
262+
conditions: [
263+
'es2015',
264+
'es2020',
265+
'module',
266+
...(browser ? ['browser'] : []),
267+
...(customConditions ?? []),
268+
],
261269
},
262270
};
263271

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 {
10+
setTargetMapping,
11+
setupConditionImport,
12+
} from '../../../../../../../../modules/testing/builder/src/dev_prod_mode';
13+
import { execute } from '../../builder';
14+
import {
15+
BASE_OPTIONS,
16+
describeBuilder,
17+
UNIT_TEST_BUILDER_INFO,
18+
setupApplicationTarget,
19+
} from '../setup';
20+
21+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
22+
describe('Behavior: "customConditions"', () => {
23+
const GOOD_TARGET = './src/good.js';
24+
const BAD_TARGET = './src/bad.js';
25+
26+
beforeEach(async () => {
27+
setupApplicationTarget(harness);
28+
await setupConditionImport(harness);
29+
30+
// The spec file imports the conditionally-resolved module and only passes
31+
// when it resolves to `good.ts`. If `compilerOptions.customConditions`
32+
// from the test tsconfig is not honored, resolution falls through to
33+
// `bad.ts` and the assertion fails.
34+
await harness.writeFile(
35+
'src/app/conditions.spec.ts',
36+
`
37+
import { VALUE } from '#target';
38+
describe('custom conditions', () => {
39+
it('should resolve through the test tsconfig customConditions', () => {
40+
expect(VALUE).toBe('good-value');
41+
});
42+
});
43+
`,
44+
);
45+
46+
// Ensure good/bad sources are reachable by the spec compilation and that
47+
// bundler-mode resolution is enabled so `#target` is resolved via the
48+
// package.json imports map.
49+
await harness.modifyFile('src/tsconfig.spec.json', (content) => {
50+
const tsConfig = JSON.parse(content);
51+
tsConfig.compilerOptions ??= {};
52+
tsConfig.compilerOptions.moduleResolution = 'bundler';
53+
tsConfig.files ??= [];
54+
tsConfig.files.push('good.ts', 'bad.ts', 'wrong.ts');
55+
56+
return JSON.stringify(tsConfig);
57+
});
58+
});
59+
60+
it('uses tsconfig customConditions when buildTarget has none', async () => {
61+
// Map `#target` so only the `staging` condition resolves to the good
62+
// target. The unit-test builder must read `customConditions` from the
63+
// test tsconfig and forward them to the application build, otherwise
64+
// resolution falls back to the `default` entry.
65+
await setTargetMapping(harness, {
66+
staging: GOOD_TARGET,
67+
default: BAD_TARGET,
68+
});
69+
70+
await harness.modifyFile('src/tsconfig.spec.json', (content) => {
71+
const tsConfig = JSON.parse(content);
72+
tsConfig.compilerOptions ??= {};
73+
tsConfig.compilerOptions.customConditions = ['staging'];
74+
75+
return JSON.stringify(tsConfig);
76+
});
77+
78+
harness.useTarget('test', { ...BASE_OPTIONS });
79+
80+
const { result } = await harness.executeOnce();
81+
expect(result?.success).toBeTrue();
82+
});
83+
84+
it('does not apply unrelated conditions when tsconfig declares none', async () => {
85+
// Same mapping but no `customConditions` declared in the tsconfig: the
86+
// `staging` entry must NOT be selected, resolution must land on the
87+
// `default` (bad) target, and the spec assertion must fail.
88+
await setTargetMapping(harness, {
89+
staging: GOOD_TARGET,
90+
default: BAD_TARGET,
91+
});
92+
93+
harness.useTarget('test', { ...BASE_OPTIONS });
94+
95+
const { result } = await harness.executeOnce();
96+
expect(result?.success).toBeFalse();
97+
});
98+
});
99+
});

0 commit comments

Comments
 (0)