diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index bd073aa6ffa6..6223f55df929 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -222,6 +222,7 @@ export type UnitTestBuilderOptions = { codeCoverageReporters?: SchemaCodeCoverageReporter[]; debug?: boolean; exclude?: string[]; + filter?: string; include?: string[]; progress?: boolean; providersFile?: string; diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 0b4f183bddbe..440ded3d20b7 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -43,7 +43,7 @@ export async function normalizeOptions( const buildTargetSpecifier = options.buildTarget ?? `::development`; const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); - const { tsConfig, runner, browsers, progress } = options; + const { tsConfig, runner, browsers, progress, filter } = options; return { // Project/workspace information @@ -55,6 +55,7 @@ export async function normalizeOptions( buildTarget, include: options.include ?? ['**/*.spec.ts'], exclude: options.exclude, + filter, runnerName: runner, codeCoverage: options.codeCoverage ? { diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts index 195a20eede37..1e47dcc3f632 100644 --- a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts @@ -8,7 +8,7 @@ import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; import type { ApplicationBuilderInternalOptions } from '../../../application/options'; -import type { KarmaBuilderOptions } from '../../../karma'; +import type { KarmaBuilderOptions, KarmaBuilderTransformsOptions } from '../../../karma'; import { NormalizedUnitTestBuilderOptions } from '../../options'; import type { TestExecutor } from '../api'; @@ -74,9 +74,31 @@ export class KarmaExecutor implements TestExecutor { aot: buildTargetOptions.aot, }; + const transformOptions = { + karmaOptions: (options) => { + if (unitTestOptions.filter) { + let filter = unitTestOptions.filter; + if (filter[0] === '/' && filter.at(-1) === '/') { + this.context.logger.warn( + 'The `--filter` option is always a regular expression.' + + 'Leading and trailing `/` are not required and will be ignored.', + ); + } else { + filter = `/${filter}/`; + } + + options.client ??= {}; + options.client.args ??= []; + options.client.args.push('--grep', filter); + } + + return options; + }, + } satisfies KarmaBuilderTransformsOptions; + const { execute } = await import('../../../karma'); - yield* execute(karmaOptions, context); + yield* execute(karmaOptions, context, transformOptions); } async [Symbol.asyncDispose](): Promise { 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 6dec1f546004..acba6463525a 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 @@ -187,6 +187,7 @@ export class VitestExecutor implements TestExecutor { project: ['base', this.projectName], name: 'base', include: [], + testNamePattern: this.options.filter, reporters: reporters ?? ['default'], watch, coverage: generateCoverageOption(codeCoverage), diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 7c73433ed15b..c8018b650344 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -41,6 +41,10 @@ }, "description": "Globs of files to exclude, relative to the project root." }, + "filter": { + "type": "string", + "description": "Specifies a regular expression pattern to match against test suite and test names. Only tests with a name matching the pattern will be executed. For example, `^App` will run only tests in suites beginning with 'App'." + }, "watch": { "type": "boolean", "description": "Re-run tests when source files change. Defaults to `true` in TTY environments and `false` otherwise." diff --git a/packages/angular/build/src/builders/unit-test/tests/options/filter_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/filter_spec.ts new file mode 100644 index 000000000000..abcfe5976f94 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/filter_spec.ts @@ -0,0 +1,62 @@ +/** + * @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 { execute } from '../../builder'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "filter"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + + await harness.writeFiles({ + 'src/app/pass.spec.ts': ` + describe('Passing Suite', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + }); + `, + 'src/app/fail.spec.ts': ` + describe('Failing Suite', () => { + it('should fail', () => { + expect(true).toBe(false); + }); + }); + `, + }); + }); + + it('should only run tests that match the filter regex', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + // This filter should only match the 'should pass' test + filter: 'pass$', + }); + + const { result } = await harness.executeOnce(); + // The overall result should be success because the failing test was filtered out. + expect(result?.success).toBe(true); + }); + + it('should run all tests when no filter is provided', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + // The overall result should be failure because the failing test was included. + expect(result?.success).toBe(false); + }); + }); +});