diff --git a/packages/angular/build/src/builders/dev-server/vite/index.ts b/packages/angular/build/src/builders/dev-server/vite/index.ts index 8c8f23c3a339..4800559a2171 100644 --- a/packages/angular/build/src/builders/dev-server/vite/index.ts +++ b/packages/angular/build/src/builders/dev-server/vite/index.ts @@ -186,8 +186,7 @@ export async function* serveWithVite( // Always enable JIT linking to support applications built with and without AOT. // In a development environment the additional scope information does not // have a negative effect unlike production where final output size is relevant. - { sourcemap: true, jit: true, thirdPartySourcemaps }, - 1, + { sourcemap: true, jit: true, thirdPartySourcemaps, maxConcurrency: 1 }, ); // The index HTML path will be updated from the build results if provided by the builder diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 92af13dfe497..00c82b328085 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -20,11 +20,7 @@ import type { import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; -import { - hasCustomMaxWorkers, - maxWorkers, - useTypeChecking, -} from '../../../utils/environment-options'; +import { maxTransformWorkers, useTypeChecking } from '../../../utils/environment-options'; import { calculateHash, initializeHash } from '../../../utils/hash'; import { AngularHostOptions } from '../../angular/angular-host'; import { AngularCompilation, DiagnosticModes } from '../../angular/compilation'; @@ -102,18 +98,14 @@ export function createCompilerPlugin( }); } } - // During bundling, esbuild runs its own multi-threaded Go process across all available cores. - // Unless explicitly configured via NG_BUILD_MAX_WORKERS, cap transformation concurrency to at - // most 4 to prevent CPU contention during bundling. - const maxTransformWorkers = hasCustomMaxWorkers ? maxWorkers : Math.min(4, maxWorkers); const javascriptTransformer = new JavaScriptTransformer( { sourcemap: !!pluginOptions.sourcemap, thirdPartySourcemaps: pluginOptions.thirdPartySourcemaps, advancedOptimizations: pluginOptions.advancedOptimizations, jit: pluginOptions.jit || pluginOptions.includeTestMetadata, + maxConcurrency: maxTransformWorkers, }, - maxTransformWorkers, cacheStore?.createCache('jstransformer'), ); diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index 7cd4ffd1bf3f..2917222a3277 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -118,6 +118,12 @@ export interface JavaScriptTransformerOptions { thirdPartySourcemaps?: boolean; advancedOptimizations?: boolean; jit?: boolean; + + /** + * The maximum number of concurrent transformation operations. + * When omitted, concurrency defaults to the available worker pool threads. + */ + maxConcurrency?: number; } /** @@ -146,7 +152,7 @@ export interface TransformOptions { */ export class JavaScriptTransformer { #workerPool: WorkerPool | undefined; - #commonOptions: Required; + #commonOptions: Required>; #fileCacheKeyBase: Uint8Array; /** Queue of pending transformation tasks waiting for an active concurrency slot. */ @@ -155,16 +161,21 @@ export class JavaScriptTransformer { /** Current count of actively executing transformation tasks. */ #activeTasks = 0; - /** Maximum number of transformation tasks allowed to execute concurrently. */ - #maxConcurrent: number; + get #maxConcurrency(): number { + return this.options.maxConcurrency ?? (this.#workerPool?.maxThreads || 1); + } constructor( - options: JavaScriptTransformerOptions, - readonly maxThreads: number, + private readonly options: JavaScriptTransformerOptions, private readonly cache?: Cache, ) { - // Maintain 2 active tasks per worker thread to keep transformation pipelines fully saturated - this.#maxConcurrent = Math.max(1, maxThreads * 2); + if ( + options.maxConcurrency !== undefined && + (!Number.isInteger(options.maxConcurrency) || options.maxConcurrency < 1) + ) { + throw new RangeError('options.maxConcurrency must be an integer greater than or equal to 1.'); + } + // Extract options to ensure only the named options are serialized and sent to the worker const { sourcemap, @@ -189,7 +200,7 @@ export class JavaScriptTransformer { * @returns A promise resolving to the transformation result. */ async #runWithThrottle(action: () => Promise): Promise { - if (this.#activeTasks >= this.#maxConcurrent) { + if (this.#activeTasks >= this.#maxConcurrency) { await new Promise((resolve, reject) => { this.#pendingTasks.push({ resolve, reject }); }); @@ -216,9 +227,11 @@ export class JavaScriptTransformer { const workerPoolOptions: WorkerPoolOptions = { filename: require.resolve('./javascript-transformer-worker'), - maxThreads: this.maxThreads, - minThreads: this.maxThreads, workerData: this.#commonOptions, + ...(this.options.maxConcurrency !== undefined && { + minThreads: this.options.maxConcurrency, + maxThreads: this.options.maxConcurrency, + }), }; // Prevent passing SSR `--import` (loader-hooks) from parent to child worker. @@ -243,7 +256,7 @@ export class JavaScriptTransformer { return this.#runWithThrottle(async () => { const data = await readFile(filename); - return this.transformData(filename, data, options); + return this.#transform(filename, data, options); }); } @@ -259,6 +272,14 @@ export class JavaScriptTransformer { filename: string, data: string | Uint8Array, options?: TransformOptions, + ): Promise { + return this.#runWithThrottle(() => this.#transform(filename, data, options)); + } + + async #transform( + filename: string, + data: string | Uint8Array, + options?: TransformOptions, ): Promise { let resolvedSideEffects: boolean | undefined; let sideEffectsQueried = false; diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts index b818077be49b..94d671ef2a72 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer_spec.ts @@ -27,13 +27,11 @@ describe('JavaScriptTransformer sourcemaps', () => { } it('should remap correctly when only advanced optimizations are applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -57,13 +55,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should remap correctly when only linking is applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - thirdPartySourcemaps: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -100,14 +96,12 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should defer and chain remapping when both linking and advanced optimizations are applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - thirdPartySourcemaps: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + thirdPartySourcemaps: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -145,13 +139,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should produce a valid sourcemap when no input sourcemap is present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const input = 'export class MyClass { static ɵprov = 42; }'; const result = await transformer.transformData('src/app.js', input, { skipLinker: true }); @@ -166,12 +158,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should remap correctly when coverage instrumentation is applied with an input sourcemap', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -198,13 +188,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should defer and chain remapping when coverage instrumentation and advanced optimizations are applied', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputMap = { version: 3, @@ -231,13 +219,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should accept a Uint8Array input in transformData', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: true, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: true, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export class MyClass { static ɵprov = 42; }', 'utf-8'); const result = await transformer.transformData('src/app.js', inputBuffer, { skipLinker: true }); @@ -251,12 +237,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should strip trailing sourcemap comments from Uint8Array input on fast-path', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( 'console.log("hello");\n//# sourceMappingURL=app.js.map', @@ -271,12 +255,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should return Uint8Array input untouched on fast-path when no sourcemap comment is present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { @@ -287,12 +269,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should return Uint8Array untouched when skipLinker is false but file contains no linker declarations', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('console.log("no linking required");\nconst x = 1;', 'utf-8'); const result = await transformer.transformData('node_modules/my-lib/lib.js', inputBuffer, { @@ -303,12 +283,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker and skip linking for @angular/core and @angular/compiler paths', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); const result = await transformer.transformData( @@ -321,12 +299,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker and skip linking for TypeScript file extensions (.ts, .tsx, .mts, .cts)', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export const ɵɵngDeclareDirective = () => {};', 'utf-8'); @@ -340,12 +316,10 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should not exclude packages with similar prefixes such as @angular/compiler-cli', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + maxConcurrency: 1, + }); const input = ` import * as i0 from "@angular/core"; @@ -371,13 +345,11 @@ describe('JavaScriptTransformer sourcemaps', () => { describe('advanced optimizations fast-path pre-filter', () => { it('should bypass worker and return input buffer directly when no candidate tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( 'function add(a, b) { return a + b; }\nconst result = add(1, 2);', @@ -391,13 +363,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker for standard classes without static properties', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( `export class UserService { @@ -414,13 +384,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker for default exports without static properties or Angular metadata', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from( `export default class UserService { @@ -437,13 +405,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker for classes with static members when no Angular metadata is present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('export class MyComponent { static prop = 42; }', 'utf-8'); const result = await transformer.transformData('src/component.js', inputBuffer, { @@ -454,13 +420,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should dispatch to worker when Angular tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const input = 'export class MyService { static ɵprov = true; }'; const result = await transformer.transformData('src/service.js', input, { skipLinker: true }); @@ -470,13 +434,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should dispatch to worker when decorator tokens are present and sideEffects is false', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); const result = await transformer.transformData('src/class.js', inputBuffer, { @@ -488,13 +450,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should bypass worker and return converted buffer when no candidate tokens are present in string input', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputString = 'function multiply(a, b) { return a * b; }'; const result = await transformer.transformData('src/math.js', inputString, { @@ -505,13 +465,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should dispatch to worker when candidate tokens are present in string input', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); const inputString = 'export class MyService { static ɵprov = true; }'; const result = await transformer.transformData('src/service.js', inputString, { @@ -523,13 +481,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should not query sideEffects when no candidate tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queried = false; const inputBuffer = Buffer.from('function add(a, b) { return a + b; }', 'utf-8'); @@ -546,13 +502,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should not query sideEffects when primary tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queried = false; const input = 'export class MyService { static ɵprov = true; }'; @@ -569,13 +523,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects for @angular/ packages and dispatch to worker when sideEffects is false', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const inputBuffer = Buffer.from('export const foo = someCall();', 'utf-8'); @@ -597,13 +549,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects for decorator tokens and evaluate at most once', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const inputBuffer = Buffer.from('const MyClass = __decorate([], class {});', 'utf-8'); @@ -621,13 +571,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects and wrap decorators when both primary and decorator tokens are present', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const input = ` @@ -650,13 +598,11 @@ describe('JavaScriptTransformer sourcemaps', () => { }); it('should query sideEffects when both primary and decorator tokens are present in Buffer', async () => { - transformer = new JavaScriptTransformer( - { - sourcemap: false, - advancedOptimizations: true, - }, - 1, - ); + transformer = new JavaScriptTransformer({ + sourcemap: false, + advancedOptimizations: true, + maxConcurrency: 1, + }); let queryCount = 0; const inputBuffer = Buffer.from( @@ -678,4 +624,39 @@ describe('JavaScriptTransformer sourcemaps', () => { expect(text).toContain('__decorate'); }); }); + + describe('maxConcurrency', () => { + it('should throw RangeError if maxConcurrency is less than 1', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 0 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: -1 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should throw RangeError if maxConcurrency is not an integer', () => { + expect(() => { + new JavaScriptTransformer({ sourcemap: false, maxConcurrency: 1.5 }); + }).toThrowError( + RangeError, + 'options.maxConcurrency must be an integer greater than or equal to 1.', + ); + }); + + it('should allow omitting maxConcurrency', async () => { + transformer = new JavaScriptTransformer({ sourcemap: false }); + const result = await transformer.transformData('src/app.js', 'const x = 1;', { + skipLinker: true, + }); + expect(result).toBeDefined(); + }); + }); }); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts index 1e225d6b7386..29faf3e7bbae 100644 --- a/packages/angular/build/src/utils/environment-options.ts +++ b/packages/angular/build/src/utils/environment-options.ts @@ -131,6 +131,16 @@ export const hasCustomMaxWorkers = customMaxWorkers !== undefined; */ export const maxWorkers = customMaxWorkers ?? Math.max(availableParallelism() - 1, 1); +/** + * The maximum number of workers to use for JavaScript transformations during bundling. + * Transformation tasks are short-lived, and esbuild concurrently utilizes all CPU cores + * for bundling. To prevent CPU starvation and thread startup overhead, concurrency is + * budgeted to a fraction of available cores, capped at 6, unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxTransformWorkers = + customMaxWorkers ?? Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + /** * When `NG_BUILD_PARALLEL_TS` is set to `0` or `false`, parallel TypeScript compilation is disabled. */ diff --git a/packages/angular/build/src/utils/environment-options_spec.ts b/packages/angular/build/src/utils/environment-options_spec.ts index 0d2ac7fa0c61..4146bec8bec1 100644 --- a/packages/angular/build/src/utils/environment-options_spec.ts +++ b/packages/angular/build/src/utils/environment-options_spec.ts @@ -114,3 +114,70 @@ describe('environment options - maxWorkers', () => { expect(maxWorkers).toBe(8); }); }); + +describe('environment options - maxTransformWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxTransformWorkers to availableParallelism / 4 (bounded between 1 and 6) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(maxTransformWorkers).toBe(expected); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(8); + }); + + it('allows maxTransformWorkers greater than 6 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(32); + }); + + it('supports maxTransformWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(1); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxTransformWorkers: zeroWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(zeroWorkers).toBe(expected); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxTransformWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(expected); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + + expect(maxTransformWorkers).toBe(expected); + }); +});