From 165d52107a37a19b8e3e42d71a3b21f39693cec0 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Fri, 27 Mar 2026 15:21:47 +0530 Subject: [PATCH] fix(@angular/build): trigger test re-run on non-spec file changes in watch mode When non-test files (services, components, etc.) change during watch mode, use Vitest's module graph to find dependent test specifications and include them in the re-run set. Previously only direct .spec.ts file changes triggered test re-runs. Consolidates the file processing into a single loop for clarity. Fixes #32159 --- .../unit-test/runners/vitest/executor.ts | 36 ++++++++------ .../tests/behavior/watch_rebuild_spec.ts | 47 +++++++++++++++++++ 2 files changed, 69 insertions(+), 14 deletions(-) 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 c5b70e9a2487..0929c78b4573 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 @@ -140,8 +140,14 @@ export class VitestExecutor implements TestExecutor { let testResults; if (buildResult.kind === ResultKind.Incremental) { // To rerun tests, Vitest needs the original test file paths, not the output paths. - const modifiedSourceFiles = new Set(); + // Process all modified files in a single loop. + const specsToRerun = []; for (const modifiedFile of [...buildResult.modified, ...buildResult.added]) { + const absoluteOutputFile = this.normalizePath( + path.join(this.options.workspaceRoot, modifiedFile), + ); + vitest.invalidateFile(absoluteOutputFile); + // The `modified` files in the build result are the output paths. // We need to find the original source file path to pass to Vitest. const source = this.entryPointToTestFile.get(modifiedFile); @@ -150,24 +156,26 @@ export class VitestExecutor implements TestExecutor { DebugLogLevel.Verbose, `Mapped output file '${modifiedFile}' to source file '${source}' for re-run.`, ); - modifiedSourceFiles.add(source); + vitest.invalidateFile(source); + const specs = vitest.getModuleSpecifications(source); + if (specs) { + specsToRerun.push(...specs); + } } else { + // For non-test files (e.g., services, components), find dependent test specs + // via Vitest's module graph so that changes to these files trigger test re-runs. this.debugLog( DebugLogLevel.Verbose, `Could not map output file '${modifiedFile}' to a source file. It may not be a test file.`, ); - } - vitest.invalidateFile( - this.normalizePath(path.join(this.options.workspaceRoot, modifiedFile)), - ); - } - - const specsToRerun = []; - for (const file of modifiedSourceFiles) { - vitest.invalidateFile(file); - const specs = vitest.getModuleSpecifications(file); - if (specs) { - specsToRerun.push(...specs); + const specs = vitest.getModuleSpecifications(absoluteOutputFile); + if (specs) { + this.debugLog( + DebugLogLevel.Verbose, + `Found ${specs.length} dependent test specification(s) for non-test file '${absoluteOutputFile}'.`, + ); + specsToRerun.push(...specs); + } } } diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts index 6ff753c7eac7..41256b8f0f08 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/watch_rebuild_spec.ts @@ -20,6 +20,53 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { setupApplicationTarget(harness); }); + it('should re-run tests when a non-spec file changes', async () => { + // Set up a component with a testable value and a spec that checks it + harness.writeFiles({ + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class AppComponent { + title = 'hello'; + }`, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + import { AppComponent } from './app.component'; + describe('AppComponent', () => { + test('should have correct title', () => { + const app = new AppComponent(); + expect(app.title).toBe('hello'); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + // 1. Initial run should succeed + ({ result }) => { + expect(result?.success).toBeTrue(); + + // 2. Modify only the non-spec component file (change the title value) + harness.writeFiles({ + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-root', template: '' }) + export class AppComponent { + title = 'changed'; + }`, + }); + }, + // 3. Test should re-run and fail because the title changed + ({ result }) => { + expect(result?.success).toBeFalse(); + }, + ]); + }); + it('should run tests when a compilation error is fixed and a test failure is introduced simultaneously', async () => { harness.useTarget('test', { ...BASE_OPTIONS,