Skip to content

Commit e111edd

Browse files
committed
refactor(@angular/cli): Change modernize_spec to mock generate calls
1 parent 17889c2 commit e111edd

File tree

3 files changed

+119
-191
lines changed

3 files changed

+119
-191
lines changed

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

Lines changed: 1 addition & 4 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) }],

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

Lines changed: 102 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -6,213 +6,128 @@
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';
11-
import { tmpdir } from 'os';
12-
import { join } from 'path';
13-
import { promisify } from 'util';
149
import { ModernizeOutput, runModernization } from './modernize';
15-
16-
const execAsync = promisify(exec);
10+
import * as processExecutor from './process-executor';
1711

1812
describe('Modernize Tool', () => {
19-
let projectDir: string;
20-
let originalPath: string | undefined;
21-
22-
beforeEach(async () => {
23-
originalPath = process.env.PATH;
24-
projectDir = await mkdtemp(join(tmpdir(), 'angular-modernize-test-'));
25-
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')}`);
13+
let execAsyncSpy: jasmine.Spy;
10014

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}`;
15+
beforeEach(() => {
16+
// Spy on the execAsync function from our new module.
17+
execAsyncSpy = spyOn(processExecutor, 'execAsync').and.resolveTo({ stdout: '', stderr: '' });
10418
});
10519

106-
afterEach(async () => {
107-
process.env.PATH = originalPath;
108-
await rm(projectDir, { recursive: true, force: true });
20+
it('should return instructions if no transformations are provided', async () => {
21+
const { structuredContent } = (await runModernization({})) as {
22+
structuredContent: ModernizeOutput;
23+
};
24+
25+
expect(execAsyncSpy).not.toHaveBeenCalled();
26+
expect(structuredContent?.instructions).toEqual([
27+
'See https://angular.dev/best-practices for Angular best practices. ' +
28+
'You can call this tool if you have specific transformation you want to run.',
29+
]);
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 directories are provided', async () => {
33+
const { structuredContent } = (await runModernization({
34+
transformations: ['control-flow'],
35+
})) as {
36+
structuredContent: ModernizeOutput;
37+
};
12538

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',
39+
expect(execAsyncSpy).not.toHaveBeenCalled();
40+
expect(structuredContent?.instructions).toEqual([
41+
'Provide this tool with a list of directory paths in your workspace ' +
42+
'to run the modernization on.',
14143
]);
44+
});
14245

143-
expect(structuredContent?.stderr).toBe('');
144-
expect(newContent).toContain('<app-bar />');
46+
it('can run a single transformation', async () => {
47+
const { structuredContent } = (await runModernization({
48+
directories: ['.'],
49+
transformations: ['self-closing-tag'],
50+
})) as { structuredContent: ModernizeOutput };
51+
52+
expect(execAsyncSpy).toHaveBeenCalledOnceWith(
53+
'ng generate @angular/core:self-closing-tag --path .',
54+
{ cwd: '.' },
55+
);
56+
expect(structuredContent?.stderr).toBeUndefined();
14557
expect(structuredContent?.instructions).toEqual([
14658
'Migration self-closing-tag on directory . completed successfully.',
14759
]);
14860
});
14961

15062
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',
63+
const { structuredContent } = (await runModernization({
64+
directories: ['.'],
65+
transformations: ['control-flow', 'self-closing-tag'],
66+
})) as { structuredContent: ModernizeOutput };
67+
68+
expect(execAsyncSpy).toHaveBeenCalledTimes(2);
69+
expect(execAsyncSpy).toHaveBeenCalledWith('ng generate @angular/core:control-flow --path .', {
70+
cwd: '.',
71+
});
72+
expect(execAsyncSpy).toHaveBeenCalledWith(
73+
'ng generate @angular/core:self-closing-tag --path .',
74+
{ cwd: '.' },
75+
);
76+
expect(structuredContent?.stderr).toBeUndefined();
77+
expect(structuredContent?.instructions).toEqual([
78+
'Migration control-flow on directory . completed successfully.',
79+
'Migration self-closing-tag on directory . completed successfully.',
16880
]);
169-
170-
expect(structuredContent?.stderr).toBe('');
171-
expect(newContent).toContain('@if (show) {<app-bar />}');
17281
});
17382

17483
it('can run multiple transformations across multiple directories', async () => {
175-
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-
191-
const subfolder2 = join(projectDir, 'subfolder2');
192-
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 />');
84+
const { structuredContent } = (await runModernization({
85+
directories: ['subfolder1', 'subfolder2'],
86+
transformations: ['control-flow', 'self-closing-tag'],
87+
})) as { structuredContent: ModernizeOutput };
88+
89+
expect(execAsyncSpy).toHaveBeenCalledTimes(4);
90+
expect(execAsyncSpy).toHaveBeenCalledWith(
91+
'ng generate @angular/core:control-flow --path subfolder1',
92+
{ cwd: '.' },
93+
);
94+
expect(execAsyncSpy).toHaveBeenCalledWith(
95+
'ng generate @angular/core:self-closing-tag --path subfolder1',
96+
{ cwd: '.' },
97+
);
98+
expect(execAsyncSpy).toHaveBeenCalledWith(
99+
'ng generate @angular/core:control-flow --path subfolder2',
100+
{ cwd: '.' },
101+
);
102+
expect(execAsyncSpy).toHaveBeenCalledWith(
103+
'ng generate @angular/core:self-closing-tag --path subfolder2',
104+
{ cwd: '.' },
105+
);
106+
expect(structuredContent?.stderr).toBeUndefined();
107+
expect(structuredContent?.instructions).toEqual([
108+
'Migration control-flow on directory subfolder1 completed successfully.',
109+
'Migration self-closing-tag on directory subfolder1 completed successfully.',
110+
'Migration control-flow on directory subfolder2 completed successfully.',
111+
'Migration self-closing-tag on directory subfolder2 completed successfully.',
112+
]);
113+
});
114+
115+
it('should report errors from transformations', async () => {
116+
// Simulate a failed execution
117+
execAsyncSpy.and.rejectWith(new Error('Command failed with error'));
118+
119+
const { structuredContent } = (await runModernization({
120+
directories: ['.'],
121+
transformations: ['self-closing-tag'],
122+
})) as { structuredContent: ModernizeOutput };
123+
124+
expect(execAsyncSpy).toHaveBeenCalledOnceWith(
125+
'ng generate @angular/core:self-closing-tag --path .',
126+
{ cwd: '.' },
127+
);
128+
expect(structuredContent?.stderr).toContain('Command failed with error');
129+
expect(structuredContent?.instructions).toEqual([
130+
'Migration self-closing-tag on directory . failed.',
131+
]);
217132
});
218133
});
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)