Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/angular/build/src/builders/unit-test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions packages/angular/build/src/builders/unit-test/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,29 @@ async function exists(path: string): Promise<boolean> {
}
}

/**
* 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;
}
Comment on lines +34 to +51
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for reading and parsing the TypeScript configuration can be simplified by using ts.getParsedCommandLineOfConfigFile. This high-level API handles reading the file and resolving the extends chain in a single call, making the code more concise. Additionally, while ignoring parsing errors (available in parsed.errors) is acceptable for this optional feature, logging them could help users debug issues with their tsconfig configuration.

function readCustomConditionsFromTsConfig(tsConfigPath: string): string[] | undefined {
  const parsed = ts.getParsedCommandLineOfConfigFile(tsConfigPath, {}, ts.sys);
  const conditions = parsed?.options.customConditions;

  return Array.isArray(conditions) && conditions.length > 0 ? [...conditions] : undefined;
}
References
  1. When refactoring code that handles configuration properties, ensure that any implicit type validation previously performed is still maintained, especially if the configuration is guaranteed to be type-safe by an upstream process.


function normalizeReporterOption(
reporters: unknown[] | undefined,
): [string, Record<string, unknown>][] | undefined {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -140,6 +178,7 @@ export async function normalizeOptions(
? true
: path.resolve(workspaceRoot, runnerConfig)
: runnerConfig,
customConditions,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export class VitestExecutor implements TestExecutor {
include,
watch,
isolate: this.options.isolate,
customConditions: this.options.customConditions,
}),
],
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface VitestConfigPluginOptions {
optimizeDepsInclude: string[];
watch: boolean;
isolate: boolean;
customConditions: string[] | undefined;
}

async function findTestEnvironment(
Expand Down Expand Up @@ -156,6 +157,7 @@ export async function createVitestConfigPlugin(
setupFiles,
projectPlugins,
projectSourceRoot,
customConditions,
} = options;

const { mergeConfig } = await import('vitest/config');
Expand Down Expand Up @@ -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 ?? []),
],
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading