From b98a08963b1072d2d18718e01d5a7024aa5dd448 Mon Sep 17 00:00:00 2001 From: Alon Mishne Date: Tue, 6 Jan 2026 16:48:23 -0800 Subject: [PATCH] feat(@angular/cli): standardize MCP tools around workspace/project options --- .../angular/cli/src/commands/mcp/devserver.ts | 37 +++- .../cli/src/commands/mcp/shared-options.ts | 24 +++ .../src/commands/mcp/testing/test-utils.ts | 14 +- .../cli/src/commands/mcp/tools/build.ts | 36 ++-- .../cli/src/commands/mcp/tools/build_spec.ts | 72 ++++--- .../mcp/tools/devserver/devserver-start.ts | 36 ++-- .../mcp/tools/devserver/devserver-stop.ts | 60 +++--- .../devserver/devserver-wait-for-build.ts | 67 +++---- .../mcp/tools/devserver/devserver_spec.ts | 54 ++++-- .../angular/cli/src/commands/mcp/tools/e2e.ts | 33 ++-- .../cli/src/commands/mcp/tools/e2e_spec.ts | 36 ++-- .../cli/src/commands/mcp/tools/modernize.ts | 83 ++++----- .../src/commands/mcp/tools/modernize_spec.ts | 156 ++++------------ .../cli/src/commands/mcp/tools/test.ts | 33 ++-- .../cli/src/commands/mcp/tools/test_spec.ts | 87 +++++---- .../angular/cli/src/commands/mcp/utils.ts | 145 ++++++++++++++- .../cli/src/commands/mcp/utils_spec.ts | 176 +++++++++++++++++- 17 files changed, 722 insertions(+), 427 deletions(-) create mode 100644 packages/angular/cli/src/commands/mcp/shared-options.ts diff --git a/packages/angular/cli/src/commands/mcp/devserver.ts b/packages/angular/cli/src/commands/mcp/devserver.ts index 6955f2d512e6..a825ac95c418 100644 --- a/packages/angular/cli/src/commands/mcp/devserver.ts +++ b/packages/angular/cli/src/commands/mcp/devserver.ts @@ -62,6 +62,16 @@ export interface Devserver { * `ng serve` port to use. */ port: number; + + /** + * The workspace path for this server. + */ + workspacePath: string; + + /** + * The project name for this server. + */ + project: string; } /** @@ -70,7 +80,8 @@ export interface Devserver { export class LocalDevserver implements Devserver { readonly host: Host; readonly port: number; - readonly project?: string; + readonly workspacePath: string; + readonly project: string; private devserverProcess: ChildProcess | null = null; private serverLogs: string[] = []; @@ -78,10 +89,21 @@ export class LocalDevserver implements Devserver { private latestBuildLogStartIndex?: number = undefined; private latestBuildStatus: BuildStatus = 'unknown'; - constructor({ host, port, project }: { host: Host; port: number; project?: string }) { + constructor({ + host, + port, + workspacePath, + project, + }: { + host: Host; + port: number; + workspacePath: string; + project: string; + }) { this.host = host; - this.project = project; this.port = port; + this.workspacePath = workspacePath; + this.project = project; } start() { @@ -96,7 +118,10 @@ export class LocalDevserver implements Devserver { args.push(`--port=${this.port}`); - this.devserverProcess = this.host.spawn('ng', args, { stdio: 'pipe' }); + this.devserverProcess = this.host.spawn('ng', args, { + stdio: 'pipe', + cwd: this.workspacePath, + }); this.devserverProcess.stdout?.on('data', (data) => { this.addLog(data.toString()); }); @@ -142,3 +167,7 @@ export class LocalDevserver implements Devserver { return this.buildInProgress; } } + +export function getDevserverKey(workspacePath: string, projectName: string): string { + return `${workspacePath}:${projectName}`; +} diff --git a/packages/angular/cli/src/commands/mcp/shared-options.ts b/packages/angular/cli/src/commands/mcp/shared-options.ts new file mode 100644 index 000000000000..a390e0704291 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/shared-options.ts @@ -0,0 +1,24 @@ +/** + * @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'; + +export const workspaceAndProjectOptions = { + workspace: z + .string() + .optional() + .describe( + 'The path to the workspace directory (containing angular.json). If not provided, uses the current directory.', + ), + project: z + .string() + .optional() + .describe( + 'Which project to target in a monorepo context. If not provided, targets the default project.', + ), +}; diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts index 888fe1d0463b..7afcd695dd7d 100644 --- a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -41,6 +41,13 @@ export interface MockContextOptions { projects?: Record; } +/** + * Same as McpToolContext, just with guaranteed nonnull workspace. + */ +export interface MockMcpToolContext extends McpToolContext { + workspace: AngularWorkspace; +} + /** * Creates a comprehensive mock for the McpToolContext, including a mock Host, * an AngularWorkspace, and a ProjectDefinitionCollection. This simplifies testing @@ -50,15 +57,14 @@ export interface MockContextOptions { */ export function createMockContext(options: MockContextOptions = {}): { host: MockHost; - context: McpToolContext; + context: MockMcpToolContext; 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 = { + const context: MockMcpToolContext = { server: {} as unknown as McpServer, workspace, logger: { warn: () => {} }, @@ -66,7 +72,7 @@ export function createMockContext(options: MockContextOptions = {}): { host, }; - return { host, context, projects, workspace }; + return { host, context, projects }; } /** diff --git a/packages/angular/cli/src/commands/mcp/tools/build.ts b/packages/angular/cli/src/commands/mcp/tools/build.ts index 3faee85ebc90..c25efa18b11b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build.ts @@ -7,9 +7,13 @@ */ import { z } from 'zod'; -import { CommandError, type Host } from '../host'; -import { createStructuredContentOutput, getCommandErrorLogs } from '../utils'; -import { type McpToolDeclaration, declareTool } from './tool-registry'; +import { workspaceAndProjectOptions } from '../shared-options'; +import { + createStructuredContentOutput, + getCommandErrorLogs, + resolveWorkspaceAndProject, +} from '../utils'; +import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; const DEFAULT_CONFIGURATION = 'development'; @@ -17,12 +21,7 @@ const buildStatusSchema = z.enum(['success', 'failure']); type BuildStatus = z.infer; const buildToolInputSchema = z.object({ - project: z - .string() - .optional() - .describe( - 'Which project to build in a monorepo context. If not provided, builds the default project.', - ), + ...workspaceAndProjectOptions, configuration: z .string() .optional() @@ -39,20 +38,23 @@ const buildToolOutputSchema = z.object({ export type BuildToolOutput = z.infer; -export async function runBuild(input: BuildToolInput, host: Host) { +export async function runBuild(input: BuildToolInput, context: McpToolContext) { + const { workspacePath, projectName } = await resolveWorkspaceAndProject({ + host: context.host, + workspacePathInput: input.workspace, + projectNameInput: input.project, + mcpWorkspace: context.workspace, + }); + // Build "ng"'s command line. - const args = ['build']; - if (input.project) { - args.push(input.project); - } - args.push('-c', input.configuration ?? DEFAULT_CONFIGURATION); + const args = ['build', projectName, '-c', input.configuration ?? DEFAULT_CONFIGURATION]; let status: BuildStatus = 'success'; let logs: string[] = []; let outputPath: string | undefined; try { - logs = (await host.runCommand('ng', args)).logs; + logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); @@ -101,5 +103,5 @@ Perform a one-off, non-watched build using "ng build". Use this tool whenever th isLocalOnly: true, inputSchema: buildToolInputSchema.shape, outputSchema: buildToolOutputSchema.shape, - factory: (context) => (input) => runBuild(input, context.host), + 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 index 387c415d5eb2..403d5e68f877 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts @@ -8,34 +8,50 @@ import { CommandError } from '../host'; import type { MockHost } from '../testing/mock-host'; -import { createMockHost } from '../testing/test-utils'; +import { + MockMcpToolContext, + addProjectToWorkspace, + createMockContext, +} from '../testing/test-utils'; import { runBuild } from './build'; describe('Build Tool', () => { let mockHost: MockHost; + let mockContext: MockMcpToolContext; beforeEach(() => { - mockHost = createMockHost(); + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; + addProjectToWorkspace(mock.projects, 'my-app'); }); it('should construct the command correctly with default configuration', async () => { - await runBuild({}, mockHost); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'development']); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; + await runBuild({}, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['build', 'my-app', '-c', 'development'], + { cwd: '/test' }, + ); }); it('should construct the command correctly with a specified project', async () => { - await runBuild({ project: 'another-app' }, mockHost); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ - 'build', - 'another-app', - '-c', - 'development', - ]); + addProjectToWorkspace(mockContext.workspace.projects, 'another-app'); + await runBuild({ project: 'another-app' }, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['build', 'another-app', '-c', 'development'], + { cwd: '/test' }, + ); }); it('should construct the command correctly for a custom configuration', async () => { - await runBuild({ configuration: 'myconfig' }, mockHost); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c', 'myconfig']); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; + await runBuild({ configuration: 'myconfig' }, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c', 'myconfig'], { + cwd: '/test', + }); }); it('should handle a successful build and extract the output path and logs', async () => { @@ -49,35 +65,34 @@ describe('Build Tool', () => { logs: buildLogs, }); - const { structuredContent } = await runBuild({ project: 'my-app' }, mockHost); + const { structuredContent } = await runBuild({ project: 'my-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ - 'build', - 'my-app', - '-c', - 'development', - ]); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['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.runCommand.and.rejectWith(error); const { structuredContent } = await runBuild( { project: 'my-failed-app', configuration: 'production' }, - mockHost, + mockContext, ); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ - 'build', - 'my-failed-app', - '-c', - 'production', - ]); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['build', 'my-failed-app', '-c', 'production'], + { cwd: '/test' }, + ); expect(structuredContent.status).toBe('failure'); expect(structuredContent.logs).toEqual([...buildLogs, 'Build failed']); expect(structuredContent.path).toBeUndefined(); @@ -87,7 +102,8 @@ describe('Build Tool', () => { const buildLogs = ["Some logs that don't match any output path."]; mockHost.runCommand.and.resolveTo({ logs: buildLogs }); - const { structuredContent } = await runBuild({}, mockHost); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; + const { structuredContent } = await runBuild({}, mockContext); expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(buildLogs); 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 44917d612ef1..1a2d6f5ab12b 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,17 +7,13 @@ */ import { z } from 'zod'; -import { LocalDevserver } from '../../devserver'; -import { createStructuredContentOutput, getDefaultProjectName } from '../../utils'; +import { LocalDevserver, getDevserverKey } from '../../devserver'; +import { workspaceAndProjectOptions } from '../../shared-options'; +import { createStructuredContentOutput, resolveWorkspaceAndProject } from '../../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; const devserverStartToolInputSchema = z.object({ - project: z - .string() - .optional() - .describe( - 'Which project to serve in a monorepo context. If not provided, serves the default project.', - ), + ...workspaceAndProjectOptions, }); export type DevserverStartToolInput = z.infer; @@ -39,15 +35,16 @@ function localhostAddress(port: number) { } export async function startDevserver(input: DevserverStartToolInput, context: McpToolContext) { - const projectName = input.project ?? getDefaultProjectName(context); + const { workspacePath, projectName } = await resolveWorkspaceAndProject({ + host: context.host, + workspacePathInput: input.workspace, + projectNameInput: input.project, + mcpWorkspace: context.workspace, + }); - if (!projectName) { - return createStructuredContentOutput({ - message: ['Project name not provided, and no default project found.'], - }); - } + const key = getDevserverKey(workspacePath, projectName); - let devserver = context.devservers.get(projectName); + let devserver = context.devservers.get(key); if (devserver) { return createStructuredContentOutput({ message: `Development server for project '${projectName}' is already running.`, @@ -57,10 +54,15 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc const port = await context.host.getAvailablePort(); - devserver = new LocalDevserver({ host: context.host, project: input.project, port }); + devserver = new LocalDevserver({ + host: context.host, + project: projectName, + port, + workspacePath, + }); devserver.start(); - context.devservers.set(projectName, devserver); + context.devservers.set(key, devserver); return createStructuredContentOutput({ message: `Development server for project '${projectName}' started and watching for workspace changes.`, 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 4342fafbfb20..5b5fcc0104a4 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,16 +7,17 @@ */ import { z } from 'zod'; -import { createStructuredContentOutput, getDefaultProjectName } from '../../utils'; +import { getDevserverKey } from '../../devserver'; +import { workspaceAndProjectOptions } from '../../shared-options'; +import { + createDevServerNotFoundError, + createStructuredContentOutput, + resolveWorkspaceAndProject, +} from '../../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; const devserverStopToolInputSchema = z.object({ - project: z - .string() - .optional() - .describe( - 'Which project to stop serving in a monorepo context. If not provided, stops the default project server.', - ), + ...workspaceAndProjectOptions, }); export type DevserverStopToolInput = z.infer; @@ -28,43 +29,26 @@ const devserverStopToolOutputSchema = z.object({ export type DevserverStopToolOutput = z.infer; -export function stopDevserver(input: DevserverStopToolInput, context: McpToolContext) { - 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); +export async function stopDevserver(input: DevserverStopToolInput, context: McpToolContext) { + const { workspacePath, projectName } = await resolveWorkspaceAndProject({ + host: context.host, + workspacePathInput: input.workspace, + projectNameInput: input.project, + mcpWorkspace: context.workspace, + }); + const key = getDevserverKey(workspacePath, projectName); + const devserver = context.devservers.get(key); - if (!devServer) { - return createStructuredContentOutput({ - message: `Development server for project '${projectName}' was not running.`, - logs: undefined, - }); + if (!devserver) { + throw createDevServerNotFoundError(context.devservers); } - devServer.stop(); - context.devservers.delete(projectName); + devserver.stop(); + context.devservers.delete(key); return createStructuredContentOutput({ message: `Development server for project '${projectName}' stopped.`, - logs: devServer.getServerLogs(), + 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 396ca451ba79..a8ebbf6f246a 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,7 +7,13 @@ */ import { z } from 'zod'; -import { createStructuredContentOutput, getDefaultProjectName } from '../../utils'; +import { Devserver, getDevserverKey } from '../../devserver'; +import { workspaceAndProjectOptions } from '../../shared-options'; +import { + createDevServerNotFoundError, + createStructuredContentOutput, + resolveWorkspaceAndProject, +} from '../../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry'; /** @@ -21,12 +27,7 @@ export const WATCH_DELAY = 1000; const DEFAULT_TIMEOUT = 180_000; // In milliseconds const devserverWaitForBuildToolInputSchema = z.object({ - project: z - .string() - .optional() - .describe( - 'Which project to wait for in a monorepo context. If not provided, waits for the default project server.', - ), + ...workspaceAndProjectOptions, timeout: z .number() .default(DEFAULT_TIMEOUT) @@ -39,7 +40,7 @@ export type DevserverWaitForBuildToolInput = z.infer({ - status: 'no_devserver_found', - logs: undefined, - }); - } + return performWait(devserver, input.timeout); +} - const deadline = Date.now() + input.timeout; +async function performWait(devserver: Devserver, timeout: number) { + const deadline = Date.now() + timeout; await wait(WATCH_DELAY); - while (devServer.isBuilding()) { + while (devserver.isBuilding()) { if (Date.now() > deadline) { return createStructuredContentOutput({ status: 'timeout', @@ -102,7 +90,7 @@ export async function waitForDevserverBuild( } return createStructuredContentOutput({ - ...devServer.getMostRecentBuild(), + ...devserver.getMostRecentBuild(), }); } @@ -123,14 +111,11 @@ recent build. tool or command. When it retuns you'll get build logs back **and** you'll know the user's devserver is up-to-date with the latest changes. -* This tool expects that a dev server was launched on the same project with the "devserver.start" tool, otherwise a "no_devserver_found" - status will be returned. +* This tool expects that a dev server was launched on the same project with the "devserver.start" tool, otherwise the tool will fail. * This tool will block until the build is complete or the timeout is reached. If you expect a long build process, consider increasing the timeout. Timeouts on initial run (right after "devserver.start" calls) or after a big change are not necessarily indicative of an error. * If you encountered a timeout and it might be reasonable, just call this tool again. * If the dev server is not building, it will return quickly, with the logs from the last build. -* A 'no_devserver_found' status can indicate the underlying server was stopped for some reason. Try first to call the "devserver.start" - tool again, before giving up. `, isReadOnly: true, 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 f3b33af417bf..93c6b367cb70 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,10 +8,12 @@ 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 { + MockMcpToolContext, + addProjectToWorkspace, + createMockContext, +} from '../../testing/test-utils'; import { startDevserver } from './devserver-start'; import { stopDevserver } from './devserver-stop'; import { WATCH_DELAY, waitForDevserverBuild } from './devserver-wait-for-build'; @@ -24,10 +26,9 @@ class MockChildProcess extends EventEmitter { describe('Serve Tools', () => { let mockHost: MockHost; - let mockContext: McpToolContext; + let mockContext: MockMcpToolContext; let mockProcess: MockChildProcess; let portCounter: number; - let mockWorkspace: AngularWorkspace; beforeEach(() => { portCounter = 12345; @@ -36,7 +37,6 @@ describe('Serve Tools', () => { const mock = createMockContext(); mockHost = mock.host; mockContext = mock.context; - mockWorkspace = mock.workspace; // Customize host spies mockHost.spawn.and.returnValue(mockProcess as unknown as ChildProcess); @@ -44,7 +44,7 @@ describe('Serve Tools', () => { // Setup default project addProjectToWorkspace(mock.projects, 'my-app'); - mockWorkspace.extensions['defaultProject'] = 'my-app'; + mockContext.workspace.extensions['defaultProject'] = 'my-app'; }); it('should start and stop a dev server', async () => { @@ -52,9 +52,12 @@ describe('Serve Tools', () => { expect(startResult.structuredContent.message).toBe( `Development server for project 'my-app' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', '--port=12345'], { stdio: 'pipe' }); + expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=12345'], { + stdio: 'pipe', + cwd: '/test', + }); - const stopResult = stopDevserver({}, mockContext); + const stopResult = await stopDevserver({}, mockContext); expect(stopResult.structuredContent.message).toBe( `Development server for project 'my-app' stopped.`, ); @@ -84,7 +87,7 @@ describe('Serve Tools', () => { it('should handle multiple dev servers', async () => { // Add extra projects - const projects = mockWorkspace.projects; + const projects = mockContext.workspace.projects; addProjectToWorkspace(projects, 'app-one'); addProjectToWorkspace(projects, 'app-two'); @@ -105,13 +108,15 @@ describe('Serve Tools', () => { expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-one', '--port=12345'], { stdio: 'pipe', + cwd: '/test', }); expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-two', '--port=12346'], { stdio: 'pipe', + cwd: '/test', }); // Stop server for project 1 - const stopResult1 = stopDevserver({ project: 'app-one' }, mockContext); + const stopResult1 = await stopDevserver({ project: 'app-one' }, mockContext); expect(stopResult1.structuredContent.message).toBe( `Development server for project 'app-one' stopped.`, ); @@ -119,7 +124,7 @@ describe('Serve Tools', () => { expect(process2.kill).not.toHaveBeenCalled(); // Stop server for project 2 - const stopResult2 = stopDevserver({ project: 'app-two' }, mockContext); + const stopResult2 = await stopDevserver({ project: 'app-two' }, mockContext); expect(stopResult2.structuredContent.message).toBe( `Development server for project 'app-two' stopped.`, ); @@ -127,20 +132,20 @@ describe('Serve Tools', () => { }); it('should handle server crash', async () => { - addProjectToWorkspace(mockWorkspace.projects, 'crash-app'); + addProjectToWorkspace(mockContext.workspace.projects, 'crash-app'); await startDevserver({ project: 'crash-app' }, mockContext); // Simulate a crash with exit code 1 mockProcess.stdout.emit('data', 'Fatal error.'); mockProcess.emit('close', 1); - const stopResult = stopDevserver({ project: 'crash-app' }, mockContext); + const stopResult = await stopDevserver({ project: 'crash-app' }, mockContext); expect(stopResult.structuredContent.message).toContain('stopped'); expect(stopResult.structuredContent.logs).toEqual(['Fatal error.']); }); it('wait should timeout if build takes too long', async () => { - addProjectToWorkspace(mockWorkspace.projects, 'timeout-app'); + addProjectToWorkspace(mockContext.workspace.projects, 'timeout-app'); await startDevserver({ project: 'timeout-app' }, mockContext); const waitResult = await waitForDevserverBuild( { project: 'timeout-app', timeout: 10 }, @@ -159,6 +164,9 @@ describe('Serve Tools', () => { const waitPromise = waitForDevserverBuild({ timeout: 5 * WATCH_DELAY }, mockContext); + // Allow the async resolveWorkspaceAndProject to complete. + await Promise.resolve(); + // Tick past the first debounce. The while loop will be entered. jasmine.clock().tick(WATCH_DELAY + 1); @@ -182,4 +190,20 @@ describe('Serve Tools', () => { jasmine.clock().uninstall(); } }); + + it('should fail with list of running servers when server not found', async () => { + addProjectToWorkspace(mockContext.workspace.projects, 'app-one'); + addProjectToWorkspace(mockContext.workspace.projects, 'app-two'); + // Start app-one + await startDevserver({ project: 'app-one' }, mockContext); + + // Try to stop app-two (which is not running) + try { + await stopDevserver({ project: 'app-two' }, mockContext); + fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).toContain('Dev server not found. Currently running servers:'); + expect((e as Error).message).toContain("- Project 'app-one' in workspace path '/test'"); + } + }); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e.ts b/packages/angular/cli/src/commands/mcp/tools/e2e.ts index 86b1ee76f2e3..20de68fef2eb 100644 --- a/packages/angular/cli/src/commands/mcp/tools/e2e.ts +++ b/packages/angular/cli/src/commands/mcp/tools/e2e.ts @@ -7,12 +7,12 @@ */ import { z } from 'zod'; -import { CommandError, type Host, LocalWorkspaceHost } from '../host'; +import { type Host } from '../host'; +import { workspaceAndProjectOptions } from '../shared-options'; import { createStructuredContentOutput, getCommandErrorLogs, - getDefaultProjectName, - getProject, + resolveWorkspaceAndProject, } from '../utils'; import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; @@ -20,12 +20,7 @@ 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.', - ), + ...workspaceAndProjectOptions, }); export type E2eToolInput = z.infer; @@ -38,11 +33,16 @@ const e2eToolOutputSchema = z.object({ export type E2eToolOutput = z.infer; export async function runE2e(input: E2eToolInput, host: Host, context: McpToolContext) { - const projectName = input.project ?? getDefaultProjectName(context); + const { workspacePath, workspace, projectName } = await resolveWorkspaceAndProject({ + host, + workspacePathInput: input.workspace, + projectNameInput: input.project, + mcpWorkspace: context.workspace, + }); - if (context.workspace && projectName) { + if (workspace && projectName) { // Verify that if a project can be found, it has an e2e testing already set up. - const targetProject = getProject(context, projectName); + const targetProject = workspace.projects.get(projectName); if (targetProject) { if (!targetProject.targets.has('e2e')) { return createStructuredContentOutput({ @@ -58,16 +58,13 @@ export async function runE2e(input: E2eToolInput, host: Host, context: McpToolCo } // Build "ng"'s command line. - const args = ['e2e']; - if (input.project) { - args.push(input.project); - } + const args = ['e2e', projectName]; let status: E2eStatus = 'success'; let logs: string[] = []; try { - logs = (await host.runCommand('ng', args)).logs; + logs = (await host.runCommand('ng', args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); @@ -104,5 +101,5 @@ Perform an end-to-end test with ng e2e. isLocalOnly: true, inputSchema: e2eToolInputSchema.shape, outputSchema: e2eToolOutputSchema.shape, - factory: (context) => (input) => runE2e(input, LocalWorkspaceHost, context), + 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 index 852381fc6ee9..d2d3949a6451 100644 --- a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts @@ -7,37 +7,40 @@ */ 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 { + MockMcpToolContext, + 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 mockContext: MockMcpToolContext; 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 () => { + addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; + await runE2e({}, mockHost, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e']); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['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.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app']); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); }); it('should error if project does not have e2e target', async () => { @@ -51,7 +54,7 @@ describe('E2E Tool', () => { }); it('should error if no project was specified and the default project does not have e2e target', async () => { - mockWorkspace.extensions['defaultProject'] = 'my-app'; + mockContext.workspace.extensions['defaultProject'] = 'my-app'; addProjectToWorkspace(mockProjects, 'my-app', { build: { builder: 'mock-builder' } }); const { structuredContent } = await runE2e({}, mockHost, mockContext); @@ -61,13 +64,6 @@ describe('E2E Tool', () => { 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 with a specified project', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E passed for my-app']; @@ -77,11 +73,11 @@ describe('E2E Tool', () => { expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app']); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); }); it('should handle a successful e2e run with the default project', async () => { - mockWorkspace.extensions['defaultProject'] = 'default-app'; + mockContext.workspace.extensions['defaultProject'] = 'default-app'; addProjectToWorkspace(mockProjects, 'default-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E passed for default-app']; mockHost.runCommand.and.resolveTo({ logs: e2eLogs }); @@ -90,7 +86,9 @@ describe('E2E Tool', () => { expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e']); + expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'default-app'], { + cwd: '/test', + }); }); it('should handle a failed e2e run', async () => { diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize.ts b/packages/angular/cli/src/commands/mcp/tools/modernize.ts index 6864ba2a338c..107d3bc01c0c 100644 --- a/packages/angular/cli/src/commands/mcp/tools/modernize.ts +++ b/packages/angular/cli/src/commands/mcp/tools/modernize.ts @@ -6,11 +6,14 @@ * found in the LICENSE file at https://angular.dev/license */ -import { dirname, join, relative } from 'path'; import { z } from 'zod'; -import { CommandError, type Host } from '../host'; -import { createStructuredContentOutput, findAngularJsonDir, getCommandErrorLogs } from '../utils'; -import { type McpToolDeclaration, declareTool } from './tool-registry'; +import { workspaceAndProjectOptions } from '../shared-options'; +import { + createStructuredContentOutput, + getCommandErrorLogs, + resolveWorkspaceAndProject, +} from '../utils'; +import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry'; interface Transformation { name: string; @@ -70,14 +73,15 @@ const TRANSFORMATIONS: Array = [ ]; const modernizeInputSchema = z.object({ - directories: z - .array(z.string()) - .optional() - .describe('A list of paths to directories with files to modernize.'), + ...workspaceAndProjectOptions, transformations: z .array(z.enum(TRANSFORMATIONS.map((t) => t.name) as [string, ...string[]])) .optional() .describe('A list of specific transformations to apply.'), + path: z + .string() + .optional() + .describe('The path to the file or directory to modernize, relative to the workspace root.'), }); const modernizeOutputSchema = z.object({ @@ -93,9 +97,8 @@ const modernizeOutputSchema = z.object({ export type ModernizeInput = z.infer; export type ModernizeOutput = z.infer; -export async function runModernization(input: ModernizeInput, host: Host) { +export async function runModernization(input: ModernizeInput, context: McpToolContext) { const transformationNames = input.transformations ?? []; - const directories = input.directories ?? []; if (transformationNames.length === 0) { return createStructuredContentOutput({ @@ -105,24 +108,13 @@ export async function runModernization(input: ModernizeInput, host: Host) { ], }); } - if (directories.length === 0) { - return createStructuredContentOutput({ - instructions: [ - 'Provide this tool with a list of directory paths in your workspace ' + - 'to run the modernization on.', - ], - }); - } - const firstDir = directories[0]; - const executionDir = (await host.stat(firstDir)).isDirectory() ? firstDir : dirname(firstDir); - - const angularProjectRoot = findAngularJsonDir(executionDir, host); - if (!angularProjectRoot) { - return createStructuredContentOutput({ - instructions: ['Could not find an angular.json file in the current or parent directories.'], - }); - } + const { workspacePath, projectName } = await resolveWorkspaceAndProject({ + host: context.host, + workspacePathInput: input.workspace, + projectNameInput: input.project, + mcpWorkspace: context.workspace, + }); const instructions: string[] = []; let logs: string[] = []; @@ -138,25 +130,22 @@ export async function runModernization(input: ModernizeInput, host: Host) { instructions.push(transformationInstructions); } else { // Simple case, run the command. - for (const dir of directories) { - const relativePath = relative(angularProjectRoot, dir) || '.'; - const command = 'ng'; - const args = ['generate', `@angular/core:${transformation.name}`, '--path', relativePath]; - try { - logs = ( - await host.runCommand(command, args, { - cwd: angularProjectRoot, - }) - ).logs; - instructions.push( - `Migration ${transformation.name} on directory ${relativePath} completed successfully.`, - ); - } catch (e) { - logs = getCommandErrorLogs(e); - instructions.push( - `Migration ${transformation.name} on directory ${relativePath} failed.`, - ); - } + const command = 'ng'; + const args = ['generate', `@angular/core:${transformation.name}`, '--project', projectName]; + if (input.path) { + args.push('--path', input.path); + } + + try { + logs = ( + await context.host.runCommand(command, args, { + cwd: workspacePath, + }) + ).logs; + instructions.push(`Migration ${transformation.name} completed successfully.`); + } catch (e) { + logs = getCommandErrorLogs(e); + instructions.push(`Migration ${transformation.name} failed.`); } } } @@ -202,5 +191,5 @@ ${TRANSFORMATIONS.map((t) => ` * ${t.name}: ${t.description}`).join('\n')} outputSchema: modernizeOutputSchema.shape, isLocalOnly: true, isReadOnly: false, - factory: (context) => (input) => runModernization(input, context.host), + factory: (context) => (input) => runModernization(input, context), }); diff --git a/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts b/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts index 82f0c70e11d3..bdfca95247db 100644 --- a/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/modernize_spec.ts @@ -6,38 +6,30 @@ * found in the LICENSE file at https://angular.dev/license */ -import { Stats } from 'fs'; -import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; -import { tmpdir } from 'os'; -import { join } from 'path'; import { CommandError } from '../host'; import type { MockHost } from '../testing/mock-host'; +import { + MockMcpToolContext, + addProjectToWorkspace, + createMockContext, +} from '../testing/test-utils'; import { type ModernizeOutput, runModernization } from './modernize'; describe('Modernize Tool', () => { - let projectDir: string; let mockHost: MockHost; + let mockContext: MockMcpToolContext; - beforeEach(async () => { - // Create a temporary directory and a fake angular.json to satisfy the tool's project root search. - projectDir = await mkdtemp(join(tmpdir(), 'angular-modernize-test-')); - await writeFile(join(projectDir, 'angular.json'), JSON.stringify({ version: 1, projects: {} })); + beforeEach(() => { + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; - mockHost = { - runCommand: jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' }), - stat: jasmine.createSpy('stat').and.resolveTo({ isDirectory: () => true } as Stats), - existsSync: jasmine.createSpy('existsSync').and.callFake((p: string) => { - return p === join(projectDir, 'angular.json'); - }), - } as MockHost; - }); - - afterEach(async () => { - await rm(projectDir, { recursive: true, force: true }); + addProjectToWorkspace(mock.projects, 'my-app'); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; }); it('should return instructions if no transformations are provided', async () => { - const { structuredContent } = (await runModernization({}, mockHost)) as { + const { structuredContent } = (await runModernization({}, mockContext)) as { structuredContent: ModernizeOutput; }; @@ -48,136 +40,73 @@ describe('Modernize Tool', () => { ]); }); - it('should return instructions if no directories are provided', async () => { + it('can run a single transformation', async () => { const { structuredContent } = (await runModernization( { - transformations: ['control-flow'], + transformations: ['self-closing-tag'], }, - mockHost, - )) as { - structuredContent: ModernizeOutput; - }; + mockContext, + )) as { structuredContent: ModernizeOutput }; - expect(mockHost.runCommand).not.toHaveBeenCalled(); + expect(mockHost.runCommand).toHaveBeenCalledOnceWith( + 'ng', + ['generate', '@angular/core:self-closing-tag', '--project', 'my-app'], + { cwd: '/test' }, + ); expect(structuredContent?.instructions).toEqual([ - 'Provide this tool with a list of directory paths in your workspace ' + - 'to run the modernization on.', + 'Migration self-closing-tag completed successfully.', ]); }); - it('can run a single transformation', async () => { + it('can run a single transformation with path', async () => { const { structuredContent } = (await runModernization( { - directories: [projectDir], transformations: ['self-closing-tag'], + path: '.', }, - mockHost, + mockContext, )) as { structuredContent: ModernizeOutput }; expect(mockHost.runCommand).toHaveBeenCalledOnceWith( 'ng', - ['generate', '@angular/core:self-closing-tag', '--path', '.'], - { cwd: projectDir }, + ['generate', '@angular/core:self-closing-tag', '--project', 'my-app', '--path', '.'], + { cwd: '/test' }, ); expect(structuredContent?.instructions).toEqual([ - 'Migration self-closing-tag on directory . completed successfully.', + 'Migration self-closing-tag completed successfully.', ]); }); it('can run multiple transformations', async () => { const { structuredContent } = (await runModernization( { - directories: [projectDir], transformations: ['control-flow', 'self-closing-tag'], }, - mockHost, + mockContext, )) as { structuredContent: ModernizeOutput }; expect(mockHost.runCommand).toHaveBeenCalledTimes(2); expect(mockHost.runCommand).toHaveBeenCalledWith( 'ng', - ['generate', '@angular/core:control-flow', '--path', '.'], + ['generate', '@angular/core:control-flow', '--project', 'my-app'], { - cwd: projectDir, + cwd: '/test', }, ); expect(mockHost.runCommand).toHaveBeenCalledWith( 'ng', - ['generate', '@angular/core:self-closing-tag', '--path', '.'], - { cwd: projectDir }, + ['generate', '@angular/core:self-closing-tag', '--project', 'my-app'], + { cwd: '/test' }, ); - expect(structuredContent?.logs).toBeUndefined(); + expect(structuredContent?.logs).toEqual([]); expect(structuredContent?.instructions).toEqual( jasmine.arrayWithExactContents([ - 'Migration control-flow on directory . completed successfully.', - 'Migration self-closing-tag on directory . completed successfully.', + 'Migration control-flow completed successfully.', + 'Migration self-closing-tag completed successfully.', ]), ); }); - it('can run multiple transformations across multiple directories', async () => { - const subfolder1 = join(projectDir, 'subfolder1'); - const subfolder2 = join(projectDir, 'subfolder2'); - await mkdir(subfolder1); - await mkdir(subfolder2); - - const { structuredContent } = (await runModernization( - { - directories: [subfolder1, subfolder2], - transformations: ['control-flow', 'self-closing-tag'], - }, - mockHost, - )) as { structuredContent: ModernizeOutput }; - - expect(mockHost.runCommand).toHaveBeenCalledTimes(4); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', - ['generate', '@angular/core:control-flow', '--path', 'subfolder1'], - { cwd: projectDir }, - ); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', - ['generate', '@angular/core:self-closing-tag', '--path', 'subfolder1'], - { cwd: projectDir }, - ); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', - ['generate', '@angular/core:control-flow', '--path', 'subfolder2'], - { cwd: projectDir }, - ); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', - ['generate', '@angular/core:self-closing-tag', '--path', 'subfolder2'], - { cwd: projectDir }, - ); - expect(structuredContent?.logs).toBeUndefined(); - expect(structuredContent?.instructions).toEqual( - jasmine.arrayWithExactContents([ - 'Migration control-flow on directory subfolder1 completed successfully.', - 'Migration self-closing-tag on directory subfolder1 completed successfully.', - 'Migration control-flow on directory subfolder2 completed successfully.', - 'Migration self-closing-tag on directory subfolder2 completed successfully.', - ]), - ); - }); - - it('should return an error if angular.json is not found', async () => { - mockHost.existsSync.and.returnValue(false); - - const { structuredContent } = (await runModernization( - { - directories: [projectDir], - transformations: ['self-closing-tag'], - }, - mockHost, - )) as { structuredContent: ModernizeOutput }; - - expect(mockHost.runCommand).not.toHaveBeenCalled(); - expect(structuredContent?.instructions).toEqual([ - 'Could not find an angular.json file in the current or parent directories.', - ]); - }); - it('should report errors from transformations', async () => { // Simulate a failed execution mockHost.runCommand.and.rejectWith( @@ -186,20 +115,17 @@ describe('Modernize Tool', () => { const { structuredContent } = (await runModernization( { - directories: [projectDir], transformations: ['self-closing-tag'], }, - mockHost, + mockContext, )) as { structuredContent: ModernizeOutput }; expect(mockHost.runCommand).toHaveBeenCalledOnceWith( 'ng', - ['generate', '@angular/core:self-closing-tag', '--path', '.'], - { cwd: projectDir }, + ['generate', '@angular/core:self-closing-tag', '--project', 'my-app'], + { cwd: '/test' }, ); expect(structuredContent?.logs).toEqual(['some logs', 'Command failed with error']); - expect(structuredContent?.instructions).toEqual([ - 'Migration self-closing-tag on directory . failed.', - ]); + expect(structuredContent?.instructions).toEqual(['Migration self-closing-tag failed.']); }); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/test.ts b/packages/angular/cli/src/commands/mcp/tools/test.ts index 829296d815ad..f7d96b9daa92 100644 --- a/packages/angular/cli/src/commands/mcp/tools/test.ts +++ b/packages/angular/cli/src/commands/mcp/tools/test.ts @@ -7,18 +7,19 @@ */ import { z } from 'zod'; -import { CommandError, type Host, LocalWorkspaceHost } from '../host'; -import { createStructuredContentOutput, getCommandErrorLogs } from '../utils'; -import { type McpToolDeclaration, declareTool } from './tool-registry'; +import { workspaceAndProjectOptions } from '../shared-options'; +import { + createStructuredContentOutput, + getCommandErrorLogs, + resolveWorkspaceAndProject, +} from '../utils'; +import { type McpToolContext, 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.'), + ...workspaceAndProjectOptions, filter: z.string().optional().describe('Filter the executed tests by spec name.'), }); @@ -31,12 +32,16 @@ const testToolOutputSchema = z.object({ export type TestToolOutput = z.infer; -export async function runTest(input: TestToolInput, host: Host) { +export async function runTest(input: TestToolInput, context: McpToolContext) { + const { workspacePath, projectName } = await resolveWorkspaceAndProject({ + host: context.host, + workspacePathInput: input.workspace, + projectNameInput: input.project, + mcpWorkspace: context.workspace, + }); + // Build "ng"'s command line. - const args = ['test']; - if (input.project) { - args.push(input.project); - } + const args = ['test', projectName]; // This is ran by the agent so we want a non-watched, headless test. args.push('--browsers', 'ChromeHeadless'); @@ -50,7 +55,7 @@ export async function runTest(input: TestToolInput, host: Host) { let logs: string[] = []; try { - logs = (await host.runCommand('ng', args)).logs; + logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); @@ -88,5 +93,5 @@ Perform a one-off, non-watched unit test execution with ng test. isLocalOnly: true, inputSchema: testToolInputSchema.shape, outputSchema: testToolOutputSchema.shape, - factory: () => (input) => runTest(input, LocalWorkspaceHost), + 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 index 1049e705697d..722432bfed6c 100644 --- a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts @@ -8,50 +8,61 @@ import { CommandError } from '../host'; import type { MockHost } from '../testing/mock-host'; -import { createMockHost } from '../testing/test-utils'; +import { + MockMcpToolContext, + addProjectToWorkspace, + createMockContext, +} from '../testing/test-utils'; import { runTest } from './test'; describe('Test Tool', () => { let mockHost: MockHost; + let mockContext: MockMcpToolContext; beforeEach(() => { - mockHost = createMockHost(); + const mock = createMockContext(); + mockHost = mock.host; + mockContext = mock.context; + addProjectToWorkspace(mock.projects, 'my-app'); }); it('should construct the command correctly with defaults', async () => { - await runTest({}, mockHost); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ - 'test', - '--browsers', - 'ChromeHeadless', - '--watch', - 'false', - ]); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; + await runTest({}, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], + { cwd: '/test' }, + ); }); 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', - ]); + addProjectToWorkspace(mockContext.workspace.projects, 'my-lib'); + await runTest({ project: 'my-lib' }, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['test', 'my-lib', '--browsers', 'ChromeHeadless', '--watch', 'false'], + { cwd: '/test' }, + ); }); 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', - ]); + mockContext.workspace.extensions['defaultProject'] = 'my-app'; + await runTest({ filter: 'AppComponent' }, mockContext); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + [ + 'test', + 'my-app', + '--browsers', + 'ChromeHeadless', + '--watch', + 'false', + '--filter', + 'AppComponent', + ], + { cwd: '/test' }, + ); }); it('should handle a successful test run and capture logs', async () => { @@ -60,26 +71,24 @@ describe('Test Tool', () => { logs: testLogs, }); - const { structuredContent } = await runTest({ project: 'my-app' }, mockHost); + const { structuredContent } = await runTest({ project: 'my-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [ - 'test', - 'my-app', - '--browsers', - 'ChromeHeadless', - '--watch', - 'false', - ]); + expect(mockHost.runCommand).toHaveBeenCalledWith( + 'ng', + ['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.runCommand.and.rejectWith(error); - const { structuredContent } = await runTest({ project: 'my-failed-app' }, mockHost); + const { structuredContent } = await runTest({ project: 'my-failed-app' }, mockContext); expect(structuredContent.status).toBe('failure'); expect(structuredContent.logs).toEqual([...testLogs, 'Test failed']); diff --git a/packages/angular/cli/src/commands/mcp/utils.ts b/packages/angular/cli/src/commands/mcp/utils.ts index 7a505513341d..8e8460b7f869 100644 --- a/packages/angular/cli/src/commands/mcp/utils.ts +++ b/packages/angular/cli/src/commands/mcp/utils.ts @@ -13,7 +13,8 @@ import { workspaces } from '@angular-devkit/core'; import { dirname, join } from 'node:path'; -import { CommandError, LocalWorkspaceHost } from './host'; +import { AngularWorkspace } from '../../utilities/config'; +import { CommandError, type Host, LocalWorkspaceHost } from './host'; import { McpToolContext } from './tools/tool-registry'; /** @@ -76,14 +77,14 @@ export function getProject( * 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; +export function getDefaultProjectName(workspace: AngularWorkspace | undefined): string | undefined { + const projects = workspace?.projects; if (!projects) { return undefined; } - const defaultProjectName = context.workspace?.extensions['defaultProject'] as string | undefined; + const defaultProjectName = workspace?.extensions['defaultProject'] as string | undefined; if (defaultProjectName) { return defaultProjectName; } @@ -110,3 +111,139 @@ export function getCommandErrorLogs(e: unknown): string[] { return [String(e)]; } } + +export function createWorkspaceNotFoundError(): Error { + return new Error( + 'Could not find an Angular workspace (angular.json) in the current directory. ' + + "You can use 'list_projects' to find available workspaces.", + ); +} + +export function createWorkspacePathDoesNotExistError(path: string): Error { + return new Error( + `Workspace path does not exist: ${path}. ` + + "You can use 'list_projects' to find available workspaces.", + ); +} + +export function createNoAngularJsonFoundError(path: string): Error { + return new Error( + `No angular.json found at ${path}. ` + + "You can use 'list_projects' to find available workspaces.", + ); +} + +export function createProjectNotFoundError(projectName: string, workspacePath: string): Error { + return new Error( + `Project '${projectName}' not found in workspace path ${workspacePath}. ` + + "You can use 'list_projects' to find available projects.", + ); +} + +export function createNoProjectResolvedError(workspacePath: string): Error { + return new Error( + `No project name provided and no default project found in workspace path ${workspacePath}. ` + + 'Please provide a project name or set a default project in angular.json. ' + + "You can use 'list_projects' to find available projects.", + ); +} + +export function createDevServerNotFoundError( + devservers: Map, +): Error { + if (devservers.size === 0) { + return new Error('No development servers are currently running.'); + } + + const runningServers = Array.from(devservers.values()) + .map((server) => `- Project '${server.project}' in workspace path '${server.workspacePath}'`) + .join('\n'); + + return new Error( + `Dev server not found. Currently running servers:\n${runningServers}\n` + + 'Please provide the correct workspace and project arguments.', + ); +} + +/** + * Resolves workspace and project for tools to operate on. + * + * If `workspacePathInput` is absent, uses the MCP's configured workspace. If none is configured, use the + * current directory as the workspace. + * If `projectNameInput` is absent, uses the default project in the workspace. + */ +export async function resolveWorkspaceAndProject({ + host, + workspacePathInput, + projectNameInput, + mcpWorkspace, + workspaceLoader = AngularWorkspace.load, +}: { + host: Host; + workspacePathInput?: string; + projectNameInput?: string; + mcpWorkspace?: AngularWorkspace; + workspaceLoader?: (path: string) => Promise; +}): Promise<{ + workspace: AngularWorkspace; + workspacePath: string; + projectName: string; +}> { + let workspacePath: string; + let workspace: AngularWorkspace; + + if (workspacePathInput) { + if (!host.existsSync(workspacePathInput)) { + throw createWorkspacePathDoesNotExistError(workspacePathInput); + } + if (!host.existsSync(join(workspacePathInput, 'angular.json'))) { + throw createNoAngularJsonFoundError(workspacePathInput); + } + workspacePath = workspacePathInput; + const configPath = join(workspacePath, 'angular.json'); + try { + workspace = await workspaceLoader(configPath); + } catch (e) { + throw new Error( + `Failed to load workspace configuration at ${configPath}: ${ + e instanceof Error ? e.message : e + }`, + ); + } + } else if (mcpWorkspace) { + workspace = mcpWorkspace; + workspacePath = workspace.basePath; + } else { + const found = findAngularJsonDir(process.cwd(), host); + + if (!found) { + throw createWorkspaceNotFoundError(); + } + workspacePath = found; + const configPath = join(workspacePath, 'angular.json'); + try { + workspace = await workspaceLoader(configPath); + } catch (e) { + throw new Error( + `Failed to load workspace configuration at ${configPath}: ${ + e instanceof Error ? e.message : e + }`, + ); + } + } + + let projectName = projectNameInput; + if (projectName) { + if (!workspace.projects.has(projectName)) { + throw createProjectNotFoundError(projectName, workspacePath); + } + } else { + projectName = getDefaultProjectName(workspace); + } + + if (!projectName) { + throw createNoProjectResolvedError(workspacePath); + } + + return { workspace, workspacePath, projectName }; +} diff --git a/packages/angular/cli/src/commands/mcp/utils_spec.ts b/packages/angular/cli/src/commands/mcp/utils_spec.ts index 26dd0798e095..d2f35c508bee 100644 --- a/packages/angular/cli/src/commands/mcp/utils_spec.ts +++ b/packages/angular/cli/src/commands/mcp/utils_spec.ts @@ -6,15 +6,23 @@ * found in the LICENSE file at https://angular.dev/license */ +import { workspaces } from '@angular-devkit/core'; import { join } from 'node:path'; +import { AngularWorkspace } from '../../utilities/config'; import { CommandError, LocalWorkspaceHost } from './host'; -import { addProjectToWorkspace, createMockContext } from './testing/test-utils'; +import { addProjectToWorkspace, createMockContext, createMockHost } from './testing/test-utils'; import { + createNoAngularJsonFoundError, + createNoProjectResolvedError, + createProjectNotFoundError, createStructuredContentOutput, + createWorkspaceNotFoundError, + createWorkspacePathDoesNotExistError, findAngularJsonDir, getCommandErrorLogs, getDefaultProjectName, getProject, + resolveWorkspaceAndProject, } from './utils'; describe('MCP Utils', () => { @@ -84,26 +92,26 @@ describe('MCP Utils', () => { it('should return undefined if workspace is missing', () => { const { context } = createMockContext(); const emptyContext = { ...context, workspace: undefined }; - expect(getDefaultProjectName(emptyContext)).toBeUndefined(); + expect(getDefaultProjectName(emptyContext.workspace)).toBeUndefined(); }); it('should return defaultProject from extensions', () => { - const { context, workspace } = createMockContext(); - workspace.extensions['defaultProject'] = 'my-app'; - expect(getDefaultProjectName(context)).toBe('my-app'); + const { context } = createMockContext(); + context.workspace.extensions['defaultProject'] = 'my-app'; + expect(getDefaultProjectName(context.workspace)).toBe('my-app'); }); it('should return single project name if only one exists and no defaultProject', () => { const { context, projects } = createMockContext(); addProjectToWorkspace(projects, 'only-app', {}, ''); - expect(getDefaultProjectName(context)).toBe('only-app'); + expect(getDefaultProjectName(context.workspace)).toBe('only-app'); }); it('should return undefined if multiple projects exist and no defaultProject', () => { const { context, projects } = createMockContext(); addProjectToWorkspace(projects, 'app1', {}, ''); addProjectToWorkspace(projects, 'app2', {}, ''); - expect(getDefaultProjectName(context)).toBeUndefined(); + expect(getDefaultProjectName(context.workspace)).toBeUndefined(); }); }); @@ -123,4 +131,158 @@ describe('MCP Utils', () => { expect(getCommandErrorLogs('weird error')).toEqual(['weird error']); }); }); + + describe('resolveWorkspaceAndProject', () => { + let mockHost: ReturnType; + let mockLoader: jasmine.Spy; + let mockWorkspace: AngularWorkspace; + const cwd = './'; + + beforeEach(() => { + mockHost = createMockHost(); + spyOn(process, 'cwd').and.returnValue(cwd); + + // Setup default mocks + mockHost.existsSync.and.callFake((p) => { + // Mock presence of angular.json in CWD + if (p === join(cwd, 'angular.json')) { + return true; + } + // Mock presence of specific workspace + if (p === '/my/workspace') { + return true; + } + if (p === '/my/workspace/angular.json') { + return true; + } + + return false; + }); + + const projects = new workspaces.ProjectDefinitionCollection(); + projects.set('app', { + root: 'app', + extensions: {}, + targets: new workspaces.TargetDefinitionCollection(), + }); + + mockWorkspace = { + projects, + extensions: { defaultProject: 'app' }, + basePath: cwd, + filePath: join(cwd, 'angular.json'), + } as unknown as AngularWorkspace; + + mockLoader = jasmine.createSpy('workspaceLoader').and.resolveTo(mockWorkspace); + }); + + it('should resolve workspace from CWD if not provided and mcpWorkspace is absent', async () => { + const result = await resolveWorkspaceAndProject({ + host: mockHost, + workspaceLoader: mockLoader, + }); + expect(result.workspacePath).toBe(cwd); + expect(result.projectName).toBe('app'); + expect(mockLoader).toHaveBeenCalledWith(join(cwd, 'angular.json')); + }); + + it('should use mcpWorkspace if provided and no input path', async () => { + const result = await resolveWorkspaceAndProject({ + host: mockHost, + mcpWorkspace: mockWorkspace, + workspaceLoader: mockLoader, + }); + expect(result.workspace).toBe(mockWorkspace); + expect(result.workspacePath).toBe(mockWorkspace.basePath); + expect(mockLoader).not.toHaveBeenCalled(); + }); + + it('should prefer workspacePathInput over mcpWorkspace', async () => { + const result = await resolveWorkspaceAndProject({ + host: mockHost, + workspacePathInput: '/my/workspace', + mcpWorkspace: mockWorkspace, + workspaceLoader: mockLoader, + }); + expect(result.workspacePath).toBe('/my/workspace'); + expect(mockLoader).toHaveBeenCalledWith('/my/workspace/angular.json'); + }); + + it('should resolve provided workspace', async () => { + const result = await resolveWorkspaceAndProject({ + host: mockHost, + workspacePathInput: '/my/workspace', + workspaceLoader: mockLoader, + }); + expect(result.workspacePath).toBe('/my/workspace'); + expect(mockLoader).toHaveBeenCalledWith('/my/workspace/angular.json'); + }); + + it('should throw if provided workspace does not exist', async () => { + mockHost.existsSync.and.returnValue(false); + await expectAsync( + resolveWorkspaceAndProject({ + host: mockHost, + workspacePathInput: '/bad/path', + workspaceLoader: mockLoader, + }), + ).toBeRejectedWithError(createWorkspacePathDoesNotExistError('/bad/path').message); + }); + + it('should throw if provided workspace has no angular.json', async () => { + mockHost.existsSync.and.callFake((p) => p === '/path'); + await expectAsync( + resolveWorkspaceAndProject({ + host: mockHost, + workspacePathInput: '/path', + workspaceLoader: mockLoader, + }), + ).toBeRejectedWithError(createNoAngularJsonFoundError('/path').message); + }); + + it('should resolve provided project', async () => { + const result = await resolveWorkspaceAndProject({ + host: mockHost, + projectNameInput: 'app', + workspaceLoader: mockLoader, + }); + expect(result.projectName).toBe('app'); + }); + + it('should throw if provided project does not exist', async () => { + await expectAsync( + resolveWorkspaceAndProject({ + host: mockHost, + projectNameInput: 'bad-app', + workspaceLoader: mockLoader, + }), + ).toBeRejectedWithError(createProjectNotFoundError('bad-app', cwd).message); + }); + + it('should throw if no project resolved', async () => { + mockWorkspace.extensions['defaultProject'] = undefined; + mockWorkspace.projects.set('app2', { + root: 'app2', + extensions: {}, + targets: new workspaces.TargetDefinitionCollection(), + }); + + await expectAsync( + resolveWorkspaceAndProject({ + host: mockHost, + workspaceLoader: mockLoader, + }), + ).toBeRejectedWithError(createNoProjectResolvedError(cwd).message); + }); + + it('should throw if mcpWorkspace is absent and no workspace found in CWD', async () => { + mockHost.existsSync.and.returnValue(false); + await expectAsync( + resolveWorkspaceAndProject({ + host: mockHost, + workspaceLoader: mockLoader, + }), + ).toBeRejectedWithError(createWorkspaceNotFoundError().message); + }); + }); });