Skip to content

Commit 951c873

Browse files
committed
refactor(@angular/cli): Address review comments.
1 parent ab4b5b5 commit 951c873

File tree

11 files changed

+115
-120
lines changed

11 files changed

+115
-120
lines changed

packages/angular/cli/BUILD.bazel

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ ts_project(
3838
],
3939
exclude = [
4040
"**/*_spec.ts",
41+
"**/testing/**",
4142
],
4243
) + [
4344
# These files are generated from the JSON schema
@@ -116,7 +117,10 @@ ts_project(
116117
name = "angular-cli_test_lib",
117118
testonly = True,
118119
srcs = glob(
119-
include = ["**/*_spec.ts"],
120+
include = [
121+
"**/*_spec.ts",
122+
"**/testing/**",
123+
],
120124
exclude = [
121125
# NB: we need to exclude the nested node_modules that is laid out by yarn workspaces
122126
"node_modules/**",

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,8 @@ export class LocalDevServer implements DevServer {
9797
if (this.project) {
9898
args.push(this.project);
9999
}
100-
if (this.port) {
101-
args.push(`--port=${this.port}`);
102-
}
100+
101+
args.push(`--port=${this.port}`);
103102

104103
this.devServerProcess = this.host.spawn('ng', args, { stdio: 'pipe' });
105104
this.devServerProcess.stdout?.on('data', (data) => {

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

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ import { createServer } from 'node:net';
2525
export class CommandError extends Error {
2626
constructor(
2727
message: string,
28-
public readonly stdout: string,
29-
public readonly stderr: string,
28+
public readonly logs: string[],
3029
public readonly code: number | null,
3130
) {
3231
super(message);
@@ -68,7 +67,7 @@ export interface Host {
6867
cwd?: string;
6968
env?: Record<string, string>;
7069
},
71-
): Promise<{ stdout: string; stderr: string }>;
70+
): Promise<{ logs: string[] }>;
7271

7372
/**
7473
* Spawns a long-running child process and returns the `ChildProcess` object.
@@ -110,7 +109,7 @@ export const LocalWorkspaceHost: Host = {
110109
cwd?: string;
111110
env?: Record<string, string>;
112111
} = {},
113-
): Promise<{ stdout: string; stderr: string }> => {
112+
): Promise<{ logs: string[] }> => {
114113
const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;
115114

116115
return new Promise((resolve, reject) => {
@@ -125,30 +124,28 @@ export const LocalWorkspaceHost: Host = {
125124
},
126125
});
127126

128-
let stdout = '';
129-
childProcess.stdout?.on('data', (data) => (stdout += data.toString()));
130-
131-
let stderr = '';
132-
childProcess.stderr?.on('data', (data) => (stderr += data.toString()));
127+
const logs: string[] = [];
128+
childProcess.stdout?.on('data', (data) => logs.push(data.toString()));
129+
childProcess.stderr?.on('data', (data) => logs.push(data.toString()));
133130

134131
childProcess.on('close', (code) => {
135132
if (code === 0) {
136-
resolve({ stdout, stderr });
133+
resolve({ logs });
137134
} else {
138135
const message = `Process exited with code ${code}.`;
139-
reject(new CommandError(message, stdout, stderr, code));
136+
reject(new CommandError(message, logs, code));
140137
}
141138
});
142139

143140
childProcess.on('error', (err) => {
144141
if (err.name === 'AbortError') {
145142
const message = `Process timed out.`;
146-
reject(new CommandError(message, stdout, stderr, null));
143+
reject(new CommandError(message, logs, null));
147144

148145
return;
149146
}
150147
const message = `Process failed with error: ${err.message}`;
151-
reject(new CommandError(message, stdout, stderr, null));
148+
reject(new CommandError(message, logs, null));
152149
});
153150
});
154151
},
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 { Host } from '../host';
10+
11+
/**
12+
* A mock implementation of the `Host` interface for testing purposes.
13+
* This class allows spying on host methods and controlling their return values.
14+
*/
15+
export class MockHost implements Host {
16+
runCommand = jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' });
17+
stat = jasmine.createSpy('stat');
18+
existsSync = jasmine.createSpy('existsSync');
19+
spawn = jasmine.createSpy('spawn');
20+
getAvailablePort = jasmine.createSpy('getAvailablePort');
21+
}

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

Lines changed: 21 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,84 +11,68 @@ import { CommandError, Host, LocalWorkspaceHost } from '../host';
1111
import { createStructuredContentOutput } from '../utils';
1212
import { McpToolDeclaration, declareTool } from './tool-registry';
1313

14-
const CONFIGURATIONS = {
15-
development: {
16-
args: '-c development',
17-
},
18-
production: {
19-
args: '',
20-
},
21-
};
14+
const DEFAULT_CONFIGURATION = 'development';
2215

23-
const DEFAULT_CONFIGURATION_NAME = 'development';
24-
25-
const BUILD_STATUSES = ['success', 'failure'] as const;
26-
type BuildStatus = (typeof BUILD_STATUSES)[number];
16+
const buildStatusSchema = z.enum(['success', 'failure']);
17+
type BuildStatus = z.infer<typeof buildStatusSchema>;
2718

2819
const buildToolInputSchema = z.object({
2920
project: z
3021
.string()
3122
.optional()
3223
.describe(
33-
'Which project to build in a monorepo context. If not provided, builds the top-level project.',
24+
'Which project to build in a monorepo context. If not provided, builds the default project.',
3425
),
3526
configuration: z
36-
.enum(Object.keys(CONFIGURATIONS) as [string, ...string[]])
27+
.string()
3728
.optional()
3829
.describe('Which build configuration to use. Defaults to "development".'),
3930
});
4031

4132
export type BuildToolInput = z.infer<typeof buildToolInputSchema>;
4233

4334
const buildToolOutputSchema = z.object({
44-
status: z.enum(BUILD_STATUSES).describe('Build status.'),
45-
stdout: z.string().optional().describe('The standard output from `ng build`.'),
46-
stderr: z.string().optional().describe('The standard error from `ng build`.'),
35+
status: buildStatusSchema.describe('Build status.'),
36+
logs: z.array(z.string()).optional().describe('Output logs from `ng build`.'),
4737
path: z.string().optional().describe('The output location for the build, if successful.'),
4838
});
4939

5040
export type BuildToolOutput = z.infer<typeof buildToolOutputSchema>;
5141

5242
export async function runBuild(input: BuildToolInput, host: Host) {
53-
const configurationName = input.configuration ?? DEFAULT_CONFIGURATION_NAME;
54-
const configuration = CONFIGURATIONS[configurationName as keyof typeof CONFIGURATIONS];
55-
5643
// Build "ng"'s command line.
5744
const args = ['build'];
5845
if (input.project) {
5946
args.push(input.project);
6047
}
61-
if (configuration.args) {
62-
args.push(configuration.args);
63-
}
48+
args.push(`-c ${input.configuration ?? DEFAULT_CONFIGURATION}`);
6449

6550
let status: BuildStatus = 'success';
66-
let stdout = '';
67-
let stderr = '';
51+
let logs: string[] = [];
6852
let outputPath: string | undefined;
6953

7054
try {
71-
const result = await host.runCommand('ng', args);
72-
stdout = result.stdout;
73-
stderr = result.stderr;
74-
const match = stdout.match(/Output location: (.*)/);
75-
if (match) {
76-
outputPath = match[1].trim();
77-
}
55+
logs = (await host.runCommand('ng', args)).logs;
7856
} catch (e) {
7957
status = 'failure';
8058
if (e instanceof CommandError) {
81-
stdout = e.stdout;
82-
stderr = e.stderr;
59+
logs = e.logs;
8360
} else if (e instanceof Error) {
84-
stderr = e.message;
61+
logs = [e.message];
62+
}
63+
}
64+
65+
for (const line of logs) {
66+
const match = line.match(/Output location: (.*)/);
67+
if (match) {
68+
outputPath = match[1].trim();
69+
break;
8570
}
8671
}
8772

8873
const structuredContent: BuildToolOutput = {
8974
status,
90-
stdout,
91-
stderr,
75+
logs,
9276
path: outputPath,
9377
};
9478

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

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

99
import { CommandError, Host } from '../host';
10+
import { MockHost } from '../testing/mock-host';
1011
import { runBuild } from './build';
1112

1213
describe('Build Tool', () => {
13-
let mockHost: Host;
14+
let mockHost: MockHost;
1415

1516
beforeEach(() => {
1617
mockHost = {
17-
runCommand: jasmine.createSpy('runCommand').and.resolveTo({ stdout: '', stderr: '' }),
18-
stat: jasmine.createSpy('stat'),
19-
existsSync: jasmine.createSpy('existsSync'),
20-
} as Partial<Host> as Host;
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 Partial<MockHost> as MockHost;
2122
});
2223

2324
it('should construct the command correctly with default configuration', async () => {
@@ -34,56 +35,58 @@ describe('Build Tool', () => {
3435
]);
3536
});
3637

37-
it('should construct the command correctly for production configuration', async () => {
38-
await runBuild({ configuration: 'production' }, mockHost);
39-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build']);
38+
it('should construct the command correctly for a custom configuration', async () => {
39+
await runBuild({ configuration: 'myconfig' }, mockHost);
40+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', '-c myconfig']);
4041
});
4142

4243
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';
44-
(mockHost.runCommand as jasmine.Spy).and.resolveTo({
45-
stdout: buildStdout,
46-
stderr: 'some warning',
44+
const buildLogs = [
45+
'Build successful!',
46+
'Some other log lines...',
47+
'some warning',
48+
'Output location: dist/my-app',
49+
];
50+
mockHost.runCommand.and.resolveTo({
51+
logs: buildLogs,
4752
});
4853

4954
const { structuredContent } = await runBuild({ project: 'my-app' }, mockHost);
5055

5156
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c development']);
5257
expect(structuredContent.status).toBe('success');
53-
expect(structuredContent.stdout).toBe(buildStdout);
54-
expect(structuredContent.stderr).toBe('some warning');
58+
expect(structuredContent.logs).toEqual(buildLogs);
5559
expect(structuredContent.path).toBe('dist/my-app');
5660
});
5761

5862
it('should handle a failed build and capture logs', async () => {
59-
const error = new CommandError(
60-
'Build failed',
61-
'Some output before the crash.',
62-
'Error: Something went wrong!',
63-
1,
64-
);
65-
(mockHost.runCommand as jasmine.Spy).and.rejectWith(error);
63+
const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!'];
64+
const error = new CommandError('Build failed', buildLogs, 1);
65+
mockHost.runCommand.and.rejectWith(error);
6666

6767
const { structuredContent } = await runBuild(
6868
{ project: 'my-failed-app', configuration: 'production' },
6969
mockHost,
7070
);
7171

72-
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-failed-app']);
72+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', [
73+
'build',
74+
'my-failed-app',
75+
'-c production',
76+
]);
7377
expect(structuredContent.status).toBe('failure');
74-
expect(structuredContent.stdout).toBe('Some output before the crash.');
75-
expect(structuredContent.stderr).toBe('Error: Something went wrong!');
78+
expect(structuredContent.logs).toEqual(buildLogs);
7679
expect(structuredContent.path).toBeUndefined();
7780
});
7881

7982
it('should handle builds where the output path is not found in logs', async () => {
80-
const buildStdout = 'Build finished, but we could not find the output path string.';
81-
(mockHost.runCommand as jasmine.Spy).and.resolveTo({ stdout: buildStdout, stderr: '' });
83+
const buildLogs = ["Some logs that don't match any output path."];
84+
mockHost.runCommand.and.resolveTo({ logs: buildLogs });
8285

8386
const { structuredContent } = await runBuild({}, mockHost);
8487

8588
expect(structuredContent.status).toBe('success');
86-
expect(structuredContent.stdout).toBe(buildStdout);
89+
expect(structuredContent.logs).toEqual(buildLogs);
8790
expect(structuredContent.path).toBeUndefined();
8891
});
8992
});

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

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ const modernizeOutputSchema = z.object({
8787
.describe(
8888
'Migration summary, as well as any instructions that need to be performed to complete the migrations.',
8989
),
90-
stdout: z.string().optional().describe('The stdout from the executed commands.'),
91-
stderr: z.string().optional().describe('The stderr from the executed commands.'),
90+
logs: z.array(z.string()).optional().describe('All logs from all executed commands.'),
9291
});
9392

9493
export type ModernizeInput = z.infer<typeof modernizeInputSchema>;
@@ -140,8 +139,7 @@ export async function runModernization(input: ModernizeInput, host: Host) {
140139
}
141140

142141
const instructions: string[] = [];
143-
const stdoutMessages: string[] = [];
144-
const stderrMessages: string[] = [];
142+
let logs: string[] = [];
145143
const transformationsToRun = TRANSFORMATIONS.filter((t) => transformationNames.includes(t.name));
146144

147145
for (const transformation of transformationsToRun) {
@@ -159,28 +157,19 @@ export async function runModernization(input: ModernizeInput, host: Host) {
159157
const command = 'ng';
160158
const args = ['generate', `@angular/core:${transformation.name}`, '--path', relativePath];
161159
try {
162-
const { stdout, stderr } = await host.runCommand(command, args, {
163-
cwd: angularProjectRoot,
164-
});
165-
if (stdout) {
166-
stdoutMessages.push(stdout);
167-
}
168-
if (stderr) {
169-
stderrMessages.push(stderr);
170-
}
160+
logs = (
161+
await host.runCommand(command, args, {
162+
cwd: angularProjectRoot,
163+
})
164+
).logs;
171165
instructions.push(
172166
`Migration ${transformation.name} on directory ${relativePath} completed successfully.`,
173167
);
174168
} catch (e) {
175169
if (e instanceof CommandError) {
176-
if (e.stdout) {
177-
stdoutMessages.push(e.stdout);
178-
}
179-
if (e.stderr) {
180-
stderrMessages.push(e.stderr);
181-
}
170+
logs = e.logs;
182171
}
183-
stderrMessages.push((e as Error).message);
172+
logs.push((e as Error).message);
184173
instructions.push(
185174
`Migration ${transformation.name} on directory ${relativePath} failed.`,
186175
);
@@ -191,8 +180,7 @@ export async function runModernization(input: ModernizeInput, host: Host) {
191180

192181
return createStructuredContentOutput({
193182
instructions: instructions.length > 0 ? instructions : undefined,
194-
stdout: stdoutMessages?.join('\n\n') || undefined,
195-
stderr: stderrMessages?.join('\n\n') || undefined,
183+
logs,
196184
});
197185
}
198186

0 commit comments

Comments
 (0)