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/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/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' }, - ); - }); -}); 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); } }