Skip to content

Commit ec9534a

Browse files
committed
refactor(@angular/cli): clean up MCP tests and use real project name for default projects
1 parent c61bfb9 commit ec9534a

File tree

11 files changed

+250
-97
lines changed

11 files changed

+250
-97
lines changed

packages/angular/cli/src/commands/mcp/devserver.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,6 @@ export interface Devserver {
6464
port: number;
6565
}
6666

67-
export function devserverKey(project?: string) {
68-
return project ?? '<default>';
69-
}
70-
7167
/**
7268
* A local Angular development server managed by the MCP server.
7369
*/
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { workspaces } from '@angular-devkit/core';
10+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11+
import { AngularWorkspace } from '../../../utilities/config';
12+
import { type Devserver } from '../devserver';
13+
import { Host } from '../host';
14+
import { McpToolContext } from '../tools/tool-registry';
15+
import { MockHost } from './mock-host';
16+
17+
/**
18+
* Creates a mock implementation of the Host interface for testing purposes.
19+
* Each method is a Jasmine spy that can be configured.
20+
*/
21+
export function createMockHost(): MockHost {
22+
return {
23+
runCommand: jasmine.createSpy<Host['runCommand']>('runCommand').and.resolveTo({ logs: [] }),
24+
stat: jasmine.createSpy<Host['stat']>('stat'),
25+
existsSync: jasmine.createSpy<Host['existsSync']>('existsSync'),
26+
spawn: jasmine.createSpy<Host['spawn']>('spawn'),
27+
getAvailablePort: jasmine
28+
.createSpy<Host['getAvailablePort']>('getAvailablePort')
29+
.and.resolveTo(0),
30+
} as unknown as MockHost;
31+
}
32+
33+
/**
34+
* Options for configuring the mock MCP tool context.
35+
*/
36+
export interface MockContextOptions {
37+
/** An optional pre-configured mock host. If not provided, a default mock host will be created. */
38+
host?: MockHost;
39+
40+
/** Initial set of projects to populate the mock workspace with. */
41+
projects?: Record<string, workspaces.ProjectDefinition>;
42+
}
43+
44+
/**
45+
* Creates a comprehensive mock for the McpToolContext, including a mock Host,
46+
* an AngularWorkspace, and a ProjectDefinitionCollection. This simplifies testing
47+
* MCP tools by providing a consistent and configurable testing environment.
48+
* @param options Configuration options for the mock context.
49+
* @returns An object containing the mock host, context, projects collection, and workspace instance.
50+
*/
51+
export function createMockContext(options: MockContextOptions = {}): {
52+
host: MockHost;
53+
context: McpToolContext;
54+
projects: workspaces.ProjectDefinitionCollection;
55+
workspace: AngularWorkspace;
56+
} {
57+
const host = options.host ?? createMockHost();
58+
const projects = new workspaces.ProjectDefinitionCollection(options.projects);
59+
const workspace = new AngularWorkspace({ projects, extensions: {} }, '/test/angular.json');
60+
61+
const context: McpToolContext = {
62+
server: {} as unknown as McpServer,
63+
workspace,
64+
logger: { warn: () => {} },
65+
devservers: new Map<string, Devserver>(),
66+
host,
67+
};
68+
69+
return { host, context, projects, workspace };
70+
}
71+
72+
/**
73+
* Adds a project to the provided mock ProjectDefinitionCollection.
74+
* This is a helper function to easily populate a mock Angular workspace.
75+
* @param projects The ProjectDefinitionCollection to add the project to.
76+
* @param name The name of the project.
77+
* @param targets A record of target definitions for the project (e.g., build, test, e2e).
78+
* @param root The root path of the project, relative to the workspace root. Defaults to `projects/${name}`.
79+
*/
80+
export function addProjectToWorkspace(
81+
projects: workspaces.ProjectDefinitionCollection,
82+
name: string,
83+
targets: Record<string, workspaces.TargetDefinition> = {},
84+
root: string = `projects/${name}`,
85+
) {
86+
projects.set(name, {
87+
root,
88+
extensions: {},
89+
targets: new workspaces.TargetDefinitionCollection(targets),
90+
});
91+
}

packages/angular/cli/src/commands/mcp/tools/build_spec.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,16 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { CommandError, Host } from '../host';
9+
import { CommandError } from '../host';
1010
import type { MockHost } from '../testing/mock-host';
11+
import { createMockHost } from '../testing/test-utils';
1112
import { runBuild } from './build';
1213

1314
describe('Build Tool', () => {
1415
let mockHost: MockHost;
1516

1617
beforeEach(() => {
17-
mockHost = {
18-
runCommand: jasmine.createSpy<Host['runCommand']>('runCommand').and.resolveTo({ logs: [] }),
19-
stat: jasmine.createSpy<Host['stat']>('stat'),
20-
existsSync: jasmine.createSpy<Host['existsSync']>('existsSync'),
21-
} as MockHost;
18+
mockHost = createMockHost();
2219
});
2320

2421
it('should construct the command correctly with default configuration', async () => {

packages/angular/cli/src/commands/mcp/tools/devserver/devserver-start.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
*/
88

99
import { z } from 'zod';
10-
import { LocalDevserver, devserverKey } from '../../devserver';
11-
import { createStructuredContentOutput } from '../../utils';
10+
import { LocalDevserver } from '../../devserver';
11+
import { createStructuredContentOutput, getDefaultProjectName } from '../../utils';
1212
import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry';
1313

1414
const devserverStartToolInputSchema = z.object({
@@ -39,12 +39,18 @@ function localhostAddress(port: number) {
3939
}
4040

4141
export async function startDevserver(input: DevserverStartToolInput, context: McpToolContext) {
42-
const projectKey = devserverKey(input.project);
42+
const projectName = input.project ?? getDefaultProjectName(context);
4343

44-
let devserver = context.devservers.get(projectKey);
44+
if (!projectName) {
45+
return createStructuredContentOutput({
46+
message: ['Project name not provided, and no default project found.'],
47+
});
48+
}
49+
50+
let devserver = context.devservers.get(projectName);
4551
if (devserver) {
4652
return createStructuredContentOutput({
47-
message: `Development server for project '${projectKey}' is already running.`,
53+
message: `Development server for project '${projectName}' is already running.`,
4854
address: localhostAddress(devserver.port),
4955
});
5056
}
@@ -54,10 +60,10 @@ export async function startDevserver(input: DevserverStartToolInput, context: Mc
5460
devserver = new LocalDevserver({ host: context.host, project: input.project, port });
5561
devserver.start();
5662

57-
context.devservers.set(projectKey, devserver);
63+
context.devservers.set(projectName, devserver);
5864

5965
return createStructuredContentOutput({
60-
message: `Development server for project '${projectKey}' started and watching for workspace changes.`,
66+
message: `Development server for project '${projectName}' started and watching for workspace changes.`,
6167
address: localhostAddress(port),
6268
});
6369
}

packages/angular/cli/src/commands/mcp/tools/devserver/devserver-stop.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
*/
88

99
import { z } from 'zod';
10-
import { devserverKey } from '../../devserver';
11-
import { createStructuredContentOutput } from '../../utils';
10+
import { createStructuredContentOutput, getDefaultProjectName } from '../../utils';
1211
import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry';
1312

1413
const devserverStopToolInputSchema = z.object({
@@ -30,21 +29,41 @@ const devserverStopToolOutputSchema = z.object({
3029
export type DevserverStopToolOutput = z.infer<typeof devserverStopToolOutputSchema>;
3130

3231
export function stopDevserver(input: DevserverStopToolInput, context: McpToolContext) {
33-
const projectKey = devserverKey(input.project);
34-
const devServer = context.devservers.get(projectKey);
32+
if (context.devservers.size === 0) {
33+
return createStructuredContentOutput({
34+
message: ['No development servers are currently running.'],
35+
logs: undefined,
36+
});
37+
}
38+
39+
let projectName = input.project ?? getDefaultProjectName(context);
40+
41+
if (!projectName) {
42+
// This should not happen. But if there's just a single running devserver, stop it.
43+
if (context.devservers.size === 1) {
44+
projectName = Array.from(context.devservers.keys())[0];
45+
} else {
46+
return createStructuredContentOutput({
47+
message: ['Project name not provided, and no default project found.'],
48+
logs: undefined,
49+
});
50+
}
51+
}
52+
53+
const devServer = context.devservers.get(projectName);
3554

3655
if (!devServer) {
3756
return createStructuredContentOutput({
38-
message: `Development server for project '${projectKey}' was not running.`,
57+
message: `Development server for project '${projectName}' was not running.`,
3958
logs: undefined,
4059
});
4160
}
4261

4362
devServer.stop();
44-
context.devservers.delete(projectKey);
63+
context.devservers.delete(projectName);
4564

4665
return createStructuredContentOutput({
47-
message: `Development server for project '${projectKey}' stopped.`,
66+
message: `Development server for project '${projectName}' stopped.`,
4867
logs: devServer.getServerLogs(),
4968
});
5069
}

packages/angular/cli/src/commands/mcp/tools/devserver/devserver-wait-for-build.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
*/
88

99
import { z } from 'zod';
10-
import { devserverKey } from '../../devserver';
11-
import { createStructuredContentOutput } from '../../utils';
10+
import { createStructuredContentOutput, getDefaultProjectName } from '../../utils';
1211
import { type McpToolContext, type McpToolDeclaration, declareTool } from '../tool-registry';
1312

1413
/**
@@ -60,21 +59,43 @@ export async function waitForDevserverBuild(
6059
input: DevserverWaitForBuildToolInput,
6160
context: McpToolContext,
6261
) {
63-
const projectKey = devserverKey(input.project);
64-
const devServer = context.devservers.get(projectKey);
65-
const deadline = Date.now() + input.timeout;
62+
if (context.devservers.size === 0) {
63+
return createStructuredContentOutput({
64+
status: 'no_devserver_found',
65+
logs: undefined,
66+
});
67+
}
68+
69+
let projectName = input.project ?? getDefaultProjectName(context);
70+
71+
if (!projectName) {
72+
// This should not happen. But if there's just a single running devserver, wait for it.
73+
if (context.devservers.size === 1) {
74+
projectName = Array.from(context.devservers.keys())[0];
75+
} else {
76+
return createStructuredContentOutput({
77+
status: 'no_devserver_found',
78+
logs: undefined,
79+
});
80+
}
81+
}
82+
83+
const devServer = context.devservers.get(projectName);
6684

6785
if (!devServer) {
6886
return createStructuredContentOutput<DevserverWaitForBuildToolOutput>({
6987
status: 'no_devserver_found',
88+
logs: undefined,
7089
});
7190
}
7291

92+
const deadline = Date.now() + input.timeout;
7393
await wait(WATCH_DELAY);
7494
while (devServer.isBuilding()) {
7595
if (Date.now() > deadline) {
7696
return createStructuredContentOutput<DevserverWaitForBuildToolOutput>({
7797
status: 'timeout',
98+
logs: undefined,
7899
});
79100
}
80101
await wait(WATCH_DELAY);

packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
import { EventEmitter } from 'events';
1010
import type { ChildProcess } from 'node:child_process';
11+
import { AngularWorkspace } from '../../../../utilities/config';
1112
import type { MockHost } from '../../testing/mock-host';
13+
import { addProjectToWorkspace, createMockContext } from '../../testing/test-utils';
1214
import type { McpToolContext } from '../tool-registry';
1315
import { startDevserver } from './devserver-start';
1416
import { stopDevserver } from './devserver-stop';
@@ -25,33 +27,36 @@ describe('Serve Tools', () => {
2527
let mockContext: McpToolContext;
2628
let mockProcess: MockChildProcess;
2729
let portCounter: number;
30+
let mockWorkspace: AngularWorkspace;
2831

2932
beforeEach(() => {
3033
portCounter = 12345;
3134
mockProcess = new MockChildProcess();
32-
mockHost = {
33-
spawn: jasmine.createSpy('spawn').and.returnValue(mockProcess as unknown as ChildProcess),
34-
getAvailablePort: jasmine.createSpy('getAvailablePort').and.callFake(() => {
35-
return Promise.resolve(portCounter++);
36-
}),
37-
} as MockHost;
38-
39-
mockContext = {
40-
devservers: new Map(),
41-
host: mockHost,
42-
} as Partial<McpToolContext> as McpToolContext;
35+
36+
const mock = createMockContext();
37+
mockHost = mock.host;
38+
mockContext = mock.context;
39+
mockWorkspace = mock.workspace;
40+
41+
// Customize host spies
42+
mockHost.spawn.and.returnValue(mockProcess as unknown as ChildProcess);
43+
mockHost.getAvailablePort.and.callFake(() => Promise.resolve(portCounter++));
44+
45+
// Setup default project
46+
addProjectToWorkspace(mock.projects, 'my-app');
47+
mockWorkspace.extensions['defaultProject'] = 'my-app';
4348
});
4449

4550
it('should start and stop a dev server', async () => {
4651
const startResult = await startDevserver({}, mockContext);
4752
expect(startResult.structuredContent.message).toBe(
48-
`Development server for project '<default>' started and watching for workspace changes.`,
53+
`Development server for project 'my-app' started and watching for workspace changes.`,
4954
);
5055
expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', '--port=12345'], { stdio: 'pipe' });
5156

5257
const stopResult = stopDevserver({}, mockContext);
5358
expect(stopResult.structuredContent.message).toBe(
54-
`Development server for project '<default>' stopped.`,
59+
`Development server for project 'my-app' stopped.`,
5560
);
5661
expect(mockProcess.kill).toHaveBeenCalled();
5762
});
@@ -78,6 +83,11 @@ describe('Serve Tools', () => {
7883
});
7984

8085
it('should handle multiple dev servers', async () => {
86+
// Add extra projects
87+
const projects = mockWorkspace.projects;
88+
addProjectToWorkspace(projects, 'app-one');
89+
addProjectToWorkspace(projects, 'app-two');
90+
8191
// Start server for project 1. This uses the basic mockProcess created for the tests.
8292
const startResult1 = await startDevserver({ project: 'app-one' }, mockContext);
8393
expect(startResult1.structuredContent.message).toBe(
@@ -117,6 +127,7 @@ describe('Serve Tools', () => {
117127
});
118128

119129
it('should handle server crash', async () => {
130+
addProjectToWorkspace(mockWorkspace.projects, 'crash-app');
120131
await startDevserver({ project: 'crash-app' }, mockContext);
121132

122133
// Simulate a crash with exit code 1
@@ -129,6 +140,7 @@ describe('Serve Tools', () => {
129140
});
130141

131142
it('wait should timeout if build takes too long', async () => {
143+
addProjectToWorkspace(mockWorkspace.projects, 'timeout-app');
132144
await startDevserver({ project: 'timeout-app' }, mockContext);
133145
const waitResult = await waitForDevserverBuild(
134146
{ project: 'timeout-app', timeout: 10 },

0 commit comments

Comments
 (0)