Skip to content

Commit 04d6890

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

File tree

15 files changed

+432
-136
lines changed

15 files changed

+432
-136
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 = `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.ts

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

99
import { z } from 'zod';
1010
import { CommandError, type Host } from '../host';
11-
import { createStructuredContentOutput } from '../utils';
11+
import { createStructuredContentOutput, getCommandErrorLogs } from '../utils';
1212
import { type McpToolDeclaration, declareTool } from './tool-registry';
1313

1414
const DEFAULT_CONFIGURATION = 'development';
@@ -55,13 +55,7 @@ export async function runBuild(input: BuildToolInput, host: Host) {
5555
logs = (await host.runCommand('ng', args)).logs;
5656
} catch (e) {
5757
status = 'failure';
58-
if (e instanceof CommandError) {
59-
logs = e.logs;
60-
} else if (e instanceof Error) {
61-
logs = [e.message];
62-
} else {
63-
logs = [String(e)];
64-
}
58+
logs = getCommandErrorLogs(e);
6559
}
6660

6761
for (const line of logs) {

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

Lines changed: 4 additions & 7 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 () => {
@@ -82,7 +79,7 @@ describe('Build Tool', () => {
8279
'production',
8380
]);
8481
expect(structuredContent.status).toBe('failure');
85-
expect(structuredContent.logs).toEqual(buildLogs);
82+
expect(structuredContent.logs).toEqual([...buildLogs, 'Build failed']);
8683
expect(structuredContent.path).toBeUndefined();
8784
});
8885

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);

0 commit comments

Comments
 (0)