Skip to content

Commit 7c722ae

Browse files
committed
fixup! refactor(@angular/cli): add a get Zoneless/OnPush MCP tool
1 parent 8f78fbc commit 7c722ae

File tree

7 files changed

+117
-126
lines changed

7 files changed

+117
-126
lines changed

packages/angular/cli/BUILD.bazel

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ ts_project(
7070
"//:node_modules/@types/semver",
7171
"//:node_modules/@types/yargs",
7272
"//:node_modules/@types/yarnpkg__lockfile",
73-
"//:node_modules/fast-glob",
7473
"//:node_modules/listr2",
7574
"//:node_modules/semver",
7675
"//:node_modules/typescript",
@@ -129,7 +128,6 @@ ts_project(
129128
":node_modules/yargs",
130129
"//:node_modules/@types/semver",
131130
"//:node_modules/@types/yargs",
132-
"//:node_modules/fast-glob",
133131
"//:node_modules/semver",
134132
],
135133
)

packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/analyze_for_unsupported_zone_uses.ts

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

9-
import ts from 'typescript';
9+
import type { SourceFile, Node, ImportSpecifier } from 'typescript';
1010
import { createUnsupportedZoneUsagesMessage } from './prompts';
11-
import { getImportSpecifier } from './ts_utils';
11+
import { getImportSpecifier, loadTypescript } from './ts_utils';
1212
import { MigrationResponse } from './types';
1313

14-
export function analyzeForUnsupportedZoneUses(sourceFile: ts.SourceFile): MigrationResponse | null {
15-
const ngZoneImport = getImportSpecifier(sourceFile, '@angular/core', 'NgZone');
14+
export async function analyzeForUnsupportedZoneUses(
15+
sourceFile: SourceFile,
16+
): Promise<MigrationResponse | null> {
17+
const ngZoneImport = await getImportSpecifier(sourceFile, '@angular/core', 'NgZone');
1618
if (!ngZoneImport) {
1719
return null;
1820
}
19-
const unsupportedUsages = findUnsupportedZoneUsages(sourceFile, ngZoneImport);
21+
const unsupportedUsages = await findUnsupportedZoneUsages(sourceFile, ngZoneImport);
2022

2123
if (unsupportedUsages.length === 0) {
2224
return null;
2325
}
2426

25-
const locations = unsupportedUsages.map((node: ts.Node) => {
27+
const locations = unsupportedUsages.map((node: Node) => {
2628
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
2729

2830
return `line ${line + 1}, character ${character + 1}: ${node.getText()}`;
@@ -37,11 +39,11 @@ export function analyzeForUnsupportedZoneUses(sourceFile: ts.SourceFile): Migrat
3739
* @param ngZoneImport The import specifier for `NgZone`.
3840
* @returns A list of nodes that are unsupported `NgZone` usages.
3941
*/
40-
export function findUnsupportedZoneUsages(
41-
sourceFile: ts.SourceFile,
42-
ngZoneImport: ts.ImportSpecifier,
43-
): ts.Node[] {
44-
const unsupportedUsages: ts.Node[] = [];
42+
export async function findUnsupportedZoneUsages(
43+
sourceFile: SourceFile,
44+
ngZoneImport: ImportSpecifier,
45+
): Promise<Node[]> {
46+
const unsupportedUsages: Node[] = [];
4547
const ngZoneClassName = ngZoneImport.name.text;
4648

4749
const staticMethods = new Set([
@@ -51,6 +53,7 @@ export function findUnsupportedZoneUsages(
5153
]);
5254
const instanceMethods = new Set(['onMicrotaskEmpty', 'onStable']);
5355

56+
const ts = await loadTypescript();
5457
ts.forEachChild(sourceFile, function visit(node) {
5558
if (ts.isPropertyAccessExpression(node)) {
5659
const propertyName = node.name.text;

packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate_single_file.ts

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

99
import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
1010
import { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
11-
import ts from 'typescript';
11+
import type { SourceFile } from 'typescript';
1212
import { analyzeForUnsupportedZoneUses } from './analyze_for_unsupported_zone_uses';
1313
import { migrateTestFile } from './migrate_test_file';
1414
import { generateZonelessMigrationInstructionsForComponent } from './prompts';
1515
import { sendDebugMessage } from './send_debug_message';
16-
import { getImportSpecifier } from './ts_utils';
16+
import { getImportSpecifier, loadTypescript } from './ts_utils';
1717
import { MigrationResponse } from './types';
1818

1919
export async function migrateSingleFile(
20-
sourceFile: ts.SourceFile,
20+
sourceFile: SourceFile,
2121
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
2222
): Promise<MigrationResponse | null> {
23-
const testBedSpecifier = getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed');
23+
const testBedSpecifier = await getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed');
2424
const isTestFile = sourceFile.fileName.endsWith('.spec.ts') || !!testBedSpecifier;
2525
if (isTestFile) {
2626
return migrateTestFile(sourceFile);
2727
}
2828

29-
const unsupportedZoneUseResponse = analyzeForUnsupportedZoneUses(sourceFile);
29+
const unsupportedZoneUseResponse = await analyzeForUnsupportedZoneUses(sourceFile);
3030
if (unsupportedZoneUseResponse) {
3131
return unsupportedZoneUseResponse;
3232
}
3333

3434
let detectedStrategy: 'OnPush' | 'Default' | undefined;
3535
let hasComponentDecorator = false;
3636

37-
const componentSpecifier = getImportSpecifier(sourceFile, '@angular/core', 'Component');
37+
const componentSpecifier = await getImportSpecifier(sourceFile, '@angular/core', 'Component');
3838
if (!componentSpecifier) {
3939
sendDebugMessage(`No component decorator found in file: ${sourceFile.fileName}`, extras);
4040

4141
return null;
4242
}
4343

44+
const ts = await loadTypescript();
4445
ts.forEachChild(sourceFile, function visit(node) {
4546
if (detectedStrategy) {
4647
return; // Already found, no need to traverse further

packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate_test_file.ts

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

9-
import { glob } from 'fast-glob';
10-
import * as fs from 'fs';
11-
import { dirname, join } from 'path';
12-
import ts from 'typescript';
9+
import { glob } from 'node:fs/promises';
10+
import * as fs from 'node:fs';
11+
import { dirname, join } from 'node:path';
12+
import type { SourceFile } from 'typescript';
1313
import { createFixResponseForZoneTests, createProvideZonelessForTestsSetupPrompt } from './prompts';
1414
import { MigrationResponse } from './types';
15+
import { loadTypescript } from './ts_utils';
1516

16-
export async function migrateTestFile(
17-
sourceFile: ts.SourceFile,
18-
): Promise<MigrationResponse | null> {
17+
export async function migrateTestFile(sourceFile: SourceFile): Promise<MigrationResponse | null> {
18+
const ts = await loadTypescript();
1919
// Check if tests use zoneless either by default through `initTestEnvironment` or by explicitly calling `provideZonelessChangeDetection`.
2020
let testsUseZonelessChangeDetection = await searchForGlobalZoneless(sourceFile.fileName);
2121
if (!testsUseZonelessChangeDetection) {
@@ -51,7 +51,7 @@ export async function searchForGlobalZoneless(startPath: string): Promise<boolea
5151

5252
try {
5353
const files = await glob(`${angularJsonDir}/**/*.ts`);
54-
for (const file of files) {
54+
for await (const file of files) {
5555
const content = fs.readFileSync(file, 'utf-8');
5656
if (
5757
content.includes('initTestEnvironment') &&

packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/prompts.ts

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

9-
import ts from 'typescript';
9+
import type { Node, SourceFile } from 'typescript';
1010
import { MigrationResponse } from './types';
11+
import { loadTypescript } from './ts_utils';
1112

1213
/* eslint-disable max-len */
1314

@@ -28,15 +29,9 @@ export function createProvideZonelessForTestsSetupPrompt(testFilePath: string):
2829
+ import { provideZonelessChangeDetection } from '@angular/core';
2930
3031
describe('MyComponent', () => {
31-
beforeEach(() => {
32-
TestBed.configureTestingModule({
33-
// ... other configuration
34-
providers: [
35-
+ provideZonelessChangeDetection(),
36-
// ... other providers
37-
]
38-
});
39-
});
32+
+ beforeEach(() => {
33+
+ TestBed.configureTestingModule({providers: [provideZonelessChangeDetection()]});
34+
+ });
4035
});
4136
\`\`\`
4237
@@ -54,26 +49,17 @@ export function createProvideZonelessForTestsSetupPrompt(testFilePath: string):
5449
});
5550
// ... rest of the test
5651
});
57-
58-
// Example for a group of failing tests
59-
describe('tests that require Zone.js', () => {
60-
beforeEach(() => {
61-
TestBed.configureTestingModule({
62-
providers: [provideZoneChangeDetection()]
63-
});
64-
});
65-
66-
// ... all failing tests go here
67-
});
6852
\`\`\`
6953
7054
### IMPORTANT: Rules and Constraints
7155
7256
You must follow these rules without exception:
73-
1. **DO** add \`provideZonelessChangeDetection()\` to the top-level \`beforeEach\` block as instructed in Step 1.
74-
2. **DO** add \`provideZoneChangeDetection()\` to the providers for each individual test or group of tests that fail after the change in Step 1.
75-
3. **DO NOT** attempt to fix the logic of the failing tests at this stage. The goal is only to enable zoneless for the suite and isolate the incompatible tests.
76-
4. **DO NOT** make any other changes to the test file or any application code.
57+
1. **DO** add \`provideZonelessChangeDetection()\` _once_ to the top-most \`describe\` in a \`beforeEach\` block as instructed in Step 1.
58+
2. **DO** run the tests after adding \`provideZonelessChangeDetection\` to see which ones fail. **DO NOT** make assumptions about which tests will might fail.
59+
3. **DO** add \`provideZoneChangeDetection()\` to the providers for each individual test or group of tests that fail after the change in Step 1.
60+
4. **DO NOT** attempt to fix the logic of the failing tests at this stage. The goal is only to enable zoneless for the suite and isolate the incompatible tests.
61+
5. **DO NOT** make any other changes to the test file or any application code.
62+
6. **DO NOT** move tests around or create new describe blocks if it can be avoided. Changes are easier to review when code moves around less.
7763
7864
### Final Step
7965
After you have applied all the required changes and followed all the rules, consult this tool again for the next steps in the migration process.`;
@@ -189,47 +175,26 @@ export function generateZonelessMigrationInstructionsForComponent(
189175
return createResponse(text);
190176
}
191177

192-
export function createTestDebuggingGuide(fileOrDirPath: string): MigrationResponse {
178+
export function createTestDebuggingGuideForNonActionableInput(
179+
fileOrDirPath: string,
180+
): MigrationResponse {
193181
const text = `You are an expert Angular developer assisting with a migration to zoneless.
194182
195183
No actionable migration steps were found in the application code for \`${fileOrDirPath}\`. However, if the tests for this code are failing with zoneless enabled, the tests themselves likely need to be updated.
196184
197185
Your task is to investigate and fix any failing tests related to the code in \`${fileOrDirPath}\`.
198186
199-
### Test Debugging Guide
200-
201-
When running tests in a zoneless environment, you may encounter new types of failures. Here are common issues and how to resolve them:
202-
203-
1. **\`ExpressionChangedAfterItHasBeenCheckedError\`**:
204-
* **Cause**: This error indicates that a value in a component's template was updated, but Angular was not notified to run change detection.
205-
* **Solution**:
206-
* If the value is in a test-only wrapper component, update the property to be a signal.
207-
* For application components, either convert the property to a signal or call \`ChangeDetectorRef.markForCheck()\` immediately after the property is updated.
208-
209-
2. **Asynchronous Operations and Timing**:
210-
* **Cause**: Without Zone.js, change detection is always scheduled asynchronously. Tests that previously relied on synchronous updates might now fail. The \`fixture.whenStable()\` utility also no longer waits for timers (like \`setTimeout\` or \`setInterval\`).
211-
* **Solution**:
212-
* Avoid relying on synchronous change detection.
213-
* To wait for asynchronous operations to complete, you may need to poll for an expected state, use \`fakeAsync\` with \`tick()\`, or use a mock clock to flush timers.
214-
215-
3. **Indirect Dependencies**:
216-
* **Cause**: The component itself might be zoneless-compatible, but it could be using a service or another dependency that is not.
217-
* **Solution**: Investigate the services and dependencies used by the component and its tests. Run this tool on those dependencies to identify and fix any issues.
218-
219-
### IMPORTANT: Rules and Constraints
220-
221-
You must follow these rules without exception:
222-
1. **DO** focus only on fixing the tests for the code in \`${fileOrDirPath}\`.
223-
2. **DO** apply the solutions described in the debugging guide to fix test failures.
224-
3. **DO NOT** make changes to application code unless it is to fix a bug revealed by the zoneless migration (e.g., converting a property to a signal to fix an \`ExpressionChangedAfterItHasBeenCheckedError\`).
225-
4. **DO NOT** re-introduce \`provideZoneChangeDetection()\` into tests that are already using \`provideZonelessChangeDetection()\`.
187+
${testDebuggingGuideText(fileOrDirPath)}
226188
`;
227189

228190
return createResponse(text);
229191
}
230192

231-
export function createFixResponseForZoneTests(sourceFile: ts.SourceFile): MigrationResponse | null {
232-
const usages: ts.Node[] = [];
193+
export async function createFixResponseForZoneTests(
194+
sourceFile: SourceFile,
195+
): Promise<MigrationResponse | null> {
196+
const ts = await loadTypescript();
197+
const usages: Node[] = [];
233198
ts.forEachChild(sourceFile, function visit(node) {
234199
if (
235200
ts.isCallExpression(node) &&
@@ -255,7 +220,16 @@ export function createFixResponseForZoneTests(sourceFile: ts.SourceFile): Migrat
255220
256221
The following usages of \`provideZoneChangeDetection\` must be removed:
257222
${locations.map((loc) => `- ${loc}`).join('\n')}
223+
${testDebuggingGuideText(sourceFile.fileName)}
258224
225+
### Final Step
226+
After you have applied all the required changes and followed all the rules, consult this tool again for the next steps in the migration process.`;
227+
228+
return createResponse(text);
229+
}
230+
231+
function testDebuggingGuideText(fileName: string) {
232+
return `
259233
### Test Debugging Guide
260234
261235
After removing \`provideZoneChangeDetection\`, the tests will likely fail. Use this guide to diagnose and fix the failures.
@@ -279,15 +253,13 @@ export function createFixResponseForZoneTests(sourceFile: ts.SourceFile): Migrat
279253
### IMPORTANT: Rules and Constraints
280254
281255
You must follow these rules without exception:
282-
1. **DO** remove all usages of \`provideZoneChangeDetection\` from the test file.
283-
2. **DO** apply the solutions described in the debugging guide to fix any resulting test failures.
284-
3. **DO NOT** make changes to application code unless it is to fix a bug revealed by the zoneless migration (e.g., converting a property to a signal to fix an \`ExpressionChangedAfterItHasBeenCheckedError\`).
285-
4. **DO NOT** make any changes unrelated to fixing the failing tests in \`${sourceFile.fileName}\`.
286-
287-
### Final Step
288-
After you have applied all the required changes and followed all the rules, consult this tool again for the next steps in the migration process.`;
289-
290-
return createResponse(text);
256+
1. **DO** focus only on fixing the tests for the code in \`${fileName}\`.
257+
2. **DO** remove all usages of \`provideZoneChangeDetection\` from the test file.
258+
3. **DO** apply the solutions described in the debugging guide to fix any resulting test failures.
259+
4. **DO** update properties of test components and directives to use signals. Tests often use plain objects and values and update the component state directly before calling \`fixture.detectChanges\`. This will not work and will result in \`ExpressionChangedAfterItHasBeenCheckedError\` because Angular was not notifed of the change.
260+
5. **DO NOT** make changes to application code unless it is to fix a bug revealed by the zoneless migration (e.g., converting a property to a signal to fix an \`ExpressionChangedAfterItHasBeenCheckedError\`).
261+
6. **DO NOT** make any changes unrelated to fixing the failing tests in \`${fileName}\`.
262+
7. **DO NOT** re-introduce \`provideZoneChangeDetection()\` into tests that are already using \`provideZonelessChangeDetection()\`.`;
291263
}
292264

293265
/* eslint-enable max-len */

0 commit comments

Comments
 (0)