From 07ca86caf885804f6aa780676eb354f808326f3b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 20 May 2026 11:11:00 -0400 Subject: [PATCH 1/2] refactor(@angular/cli): migrate build and test MCP tools into specialized target strategy handlers Migrate build and unit-test MCP tools from standalone tools into specialized target strategy handlers in the tools/run-target subdirectory. Restrict BuildTargetStrategy to known official builders, parsing execution logs to extract output paths and extending payload metadata. Restrict UnitTestTargetStrategy to known test runner builders, auto-injecting non-interactive one-off watch mode flags and automatically configuring headless options for Karma jasmine suites. Handle all other targets and builders via the GenericTargetStrategy. --- .../tools/run-target/build-target-strategy.ts | 63 +++++++ .../run-target/build-target-strategy_spec.ts | 93 ++++++++++ .../run-target/generic-target-strategy.ts | 33 +--- .../tools/run-target/options-serializer.ts | 47 +++++ .../run-target/options-serializer_spec.ts | 49 +++++ .../mcp/tools/run-target/run-target.ts | 10 +- .../commands/mcp/tools/run-target/strategy.ts | 2 +- .../commands/mcp/tools/run-target/types.ts | 7 +- .../tools/run-target/unit-test-strategy.ts | 66 +++++++ .../run-target/unit-test-strategy_spec.ts | 174 ++++++++++++++++++ tests/e2e/tests/mcp/run-target.ts | 26 ++- 11 files changed, 540 insertions(+), 30 deletions(-) create mode 100644 packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy_spec.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer_spec.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts create mode 100644 packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts new file mode 100644 index 000000000000..1fbd8f83e47a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy.ts @@ -0,0 +1,63 @@ +/** + * @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 { getCommandErrorLogs } from '../../utils'; +import type { McpToolContext } from '../tool-registry'; +import { serializeOptions } from './options-serializer'; +import type { TargetStrategy } from './strategy'; +import type { RunTargetOutput, StrategyExecutionContext } from './types'; + +export class BuildTargetStrategy implements TargetStrategy { + canHandle(targetName: string, builder?: string): boolean { + return ( + targetName === 'build' && + (builder === '@angular-devkit/build-angular:application' || + builder === '@angular-devkit/build-angular:browser' || + builder === '@angular/build:application' || + builder === '@angular-devkit/build-angular:ng-packagr') + ); + } + + async execute( + input: StrategyExecutionContext, + context: McpToolContext, + ): Promise { + const args = ['build', input.projectName]; + if (input.configuration) { + args.push('-c', input.configuration); + } + + args.push(...serializeOptions(input.options)); + + let status: 'success' | 'failure' = 'success'; + let logs: string[]; + + try { + const result = await context.host.executeNgCommand(args, { cwd: input.workspacePath }); + logs = result.logs; + } catch (e) { + status = 'failure'; + logs = getCommandErrorLogs(e); + } + + let outputPath: string | undefined; + for (const line of logs) { + const match = line.match(/Output location: (.*)/); + if (match) { + outputPath = match[1].trim(); + break; + } + } + + return { + status, + logs, + extensions: outputPath ? { outputPath } : undefined, + }; + } +} diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy_spec.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy_spec.ts new file mode 100644 index 000000000000..86909310cd35 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/build-target-strategy_spec.ts @@ -0,0 +1,93 @@ +/** + * @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 { MockHost } from '../../testing/mock-host'; +import { + type MockMcpToolContext, + addProjectToWorkspace, + createMockContext, +} from '../../testing/test-utils'; +import { BuildTargetStrategy } from './build-target-strategy'; + +describe('BuildTargetStrategy', () => { + let mockHost: MockHost; + let mockContext: MockMcpToolContext; + let strategy: BuildTargetStrategy; + + beforeEach(() => { + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; + addProjectToWorkspace(mock.projects, 'my-app'); + strategy = new BuildTargetStrategy(); + }); + + describe('canHandle', () => { + it('should match build target with official builders', () => { + expect(strategy.canHandle('build', '@angular-devkit/build-angular:application')).toBeTrue(); + expect(strategy.canHandle('build', '@angular-devkit/build-angular:browser')).toBeTrue(); + expect(strategy.canHandle('build', '@angular/build:application')).toBeTrue(); + expect(strategy.canHandle('build', '@angular-devkit/build-angular:ng-packagr')).toBeTrue(); + }); + + it('should not match build target with custom builders', () => { + expect(strategy.canHandle('build', 'custom-builder')).toBeFalse(); + expect(strategy.canHandle('build', undefined)).toBeFalse(); + }); + + it('should not match other targets', () => { + expect(strategy.canHandle('test', '@angular-devkit/build-angular:browser')).toBeFalse(); + }); + }); + + describe('execute', () => { + it('should spawn ng build and parse outputPath successfully', async () => { + const buildLogs = ['Build successful!', 'Output location: dist/my-app']; + mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs }); + + const result = await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'build', + targetDefinition: { + builder: '@angular/build:application', + }, + }, + mockContext, + ); + + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['build', 'my-app'], { + cwd: '/test', + }); + expect(result.status).toBe('success'); + expect(result.logs).toEqual(buildLogs); + expect(result.extensions).toEqual({ outputPath: 'dist/my-app' }); + }); + + it('should return undefined outputPath if parsing matches nothing', async () => { + const buildLogs = ['Build successful!']; + mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs }); + + const result = await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'build', + targetDefinition: { + builder: '@angular/build:application', + }, + }, + mockContext, + ); + + expect(result.status).toBe('success'); + expect(result.extensions).toBeUndefined(); + }); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts index 079c30fc0793..f1d66b5597e7 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/generic-target-strategy.ts @@ -8,6 +8,7 @@ import { getCommandErrorLogs } from '../../utils'; import type { McpToolContext } from '../tool-registry'; +import { serializeOptions } from './options-serializer'; import type { TargetStrategy } from './strategy'; import type { RunTargetOutput, StrategyExecutionContext } from './types'; @@ -22,7 +23,7 @@ const BUILT_IN_COMMANDS = new Set([ ]); export class GenericTargetStrategy implements TargetStrategy { - canHandle(target: string, builder?: string): boolean { + canHandle(targetName: string, builder?: string): boolean { return true; // Universal fallback strategy } @@ -30,7 +31,7 @@ export class GenericTargetStrategy implements TargetStrategy { input: StrategyExecutionContext, context: McpToolContext, ): Promise { - if (input.target === 'serve' || input.options?.['watch'] === true) { + if (input.targetName === 'serve' || input.options?.['watch'] === true) { throw new Error( `Watch mode execution (serve target or watch option) is not yet supported by 'run_target'. ` + `Please use the legacy 'devserver.start' / 'devserver.wait_for_build' tools instead.`, @@ -38,10 +39,10 @@ export class GenericTargetStrategy implements TargetStrategy { } const args: string[] = []; - if (BUILT_IN_COMMANDS.has(input.target)) { - args.push(input.target, input.projectName); + if (BUILT_IN_COMMANDS.has(input.targetName)) { + args.push(input.targetName, input.projectName); } else { - args.push('run', `${input.projectName}:${input.target}`); + args.push('run', `${input.projectName}:${input.targetName}`); } if (input.configuration) { @@ -49,32 +50,14 @@ export class GenericTargetStrategy implements TargetStrategy { } let options = input.options; - if (input.target === 'test') { + if (input.targetName === 'test') { options = { ...options, watch: false, }; } - if (options) { - for (const [key, value] of Object.entries(options)) { - if (!/^[a-zA-Z0-9-_]+$/.test(key)) { - throw new Error( - `Invalid option key: '${key}'. Option keys must be alphanumeric, hyphens, or underscores.`, - ); - } - - if (typeof value === 'boolean') { - args.push(value ? `--${key}` : `--no-${key}`); - } else if (Array.isArray(value)) { - for (const item of value) { - args.push(`--${key}=${item}`); - } - } else if (value !== null && value !== undefined) { - args.push(`--${key}=${value}`); - } - } - } + args.push(...serializeOptions(options)); let status: 'success' | 'failure' = 'success'; let logs: string[]; diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer.ts new file mode 100644 index 000000000000..b5f5a7414b97 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer.ts @@ -0,0 +1,47 @@ +/** + * @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 { OptionValue } from './types'; + +/** + * Serializes a Zod-validated options record into standard CLI argument flags. + * Enforces strict regex validation on option keys to prevent flag manipulation. + */ +export function serializeOptions( + options: Record | undefined, + excludeKeys: Set = new Set(), +): string[] { + const args: string[] = []; + if (!options) { + return args; + } + + for (const [key, value] of Object.entries(options)) { + if (excludeKeys.has(key)) { + continue; + } + + if (!/^[a-zA-Z0-9-_]+$/.test(key)) { + throw new Error( + `Invalid option key: '${key}'. Option keys must be alphanumeric, hyphens, or underscores.`, + ); + } + + if (typeof value === 'boolean') { + args.push(value ? `--${key}` : `--no-${key}`); + } else if (Array.isArray(value)) { + for (const item of value) { + args.push(`--${key}=${item}`); + } + } else if (value !== null && value !== undefined) { + args.push(`--${key}=${value}`); + } + } + + return args; +} diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer_spec.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer_spec.ts new file mode 100644 index 000000000000..197a3855799a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/options-serializer_spec.ts @@ -0,0 +1,49 @@ +/** + * @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 { serializeOptions } from './options-serializer'; + +describe('run-target options-serializer', () => { + describe('serializeOptions', () => { + it('should return empty array if options are undefined', () => { + expect(serializeOptions(undefined)).toEqual([]); + }); + + it('should serialize boolean options correctly', () => { + expect(serializeOptions({ fix: true, quiet: false })).toEqual(['--fix', '--no-quiet']); + }); + + it('should serialize string and number options correctly', () => { + expect(serializeOptions({ browsers: 'Chrome', timeout: 5000 })).toEqual([ + '--browsers=Chrome', + '--timeout=5000', + ]); + }); + + it('should serialize array options as multiple occurrences of the flag', () => { + expect(serializeOptions({ include: ['a', 'b'] })).toEqual(['--include=a', '--include=b']); + }); + + it('should ignore excluded keys successfully', () => { + expect(serializeOptions({ watch: true, fix: true }, new Set(['watch']))).toEqual(['--fix']); + }); + + it('should ignore null and undefined values successfully', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(serializeOptions({ empty: null, missing: undefined, fix: true } as any)).toEqual([ + '--fix', + ]); + }); + + it('should throw an error if key is malformed (whitespace or special characters)', () => { + expect(() => serializeOptions({ 'fix --danger': true })).toThrowError( + /Invalid option key: 'fix --danger'/, + ); + }); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/run-target.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/run-target.ts index 0dacdaecb209..8b6f62d8fcba 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/run-target.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/run-target.ts @@ -9,12 +9,14 @@ import { createStructuredContentOutput } from '../../utils'; import { resolveWorkspaceAndProject } from '../../workspace-utils'; import { type McpToolContext, declareTool } from '../tool-registry'; +import { BuildTargetStrategy } from './build-target-strategy'; import { GenericTargetStrategy } from './generic-target-strategy'; import type { TargetStrategy } from './strategy'; import { type RunTargetInput, runTargetInputSchema, runTargetOutputSchema } from './types'; +import { UnitTestTargetStrategy } from './unit-test-strategy'; const FALLBACK_STRATEGY = new GenericTargetStrategy(); -const STRATEGIES: TargetStrategy[] = []; +const STRATEGIES: TargetStrategy[] = [new BuildTargetStrategy(), new UnitTestTargetStrategy()]; export async function runTarget(input: RunTargetInput, context: McpToolContext) { const { workspace, workspacePath, projectName } = await resolveWorkspaceAndProject({ @@ -34,7 +36,8 @@ export async function runTarget(input: RunTargetInput, context: McpToolContext) { workspacePath, projectName, - target: input.target, + targetName: input.target, + targetDefinition, configuration: input.configuration, options: input.options, }, @@ -59,6 +62,9 @@ This is the single, unified interface for executing all project tasks natively. * Mandatory Discovery: You MUST discover available project targets by calling 'list_projects' first. +* Headless Testing: For official builders, the test target automatically runs in headless mode + and disables watch mode to guarantee clean execution. +* Output Paths: For official builders, successful builds return the build directory in 'outputPath' under the extensions metadata. * Watch mode (serve target or watch options) is NOT yet supported in this version of run_target. You MUST use the legacy 'devserver.*' tools for background server lifecycles. `, diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/strategy.ts index 8c149a174f93..d257366e3b0f 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/strategy.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/strategy.ts @@ -11,7 +11,7 @@ import type { RunTargetOutput, StrategyExecutionContext } from './types'; export interface TargetStrategy { /** Whether this strategy is responsible for handling the given target/builder */ - canHandle(target: string, builder?: string): boolean; + canHandle(targetName: string, builder?: string): boolean; /** Executes the target using this strategy */ execute(input: StrategyExecutionContext, context: McpToolContext): Promise; diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/types.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/types.ts index aa0d3a0cf7ca..aa65f6551e24 100644 --- a/packages/angular/cli/src/commands/mcp/tools/run-target/types.ts +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/types.ts @@ -49,7 +49,12 @@ export type RunTargetOutput = z.infer; export interface StrategyExecutionContext { workspacePath: string; projectName: string; - target: string; + targetName: string; + targetDefinition?: { + builder: string; + options?: Record; + configurations?: Record | undefined>; + }; configuration?: string; options?: Record; } diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts new file mode 100644 index 000000000000..77ca5797e71c --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy.ts @@ -0,0 +1,66 @@ +/** + * @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 { getCommandErrorLogs } from '../../utils'; +import type { McpToolContext } from '../tool-registry'; +import { serializeOptions } from './options-serializer'; +import type { TargetStrategy } from './strategy'; +import type { RunTargetOutput, StrategyExecutionContext } from './types'; + +export class UnitTestTargetStrategy implements TargetStrategy { + canHandle(targetName: string, builder?: string): boolean { + return ( + targetName === 'test' && + (builder === '@angular-devkit/build-angular:karma' || + builder === '@angular/build:karma' || + builder === '@angular/build:unit-test') + ); + } + + async execute( + input: StrategyExecutionContext, + context: McpToolContext, + ): Promise { + const args = ['test', input.projectName]; + if (input.configuration) { + args.push('-c', input.configuration); + } + + const builder = input.targetDefinition?.builder; + + if (builder === '@angular/build:unit-test') { + const isKarma = input.targetDefinition?.options?.['runner'] === 'karma'; + if (isKarma) { + args.push('--browsers', 'ChromeHeadless'); + } else { + args.push('--headless', 'true'); + } + } else { + // Default Karma-based builders require explicit ChromeHeadless + args.push('--browsers', 'ChromeHeadless'); + } + + // Force non-interactive one-off execution + args.push('--watch', 'false'); + + args.push(...serializeOptions(input.options, new Set(['watch']))); + + let status: 'success' | 'failure' = 'success'; + let logs: string[]; + + try { + const result = await context.host.executeNgCommand(args, { cwd: input.workspacePath }); + logs = result.logs; + } catch (e) { + status = 'failure'; + logs = getCommandErrorLogs(e); + } + + return { status, logs }; + } +} diff --git a/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts new file mode 100644 index 000000000000..f1467f0002c9 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/run-target/unit-test-strategy_spec.ts @@ -0,0 +1,174 @@ +/** + * @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 { MockHost } from '../../testing/mock-host'; +import { + type MockMcpToolContext, + addProjectToWorkspace, + createMockContext, +} from '../../testing/test-utils'; +import { UnitTestTargetStrategy } from './unit-test-strategy'; + +describe('UnitTestTargetStrategy', () => { + let mockHost: MockHost; + let mockContext: MockMcpToolContext; + let strategy: UnitTestTargetStrategy; + + beforeEach(() => { + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; + addProjectToWorkspace(mock.projects, 'my-app'); + strategy = new UnitTestTargetStrategy(); + }); + + describe('canHandle', () => { + it('should match test target with official builders', () => { + expect(strategy.canHandle('test', '@angular-devkit/build-angular:karma')).toBeTrue(); + expect(strategy.canHandle('test', '@angular/build:karma')).toBeTrue(); + expect(strategy.canHandle('test', '@angular/build:unit-test')).toBeTrue(); + }); + + it('should not match test target with custom builders', () => { + expect(strategy.canHandle('test', 'custom-test-builder')).toBeFalse(); + expect(strategy.canHandle('test', undefined)).toBeFalse(); + }); + + it('should not match other targets', () => { + expect(strategy.canHandle('build', '@angular-devkit/build-angular:karma')).toBeFalse(); + }); + }); + + describe('execute', () => { + it('should append configuration arguments if provided', async () => { + mockContext.workspace.projects.get('my-app')?.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + }); + mockHost.executeNgCommand.and.resolveTo({ logs: ['Karma success'] }); + + await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'test', + configuration: 'ci', + targetDefinition: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + mockContext, + ); + + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( + ['test', 'my-app', '-c', 'ci', '--browsers', 'ChromeHeadless', '--watch', 'false'], + { cwd: '/test' }, + ); + }); + + it('should auto-inject no-watch and --browsers ChromeHeadless for Karma devkit builder', async () => { + mockContext.workspace.projects.get('my-app')?.targets.set('test', { + builder: '@angular-devkit/build-angular:karma', + }); + mockHost.executeNgCommand.and.resolveTo({ logs: ['Karma success'] }); + + await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'test', + targetDefinition: { + builder: '@angular-devkit/build-angular:karma', + }, + }, + mockContext, + ); + + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( + ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], + { cwd: '/test' }, + ); + }); + + it('should auto-inject no-watch and --browsers ChromeHeadless for Karma build builder', async () => { + mockContext.workspace.projects.get('my-app')?.targets.set('test', { + builder: '@angular/build:karma', + }); + mockHost.executeNgCommand.and.resolveTo({ logs: ['Karma success'] }); + + await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'test', + targetDefinition: { + builder: '@angular/build:karma', + }, + }, + mockContext, + ); + + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( + ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], + { cwd: '/test' }, + ); + }); + + it('should inject --headless true for modern unit-test builder with non-karma runner', async () => { + mockContext.workspace.projects.get('my-app')?.targets.set('test', { + builder: '@angular/build:unit-test', + options: { runner: 'vitest' }, + }); + mockHost.executeNgCommand.and.resolveTo({ logs: ['Vite success'] }); + + await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'test', + targetDefinition: { + builder: '@angular/build:unit-test', + options: { runner: 'vitest' }, + }, + }, + mockContext, + ); + + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( + ['test', 'my-app', '--headless', 'true', '--watch', 'false'], + { cwd: '/test' }, + ); + }); + + it('should override watch options passed explicitly by LLM', async () => { + mockContext.workspace.projects.get('my-app')?.targets.set('test', { + builder: '@angular/build:unit-test', + options: { runner: 'vitest' }, + }); + mockHost.executeNgCommand.and.resolveTo({ logs: ['Vite success'] }); + + await strategy.execute( + { + workspacePath: '/test', + projectName: 'my-app', + targetName: 'test', + targetDefinition: { + builder: '@angular/build:unit-test', + options: { runner: 'vitest' }, + }, + options: { watch: true, browsers: 'Firefox' }, + }, + mockContext, + ); + + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( + ['test', 'my-app', '--headless', 'true', '--watch', 'false', '--browsers=Firefox'], + { cwd: '/test' }, + ); + }); + }); +}); diff --git a/tests/e2e/tests/mcp/run-target.ts b/tests/e2e/tests/mcp/run-target.ts index 2e0aa280fa86..936f3a9fc7f6 100644 --- a/tests/e2e/tests/mcp/run-target.ts +++ b/tests/e2e/tests/mcp/run-target.ts @@ -1,3 +1,4 @@ +import { getGlobalVariable } from '../../utils/env'; import { exec, ProcessOutput, silentNpm } from '../../utils/process'; import assert from 'node:assert/strict'; @@ -53,8 +54,31 @@ export default async function () { 'target=build', ); assert.match(stdoutCall, /"status":\s*"success"/); + // Webpack-based browser builder does not print output paths to stdout logs. + // Only esbuild-based application builders output 'Output location: ...' and support outputPath extraction. + const esbuild = getGlobalVariable('argv')['esbuild']; + if (esbuild) { + assert.match(stdoutCall, /"outputPath":\s*"dist\/.+"/); + } else { + assert.doesNotMatch(stdoutCall, /"outputPath"/); + } + + // 4. Call run_target with test target (only for esbuild/Vite test runner, as webpack-based Karma fails on this bazel CI headless runner) + if (esbuild) { + const { stdout: stdoutTestCall } = await runInspector( + '-E', + 'run_target', + '--method', + 'tools/call', + '--tool-name', + 'run_target', + '--tool-arg', + 'target=test', + ); + assert.match(stdoutTestCall, /"status":\s*"success"/); + } } finally { - // 4. Clean up global installation + // 5. Clean up global installation await silentNpm('uninstall', '-g', MCP_INSPECTOR_PACKAGE_NAME); } } From f8c40a00479dbde20b8b22396219ed3a1f11cf7a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 21 May 2026 09:55:30 -0400 Subject: [PATCH 2/2] refactor(@angular/cli): remove granular build, test, and e2e MCP tools Remove build, test, and e2e standalone tool declarations from the MCP server registry. Now that the unified run_target Strategy Dispatcher facade is fully operational and verified, these granular tools are redundant. Removing them reduces system prompt context footprint by over 1,000 tokens, eliminates semantic clashing, and increases tool selection accuracy for client LLMs. --- .../cli/src/commands/mcp/mcp-server.ts | 11 +- .../cli/src/commands/mcp/tools/build.ts | 105 --------------- .../cli/src/commands/mcp/tools/build_spec.ts | 108 --------------- .../angular/cli/src/commands/mcp/tools/e2e.ts | 103 -------------- .../cli/src/commands/mcp/tools/e2e_spec.ts | 104 --------------- .../cli/src/commands/mcp/tools/test.ts | 108 --------------- .../cli/src/commands/mcp/tools/test_spec.ts | 126 ------------------ 7 files changed, 1 insertion(+), 664 deletions(-) delete mode 100644 packages/angular/cli/src/commands/mcp/tools/build.ts delete mode 100644 packages/angular/cli/src/commands/mcp/tools/build_spec.ts delete mode 100644 packages/angular/cli/src/commands/mcp/tools/e2e.ts delete mode 100644 packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts delete mode 100644 packages/angular/cli/src/commands/mcp/tools/test.ts delete mode 100644 packages/angular/cli/src/commands/mcp/tools/test_spec.ts diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 4697f3bff421..43320217db6d 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -17,16 +17,13 @@ import { LocalWorkspaceHost, createRootRestrictedHost } from './host'; import { registerInstructionsResource } from './resources/instructions'; import { AI_TUTOR_TOOL } from './tools/ai-tutor'; import { BEST_PRACTICES_TOOL } from './tools/best-practices'; -import { BUILD_TOOL } from './tools/build'; import { DEVSERVER_START_TOOL } from './tools/devserver/devserver-start'; import { DEVSERVER_STOP_TOOL } from './tools/devserver/devserver-stop'; import { DEVSERVER_WAIT_FOR_BUILD_TOOL } from './tools/devserver/devserver-wait-for-build'; import { DOC_SEARCH_TOOL } from './tools/doc-search'; -import { E2E_TOOL } from './tools/e2e'; import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration'; import { LIST_PROJECTS_TOOL } from './tools/projects'; import { RUN_TARGET_TOOL } from './tools/run-target/run-target'; -import { TEST_TOOL } from './tools/test'; import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry'; /** @@ -50,13 +47,7 @@ const STABLE_TOOLS = [ * The set of tools that are available but not enabled by default. * These tools are considered experimental and may have limitations. */ -export const EXPERIMENTAL_TOOLS = [ - BUILD_TOOL, - E2E_TOOL, - TEST_TOOL, - RUN_TARGET_TOOL, - ...DEVSERVER_TOOLS, -] as const; +export const EXPERIMENTAL_TOOLS = [RUN_TARGET_TOOL, ...DEVSERVER_TOOLS] as const; /** * Experimental tools that are grouped together under a single name. diff --git a/packages/angular/cli/src/commands/mcp/tools/build.ts b/packages/angular/cli/src/commands/mcp/tools/build.ts deleted file mode 100644 index fbf2729bf8bf..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/build.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @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 { z } from 'zod'; -import { workspaceAndProjectOptions } from '../shared-options'; -import { createStructuredContentOutput, getCommandErrorLogs } from '../utils'; -import { resolveWorkspaceAndProject } from '../workspace-utils'; -import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; - -const DEFAULT_CONFIGURATION = 'development'; - -const buildStatusSchema = z.enum(['success', 'failure']); -type BuildStatus = z.infer; - -const buildToolInputSchema = z.object({ - ...workspaceAndProjectOptions, - configuration: z - .string() - .optional() - .describe('Which build configuration to use. Defaults to "development".'), -}); - -export type BuildToolInput = z.infer; - -const buildToolOutputSchema = z.object({ - status: buildStatusSchema.describe('Build status.'), - logs: z.array(z.string()).optional().describe('Output logs from `ng build`.'), - path: z.string().optional().describe('The output location for the build, if successful.'), -}); - -export type BuildToolOutput = z.infer; - -export async function runBuild(input: BuildToolInput, context: McpToolContext) { - const { workspacePath, projectName } = await resolveWorkspaceAndProject({ - host: context.host, - server: context.server, - workspacePathInput: input.workspace, - projectNameInput: input.project, - mcpWorkspace: context.workspace, - }); - - // Build "ng"'s command line. - const args = ['build', projectName, '-c', input.configuration ?? DEFAULT_CONFIGURATION]; - - let status: BuildStatus = 'success'; - let logs: string[]; - let outputPath: string | undefined; - - try { - logs = (await context.host.executeNgCommand(args, { cwd: workspacePath })).logs; - } catch (e) { - status = 'failure'; - logs = getCommandErrorLogs(e); - } - - for (const line of logs) { - const match = line.match(/Output location: (.*)/); - if (match) { - outputPath = match[1].trim(); - break; - } - } - - const structuredContent: BuildToolOutput = { - status, - logs, - path: outputPath, - }; - - return createStructuredContentOutput(structuredContent); -} - -export const BUILD_TOOL: McpToolDeclaration< - typeof buildToolInputSchema.shape, - typeof buildToolOutputSchema.shape -> = declareTool({ - name: 'build', - title: 'Build Tool', - description: ` - -Perform a one-off, non-watched build using "ng build". Use this tool whenever the user wants to build an Angular project; this is similar to -"ng build", but the tool is smarter about using the right configuration and collecting the output logs. - - -* Building an Angular project and getting build logs back. - - -* This tool runs "ng build" so it expects to run within an Angular workspace. -* If you want a watched build which updates as files are changed, use "devserver.start" instead, which also serves the app. -* You can provide a project instead of building the root one. The "list_projects" MCP tool could be used to obtain the list of projects. -* This tool defaults to a development environment while a regular "ng build" defaults to a production environment. An unexpected build - failure might suggest the project is not configured for the requested environment. - -`, - isReadOnly: false, - isLocalOnly: true, - inputSchema: buildToolInputSchema.shape, - outputSchema: buildToolOutputSchema.shape, - factory: (context) => (input) => runBuild(input, context), -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts deleted file mode 100644 index 3fd7318c554b..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @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 { CommandError } from '../host'; -import type { MockHost } from '../testing/mock-host'; -import { - MockMcpToolContext, - addProjectToWorkspace, - createMockContext, -} from '../testing/test-utils'; -import { runBuild } from './build'; - -describe('Build Tool', () => { - let mockHost: MockHost; - let mockContext: MockMcpToolContext; - - beforeEach(() => { - const mock = createMockContext(); - mockHost = mock.host; - mockContext = mock.context; - addProjectToWorkspace(mock.projects, 'my-app'); - }); - - it('should construct the command correctly with default configuration', async () => { - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - await runBuild({}, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['build', 'my-app', '-c', 'development'], - { cwd: '/test' }, - ); - }); - - it('should construct the command correctly with a specified project', async () => { - addProjectToWorkspace(mockContext.workspace.projects, 'another-app'); - await runBuild({ project: 'another-app' }, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['build', 'another-app', '-c', 'development'], - { cwd: '/test' }, - ); - }); - - it('should construct the command correctly for a custom configuration', async () => { - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - await runBuild({ configuration: 'myconfig' }, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['build', 'my-app', '-c', 'myconfig'], { - cwd: '/test', - }); - }); - - it('should handle a successful build and extract the output path and logs', async () => { - const buildLogs = [ - 'Build successful!', - 'Some other log lines...', - 'some warning', - 'Output location: dist/my-app', - ]; - mockHost.executeNgCommand.and.resolveTo({ - logs: buildLogs, - }); - - const { structuredContent } = await runBuild({ project: 'my-app' }, mockContext); - - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['build', 'my-app', '-c', 'development'], - { cwd: '/test' }, - ); - expect(structuredContent.status).toBe('success'); - expect(structuredContent.logs).toEqual(buildLogs); - expect(structuredContent.path).toBe('dist/my-app'); - }); - - it('should handle a failed build and capture logs', async () => { - addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app'); - const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!']; - const error = new CommandError('Build failed', buildLogs, 1); - mockHost.executeNgCommand.and.rejectWith(error); - - const { structuredContent } = await runBuild( - { project: 'my-failed-app', configuration: 'production' }, - mockContext, - ); - - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['build', 'my-failed-app', '-c', 'production'], - { cwd: '/test' }, - ); - expect(structuredContent.status).toBe('failure'); - expect(structuredContent.logs).toEqual([...buildLogs, 'Build failed']); - expect(structuredContent.path).toBeUndefined(); - }); - - it('should handle builds where the output path is not found in logs', async () => { - const buildLogs = ["Some logs that don't match any output path."]; - mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs }); - - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - const { structuredContent } = await runBuild({}, mockContext); - - expect(structuredContent.status).toBe('success'); - expect(structuredContent.logs).toEqual(buildLogs); - expect(structuredContent.path).toBeUndefined(); - }); -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e.ts b/packages/angular/cli/src/commands/mcp/tools/e2e.ts deleted file mode 100644 index 726308b12c87..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/e2e.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @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 { z } from 'zod'; -import { type Host } from '../host'; -import { workspaceAndProjectOptions } from '../shared-options'; -import { createStructuredContentOutput, getCommandErrorLogs } from '../utils'; -import { resolveWorkspaceAndProject } from '../workspace-utils'; -import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; - -const e2eStatusSchema = z.enum(['success', 'failure']); -type E2eStatus = z.infer; - -const e2eToolInputSchema = z.object({ - ...workspaceAndProjectOptions, -}); - -export type E2eToolInput = z.infer; - -const e2eToolOutputSchema = z.object({ - status: e2eStatusSchema.describe('E2E execution status.'), - logs: z.array(z.string()).optional().describe('Output logs from `ng e2e`.'), -}); - -export type E2eToolOutput = z.infer; - -export async function runE2e(input: E2eToolInput, host: Host, context: McpToolContext) { - const { workspacePath, workspace, projectName } = await resolveWorkspaceAndProject({ - host, - server: context.server, - workspacePathInput: input.workspace, - projectNameInput: input.project, - mcpWorkspace: context.workspace, - }); - - if (workspace && projectName) { - // Verify that if a project can be found, it has an e2e testing already set up. - const targetProject = workspace.projects.get(projectName); - if (targetProject) { - if (!targetProject.targets.has('e2e')) { - return createStructuredContentOutput({ - status: 'failure', - logs: [ - `No e2e target is defined for project '${projectName}'. Please set up e2e testing` + - ' first by calling `ng e2e` in an interactive console.' + - ' See https://angular.dev/tools/cli/end-to-end.', - ], - }); - } - } - } - - // Build "ng"'s command line. - const args = ['e2e', projectName]; - - let status: E2eStatus = 'success'; - let logs: string[]; - - try { - logs = (await host.executeNgCommand(args, { cwd: workspacePath })).logs; - } catch (e) { - status = 'failure'; - logs = getCommandErrorLogs(e); - } - - const structuredContent: E2eToolOutput = { - status, - logs, - }; - - return createStructuredContentOutput(structuredContent); -} - -export const E2E_TOOL: McpToolDeclaration< - typeof e2eToolInputSchema.shape, - typeof e2eToolOutputSchema.shape -> = declareTool({ - name: 'e2e', - title: 'E2E Tool', - description: ` - -Perform an end-to-end test with ng e2e. - - -* When the user requests running end-to-end tests for the project. -* When verifying changes that cross unit boundaries, such as changes to both client and server, changes to shared data types, etc. - - -* This tool uses "ng e2e". -* Important: this relies on e2e tests being already configured for this project. It will error out if no "e2e" target is defined. - -`, - isReadOnly: false, - isLocalOnly: true, - inputSchema: e2eToolInputSchema.shape, - outputSchema: e2eToolOutputSchema.shape, - factory: (context) => (input) => runE2e(input, context.host, context), -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts deleted file mode 100644 index 318dd41aea52..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * @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 { workspaces } from '@angular-devkit/core'; -import { CommandError } from '../host'; -import type { MockHost } from '../testing/mock-host'; -import { - MockMcpToolContext, - addProjectToWorkspace, - createMockContext, -} from '../testing/test-utils'; -import { runE2e } from './e2e'; - -describe('E2E Tool', () => { - let mockHost: MockHost; - let mockContext: MockMcpToolContext; - let mockProjects: workspaces.ProjectDefinitionCollection; - - beforeEach(() => { - const mock = createMockContext(); - mockHost = mock.host; - mockContext = mock.context; - mockProjects = mock.projects; - }); - - it('should construct the command correctly with defaults', async () => { - addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - - await runE2e({}, mockHost, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); - }); - - it('should construct the command correctly with a specified project', async () => { - addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); - - await runE2e({ project: 'my-app' }, mockHost, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); - }); - - it('should error if project does not have e2e target', async () => { - addProjectToWorkspace(mockProjects, 'my-app', { build: { builder: 'mock-builder' } }); - - const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); - - expect(structuredContent.status).toBe('failure'); - expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'"); - expect(mockHost.executeNgCommand).not.toHaveBeenCalled(); - }); - - it('should error if no project was specified and the default project does not have e2e target', async () => { - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - addProjectToWorkspace(mockProjects, 'my-app', { build: { builder: 'mock-builder' } }); - - const { structuredContent } = await runE2e({}, mockHost, mockContext); - - expect(structuredContent.status).toBe('failure'); - expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'"); - expect(mockHost.executeNgCommand).not.toHaveBeenCalled(); - }); - - it('should handle a successful e2e run with a specified project', async () => { - addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); - const e2eLogs = ['E2E passed for my-app']; - mockHost.executeNgCommand.and.resolveTo({ logs: e2eLogs }); - - const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); - - expect(structuredContent.status).toBe('success'); - expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); - }); - - it('should handle a successful e2e run with the default project', async () => { - mockContext.workspace.extensions['defaultProject'] = 'default-app'; - addProjectToWorkspace(mockProjects, 'default-app', { e2e: { builder: 'mock-builder' } }); - const e2eLogs = ['E2E passed for default-app']; - mockHost.executeNgCommand.and.resolveTo({ logs: e2eLogs }); - - const { structuredContent } = await runE2e({}, mockHost, mockContext); - - expect(structuredContent.status).toBe('success'); - expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'default-app'], { - cwd: '/test', - }); - }); - - it('should handle a failed e2e run', async () => { - addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); - const e2eLogs = ['E2E failed']; - mockHost.executeNgCommand.and.rejectWith(new CommandError('Failed', e2eLogs, 1)); - - const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); - - expect(structuredContent.status).toBe('failure'); - expect(structuredContent.logs).toEqual([...e2eLogs, 'Failed']); - }); -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/test.ts b/packages/angular/cli/src/commands/mcp/tools/test.ts deleted file mode 100644 index 72093c268a1b..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @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 { z } from 'zod'; -import { workspaceAndProjectOptions } from '../shared-options'; -import { createStructuredContentOutput, getCommandErrorLogs } from '../utils'; -import { resolveWorkspaceAndProject } from '../workspace-utils'; -import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; - -const testStatusSchema = z.enum(['success', 'failure']); -type TestStatus = z.infer; - -const testToolInputSchema = z.object({ - ...workspaceAndProjectOptions, - filter: z.string().optional().describe('Filter the executed tests by spec name.'), -}); - -export type TestToolInput = z.infer; - -const testToolOutputSchema = z.object({ - status: testStatusSchema.describe('Test execution status.'), - logs: z.array(z.string()).optional().describe('Output logs from `ng test`.'), -}); - -export type TestToolOutput = z.infer; - -function shouldUseHeadlessOption( - testTarget: import('@angular-devkit/core').workspaces.TargetDefinition | undefined, -): boolean { - return ( - testTarget?.builder === '@angular/build:unit-test' && testTarget.options?.['runner'] !== 'karma' - ); -} - -export async function runTest(input: TestToolInput, context: McpToolContext) { - const { workspace, workspacePath, projectName } = await resolveWorkspaceAndProject({ - host: context.host, - workspacePathInput: input.workspace, - projectNameInput: input.project, - mcpWorkspace: context.workspace, - }); - - // Build "ng"'s command line. - const args = ['test', projectName]; - - if (shouldUseHeadlessOption(workspace.projects.get(projectName)?.targets.get('test'))) { - args.push('--headless', 'true'); - } else { - // Karma-based projects need an explicit headless browser for non-interactive MCP execution. - args.push('--browsers', 'ChromeHeadless'); - } - - args.push('--watch', 'false'); - - if (input.filter) { - args.push('--filter', input.filter); - } - - let status: TestStatus = 'success'; - let logs: string[]; - - try { - logs = (await context.host.executeNgCommand(args, { cwd: workspacePath })).logs; - } catch (e) { - status = 'failure'; - logs = getCommandErrorLogs(e); - } - - const structuredContent: TestToolOutput = { - status, - logs, - }; - - return createStructuredContentOutput(structuredContent); -} - -export const TEST_TOOL: McpToolDeclaration< - typeof testToolInputSchema.shape, - typeof testToolOutputSchema.shape -> = declareTool({ - name: 'test', - title: 'Test Tool', - description: ` - -Perform a one-off, non-watched unit test execution with ng test. - - -* Running unit tests for the project. -* Verifying code changes with tests. - - -* This tool uses "ng test". -* It supports filtering by spec name if the underlying builder supports it (e.g., 'unit-test' builder). -* For the "@angular/build:unit-test" builder with Vitest, this tool requests headless execution via "--headless true". -* For Karma-based projects, this tool forces headless Chrome with "--browsers ChromeHeadless", so Chrome must be installed. - -`, - isReadOnly: false, - isLocalOnly: true, - inputSchema: testToolInputSchema.shape, - outputSchema: testToolOutputSchema.shape, - factory: (context) => (input) => runTest(input, context), -}); diff --git a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts deleted file mode 100644 index a56307dcf3cb..000000000000 --- a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @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 { CommandError } from '../host'; -import type { MockHost } from '../testing/mock-host'; -import { - MockMcpToolContext, - addProjectToWorkspace, - createMockContext, -} from '../testing/test-utils'; -import { runTest } from './test'; - -describe('Test Tool', () => { - let mockHost: MockHost; - let mockContext: MockMcpToolContext; - - beforeEach(() => { - const mock = createMockContext(); - mockHost = mock.host; - mockContext = mock.context; - addProjectToWorkspace(mock.projects, 'my-app'); - }); - - it('should construct the command correctly with defaults', async () => { - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - await runTest({}, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], - { cwd: '/test' }, - ); - }); - - it('should construct the command correctly with a specified project', async () => { - addProjectToWorkspace(mockContext.workspace.projects, 'my-lib'); - await runTest({ project: 'my-lib' }, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['test', 'my-lib', '--browsers', 'ChromeHeadless', '--watch', 'false'], - { cwd: '/test' }, - ); - }); - - it('should construct the command correctly with filter', async () => { - mockContext.workspace.extensions['defaultProject'] = 'my-app'; - await runTest({ filter: 'AppComponent' }, mockContext); - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - [ - 'test', - 'my-app', - '--browsers', - 'ChromeHeadless', - '--watch', - 'false', - '--filter', - 'AppComponent', - ], - { cwd: '/test' }, - ); - }); - - it('should handle a successful test run and capture logs', async () => { - const testLogs = ['Executed 10 of 10 SUCCESS', 'Total: 10 success']; - mockHost.executeNgCommand.and.resolveTo({ - logs: testLogs, - }); - - const { structuredContent } = await runTest({ project: 'my-app' }, mockContext); - - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], - { cwd: '/test' }, - ); - expect(structuredContent.status).toBe('success'); - expect(structuredContent.logs).toEqual(testLogs); - }); - - it('should handle a failed test run and capture logs', async () => { - addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app'); - const testLogs = ['Executed 10 of 10 FAILED', 'Error: Some test failed']; - const error = new CommandError('Test failed', testLogs, 1); - mockHost.executeNgCommand.and.rejectWith(error); - - const { structuredContent } = await runTest({ project: 'my-failed-app' }, mockContext); - - expect(structuredContent.status).toBe('failure'); - expect(structuredContent.logs).toEqual([...testLogs, 'Test failed']); - }); - - it('should use the headless option for the unit-test builder when using Vitest', async () => { - addProjectToWorkspace(mockContext.workspace.projects, 'my-vitest-app', { - test: { - builder: '@angular/build:unit-test', - options: { - runner: 'vitest', - }, - }, - }); - - await runTest({ project: 'my-vitest-app' }, mockContext); - - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['test', 'my-vitest-app', '--headless', 'true', '--watch', 'false'], - { cwd: '/test' }, - ); - }); - - it('should use the headless option for the unit-test builder when the runner is omitted', async () => { - addProjectToWorkspace(mockContext.workspace.projects, 'my-default-vitest-app', { - test: { - builder: '@angular/build:unit-test', - options: {}, - }, - }); - - await runTest({ project: 'my-default-vitest-app' }, mockContext); - - expect(mockHost.executeNgCommand).toHaveBeenCalledWith( - ['test', 'my-default-vitest-app', '--headless', 'true', '--watch', 'false'], - { cwd: '/test' }, - ); - }); -});