diff --git a/packages/angular/cli/src/commands/mcp/devserver.ts b/packages/angular/cli/src/commands/mcp/devserver.ts index cf8378294edd..6955f2d512e6 100644 --- a/packages/angular/cli/src/commands/mcp/devserver.ts +++ b/packages/angular/cli/src/commands/mcp/devserver.ts @@ -64,10 +64,6 @@ export interface Devserver { port: number; } -export function devserverKey(project?: string) { - return project ?? ''; -} - /** * A local Angular development server managed by the MCP server. */ diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts index 512398876513..a2bc1b0f9aeb 100644 --- a/packages/angular/cli/src/commands/mcp/mcp-server.ts +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -20,10 +20,12 @@ 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 { FIND_EXAMPLE_TOOL } from './tools/examples/index'; import { MODERNIZE_TOOL } from './tools/modernize'; import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration'; import { LIST_PROJECTS_TOOL } from './tools/projects'; +import { TEST_TOOL } from './tools/test'; import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry'; /** @@ -31,15 +33,6 @@ import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry */ const DEVSERVER_TOOLS = [DEVSERVER_START_TOOL, DEVSERVER_STOP_TOOL, DEVSERVER_WAIT_FOR_BUILD_TOOL]; -/** - * Experimental tools that are grouped together under a single name. - * - * Used for enabling them as a group. - */ -export const EXPERIMENTAL_TOOL_GROUPS = { - 'devserver': DEVSERVER_TOOLS, -}; - /** * The set of tools that are enabled by default for the MCP server. * These tools are considered stable and suitable for general use. @@ -57,7 +50,23 @@ 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, MODERNIZE_TOOL, ...DEVSERVER_TOOLS] as const; +export const EXPERIMENTAL_TOOLS = [ + BUILD_TOOL, + E2E_TOOL, + MODERNIZE_TOOL, + TEST_TOOL, + ...DEVSERVER_TOOLS, +] as const; + +/** + * Experimental tools that are grouped together under a single name. + * + * Used for enabling them as a group. + */ +export const EXPERIMENTAL_TOOL_GROUPS = { + 'all': EXPERIMENTAL_TOOLS, + 'devserver': DEVSERVER_TOOLS, +}; export async function createMcpServer( options: { diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts new file mode 100644 index 000000000000..8dbe1ef1b4c8 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -0,0 +1,91 @@ +/** + * @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 { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { AngularWorkspace } from '../../../utilities/config'; +import { type Devserver } from '../devserver'; +import { Host } from '../host'; +import { McpToolContext } from '../tools/tool-registry'; +import { MockHost } from './mock-host'; + +/** + * Creates a mock implementation of the Host interface for testing purposes. + * Each method is a Jasmine spy that can be configured. + */ +export function createMockHost(): MockHost { + return { + runCommand: jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }), + stat: jasmine.createSpy('stat'), + existsSync: jasmine.createSpy('existsSync'), + spawn: jasmine.createSpy('spawn'), + getAvailablePort: jasmine + .createSpy('getAvailablePort') + .and.resolveTo(0), + } as unknown as MockHost; +} + +/** + * Options for configuring the mock MCP tool context. + */ +export interface MockContextOptions { + /** An optional pre-configured mock host. If not provided, a default mock host will be created. */ + host?: MockHost; + + /** Initial set of projects to populate the mock workspace with. */ + projects?: Record; +} + +/** + * Creates a comprehensive mock for the McpToolContext, including a mock Host, + * an AngularWorkspace, and a ProjectDefinitionCollection. This simplifies testing + * MCP tools by providing a consistent and configurable testing environment. + * @param options Configuration options for the mock context. + * @returns An object containing the mock host, context, projects collection, and workspace instance. + */ +export function createMockContext(options: MockContextOptions = {}): { + host: MockHost; + context: McpToolContext; + projects: workspaces.ProjectDefinitionCollection; + workspace: AngularWorkspace; +} { + const host = options.host ?? createMockHost(); + const projects = new workspaces.ProjectDefinitionCollection(options.projects); + const workspace = new AngularWorkspace({ projects, extensions: {} }, '/test/angular.json'); + + const context: McpToolContext = { + server: {} as unknown as McpServer, + workspace, + logger: { warn: () => {} }, + devservers: new Map(), + host, + }; + + return { host, context, projects, workspace }; +} + +/** + * Adds a project to the provided mock ProjectDefinitionCollection. + * This is a helper function to easily populate a mock Angular workspace. + * @param projects The ProjectDefinitionCollection to add the project to. + * @param name The name of the project. + * @param targets A record of target definitions for the project (e.g., build, test, e2e). + * @param root The root path of the project, relative to the workspace root. Defaults to `projects/${name}`. + */ +export function addProjectToWorkspace( + projects: workspaces.ProjectDefinitionCollection, + name: string, + targets: Record = {}, + root: string = `projects/${name}`, +) { + projects.set(name, { + root, + extensions: {}, + targets: new workspaces.TargetDefinitionCollection(targets), + }); +} diff --git a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts index 4ad98e0456b9..a8dfe0fa3028 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts @@ -6,19 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import { CommandError, Host } from '../host'; +import { CommandError } from '../host'; import type { MockHost } from '../testing/mock-host'; +import { createMockHost } from '../testing/test-utils'; import { runBuild } from './build'; describe('Build Tool', () => { let mockHost: MockHost; beforeEach(() => { - mockHost = { - runCommand: jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }), - stat: jasmine.createSpy('stat'), - existsSync: jasmine.createSpy('existsSync'), - } as MockHost; + mockHost = createMockHost(); }); it('should construct the command correctly with default configuration', async () => { diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts index 831cc5488e56..c2bde1c9b0ff 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts @@ -7,8 +7,8 @@ */ import { z } from 'zod'; -import { LocalDevserver, devserverKey } from '../../devserver'; -import { createStructuredContentOutput } from '../../utils'; +import { LocalDevserver } from '../../devserver'; +import { createStructuredContentOutput, getDefaultProjectName } from '../../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; const devserverStartToolInputSchema = z.object({ @@ -39,12 +39,18 @@ function localhostAddress(port: number) { } export async function startDevserver(input: DevserverStartToolInput, context: McpToolContext) { - const projectKey = devserverKey(input.project); + const projectName = input.project ?? getDefaultProjectName(context); - let devserver = context.devservers.get(projectKey); + if (!projectName) { + return createStructuredContentOutput({ + message: ['Project name not provided, and no default project found.'], + }); + } + + let devserver = context.devservers.get(projectName); if (devserver) { return createStructuredContentOutput({ - message: `Development server for project '${projectKey}' is already running.`, + message: `Development server for project '${projectName}' is already running.`, address: localhostAddress(devserver.port), }); } @@ -54,10 +60,10 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc devserver = new LocalDevserver({ host: context.host, project: input.project, port }); devserver.start(); - context.devservers.set(projectKey, devserver); + context.devservers.set(projectName, devserver); return createStructuredContentOutput({ - message: `Development server for project '${projectKey}' started and watching for workspace changes.`, + message: `Development server for project '${projectName}' started and watching for workspace changes.`, address: localhostAddress(port), }); } diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts index faefbae3b73f..09f6348cd8e4 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts @@ -7,8 +7,7 @@ */ import { z } from 'zod'; -import { devserverKey } from '../../devserver'; -import { createStructuredContentOutput } from '../../utils'; +import { createStructuredContentOutput, getDefaultProjectName } from '../../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; const devserverStopToolInputSchema = z.object({ @@ -30,21 +29,41 @@ const devserverStopToolOutputSchema = z.object({ export type DevserverStopToolOutput = z.infer; export function stopDevserver(input: DevserverStopToolInput, context: McpToolContext) { - const projectKey = devserverKey(input.project); - const devServer = context.devservers.get(projectKey); + if (context.devservers.size === 0) { + return createStructuredContentOutput({ + message: ['No development servers are currently running.'], + logs: undefined, + }); + } + + let projectName = input.project ?? getDefaultProjectName(context); + + if (!projectName) { + // This should not happen. But if there's just a single running devserver, stop it. + if (context.devservers.size === 1) { + projectName = Array.from(context.devservers.keys())[0]; + } else { + return createStructuredContentOutput({ + message: ['Project name not provided, and no default project found.'], + logs: undefined, + }); + } + } + + const devServer = context.devservers.get(projectName); if (!devServer) { return createStructuredContentOutput({ - message: `Development server for project '${projectKey}' was not running.`, + message: `Development server for project '${projectName}' was not running.`, logs: undefined, }); } devServer.stop(); - context.devservers.delete(projectKey); + context.devservers.delete(projectName); return createStructuredContentOutput({ - message: `Development server for project '${projectKey}' stopped.`, + message: `Development server for project '${projectName}' stopped.`, logs: devServer.getServerLogs(), }); } diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-wait-for-build.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-wait-for-build.ts index 38e162123428..b907612860bd 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-wait-for-build.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver-wait-for-build.ts @@ -7,8 +7,7 @@ */ import { z } from 'zod'; -import { devserverKey } from '../../devserver'; -import { createStructuredContentOutput } from '../../utils'; +import { createStructuredContentOutput, getDefaultProjectName } from '../../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; /** @@ -60,21 +59,43 @@ export async function waitForDevserverBuild( input: DevserverWaitForBuildToolInput, context: McpToolContext, ) { - const projectKey = devserverKey(input.project); - const devServer = context.devservers.get(projectKey); - const deadline = Date.now() + input.timeout; + if (context.devservers.size === 0) { + return createStructuredContentOutput({ + status: 'no_devserver_found', + logs: undefined, + }); + } + + let projectName = input.project ?? getDefaultProjectName(context); + + if (!projectName) { + // This should not happen. But if there's just a single running devserver, wait for it. + if (context.devservers.size === 1) { + projectName = Array.from(context.devservers.keys())[0]; + } else { + return createStructuredContentOutput({ + status: 'no_devserver_found', + logs: undefined, + }); + } + } + + const devServer = context.devservers.get(projectName); if (!devServer) { return createStructuredContentOutput({ status: 'no_devserver_found', + logs: undefined, }); } + const deadline = Date.now() + input.timeout; await wait(WATCH_DELAY); while (devServer.isBuilding()) { if (Date.now() > deadline) { return createStructuredContentOutput({ status: 'timeout', + logs: undefined, }); } await wait(WATCH_DELAY); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts index ea6449ceeffa..f3b33af417bf 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts @@ -8,7 +8,9 @@ import { EventEmitter } from 'events'; import type { ChildProcess } from 'node:child_process'; +import { AngularWorkspace } from '../../../../utilities/config'; import type { MockHost } from '../../testing/mock-host'; +import { addProjectToWorkspace, createMockContext } from '../../testing/test-utils'; import type { McpToolContext } from '../tool-registry'; import { startDevserver } from './devserver-start'; import { stopDevserver } from './devserver-stop'; @@ -25,33 +27,36 @@ describe('Serve Tools', () => { let mockContext: McpToolContext; let mockProcess: MockChildProcess; let portCounter: number; + let mockWorkspace: AngularWorkspace; beforeEach(() => { portCounter = 12345; mockProcess = new MockChildProcess(); - mockHost = { - spawn: jasmine.createSpy('spawn').and.returnValue(mockProcess as unknown as ChildProcess), - getAvailablePort: jasmine.createSpy('getAvailablePort').and.callFake(() => { - return Promise.resolve(portCounter++); - }), - } as MockHost; - - mockContext = { - devservers: new Map(), - host: mockHost, - } as Partial as McpToolContext; + + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; + mockWorkspace = mock.workspace; + + // Customize host spies + mockHost.spawn.and.returnValue(mockProcess as unknown as ChildProcess); + mockHost.getAvailablePort.and.callFake(() => Promise.resolve(portCounter++)); + + // Setup default project + addProjectToWorkspace(mock.projects, 'my-app'); + mockWorkspace.extensions['defaultProject'] = 'my-app'; }); it('should start and stop a dev server', async () => { const startResult = await startDevserver({}, mockContext); expect(startResult.structuredContent.message).toBe( - `Development server for project '' started and watching for workspace changes.`, + `Development server for project 'my-app' started and watching for workspace changes.`, ); expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', '--port=12345'], { stdio: 'pipe' }); const stopResult = stopDevserver({}, mockContext); expect(stopResult.structuredContent.message).toBe( - `Development server for project '' stopped.`, + `Development server for project 'my-app' stopped.`, ); expect(mockProcess.kill).toHaveBeenCalled(); }); @@ -78,6 +83,11 @@ describe('Serve Tools', () => { }); it('should handle multiple dev servers', async () => { + // Add extra projects + const projects = mockWorkspace.projects; + addProjectToWorkspace(projects, 'app-one'); + addProjectToWorkspace(projects, 'app-two'); + // Start server for project 1. This uses the basic mockProcess created for the tests. const startResult1 = await startDevserver({ project: 'app-one' }, mockContext); expect(startResult1.structuredContent.message).toBe( @@ -117,6 +127,7 @@ describe('Serve Tools', () => { }); it('should handle server crash', async () => { + addProjectToWorkspace(mockWorkspace.projects, 'crash-app'); await startDevserver({ project: 'crash-app' }, mockContext); // Simulate a crash with exit code 1 @@ -129,6 +140,7 @@ describe('Serve Tools', () => { }); it('wait should timeout if build takes too long', async () => { + addProjectToWorkspace(mockWorkspace.projects, 'timeout-app'); await startDevserver({ project: 'timeout-app' }, mockContext); const waitResult = await waitForDevserverBuild( { project: 'timeout-app', timeout: 10 }, diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e.ts b/packages/angular/cli/src/commands/mcp/tools/e2e.ts new file mode 100644 index 000000000000..a4bc9c8d796b --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/e2e.ts @@ -0,0 +1,106 @@ +/** + * @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 { CommandError, type Host, LocalWorkspaceHost } from '../host'; +import { createStructuredContentOutput, getDefaultProjectName, getProject } from '../utils'; +import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; + +const e2eStatusSchema = z.enum(['success', 'failure']); +type E2eStatus = z.infer; + +const e2eToolInputSchema = z.object({ + project: z + .string() + .optional() + .describe( + 'Which project to test in a monorepo context. If not provided, tests the default project.', + ), +}); + +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 projectName = input.project ?? getDefaultProjectName(context); + + if (context.workspace && projectName) { + const targetProject = getProject(context, projectName); + + if (targetProject) { + if (!targetProject.targets.has('e2e')) { + return createStructuredContentOutput({ + status: 'failure', + logs: [ + `No e2e target is defined for project '${projectName}'. Please setup e2e testing first.`, + ], + }); + } + } + } + + // Build "ng"'s command line. + const args = ['e2e']; + if (input.project) { + args.push(input.project); + } + + let status: E2eStatus = 'success'; + let logs: string[] = []; + + try { + logs = (await host.runCommand('ng', args)).logs; + } catch (e) { + status = 'failure'; + if (e instanceof CommandError) { + logs = e.logs; + } else if (e instanceof Error) { + logs = [e.message]; + } else { + logs = [String(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. + + +* Running end-to-end tests for the project. + + +* This tool runs "ng e2e". +* It will error if no "e2e" target is defined in the project, to avoid interactive setup prompts. + +`, + isReadOnly: false, + isLocalOnly: true, + inputSchema: e2eToolInputSchema.shape, + outputSchema: e2eToolOutputSchema.shape, + factory: (context) => (input) => runE2e(input, LocalWorkspaceHost, 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 new file mode 100644 index 000000000000..955e3f514d46 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts @@ -0,0 +1,92 @@ +/** + * @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 { AngularWorkspace } from '../../../utilities/config'; +import { CommandError } from '../host'; +import type { MockHost } from '../testing/mock-host'; +import { addProjectToWorkspace, createMockContext } from '../testing/test-utils'; +import { runE2e } from './e2e'; +import type { McpToolContext } from './tool-registry'; + +describe('E2E Tool', () => { + let mockHost: MockHost; + let mockContext: McpToolContext; + let mockProjects: workspaces.ProjectDefinitionCollection; + let mockWorkspace: AngularWorkspace; + + beforeEach(() => { + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; + mockProjects = mock.projects; + mockWorkspace = mock.workspace; + }); + + it('should construct the command correctly with defaults', async () => { + await runE2e({}, mockHost, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e']); + }); + + 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.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app']); + }); + + 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.runCommand).not.toHaveBeenCalled(); + }); + + it('should error if no project was specified and the default project does not have e2e target', async () => { + mockWorkspace.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.runCommand).not.toHaveBeenCalled(); + }); + + it('should proceed if no workspace context is available (fallback)', async () => { + // If context.workspace is undefined, it should try to run ng e2e. + const noWorkspaceContext = {} as McpToolContext; + await runE2e({}, mockHost, noWorkspaceContext); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e']); + }); + + it('should handle a successful e2e run', async () => { + addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); + const e2eLogs = ['E2E passed']; + mockHost.runCommand.and.resolveTo({ logs: e2eLogs }); + + const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); + + expect(structuredContent.status).toBe('success'); + expect(structuredContent.logs).toEqual(e2eLogs); + }); + + it('should handle a failed e2e run', async () => { + addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); + const e2eLogs = ['E2E failed']; + mockHost.runCommand.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); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/test.ts b/packages/angular/cli/src/commands/mcp/tools/test.ts new file mode 100644 index 000000000000..23a978b9d7dc --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/test.ts @@ -0,0 +1,97 @@ +/** + * @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 { CommandError, type Host, LocalWorkspaceHost } from '../host'; +import { createStructuredContentOutput } from '../utils'; +import { type McpToolDeclaration, declareTool } from './tool-registry'; + +const testStatusSchema = z.enum(['success', 'failure']); +type TestStatus = z.infer; + +const testToolInputSchema = z.object({ + project: z + .string() + .optional() + .describe('Which project to test in a monorepo context. If not provided, tests all projects.'), + 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; + +export async function runTest(input: TestToolInput, host: Host) { + // Build "ng"'s command line. + const args = ['test']; + if (input.project) { + args.push(input.project); + } + + // This is ran by the agent so we want a non-watched, headless test. + 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 host.runCommand('ng', args)).logs; + } catch (e) { + status = 'failure'; + if (e instanceof CommandError) { + logs = e.logs; + } else if (e instanceof Error) { + logs = [e.message]; + } else { + logs = [String(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 runs "ng test" with "--watch false". +* It supports filtering by spec name if the underlying builder supports it (e.g., 'unit-test' builder). + +`, + isReadOnly: false, + isLocalOnly: true, + inputSchema: testToolInputSchema.shape, + outputSchema: testToolOutputSchema.shape, + factory: () => (input) => runTest(input, LocalWorkspaceHost), +}); diff --git a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts new file mode 100644 index 000000000000..81c7f432b0fe --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts @@ -0,0 +1,87 @@ +/** + * @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 { createMockHost } from '../testing/test-utils'; +import { runTest } from './test'; + +describe('Test Tool', () => { + let mockHost: MockHost; + + beforeEach(() => { + mockHost = createMockHost(); + }); + + it('should construct the command correctly with defaults', async () => { + await runTest({}, mockHost); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'test', + '--browsers', + 'ChromeHeadless', + '--watch', + 'false', + ]); + }); + + it('should construct the command correctly with a specified project', async () => { + await runTest({ project: 'my-lib' }, mockHost); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'test', + 'my-lib', + '--browsers', + 'ChromeHeadless', + '--watch', + 'false', + ]); + }); + + it('should construct the command correctly with filter', async () => { + await runTest({ filter: 'AppComponent' }, mockHost); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'test', + '--browsers', + 'ChromeHeadless', + '--watch', + 'false', + '--filter', + 'AppComponent', + ]); + }); + + it('should handle a successful test run and capture logs', async () => { + const testLogs = ['Executed 10 of 10 SUCCESS', 'Total: 10 success']; + mockHost.runCommand.and.resolveTo({ + logs: testLogs, + }); + + const { structuredContent } = await runTest({ project: 'my-app' }, mockHost); + + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ + 'test', + 'my-app', + '--browsers', + 'ChromeHeadless', + '--watch', + 'false', + ]); + expect(structuredContent.status).toBe('success'); + expect(structuredContent.logs).toEqual(testLogs); + }); + + it('should handle a failed test run and capture logs', async () => { + const testLogs = ['Executed 10 of 10 FAILED', 'Error: Some test failed']; + const error = new CommandError('Test failed', testLogs, 1); + mockHost.runCommand.and.rejectWith(error); + + const { structuredContent } = await runTest({ project: 'my-failed-app' }, mockHost); + + expect(structuredContent.status).toBe('failure'); + expect(structuredContent.logs).toEqual(testLogs); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/utils.ts b/packages/angular/cli/src/commands/mcp/utils.ts index f5fdd70ef40e..391faaa877c9 100644 --- a/packages/angular/cli/src/commands/mcp/utils.ts +++ b/packages/angular/cli/src/commands/mcp/utils.ts @@ -11,8 +11,10 @@ * Utility functions shared across MCP tools. */ +import { workspaces } from '@angular-devkit/core'; import { dirname, join } from 'node:path'; import { LocalWorkspaceHost } from './host'; +import { McpToolContext } from './tools/tool-registry'; /** * Returns simple structured content output from an MCP tool. @@ -52,3 +54,44 @@ export function findAngularJsonDir(startDir: string, host = LocalWorkspaceHost): currentDir = parentDir; } } + +/** + * Searches for a project in the current workspace, by name. + */ +export function getProject( + context: McpToolContext, + name: string, +): workspaces.ProjectDefinition | undefined { + const projects = context.workspace?.projects; + if (!projects) { + return undefined; + } + + return projects.get(name); +} + +/** + * Returns the name of the default project in the current workspace, or undefined if none exists. + * + * If no default project is defined but there's only a single project in the workspace, its name will + * be returned. + */ +export function getDefaultProjectName(context: McpToolContext): string | undefined { + const projects = context.workspace?.projects; + + if (!projects) { + return undefined; + } + + const defaultProjectName = context.workspace?.extensions['defaultProject'] as string | undefined; + if (defaultProjectName) { + return defaultProjectName; + } + + // No default project defined? This might still be salvageable if only a single project exists. + if (projects.size === 1) { + return Array.from(projects.keys())[0]; + } + + return undefined; +}