Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/angular/cli/src/commands/mcp/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export interface Host {
* @param encoding The encoding to use.
* @returns A promise that resolves to the file content.
*/
readFile(path: string, encoding: 'utf-8'): Promise<string>;
readFile(path: string, encoding: BufferEncoding): Promise<string>;
Comment thread
clydin marked this conversation as resolved.

/**
* Finds files matching a glob pattern.
Expand Down Expand Up @@ -348,7 +348,7 @@ export function createRootRestrictedHost(

return baseHost.existsSync(path);
},
readFile(path: string, encoding: 'utf-8') {
readFile(path: string, encoding: BufferEncoding) {
checkPath(path);

return baseHost.readFile(path, encoding);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
import type { SourceFile } from 'typescript';
import type { Host } from '../../host';
import { analyzeForUnsupportedZoneUses } from './analyze-for-unsupported-zone-uses';
import { migrateTestFile } from './migrate-test-file';
import { generateZonelessMigrationInstructionsForComponent } from './prompts';
Expand All @@ -20,12 +21,13 @@ const supportedStrategies: ReadonlySet<string> = new Set(['OnPush', 'Default', '

export async function migrateSingleFile(
sourceFile: SourceFile,
host: Host,
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
): Promise<MigrationResponse | null> {
const testBedSpecifier = await getImportSpecifier(sourceFile, '@angular/core/testing', 'TestBed');
const isTestFile = sourceFile.fileName.endsWith('.spec.ts') || !!testBedSpecifier;
if (isTestFile) {
return migrateTestFile(sourceFile);
return migrateTestFile(sourceFile, host);
}

const unsupportedZoneUseResponse = await analyzeForUnsupportedZoneUses(sourceFile);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
import ts from 'typescript';
import { createMockHost } from '../../testing/test-utils';
import { migrateSingleFile } from './migrate-single-file';

const fakeExtras = {
Expand All @@ -17,11 +18,13 @@ const fakeExtras = {
} as unknown as RequestHandlerExtra<ServerRequest, ServerNotification>;

describe('migrateSingleFile', () => {
const mockHost = createMockHost();

it('should identify test files by extension', async () => {
const fileName = 'test.spec.ts';
const sourceFile = ts.createSourceFile(fileName, '', ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result?.content[0].text).toContain(
'The test file `test.spec.ts` is not yet configured for zoneless change detection.' +
Expand All @@ -34,7 +37,7 @@ describe('migrateSingleFile', () => {
const content = `import { TestBed } from '@angular/core/testing';`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result?.content[0].text).toContain(
'The test file `test.ts` is not yet configured for zoneless change detection.' +
Expand All @@ -59,7 +62,7 @@ describe('migrateSingleFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result?.content[0].text).toContain(
'The component uses NgZone APIs that are incompatible with zoneless applications',
Expand All @@ -80,7 +83,7 @@ describe('migrateSingleFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result).toBeNull();
});
Expand All @@ -99,7 +102,7 @@ describe('migrateSingleFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result).toBeNull();
});
Expand All @@ -117,7 +120,7 @@ describe('migrateSingleFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result?.content[0].text).toContain(
'The component does not currently use a change detection strategy, which means it may rely on Zone.js',
Expand All @@ -134,7 +137,7 @@ describe('migrateSingleFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result).toBeNull();
});
Expand All @@ -144,7 +147,7 @@ describe('migrateSingleFile', () => {
const content = ``;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateSingleFile(sourceFile, fakeExtras);
const result = await migrateSingleFile(sourceFile, mockHost, fakeExtras);

expect(result).toBeNull();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { existsSync, readFileSync } from 'node:fs';
import { glob } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { join } from 'node:path';
import type { SourceFile } from 'typescript';
import { type Host } from '../../host';
import { findAngularJsonDir } from '../../workspace-utils';
import { createFixResponseForZoneTests, createProvideZonelessForTestsSetupPrompt } from './prompts';
import { loadTypescript } from './ts-utils';
import { MigrationResponse } from './types';

export async function migrateTestFile(sourceFile: SourceFile): Promise<MigrationResponse | null> {
export async function migrateTestFile(
sourceFile: SourceFile,
host: Host,
): Promise<MigrationResponse | null> {
const ts = await loadTypescript();
// Check if tests use zoneless either by default through `initTestEnvironment` or by explicitly calling `provideZonelessChangeDetection`.
let testsUseZonelessChangeDetection = await searchForGlobalZoneless(sourceFile.fileName);
let testsUseZonelessChangeDetection = await searchForGlobalZoneless(sourceFile.fileName, host);
if (!testsUseZonelessChangeDetection) {
ts.forEachChild(sourceFile, function visit(node) {
if (
Expand All @@ -42,18 +44,19 @@ export async function migrateTestFile(sourceFile: SourceFile): Promise<Migration
return createFixResponseForZoneTests(sourceFile);
}

export async function searchForGlobalZoneless(startPath: string): Promise<boolean> {
const angularJsonDir = findAngularJsonDir(startPath);
export async function searchForGlobalZoneless(startPath: string, host: Host): Promise<boolean> {
const angularJsonDir = findAngularJsonDir(startPath, host);
if (!angularJsonDir) {
// Cannot determine project root, fallback to original behavior or assume false.
// For now, let's assume no global setup if angular.json is not found.
return false;
}

try {
const files = glob(`${angularJsonDir}/**/*.ts`);
const files = host.glob('**/*.ts', { cwd: angularJsonDir });
for await (const file of files) {
const content = readFileSync(file, 'utf-8');
const fullPath = join(file.parentPath, file.name);
const content = await host.readFile(fullPath, 'utf-8');
if (
content.includes('initTestEnvironment') &&
content.includes('provideZonelessChangeDetection')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
*/

import ts from 'typescript';
import { createMockHost } from '../../testing/test-utils';
import { migrateTestFile } from './migrate-test-file';

describe('migrateTestFile', () => {
const mockHost = createMockHost();
it('should return setup prompt when zoneless is not detected', async () => {
const fileName = 'test.spec.ts';
const sourceFile = ts.createSourceFile(fileName, '', ts.ScriptTarget.ESNext, true);

const result = await migrateTestFile(sourceFile);
const result = await migrateTestFile(sourceFile, mockHost);

expect(result?.content[0].text).toContain(
'The test file `test.spec.ts` is not yet configured for zoneless change detection.',
Expand All @@ -33,7 +35,7 @@ describe('migrateTestFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateTestFile(sourceFile);
const result = await migrateTestFile(sourceFile, mockHost);

expect(result).toBeNull();
});
Expand All @@ -60,7 +62,7 @@ describe('migrateTestFile', () => {
`;
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.ESNext, true);

const result = await migrateTestFile(sourceFile);
const result = await migrateTestFile(sourceFile, mockHost);

expect(result?.content[0].text).toContain(
'You must refactor these tests to work in a zoneless environment and remove the `provideZoneChangeDetection` calls.',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/

import { readFileSync } from 'node:fs';
import type ts from 'typescript';
import type { Host } from '../../host';

let typescriptModule: typeof ts;

Expand Down Expand Up @@ -116,8 +116,8 @@ export function findImportSpecifier(
}

/** Creates a TypeScript source file from a file path. */
export async function createSourceFile(file: string) {
const content = readFileSync(file, 'utf8');
export async function createSourceFile(file: string, host: Host) {
const content = await host.readFile(file, 'utf8');

const ts = await loadTypescript();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@

import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types';
import { existsSync, statSync } from 'node:fs';
import { glob } from 'node:fs/promises';
import { join } from 'node:path';
import type { SourceFile } from 'typescript';
import { z } from 'zod';
import type { Host } from '../../host';
import { declareTool } from '../tool-registry';
import { analyzeForUnsupportedZoneUses } from './analyze-for-unsupported-zone-uses';
import { migrateSingleFile } from './migrate-single-file';
Expand Down Expand Up @@ -61,19 +61,20 @@ most important action to take in the migration journey.
),
},
factory:
() =>
({ host }) =>
({ fileOrDirPath }, requestHandlerExtra) =>
registerZonelessMigrationTool(fileOrDirPath, requestHandlerExtra),
registerZonelessMigrationTool(fileOrDirPath, host, requestHandlerExtra),
});

export async function registerZonelessMigrationTool(
fileOrDirPath: string,
host: Host,
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
) {
let filesWithComponents, componentTestFiles, zoneFiles, categorizationErrors;
try {
({ filesWithComponents, componentTestFiles, zoneFiles, categorizationErrors } =
await discoverAndCategorizeFiles(fileOrDirPath, extras));
await discoverAndCategorizeFiles(fileOrDirPath, host, extras));
} catch (e) {
return createResponse(
`Error: Could not access the specified path. Please ensure the following path is correct ` +
Expand All @@ -97,15 +98,15 @@ export async function registerZonelessMigrationTool(
: Array.from(filesWithComponents);

for (const file of rankedFiles) {
const result = await migrateSingleFile(file, extras);
const result = await migrateSingleFile(file, host, extras);
if (result !== null) {
return result;
}
}
}

for (const file of componentTestFiles) {
const result = await migrateTestFile(file);
const result = await migrateTestFile(file, host);
if (result !== null) {
return result;
}
Expand All @@ -124,6 +125,7 @@ export async function registerZonelessMigrationTool(

async function discoverAndCategorizeFiles(
fileOrDirPath: string,
host: Host,
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
) {
const filePaths: string[] = [];
Expand All @@ -134,19 +136,20 @@ async function discoverAndCategorizeFiles(

let isDirectory: boolean;
try {
isDirectory = statSync(fileOrDirPath).isDirectory();
isDirectory = (await host.stat(fileOrDirPath)).isDirectory();
} catch (e) {
// Re-throw to be handled by the main function as a user input error
throw new Error(`Failed to access path: ${fileOrDirPath}`, { cause: e });
}

if (isDirectory) {
for await (const file of glob(`${fileOrDirPath}/**/*.ts`)) {
filePaths.push(file);
const files = host.glob('**/*.ts', { cwd: fileOrDirPath });
for await (const file of files) {
filePaths.push(join(file.parentPath, file.name));
}
} else {
filePaths.push(fileOrDirPath);
const maybeTestFile = await getTestFilePath(fileOrDirPath);
const maybeTestFile = await getTestFilePath(fileOrDirPath, host);
if (maybeTestFile) {
// Eagerly add the test file path for categorization.
filePaths.push(maybeTestFile);
Expand All @@ -159,8 +162,8 @@ async function discoverAndCategorizeFiles(
const batch = filesToProcess.splice(0, CONCURRENCY_LIMIT);
const results = await Promise.allSettled(
batch.map(async (filePath) => {
const sourceFile = await createSourceFile(filePath);
await categorizeFile(sourceFile, extras, {
const sourceFile = await createSourceFile(filePath, host);
await categorizeFile(sourceFile, host, extras, {
filesWithComponents,
componentTestFiles,
zoneFiles,
Expand All @@ -183,6 +186,7 @@ async function discoverAndCategorizeFiles(

async function categorizeFile(
sourceFile: SourceFile,
host: Host,
extras: RequestHandlerExtra<ServerRequest, ServerNotification>,
categorizedFiles: {
filesWithComponents: Set<SourceFile>;
Expand Down Expand Up @@ -214,9 +218,9 @@ async function categorizeFile(
);
}

const testFilePath = await getTestFilePath(sourceFile.fileName);
const testFilePath = await getTestFilePath(sourceFile.fileName, host);
if (testFilePath) {
componentTestFiles.add(await createSourceFile(testFilePath));
componentTestFiles.add(await createSourceFile(testFilePath, host));
}
} else if (zoneSpecifier) {
zoneFiles.add(sourceFile);
Expand Down Expand Up @@ -271,9 +275,9 @@ async function rankComponentFilesForMigration(
return componentFiles; // Fallback to original order if the response fails
}

async function getTestFilePath(filePath: string): Promise<string | undefined> {
async function getTestFilePath(filePath: string, host: Host): Promise<string | undefined> {
const testFilePath = filePath.replace(/\.ts$/, '.spec.ts');
if (existsSync(testFilePath)) {
if (host.existsSync(testFilePath)) {
return testFilePath;
}

Expand Down
Loading