Skip to content

Commit a6dc1f2

Browse files
committed
refactor(@angular/cli): Added tests to MCP's serve tools
1 parent 71179fc commit a6dc1f2

File tree

6 files changed

+195
-51
lines changed

6 files changed

+195
-51
lines changed

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { existsSync as nodeExistsSync } from 'fs';
1717
import { ChildProcess, spawn } from 'node:child_process';
1818
import { Stats } from 'node:fs';
1919
import { stat } from 'node:fs/promises';
20+
import { createServer } from 'node:net';
2021

2122
/**
2223
* An error thrown when a command fails to execute.
@@ -85,6 +86,11 @@ export interface Host {
8586
env?: Record<string, string>;
8687
},
8788
): ChildProcess;
89+
90+
/**
91+
* Finds an available TCP port on the system.
92+
*/
93+
getAvailablePort(): Promise<number>;
8894
}
8995

9096
/**
@@ -166,4 +172,30 @@ export const LocalWorkspaceHost: Host = {
166172
},
167173
});
168174
},
175+
176+
getAvailablePort(): Promise<number> {
177+
return new Promise((resolve, reject) => {
178+
// Create a new temporary server from Node's net library.
179+
const server = createServer();
180+
181+
server.once('error', (err: unknown) => {
182+
reject(err);
183+
});
184+
185+
// Listen on port 0 to let the OS assign an available port.
186+
server.listen(0, () => {
187+
const address = server.address();
188+
189+
// Ensure address is an object with a port property.
190+
if (address && typeof address === 'object') {
191+
const port = address.port;
192+
193+
server.close();
194+
resolve(port);
195+
} else {
196+
reject(new Error('Unable to retrieve address information from server.'));
197+
}
198+
});
199+
});
200+
},
169201
};

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import { Stats } from 'fs';
1010
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
1111
import { tmpdir } from 'os';
1212
import { join } from 'path';
13-
import * as host from '../host';
13+
import { CommandError, Host } from '../host';
1414
import { ModernizeOutput, runModernization } from './modernize';
1515

1616
describe('Modernize Tool', () => {
1717
let projectDir: string;
18-
let mockHost: host.Host;
18+
let mockHost: Host;
1919

2020
beforeEach(async () => {
2121
// Create a temporary directory and a fake angular.json to satisfy the tool's project root search.
@@ -28,7 +28,7 @@ describe('Modernize Tool', () => {
2828
existsSync: jasmine.createSpy('existsSync').and.callFake((p: string) => {
2929
return p === join(projectDir, 'angular.json');
3030
}),
31-
};
31+
} as Partial<Host> as Host;
3232
});
3333

3434
afterEach(async () => {
@@ -180,7 +180,7 @@ describe('Modernize Tool', () => {
180180
it('should report errors from transformations', async () => {
181181
// Simulate a failed execution
182182
(mockHost.runCommand as jasmine.Spy).and.rejectWith(
183-
new host.CommandError('Command failed with error', 'stdout', 'stderr', 1),
183+
new CommandError('Command failed with error', 'stdout', 'stderr', 1),
184184
);
185185

186186
const { structuredContent } = (await runModernization(
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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 { EventEmitter } from 'events';
10+
import { ChildProcess } from 'node:child_process';
11+
import { Host } from '../host';
12+
import { startDevServer } from './start-devserver';
13+
import { stopDevserver } from './stop-devserver';
14+
import { McpToolContext } from './tool-registry';
15+
import { waitForDevserverBuild } from './wait-for-devserver-build';
16+
17+
class MockChildProcess extends EventEmitter {
18+
stdout = new EventEmitter();
19+
stderr = new EventEmitter();
20+
kill = jasmine.createSpy('kill');
21+
}
22+
23+
describe('Serve Tools', () => {
24+
let mockHost: Host;
25+
let mockContext: McpToolContext;
26+
let mockProcess: MockChildProcess;
27+
let portCounter: number;
28+
29+
beforeEach(() => {
30+
portCounter = 12345;
31+
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 Partial<Host> as Host;
38+
39+
mockContext = {
40+
devServers: new Map(),
41+
} as Partial<McpToolContext> as McpToolContext;
42+
});
43+
44+
it('should start and stop a dev server', async () => {
45+
const startResult = await startDevServer({}, mockContext, mockHost);
46+
expect(startResult.structuredContent.message).toBe(
47+
`Development server for project '<default>' started and watching for workspace changes.`,
48+
);
49+
expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', '--port=12345'], { stdio: 'pipe' });
50+
51+
const stopResult = stopDevserver({}, mockContext);
52+
expect(stopResult.structuredContent.message).toBe(
53+
`Development server for project '<default>' stopped.`,
54+
);
55+
expect(mockProcess.kill).toHaveBeenCalled();
56+
});
57+
58+
it('should find a free port if none is provided', async () => {
59+
await startDevServer({}, mockContext, mockHost);
60+
expect(mockHost.getAvailablePort).toHaveBeenCalled();
61+
});
62+
63+
it('should wait for a build to complete', async () => {
64+
await startDevServer({}, mockContext, mockHost);
65+
66+
const waitPromise = waitForDevserverBuild({ timeout: 10 }, mockContext);
67+
68+
// Simulate build logs
69+
mockProcess.stdout.emit('data', '... building ...');
70+
mockProcess.stdout.emit('data', '✔ Changes detected. Rebuilding...');
71+
mockProcess.stdout.emit('data', '... more logs ...');
72+
mockProcess.stdout.emit('data', 'Application bundle generation complete.');
73+
74+
const waitResult = await waitPromise;
75+
expect(waitResult.structuredContent.status).toBe('success');
76+
expect(waitResult.structuredContent.logs).toEqual([
77+
'✔ Changes detected. Rebuilding...',
78+
'... more logs ...',
79+
'Application bundle generation complete.',
80+
]);
81+
});
82+
83+
it('should handle multiple dev servers', async () => {
84+
// Start server for project 1
85+
const startResult1 = await startDevServer({ project: 'app-one' }, mockContext, mockHost);
86+
expect(startResult1.structuredContent.message).toBe(
87+
`Development server for project 'app-one' started and watching for workspace changes.`,
88+
);
89+
const process1 = mockProcess;
90+
91+
// Start server for project 2
92+
mockProcess = new MockChildProcess();
93+
(mockHost.spawn as jasmine.Spy).and.returnValue(mockProcess as unknown as ChildProcess);
94+
const startResult2 = await startDevServer({ project: 'app-two' }, mockContext, mockHost);
95+
expect(startResult2.structuredContent.message).toBe(
96+
`Development server for project 'app-two' started and watching for workspace changes.`,
97+
);
98+
const process2 = mockProcess;
99+
100+
expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-one', '--port=12345'], {
101+
stdio: 'pipe',
102+
});
103+
expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-two', '--port=12346'], {
104+
stdio: 'pipe',
105+
});
106+
107+
// Stop server for project 1
108+
const stopResult1 = stopDevserver({ project: 'app-one' }, mockContext);
109+
expect(stopResult1.structuredContent.message).toBe(
110+
`Development server for project 'app-one' stopped.`,
111+
);
112+
expect(process1.kill).toHaveBeenCalled();
113+
expect(process2.kill).not.toHaveBeenCalled();
114+
115+
// Stop server for project 2
116+
const stopResult2 = stopDevserver({ project: 'app-two' }, mockContext);
117+
expect(stopResult2.structuredContent.message).toBe(
118+
`Development server for project 'app-two' stopped.`,
119+
);
120+
expect(process2.kill).toHaveBeenCalled();
121+
});
122+
123+
it('should handle server crash', async () => {
124+
await startDevServer({ project: 'crash-app' }, mockContext, mockHost);
125+
mockProcess.emit('close', 1); // Simulate a crash with exit code 1
126+
127+
const stopResult = stopDevserver({ project: 'crash-app' }, mockContext);
128+
expect(stopResult.structuredContent.message).toContain('is not running');
129+
});
130+
131+
it('wait should timeout if build takes too long', async () => {
132+
await startDevServer({ project: 'timeout-app' }, mockContext, mockHost);
133+
const waitResult = await waitForDevserverBuild(
134+
{ project: 'timeout-app', timeout: 10 },
135+
mockContext,
136+
);
137+
expect(waitResult.structuredContent.status).toBe('timeout');
138+
});
139+
});

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

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

9-
import { createServer } from 'net';
109
import { z } from 'zod';
1110
import { LocalDevServer, devServerKey } from '../dev-server';
1211
import { Host, LocalWorkspaceHost } from '../host';
1312
import { createStructureContentOutput } from '../utils';
1413
import { McpToolContext, McpToolDeclaration, declareTool } from './tool-registry';
1514

16-
const startDevserverToolInputSchema = z.object({
15+
const startDevServerToolInputSchema = z.object({
1716
project: z
1817
.string()
1918
.optional()
@@ -22,9 +21,9 @@ const startDevserverToolInputSchema = z.object({
2221
),
2322
});
2423

25-
export type StartDevserverToolInput = z.infer<typeof startDevserverToolInputSchema>;
24+
export type StartDevserverToolInput = z.infer<typeof startDevServerToolInputSchema>;
2625

27-
const startDevserverToolOutputSchema = z.object({
26+
const startDevServerToolOutputSchema = z.object({
2827
message: z.string().describe('A message indicating the result of the operation.'),
2928
address: z
3029
.string()
@@ -34,42 +33,17 @@ const startDevserverToolOutputSchema = z.object({
3433
),
3534
});
3635

37-
export type StartDevserverToolOutput = z.infer<typeof startDevserverToolOutputSchema>;
38-
39-
/**
40-
* Finds an available TCP port on the system.
41-
*/
42-
function getAvailablePort(): Promise<number> {
43-
return new Promise((resolve, reject) => {
44-
// Create a new temporary server from Node's net library.
45-
const server = createServer();
46-
47-
server.once('error', (err: unknown) => {
48-
reject(err);
49-
});
50-
51-
// Listen on port 0 to let the OS assign an available port.
52-
server.listen(0, () => {
53-
const address = server.address();
54-
55-
// Ensure address is an object with a port property.
56-
if (address && typeof address === 'object') {
57-
const port = address.port;
58-
59-
server.close();
60-
resolve(port);
61-
} else {
62-
reject(new Error('Unable to retrieve address information from server.'));
63-
}
64-
});
65-
});
66-
}
36+
export type StartDevserverToolOutput = z.infer<typeof startDevServerToolOutputSchema>;
6737

6838
function localhostAddress(port: number) {
6939
return `http://localhost:${port}/`;
7040
}
7141

72-
async function startDevserver(input: StartDevserverToolInput, context: McpToolContext, host: Host) {
42+
export async function startDevServer(
43+
input: StartDevserverToolInput,
44+
context: McpToolContext,
45+
host: Host,
46+
) {
7347
const projectKey = devServerKey(input.project);
7448

7549
let devServer = context.devServers.get(projectKey);
@@ -80,7 +54,7 @@ async function startDevserver(input: StartDevserverToolInput, context: McpToolCo
8054
});
8155
}
8256

83-
const port = await getAvailablePort();
57+
const port = await host.getAvailablePort();
8458

8559
devServer = new LocalDevServer({ host, project: input.project, port });
8660
devServer.start();
@@ -94,8 +68,8 @@ async function startDevserver(input: StartDevserverToolInput, context: McpToolCo
9468
}
9569

9670
export const START_DEVSERVER_TOOL: McpToolDeclaration<
97-
typeof startDevserverToolInputSchema.shape,
98-
typeof startDevserverToolOutputSchema.shape
71+
typeof startDevServerToolInputSchema.shape,
72+
typeof startDevServerToolOutputSchema.shape
9973
> = declareTool({
10074
name: 'start_devserver',
10175
title: 'Start Development Server',
@@ -117,9 +91,9 @@ Starts the Angular development server ("ng serve") as a background process.
11791
`,
11892
isReadOnly: false,
11993
isLocalOnly: true,
120-
inputSchema: startDevserverToolInputSchema.shape,
121-
outputSchema: startDevserverToolOutputSchema.shape,
94+
inputSchema: startDevServerToolInputSchema.shape,
95+
outputSchema: startDevServerToolOutputSchema.shape,
12296
factory: (context) => (input) => {
123-
return startDevserver(input, context, LocalWorkspaceHost);
97+
return startDevServer(input, context, LocalWorkspaceHost);
12498
},
12599
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const stopDevserverToolOutputSchema = z.object({
2929

3030
export type StopDevserverToolOutput = z.infer<typeof stopDevserverToolOutputSchema>;
3131

32-
function stopDevserver(input: StopDevserverToolInput, context: McpToolContext) {
32+
export function stopDevserver(input: StopDevserverToolInput, context: McpToolContext) {
3333
const projectKey = devServerKey(input.project);
3434
const devServer = context.devServers.get(projectKey);
3535

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ const waitForDevserverBuildToolInputSchema = z.object({
2525
),
2626
timeout: z
2727
.number()
28-
.optional()
2928
.default(30000)
3029
.describe('The maximum time to wait for the build to complete, in milliseconds.'),
3130
});
@@ -50,7 +49,7 @@ function wait(ms: number): Promise<void> {
5049
return new Promise((resolve) => setTimeout(resolve, ms));
5150
}
5251

53-
async function waitForDevserverBuild(
52+
export async function waitForDevserverBuild(
5453
input: WaitForDevserverBuildToolInput,
5554
context: McpToolContext,
5655
) {
@@ -59,22 +58,22 @@ async function waitForDevserverBuild(
5958
const deadline = Date.now() + input.timeout;
6059

6160
if (!devServer) {
62-
return createStructureContentOutput({
61+
return createStructureContentOutput<WaitForDevserverBuildToolOutput>({
6362
status: 'no_devserver_found',
6463
});
6564
}
6665

6766
await wait(DEBOUNCE_DELAY);
6867
while (devServer.isBuilding()) {
6968
if (Date.now() > deadline) {
70-
return createStructureContentOutput({
69+
return createStructureContentOutput<WaitForDevserverBuildToolOutput>({
7170
status: 'timeout',
7271
});
7372
}
7473
await wait(DEBOUNCE_DELAY);
7574
}
7675

77-
return createStructureContentOutput({
76+
return createStructureContentOutput<WaitForDevserverBuildToolOutput>({
7877
...devServer.getMostRecentBuild(),
7978
});
8079
}

0 commit comments

Comments
 (0)