Skip to content

Commit aac3736

Browse files
committed
refactor(@angular/cli): Change modernize_spec to mock generate calls
1 parent 02172e4 commit aac3736

File tree

3 files changed

+132
-183
lines changed

3 files changed

+132
-183
lines changed

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

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

9-
import { exec } from 'child_process';
109
import { existsSync } from 'fs';
1110
import { stat } from 'fs/promises';
1211
import { dirname, join, relative } from 'path';
13-
import { promisify } from 'util';
1412
import { z } from 'zod';
13+
import { execAsync } from './process-executor';
1514
import { McpToolDeclaration, declareTool } from './tool-registry';
1615

1716
interface Transformation {
@@ -96,8 +95,6 @@ const modernizeOutputSchema = z.object({
9695
export type ModernizeInput = z.infer<typeof modernizeInputSchema>;
9796
export type ModernizeOutput = z.infer<typeof modernizeOutputSchema>;
9897

99-
const execAsync = promisify(exec);
100-
10198
function createToolOutput(structuredContent: ModernizeOutput) {
10299
return {
103100
content: [{ type: 'text' as const, text: JSON.stringify(structuredContent, null, 2) }],
@@ -191,8 +188,8 @@ export async function runModernization(input: ModernizeInput) {
191188

192189
return createToolOutput({
193190
instructions: instructions.length > 0 ? instructions : undefined,
194-
stdout: stdoutMessages?.join('\n\n') ?? undefined,
195-
stderr: stderrMessages?.join('\n\n') ?? undefined,
191+
stdout: stdoutMessages?.join('\n\n') || undefined,
192+
stderr: stderrMessages?.join('\n\n') || undefined,
196193
});
197194
}
198195

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

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

9-
import { exec } from 'child_process';
10-
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises';
9+
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
1110
import { tmpdir } from 'os';
1211
import { join } from 'path';
13-
import { promisify } from 'util';
1412
import { ModernizeOutput, runModernization } from './modernize';
15-
16-
const execAsync = promisify(exec);
13+
import * as processExecutor from './process-executor';
1714

1815
describe('Modernize Tool', () => {
16+
let execAsyncSpy: jasmine.Spy;
1917
let projectDir: string;
20-
let originalPath: string | undefined;
2118

2219
beforeEach(async () => {
23-
originalPath = process.env.PATH;
20+
// Create a temporary directory and a fake angular.json to satisfy the tool's project root search.
2421
projectDir = await mkdtemp(join(tmpdir(), 'angular-modernize-test-'));
22+
await writeFile(join(projectDir, 'angular.json'), JSON.stringify({ version: 1, projects: {} }));
2523

26-
// Create a dummy Angular project structure.
27-
await writeFile(
28-
join(projectDir, 'angular.json'),
29-
JSON.stringify(
30-
{
31-
version: 1,
32-
projects: {
33-
app: {
34-
root: '',
35-
projectType: 'application',
36-
architect: {
37-
build: {
38-
options: {
39-
tsConfig: 'tsconfig.json',
40-
},
41-
},
42-
},
43-
},
44-
},
45-
},
46-
null,
47-
2,
48-
),
49-
);
50-
await writeFile(
51-
join(projectDir, 'package.json'),
52-
JSON.stringify(
53-
{
54-
dependencies: {
55-
'@angular/core': 'latest',
56-
},
57-
devDependencies: {
58-
'@angular/cli': 'latest',
59-
'@angular-devkit/schematics': 'latest',
60-
typescript: 'latest',
61-
},
62-
},
63-
null,
64-
2,
65-
),
66-
);
67-
await writeFile(
68-
join(projectDir, 'tsconfig.base.json'),
69-
JSON.stringify(
70-
{
71-
compilerOptions: {
72-
strict: true,
73-
forceConsistentCasingInFileNames: true,
74-
skipLibCheck: true,
75-
},
76-
},
77-
null,
78-
2,
79-
),
80-
);
81-
await writeFile(
82-
join(projectDir, 'tsconfig.json'),
83-
JSON.stringify(
84-
{
85-
extends: './tsconfig.base.json',
86-
compilerOptions: {
87-
outDir: './dist/out-tsc',
88-
},
89-
},
90-
null,
91-
2,
92-
),
93-
);
94-
95-
// Symlink the node_modules directory from the runfiles to the temporary project.
96-
const nodeModulesPath = require
97-
.resolve('@angular/core/package.json')
98-
.replace(/\/@angular\/core\/package\.json$/, '');
99-
await execAsync(`ln -s ${nodeModulesPath} ${join(projectDir, 'node_modules')}`);
100-
101-
// Prepend the node_modules/.bin path to the PATH environment variable
102-
// so that `ng` can be found by `execAsync` calls.
103-
process.env.PATH = `${join(nodeModulesPath, '.bin')}:${process.env.PATH}`;
24+
// Spy on the execAsync function from our new module.
25+
execAsyncSpy = spyOn(processExecutor, 'execAsync').and.resolveTo({ stdout: '', stderr: '' });
10426
});
10527

10628
afterEach(async () => {
107-
process.env.PATH = originalPath;
10829
await rm(projectDir, { recursive: true, force: true });
10930
});
11031

111-
async function modernize(
112-
dir: string,
113-
file: string,
114-
transformations: string[],
115-
): Promise<{ structuredContent: ModernizeOutput; newContent: string }> {
116-
const structuredContent = (
117-
(await runModernization({ directories: [dir], transformations })) as {
118-
structuredContent: ModernizeOutput;
119-
}
120-
).structuredContent;
121-
const newContent = await readFile(file, 'utf8');
122-
123-
return { structuredContent, newContent };
124-
}
32+
it('should return instructions if no transformations are provided', async () => {
33+
const { structuredContent } = (await runModernization({})) as {
34+
structuredContent: ModernizeOutput;
35+
};
12536

126-
it('can run a single transformation', async () => {
127-
const componentPath = join(projectDir, 'test.component.ts');
128-
const componentContent = `
129-
import { Component } from '@angular/core';
130-
131-
@Component({
132-
selector: 'app-foo',
133-
template: '<app-bar></app-bar>',
134-
})
135-
export class FooComponent {}
136-
`;
137-
await writeFile(componentPath, componentContent);
138-
139-
const { structuredContent, newContent } = await modernize(projectDir, componentPath, [
140-
'self-closing-tag',
37+
expect(execAsyncSpy).not.toHaveBeenCalled();
38+
expect(structuredContent?.instructions).toEqual([
39+
'See https://angular.dev/best-practices for Angular best practices. ' +
40+
'You can call this tool if you have specific transformation you want to run.',
14141
]);
42+
});
14243

143-
expect(structuredContent?.stderr).toBe('');
144-
expect(newContent).toContain('<app-bar />');
44+
it('should return instructions if no directories are provided', async () => {
45+
const { structuredContent } = (await runModernization({
46+
transformations: ['control-flow'],
47+
})) as {
48+
structuredContent: ModernizeOutput;
49+
};
50+
51+
expect(execAsyncSpy).not.toHaveBeenCalled();
14552
expect(structuredContent?.instructions).toEqual([
146-
'Migration self-closing-tag on directory . completed successfully.',
53+
'Provide this tool with a list of directory paths in your workspace ' +
54+
'to run the modernization on.',
14755
]);
14856
});
14957

150-
it('can run multiple transformations', async () => {
151-
const componentPath = join(projectDir, 'test.component.ts');
152-
const componentContent = `
153-
import { Component } from '@angular/core';
154-
155-
@Component({
156-
selector: 'app-foo',
157-
template: '<app-bar *ngIf="show"></app-bar>',
158-
})
159-
export class FooComponent {
160-
show = true;
161-
}
162-
`;
163-
await writeFile(componentPath, componentContent);
164-
165-
const { structuredContent, newContent } = await modernize(projectDir, componentPath, [
166-
'control-flow',
167-
'self-closing-tag',
58+
it('can run a single transformation', async () => {
59+
const { structuredContent } = (await runModernization({
60+
directories: [projectDir],
61+
transformations: ['self-closing-tag'],
62+
})) as { structuredContent: ModernizeOutput };
63+
64+
expect(execAsyncSpy).toHaveBeenCalledOnceWith(
65+
'ng generate @angular/core:self-closing-tag --path .',
66+
{ cwd: projectDir },
67+
);
68+
expect(structuredContent?.stderr).toBeUndefined();
69+
expect(structuredContent?.instructions).toEqual([
70+
'Migration self-closing-tag on directory . completed successfully.',
16871
]);
72+
});
16973

170-
expect(structuredContent?.stderr).toBe('');
171-
expect(newContent).toContain('@if (show) {<app-bar />}');
74+
it('can run multiple transformations', async () => {
75+
const { structuredContent } = (await runModernization({
76+
directories: [projectDir],
77+
transformations: ['control-flow', 'self-closing-tag'],
78+
})) as { structuredContent: ModernizeOutput };
79+
80+
expect(execAsyncSpy).toHaveBeenCalledTimes(2);
81+
expect(execAsyncSpy).toHaveBeenCalledWith('ng generate @angular/core:control-flow --path .', {
82+
cwd: projectDir,
83+
});
84+
expect(execAsyncSpy).toHaveBeenCalledWith(
85+
'ng generate @angular/core:self-closing-tag --path .',
86+
{ cwd: projectDir },
87+
);
88+
expect(structuredContent?.stderr).toBeUndefined();
89+
expect(structuredContent?.instructions).toEqual(
90+
jasmine.arrayWithExactContents([
91+
'Migration control-flow on directory . completed successfully.',
92+
'Migration self-closing-tag on directory . completed successfully.',
93+
]),
94+
);
17295
});
17396

17497
it('can run multiple transformations across multiple directories', async () => {
17598
const subfolder1 = join(projectDir, 'subfolder1');
176-
await mkdir(subfolder1);
177-
const componentPath1 = join(subfolder1, 'test.component.ts');
178-
const componentContent1 = `
179-
import { Component } from '@angular/core';
180-
181-
@Component({
182-
selector: 'app-foo',
183-
template: '<app-bar *ngIf="show"></app-bar>',
184-
})
185-
export class FooComponent {
186-
show = true;
187-
}
188-
`;
189-
await writeFile(componentPath1, componentContent1);
190-
19199
const subfolder2 = join(projectDir, 'subfolder2');
100+
await mkdir(subfolder1);
192101
await mkdir(subfolder2);
193-
const componentPath2 = join(subfolder2, 'test.component.ts');
194-
const componentContent2 = `
195-
import { Component } from '@angular/core';
196-
197-
@Component({
198-
selector: 'app-bar',
199-
template: '<app-baz></app-baz>',
200-
})
201-
export class BarComponent {}
202-
`;
203-
await writeFile(componentPath2, componentContent2);
204-
205-
const structuredContent = (
206-
(await runModernization({
207-
directories: [subfolder1, subfolder2],
208-
transformations: ['control-flow', 'self-closing-tag'],
209-
})) as { structuredContent: ModernizeOutput }
210-
).structuredContent;
211-
const newContent1 = await readFile(componentPath1, 'utf8');
212-
const newContent2 = await readFile(componentPath2, 'utf8');
213-
214-
expect(structuredContent?.stderr).toBe('');
215-
expect(newContent1).toContain('@if (show) {<app-bar />}');
216-
expect(newContent2).toContain('<app-baz />');
102+
103+
const { structuredContent } = (await runModernization({
104+
directories: [subfolder1, subfolder2],
105+
transformations: ['control-flow', 'self-closing-tag'],
106+
})) as { structuredContent: ModernizeOutput };
107+
108+
expect(execAsyncSpy).toHaveBeenCalledTimes(4);
109+
expect(execAsyncSpy).toHaveBeenCalledWith(
110+
'ng generate @angular/core:control-flow --path subfolder1',
111+
{ cwd: projectDir },
112+
);
113+
expect(execAsyncSpy).toHaveBeenCalledWith(
114+
'ng generate @angular/core:self-closing-tag --path subfolder1',
115+
{ cwd: projectDir },
116+
);
117+
expect(execAsyncSpy).toHaveBeenCalledWith(
118+
'ng generate @angular/core:control-flow --path subfolder2',
119+
{ cwd: projectDir },
120+
);
121+
expect(execAsyncSpy).toHaveBeenCalledWith(
122+
'ng generate @angular/core:self-closing-tag --path subfolder2',
123+
{ cwd: projectDir },
124+
);
125+
expect(structuredContent?.stderr).toBeUndefined();
126+
expect(structuredContent?.instructions).toEqual(
127+
jasmine.arrayWithExactContents([
128+
'Migration control-flow on directory subfolder1 completed successfully.',
129+
'Migration self-closing-tag on directory subfolder1 completed successfully.',
130+
'Migration control-flow on directory subfolder2 completed successfully.',
131+
'Migration self-closing-tag on directory subfolder2 completed successfully.',
132+
]),
133+
);
134+
});
135+
136+
it('should report errors from transformations', async () => {
137+
// Simulate a failed execution
138+
execAsyncSpy.and.rejectWith(new Error('Command failed with error'));
139+
140+
const { structuredContent } = (await runModernization({
141+
directories: [projectDir],
142+
transformations: ['self-closing-tag'],
143+
})) as { structuredContent: ModernizeOutput };
144+
145+
expect(execAsyncSpy).toHaveBeenCalledOnceWith(
146+
'ng generate @angular/core:self-closing-tag --path .',
147+
{ cwd: projectDir },
148+
);
149+
expect(structuredContent?.stderr).toContain('Command failed with error');
150+
expect(structuredContent?.instructions).toEqual([
151+
'Migration self-closing-tag on directory . failed.',
152+
]);
217153
});
218154
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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 { exec } from 'child_process';
10+
import { promisify } from 'util';
11+
12+
/**
13+
* A promisified version of the Node.js `exec` function.
14+
* This is isolated in its own file to allow for easy mocking in tests.
15+
*/
16+
export const execAsync = promisify(exec);

0 commit comments

Comments
 (0)