From 34a4902d2839ea1f35aa28363756319c8190abe2 Mon Sep 17 00:00:00 2001 From: Robby Rabbitman Date: Sat, 23 May 2026 13:58:09 +0200 Subject: [PATCH] fix(@angular/build): respect tsconfig customConditions in unit-test builder The unit-test builder synthesizes an application-builder build from the configured buildTarget. The application builder forwards `conditions` into esbuild's `build.initialOptions.conditions`, and the Angular compiler plugin in turn assigns that array onto the in-plugin TypeScript program's `compilerOptions.customConditions`. When the buildTarget is `@angular/build:ng-packagr`, the synthesized application options never set `conditions` (ng-packagr has no such schema field; it honors `compilerOptions.customConditions` natively at the tsconfig level instead). As a result both esbuild and the TypeScript program resolve with default conditions only, diverging from `ng build` and silently breaking monorepo setups that use `customConditions` to redirect workspace library imports to local sources during development. Read `compilerOptions.customConditions` from the test tsconfig (following the `extends` chain via `ts.parseJsonConfigFileContent`) and: - forward them as `conditions` to the synthesized application build, but only when the buildTarget did not already set `conditions` (preserves application-builder behavior including explicit `conditions: []` and user-supplied lists), and - append them to Vite's `resolve.conditions` so the Vitest runner's own resolver matches the build-time resolution. This aligns esbuild, the compiler plugin's TypeScript program, and Vitest on the same condition set without introducing new options or schema fields; the new behavior is opt-in via the existing tsconfig field. --- .../build/src/builders/unit-test/builder.ts | 16 +++ .../build/src/builders/unit-test/options.ts | 39 ++++++++ .../unit-test/runners/vitest/executor.ts | 1 + .../unit-test/runners/vitest/plugins.ts | 10 +- .../tests/options/conditions_spec.ts | 99 +++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/angular/build/src/builders/unit-test/tests/options/conditions_spec.ts diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index 542f5f978b90..8ef813dd6816 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -327,6 +327,22 @@ export async function* execute( progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress, quiet: normalizedOptions.quiet, ...(normalizedOptions.tsConfig ? { tsConfig: normalizedOptions.tsConfig } : {}), + // The `@angular/build:ng-packagr` builder has no `conditions` option, so + // its synthesized application options never carry custom resolve + // conditions. The application builder forwards `conditions` into + // esbuild, and the Angular compiler plugin reuses esbuild's conditions + // for the in-plugin TypeScript program; leaving `conditions` unset + // therefore makes both esbuild and TypeScript resolve with default + // conditions only, diverging from `ng build` via + // `@angular/build:ng-packagr` which honors + // `compilerOptions.customConditions` natively. Backfill from the test + // tsconfig's `compilerOptions.customConditions` to realign all + // resolvers. The `@angular/build:application` buildTarget already + // exposes `conditions`; only fill in when it wasn't explicitly set. + ...((buildTargetOptions as { conditions?: string[] }).conditions === undefined && + normalizedOptions.customConditions + ? { conditions: normalizedOptions.customConditions } + : {}), } satisfies ApplicationBuilderInternalOptions; const dumpDirectory = normalizedOptions.dumpVirtualFiles diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 5cd04c4ca7bf..64d33d022744 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -9,6 +9,7 @@ import { type BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; import { constants, promises as fs } from 'node:fs'; import path from 'node:path'; +import ts from 'typescript'; import { normalizeCacheOptions } from '../../utils/normalize-cache'; import { getProjectRootPaths } from '../../utils/project-metadata'; import { isTTY } from '../../utils/tty'; @@ -26,6 +27,29 @@ async function exists(path: string): Promise { } } +/** + * Reads the `compilerOptions.customConditions` from a tsconfig file, honoring + * the `extends` chain. Returns `undefined` when no conditions are declared. + */ +function readCustomConditionsFromTsConfig(tsConfigPath: string): string[] | undefined { + const { config, error } = ts.readConfigFile(tsConfigPath, (p) => ts.sys.readFile(p)); + if (error || !config) { + return undefined; + } + + const parsed = ts.parseJsonConfigFileContent( + config, + ts.sys, + path.dirname(tsConfigPath), + /* existingOptions */ undefined, + tsConfigPath, + ); + + const conditions = parsed.options.customConditions; + + return Array.isArray(conditions) && conditions.length > 0 ? [...conditions] : undefined; +} + function normalizeReporterOption( reporters: unknown[] | undefined, ): [string, Record][] | undefined { @@ -89,6 +113,20 @@ export async function normalizeOptions( watch = true; } + // Resolve custom package resolution conditions from the test tsconfig so + // library tests get parity with `ng build` via `@angular/build:ng-packagr`, + // which honors `compilerOptions.customConditions` natively. The unit-test + // builder synthesizes an application-builder build whose `conditions` then + // feed esbuild's `build.initialOptions.conditions`; the Angular compiler + // plugin assigns those esbuild conditions onto `compilerOptions.customConditions` + // for the in-plugin TypeScript program. Forwarding the tsconfig values here + // therefore aligns esbuild, the in-plugin TS program, and Vitest's resolver + // on the same condition set. The `extends` chain is followed by the + // TypeScript config parser. + const customConditions = tsConfig + ? readCustomConditionsFromTsConfig(path.join(workspaceRoot, tsConfig)) + : undefined; + return { // Project/workspace information workspaceRoot, @@ -140,6 +178,7 @@ export async function normalizeOptions( ? true : path.resolve(workspaceRoot, runnerConfig) : runnerConfig, + customConditions, }; } diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index 0a7a3c7ea63b..ffef02bd8d79 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -379,6 +379,7 @@ export class VitestExecutor implements TestExecutor { include, watch, isolate: this.options.isolate, + customConditions: this.options.customConditions, }), ], }; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index eb3d7d106ab4..6af69a5c63f6 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -55,6 +55,7 @@ interface VitestConfigPluginOptions { optimizeDepsInclude: string[]; watch: boolean; isolate: boolean; + customConditions: string[] | undefined; } async function findTestEnvironment( @@ -156,6 +157,7 @@ export async function createVitestConfigPlugin( setupFiles, projectPlugins, projectSourceRoot, + customConditions, } = options; const { mergeConfig } = await import('vitest/config'); @@ -257,7 +259,13 @@ export async function createVitestConfigPlugin( }, resolve: { mainFields: ['es2020', 'module', 'main'], - conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])], + conditions: [ + 'es2015', + 'es2020', + 'module', + ...(browser ? ['browser'] : []), + ...(customConditions ?? []), + ], }, }; diff --git a/packages/angular/build/src/builders/unit-test/tests/options/conditions_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/conditions_spec.ts new file mode 100644 index 000000000000..8bcb7170db8f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/conditions_spec.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + setTargetMapping, + setupConditionImport, +} from '../../../../../../../../modules/testing/builder/src/dev_prod_mode'; +import { execute } from '../../builder'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "customConditions"', () => { + const GOOD_TARGET = './src/good.js'; + const BAD_TARGET = './src/bad.js'; + + beforeEach(async () => { + setupApplicationTarget(harness); + await setupConditionImport(harness); + + // The spec file imports the conditionally-resolved module and only passes + // when it resolves to `good.ts`. If `compilerOptions.customConditions` + // from the test tsconfig is not honored, resolution falls through to + // `bad.ts` and the assertion fails. + await harness.writeFile( + 'src/app/conditions.spec.ts', + ` + import { VALUE } from '#target'; + describe('custom conditions', () => { + it('should resolve through the test tsconfig customConditions', () => { + expect(VALUE).toBe('good-value'); + }); + }); + `, + ); + + // Ensure good/bad sources are reachable by the spec compilation and that + // bundler-mode resolution is enabled so `#target` is resolved via the + // package.json imports map. + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.compilerOptions ??= {}; + tsConfig.compilerOptions.moduleResolution = 'bundler'; + tsConfig.files ??= []; + tsConfig.files.push('good.ts', 'bad.ts', 'wrong.ts'); + + return JSON.stringify(tsConfig); + }); + }); + + it('uses tsconfig customConditions when buildTarget has none', async () => { + // Map `#target` so only the `staging` condition resolves to the good + // target. The unit-test builder must read `customConditions` from the + // test tsconfig and forward them to the application build, otherwise + // resolution falls back to the `default` entry. + await setTargetMapping(harness, { + staging: GOOD_TARGET, + default: BAD_TARGET, + }); + + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.compilerOptions ??= {}; + tsConfig.compilerOptions.customConditions = ['staging']; + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('test', { ...BASE_OPTIONS }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('does not apply unrelated conditions when tsconfig declares none', async () => { + // Same mapping but no `customConditions` declared in the tsconfig: the + // `staging` entry must NOT be selected, resolution must land on the + // `default` (bad) target, and the spec assertion must fail. + await setTargetMapping(harness, { + staging: GOOD_TARGET, + default: BAD_TARGET, + }); + + harness.useTarget('test', { ...BASE_OPTIONS }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + }); + }); +});