Skip to content

Commit 430400f

Browse files
committed
refactor(@angular/cli): Polish MCP build/serve tests and corner cases
1 parent a6dc1f2 commit 430400f

File tree

10 files changed

+111
-81
lines changed

10 files changed

+111
-81
lines changed

packages/angular/cli/src/commands/mcp/dev-server.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,18 @@ export class LocalDevServer implements DevServer {
108108
this.devServerProcess.stderr?.on('data', (data) => {
109109
this.addLog(data.toString());
110110
});
111+
this.devServerProcess.stderr?.on('close', () => {
112+
this.stop();
113+
});
114+
this.buildInProgress = true;
111115
}
112116

113117
private addLog(log: string) {
114118
this.serverLogs.push(log);
115119

116120
if (BUILD_START_MESSAGES.some((message) => log.startsWith(message))) {
117121
this.buildInProgress = true;
118-
this.latestBuildLogStartIndex = this.serverLogs.length;
122+
this.latestBuildLogStartIndex = this.serverLogs.length - 1;
119123
} else if (BUILD_END_MESSAGES.some((message) => log.startsWith(message))) {
120124
this.buildInProgress = false;
121125
// We consider everything except a specific failure message to be a success.

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
2626
import { WAIT_FOR_DEVSERVER_BUILD_TOOL } from './tools/wait-for-devserver-build';
2727

2828
/**
29-
* Tools to manage devservers. Should be bundled together.
29+
* Tools to manage devservers. Should be bundled together, then added to experimental or stable as a group.
3030
*/
3131
const SERVE_TOOLS = [START_DEVSERVER_TOOL, STOP_DEVSERVER_TOOL, WAIT_FOR_DEVSERVER_BUILD_TOOL];
3232

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

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

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

1414
const CONFIGURATIONS = {
@@ -20,6 +20,8 @@ const CONFIGURATIONS = {
2020
},
2121
};
2222

23+
const DEFAULT_CONFIGURATION_NAME = 'development';
24+
2325
const BUILD_STATUSES = ['success', 'failure'] as const;
2426
type BuildStatus = (typeof BUILD_STATUSES)[number];
2527

@@ -42,15 +44,16 @@ const buildToolOutputSchema = z.object({
4244
status: z.enum(BUILD_STATUSES).describe('Build status.'),
4345
stdout: z.string().optional().describe('The standard output from `ng build`.'),
4446
stderr: z.string().optional().describe('The standard error from `ng build`.'),
45-
path: z.string().optional().describe('The output path the build project was written into.'),
47+
path: z.string().optional().describe('The output location for the build, if successful.'),
4648
});
4749

4850
export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
4951

5052
export async function runBuild(input: BuildToolInput, host: Host) {
51-
const configurationName = input.configuration ?? 'development';
53+
const configurationName = input.configuration ?? DEFAULT_CONFIGURATION_NAME;
5254
const configuration = CONFIGURATIONS[configurationName as keyof typeof CONFIGURATIONS];
5355

56+
// Build "ng"'s command line.
5457
const args = ['build'];
5558
if (input.project) {
5659
args.push(input.project);
@@ -89,7 +92,7 @@ export async function runBuild(input: BuildToolInput, host: Host) {
8992
path: outputPath,
9093
};
9194

92-
return createStructureContentOutput(structuredContent);
95+
return createStructuredContentOutput(structuredContent);
9396
}
9497

9598
export const BUILD_TOOL: McpToolDeclaration<
@@ -100,11 +103,11 @@ export const BUILD_TOOL: McpToolDeclaration<
100103
title: 'Build Tool',
101104
description: `
102105
<Purpose>
103-
Perform a one-off, non-watched build with "ng build". Use this tool whenever the user wants to build an Angular project; this is similar to
106+
Perform a one-off, non-watched build using "ng build". Use this tool whenever the user wants to build an Angular project; this is similar to
104107
"ng build", but the tool is smarter about using the right configuration and collecting the output logs.
105108
</Purpose>
106109
<Use Cases>
107-
* Building the Angular project and getting build logs back.
110+
* Building an Angular project and getting build logs back.
108111
</Use Cases>
109112
<Operational Notes>
110113
* This tool runs "ng build" so it expects to run within an Angular workspace.

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

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

99
import { CommandError, Host } from '../host';
10-
import { BuildToolInput, runBuild } from './build';
10+
import { runBuild } from './build';
1111

1212
describe('Build Tool', () => {
1313
let mockHost: Host;
@@ -20,25 +20,39 @@ describe('Build Tool', () => {
2020
} as Partial<Host> as Host;
2121
});
2222

23-
it('should handle a successful build and extract the output path', async () => {
24-
const buildStdout =
25-
'Build successful!\nSome other log lines...\nOutput location: dist/my-cool-app';
23+
it('should construct the command correctly with default configuration', async () => {
24+
await runBuild({}, mockHost);
25+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c development']);
26+
});
27+
28+
it('should construct the command correctly with a specified project', async () => {
29+
await runBuild({ project: 'another-app' }, mockHost);
30+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
31+
'build',
32+
'another-app',
33+
'-c development',
34+
]);
35+
});
36+
37+
it('should construct the command correctly for production configuration', async () => {
38+
await runBuild({ configuration: 'production' }, mockHost);
39+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build']);
40+
});
41+
42+
it('should handle a successful build and extract the output path and logs', async () => {
43+
const buildStdout = 'Build successful!\nSome other log lines...\nOutput location: dist/my-app';
2644
(mockHost.runCommand as jasmine.Spy).and.resolveTo({
2745
stdout: buildStdout,
2846
stderr: 'some warning',
2947
});
3048

31-
const { structuredContent } = await runBuild({ project: 'my-cool-app' }, mockHost);
49+
const { structuredContent } = await runBuild({ project: 'my-app' }, mockHost);
3250

33-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
34-
'build',
35-
'my-cool-app',
36-
'-c development',
37-
]);
51+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c development']);
3852
expect(structuredContent.status).toBe('success');
3953
expect(structuredContent.stdout).toBe(buildStdout);
4054
expect(structuredContent.stderr).toBe('some warning');
41-
expect(structuredContent.path).toBe('dist/my-cool-app');
55+
expect(structuredContent.path).toBe('dist/my-app');
4256
});
4357

4458
it('should handle a failed build and capture logs', async () => {
@@ -62,25 +76,6 @@ describe('Build Tool', () => {
6276
expect(structuredContent.path).toBeUndefined();
6377
});
6478

65-
it('should construct the command correctly with default configuration', async () => {
66-
await runBuild({}, mockHost);
67-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c development']);
68-
});
69-
70-
it('should construct the command correctly with a specified project', async () => {
71-
await runBuild({ project: 'another-app' }, mockHost);
72-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
73-
'build',
74-
'another-app',
75-
'-c development',
76-
]);
77-
});
78-
79-
it('should construct the command correctly for production configuration', async () => {
80-
await runBuild({ configuration: 'production' }, mockHost);
81-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build']);
82-
});
83-
8479
it('should handle builds where the output path is not found in logs', async () => {
8580
const buildStdout = 'Build finished, but we could not find the output path string.';
8681
(mockHost.runCommand as jasmine.Spy).and.resolveTo({ stdout: buildStdout, stderr: '' });

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

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { dirname, join, relative } from 'path';
1010
import { z } from 'zod';
1111
import { CommandError, Host, LocalWorkspaceHost } from '../host';
12+
import { createStructuredContentOutput } from '../utils';
1213
import { McpToolDeclaration, declareTool } from './tool-registry';
1314

1415
interface Transformation {
@@ -93,13 +94,6 @@ const modernizeOutputSchema = z.object({
9394
export type ModernizeInput = z.infer<typeof modernizeInputSchema>;
9495
export type ModernizeOutput = z.infer<typeof modernizeOutputSchema>;
9596

96-
function createToolOutput(structuredContent: ModernizeOutput) {
97-
return {
98-
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent, null, 2) }],
99-
structuredContent,
100-
};
101-
}
102-
10397
function findAngularJsonDir(startDir: string, host: Host): string | null {
10498
let currentDir = startDir;
10599
while (true) {
@@ -119,15 +113,15 @@ export async function runModernization(input: ModernizeInput, host: Host) {
119113
const directories = input.directories ?? [];
120114

121115
if (transformationNames.length === 0) {
122-
return createToolOutput({
116+
return createStructuredContentOutput({
123117
instructions: [
124118
'See https://angular.dev/best-practices for Angular best practices. ' +
125119
'You can call this tool if you have specific transformation you want to run.',
126120
],
127121
});
128122
}
129123
if (directories.length === 0) {
130-
return createToolOutput({
124+
return createStructuredContentOutput({
131125
instructions: [
132126
'Provide this tool with a list of directory paths in your workspace ' +
133127
'to run the modernization on.',
@@ -140,7 +134,7 @@ export async function runModernization(input: ModernizeInput, host: Host) {
140134

141135
const angularProjectRoot = findAngularJsonDir(executionDir, host);
142136
if (!angularProjectRoot) {
143-
return createToolOutput({
137+
return createStructuredContentOutput({
144138
instructions: ['Could not find an angular.json file in the current or parent directories.'],
145139
});
146140
}
@@ -195,7 +189,7 @@ export async function runModernization(input: ModernizeInput, host: Host) {
195189
}
196190
}
197191

198-
return createToolOutput({
192+
return createStructuredContentOutput({
199193
instructions: instructions.length > 0 ? instructions : undefined,
200194
stdout: stdoutMessages?.join('\n\n') || undefined,
201195
stderr: stderrMessages?.join('\n\n') || undefined,

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

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Host } from '../host';
1212
import { startDevServer } from './start-devserver';
1313
import { stopDevserver } from './stop-devserver';
1414
import { McpToolContext } from './tool-registry';
15-
import { waitForDevserverBuild } from './wait-for-devserver-build';
15+
import { WATCH_DELAY, waitForDevserverBuild } from './wait-for-devserver-build';
1616

1717
class MockChildProcess extends EventEmitter {
1818
stdout = new EventEmitter();
@@ -55,17 +55,12 @@ describe('Serve Tools', () => {
5555
expect(mockProcess.kill).toHaveBeenCalled();
5656
});
5757

58-
it('should find a free port if none is provided', async () => {
59-
await startDevServer({}, mockContext, mockHost);
60-
expect(mockHost.getAvailablePort).toHaveBeenCalled();
61-
});
62-
6358
it('should wait for a build to complete', async () => {
6459
await startDevServer({}, mockContext, mockHost);
6560

6661
const waitPromise = waitForDevserverBuild({ timeout: 10 }, mockContext);
6762

68-
// Simulate build logs
63+
// Simulate build logs.
6964
mockProcess.stdout.emit('data', '... building ...');
7065
mockProcess.stdout.emit('data', '✔ Changes detected. Rebuilding...');
7166
mockProcess.stdout.emit('data', '... more logs ...');
@@ -74,28 +69,28 @@ describe('Serve Tools', () => {
7469
const waitResult = await waitPromise;
7570
expect(waitResult.structuredContent.status).toBe('success');
7671
expect(waitResult.structuredContent.logs).toEqual([
72+
'... building ...',
7773
'✔ Changes detected. Rebuilding...',
7874
'... more logs ...',
7975
'Application bundle generation complete.',
8076
]);
8177
});
8278

8379
it('should handle multiple dev servers', async () => {
84-
// Start server for project 1
80+
// Start server for project 1. This uses the basic mockProcess created for the tests.
8581
const startResult1 = await startDevServer({ project: 'app-one' }, mockContext, mockHost);
8682
expect(startResult1.structuredContent.message).toBe(
8783
`Development server for project 'app-one' started and watching for workspace changes.`,
8884
);
8985
const process1 = mockProcess;
9086

91-
// Start server for project 2
92-
mockProcess = new MockChildProcess();
93-
(mockHost.spawn as jasmine.Spy).and.returnValue(mockProcess as unknown as ChildProcess);
87+
// Start server for project 2, returning a new mock process.
88+
const process2 = new MockChildProcess();
89+
(mockHost.spawn as jasmine.Spy).and.returnValue(process2 as unknown as ChildProcess);
9490
const startResult2 = await startDevServer({ project: 'app-two' }, mockContext, mockHost);
9591
expect(startResult2.structuredContent.message).toBe(
9692
`Development server for project 'app-two' started and watching for workspace changes.`,
9793
);
98-
const process2 = mockProcess;
9994

10095
expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-one', '--port=12345'], {
10196
stdio: 'pipe',
@@ -122,10 +117,14 @@ describe('Serve Tools', () => {
122117

123118
it('should handle server crash', async () => {
124119
await startDevServer({ project: 'crash-app' }, mockContext, mockHost);
125-
mockProcess.emit('close', 1); // Simulate a crash with exit code 1
120+
121+
// Simulate a crash with exit code 1
122+
mockProcess.stdout.emit('data', 'Fatal error.');
123+
mockProcess.emit('close', 1);
126124

127125
const stopResult = stopDevserver({ project: 'crash-app' }, mockContext);
128-
expect(stopResult.structuredContent.message).toContain('is not running');
126+
expect(stopResult.structuredContent.message).toContain('stopped');
127+
expect(stopResult.structuredContent.logs).toEqual(['Fatal error.']);
129128
});
130129

131130
it('wait should timeout if build takes too long', async () => {
@@ -136,4 +135,38 @@ describe('Serve Tools', () => {
136135
);
137136
expect(waitResult.structuredContent.status).toBe('timeout');
138137
});
138+
139+
it('should wait through multiple cycles for a build to complete', async () => {
140+
jasmine.clock().install();
141+
try {
142+
await startDevServer({}, mockContext, mockHost);
143+
144+
// Immediately simulate a build starting so isBuilding() is true.
145+
mockProcess.stdout.emit('data', '❯ Changes detected. Rebuilding...');
146+
147+
const waitPromise = waitForDevserverBuild({ timeout: 5 * WATCH_DELAY }, mockContext);
148+
149+
// Tick past the first debounce. The while loop will be entered.
150+
jasmine.clock().tick(WATCH_DELAY + 1);
151+
152+
// Tick past the second debounce (inside the loop).
153+
jasmine.clock().tick(WATCH_DELAY + 1);
154+
155+
// Now finish the build.
156+
mockProcess.stdout.emit('data', 'Application bundle generation complete.');
157+
158+
// Tick past another debounce to exit the loop.
159+
jasmine.clock().tick(WATCH_DELAY + 1);
160+
161+
const waitResult = await waitPromise;
162+
163+
expect(waitResult.structuredContent.status).toBe('success');
164+
expect(waitResult.structuredContent.logs).toEqual([
165+
'❯ Changes detected. Rebuilding...',
166+
'Application bundle generation complete.',
167+
]);
168+
} finally {
169+
jasmine.clock().uninstall();
170+
}
171+
});
139172
});

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { z } from 'zod';
1010
import { LocalDevServer, devServerKey } from '../dev-server';
1111
import { Host, LocalWorkspaceHost } from '../host';
12-
import { createStructureContentOutput } from '../utils';
12+
import { createStructuredContentOutput } from '../utils';
1313
import { McpToolContext, McpToolDeclaration, declareTool } from './tool-registry';
1414

1515
const startDevServerToolInputSchema = z.object({
@@ -48,7 +48,7 @@ export async function startDevServer(
4848

4949
let devServer = context.devServers.get(projectKey);
5050
if (devServer) {
51-
return createStructureContentOutput({
51+
return createStructuredContentOutput({
5252
message: `Development server for project '${projectKey}' is already running.`,
5353
address: localhostAddress(devServer.port),
5454
});
@@ -61,7 +61,7 @@ export async function startDevServer(
6161

6262
context.devServers.set(projectKey, devServer);
6363

64-
return createStructureContentOutput({
64+
return createStructuredContentOutput({
6565
message: `Development server for project '${projectKey}' started and watching for workspace changes.`,
6666
address: localhostAddress(port),
6767
});

0 commit comments

Comments
 (0)