diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts index 00a17ccc453e..695001e363f0 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation.ts @@ -9,8 +9,14 @@ import type * as ng from '@angular/compiler-cli'; import type { PartialMessage } from 'esbuild'; import type ts from 'typescript'; +import { isMainThread } from 'node:worker_threads'; import { convertTypeScriptDiagnostic } from '../../esbuild/angular/diagnostics'; -import { profileAsync, profileSync } from '../../esbuild/profiling'; +import { + logCumulativeDurations, + profileAsync, + profileSync, + resetCumulativeDurations, +} from '../../esbuild/profiling'; import type { AngularHostOptions } from '../angular-host'; export interface EmitFileResult { @@ -94,20 +100,39 @@ export abstract class AngularCompilation { // This allows for avoiding the load of typescript in the main thread when using the parallel compilation. const typescript = await AngularCompilation.loadTypescript(); - await profileAsync('NG_DIAGNOSTICS_TOTAL', async () => { - for (const diagnostic of await this.collectDiagnostics(modes)) { - const message = convertTypeScriptDiagnostic(typescript, diagnostic); - if (diagnostic.category === typescript.DiagnosticCategory.Error) { - (result.errors ??= []).push(message); - } else { - (result.warnings ??= []).push(message); + // When running inside a worker (ParallelCompilation), the cumulative duration Map lives in + // this module's memory — the main thread never sees it. So we own the full reset→log + // lifecycle here rather than relying on the main thread to do it. + // On the main thread we skip this to avoid clearing/printing timings accumulated elsewhere. + const flushTimings = this.shouldFlushPerformanceTimings(); + if (flushTimings) { + resetCumulativeDurations(); + } + + try { + await profileAsync('NG_DIAGNOSTICS_TOTAL', async () => { + for (const diagnostic of await this.collectDiagnostics(modes)) { + const message = convertTypeScriptDiagnostic(typescript, diagnostic); + if (diagnostic.category === typescript.DiagnosticCategory.Error) { + (result.errors ??= []).push(message); + } else { + (result.warnings ??= []).push(message); + } } + }); + } finally { + if (flushTimings) { + logCumulativeDurations(); } - }); + } return result; } + protected shouldFlushPerformanceTimings(): boolean { + return !isMainThread; + } + update?(files: Set): Promise; close?(): Promise; diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts new file mode 100644 index 000000000000..be99029c865a --- /dev/null +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts @@ -0,0 +1,148 @@ +/** + * @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 type * as ng from '@angular/compiler-cli'; +import type { PartialMessage } from 'esbuild'; +import type ts from 'typescript'; +import * as diagnosticsModule from '../../esbuild/angular/diagnostics'; +import * as profilingModule from '../../esbuild/profiling'; +import type { AngularHostOptions } from '../angular-host'; +import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation'; + +/** + * Minimal stub for the TypeScript module: only DiagnosticCategory is accessed in diagnoseFiles. + */ +const MOCK_TYPESCRIPT = { + DiagnosticCategory: { Error: 1, Warning: 0, Message: 2, Suggestion: 3 }, +} as unknown as typeof ts; + +/** Concrete subclass used to control collectDiagnostics behaviour in tests. */ +class ConcreteCompilation extends AngularCompilation { + private diagnostics_: ts.Diagnostic[] = []; + private throwError_: Error | undefined; + + setDiagnostics(diagnostics: ts.Diagnostic[]): void { + this.diagnostics_ = diagnostics; + } + + setThrowError(error: Error): void { + this.throwError_ = error; + } + + override async initialize( + _tsconfig: string, + _hostOptions: AngularHostOptions, + _compilerOptionsTransformer?: (compilerOptions: ng.CompilerOptions) => ng.CompilerOptions, + ): Promise<{ + affectedFiles: ReadonlySet; + compilerOptions: ng.CompilerOptions; + referencedFiles: readonly string[]; + }> { + return { + affectedFiles: new Set(), + compilerOptions: {} as ng.CompilerOptions, + referencedFiles: [], + }; + } + + override emitAffectedFiles(): Iterable { + return []; + } + + protected override *collectDiagnostics(_modes: DiagnosticModes): Iterable { + if (this.throwError_) { + throw this.throwError_; + } + yield* this.diagnostics_; + } + + protected override shouldFlushPerformanceTimings(): boolean { + return true; + } +} + +describe('AngularCompilation.diagnoseFiles', () => { + let compilation: ConcreteCompilation; + let resetSpy: jasmine.Spy; + let logSpy: jasmine.Spy; + let profileAsyncSpy: jasmine.Spy; + + beforeEach(() => { + compilation = new ConcreteCompilation(); + + resetSpy = spyOn(profilingModule, 'resetCumulativeDurations'); + logSpy = spyOn(profilingModule, 'logCumulativeDurations'); + // Default: transparent passthrough so the real loop still runs. + profileAsyncSpy = spyOn(profilingModule, 'profileAsync').and.callFake( + (_name: string, action: () => Promise): Promise => action(), + ); + spyOn(AngularCompilation, 'loadTypescript').and.resolveTo(MOCK_TYPESCRIPT); + }); + + it('calls resetCumulativeDurations once before profileAsync', async () => { + const callOrder: string[] = []; + resetSpy.and.callFake(() => { + callOrder.push('reset'); + }); + profileAsyncSpy.and.callFake(async (_name: string, action: () => Promise) => { + callOrder.push('profileAsync'); + return action(); + }); + + await compilation.diagnoseFiles(); + + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['reset', 'profileAsync']); + }); + + it('calls logCumulativeDurations once after profileAsync completes, even with no diagnostics', async () => { + const callOrder: string[] = []; + profileAsyncSpy.and.callFake(async (_name: string, action: () => Promise) => { + const result = await action(); + callOrder.push('profileAsync-done'); + return result; + }); + logSpy.and.callFake(() => { + callOrder.push('log'); + }); + + await compilation.diagnoseFiles(); + + expect(logSpy).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['profileAsync-done', 'log']); + }); + + it('returns correct errors and warnings, unaffected by profiling calls', async () => { + const errorDiagnostic = { category: 1 /* Error */ } as ts.Diagnostic; + const warningDiagnostic = { category: 0 /* Warning */ } as ts.Diagnostic; + compilation.setDiagnostics([errorDiagnostic, warningDiagnostic]); + + spyOn(diagnosticsModule, 'convertTypeScriptDiagnostic').and.callFake( + (_ts: typeof ts, diagnostic: ts.Diagnostic): PartialMessage => ({ + text: diagnostic.category === 1 ? 'error message' : 'warning message', + }), + ); + + const result = await compilation.diagnoseFiles(); + + expect(result.errors).toEqual([{ text: 'error message' }]); + expect(result.warnings).toEqual([{ text: 'warning message' }]); + // Profiling hooks ran but did not affect the diagnostic output. + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledTimes(1); + }); + + it('calls logCumulativeDurations even when collectDiagnostics throws, and re-throws the error', async () => { + // logCumulativeDurations sits in a finally block, so it always runs regardless of errors. + compilation.setThrowError(new Error('diagnostics failure')); + + await expectAsync(compilation.diagnoseFiles()).toBeRejectedWithError('diagnostics failure'); + + expect(logSpy).toHaveBeenCalledTimes(1); + }); +});