diff --git a/packages/angular/cli/src/commands/mcp/devserver.ts b/packages/angular/cli/src/commands/mcp/devserver.ts index 5b86f7125b37..1a667afed6c9 100644 --- a/packages/angular/cli/src/commands/mcp/devserver.ts +++ b/packages/angular/cli/src/commands/mcp/devserver.ts @@ -118,7 +118,7 @@ export class LocalDevserver implements Devserver { args.push(`--port=${this.port}`); - this.devserverProcess = this.host.spawn('ng', args, { + this.devserverProcess = this.host.startNgProcess(args, { stdio: 'pipe', cwd: this.workspacePath, }); diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts index 31cec340cce1..5dda0ade077f 100644 --- a/packages/angular/cli/src/commands/mcp/host.ts +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -60,7 +60,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; + readFile(path: string, encoding: BufferEncoding): Promise; /** * Finds files matching a glob pattern. @@ -76,13 +76,11 @@ export interface Host { /** * Spawns a child process and returns a promise that resolves with the process's * output or rejects with a structured error. - * @param command The command to run. * @param args The arguments to pass to the command. * @param options Options for the child process. * @returns A promise that resolves with the standard output and standard error of the command. */ - runCommand( - command: string, + executeNgCommand( args: readonly string[], options?: { timeout?: number; @@ -94,13 +92,11 @@ export interface Host { /** * Spawns a long-running child process and returns the `ChildProcess` object. - * @param command The command to run. * @param args The arguments to pass to the command. * @param options Options for the child process. * @returns The spawned `ChildProcess` instance. */ - spawn( - command: string, + startNgProcess( args: readonly string[], options?: { stdio?: 'pipe' | 'ignore'; @@ -125,13 +121,13 @@ export interface Host { setRoots(roots: string[]): void; } -function resolveCommand( - command: string, +function resolveNgCommand( args: readonly string[], cwd?: string, ): { command: string; args: readonly string[] } { - if (command !== 'ng' || !cwd) { - return { command, args }; + const defaultCommand = { command: 'ng', args }; + if (!cwd) { + return defaultCommand; } try { @@ -152,7 +148,7 @@ function resolveCommand( // Failed to resolve the CLI binary, fall back to assuming `ng` is on PATH. } - return { command, args }; + return defaultCommand; } /** @@ -172,8 +168,7 @@ export const LocalWorkspaceHost: Host = { return nodeGlob(pattern, { ...options, withFileTypes: true }); }, - runCommand: async ( - command: string, + executeNgCommand: async ( args: readonly string[], options: { timeout?: number; @@ -182,7 +177,7 @@ export const LocalWorkspaceHost: Host = { env?: Record; } = {}, ): Promise<{ logs: string[] }> => { - const resolved = resolveCommand(command, args, options.cwd); + const resolved = resolveNgCommand(args, options.cwd); const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined; return new Promise((resolve, reject) => { @@ -223,8 +218,7 @@ export const LocalWorkspaceHost: Host = { }); }, - spawn( - command: string, + startNgProcess( args: readonly string[], options: { stdio?: 'pipe' | 'ignore'; @@ -232,7 +226,7 @@ export const LocalWorkspaceHost: Host = { env?: Record; } = {}, ): ChildProcess { - const resolved = resolveCommand(command, args, options.cwd); + const resolved = resolveNgCommand(args, options.cwd); return spawn(resolved.command, resolved.args, { shell: false, @@ -350,7 +344,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); @@ -372,23 +366,20 @@ export function createRootRestrictedHost( return baseHost.glob(pattern, options); }, - runCommand(command: string, args: readonly string[], options: { cwd?: string } = {}) { - const effectiveCwd = options.cwd ?? process.cwd(); + executeNgCommand( + args: readonly string[], + options: Parameters[1] = {}, + ) { + const effectiveCwd = options?.cwd ?? process.cwd(); checkPath(effectiveCwd); - if (command.includes('/') || command.includes('\\')) { - checkPath(resolve(effectiveCwd, command)); - } - return baseHost.runCommand(command, args, options); + return baseHost.executeNgCommand(args, options); }, - spawn(command: string, args: readonly string[], options: { cwd?: string } = {}) { - const effectiveCwd = options.cwd ?? process.cwd(); + startNgProcess(args: readonly string[], options: Parameters[1] = {}) { + const effectiveCwd = options?.cwd ?? process.cwd(); checkPath(effectiveCwd); - if (command.includes('/') || command.includes('\\')) { - checkPath(resolve(effectiveCwd, command)); - } - return baseHost.spawn(command, args, options); + return baseHost.startNgProcess(args, options); }, }; } diff --git a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts index ef818062d559..1062191aebe1 100644 --- a/packages/angular/cli/src/commands/mcp/testing/mock-host.ts +++ b/packages/angular/cli/src/commands/mcp/testing/mock-host.ts @@ -13,12 +13,12 @@ import type { Host } from '../host'; * This class allows spying on host methods and controlling their return values. */ export class MockHost implements Host { - runCommand = jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }); + executeNgCommand = jasmine.createSpy('executeNgCommand').and.resolveTo({ logs: [] }); stat = jasmine.createSpy('stat'); existsSync = jasmine.createSpy('existsSync'); readFile = jasmine.createSpy('readFile').and.resolveTo(''); glob = jasmine.createSpy('glob').and.returnValue((async function* () {})()); - spawn = jasmine.createSpy('spawn'); + startNgProcess = jasmine.createSpy('startNgProcess'); getAvailablePort = jasmine.createSpy('getAvailablePort'); isPortAvailable = jasmine.createSpy('isPortAvailable').and.resolveTo(true); setRoots = jasmine.createSpy('setRoots'); diff --git a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts index 1bdf2ef416a5..1c95c51fe25e 100644 --- a/packages/angular/cli/src/commands/mcp/testing/test-utils.ts +++ b/packages/angular/cli/src/commands/mcp/testing/test-utils.ts @@ -20,10 +20,12 @@ import { MockHost } from './mock-host'; */ export function createMockHost(): MockHost { return { - runCommand: jasmine.createSpy('runCommand').and.resolveTo({ logs: [] }), + executeNgCommand: jasmine + .createSpy('executeNgCommand') + .and.resolveTo({ logs: [] }), stat: jasmine.createSpy('stat'), existsSync: jasmine.createSpy('existsSync'), - spawn: jasmine.createSpy('spawn'), + startNgProcess: jasmine.createSpy('startNgProcess'), getAvailablePort: jasmine .createSpy('getAvailablePort') .and.resolveTo(0), diff --git a/packages/angular/cli/src/commands/mcp/tools/build.ts b/packages/angular/cli/src/commands/mcp/tools/build.ts index a04812f8544b..fbf2729bf8bf 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build.ts @@ -52,7 +52,7 @@ export async function runBuild(input: BuildToolInput, context: McpToolContext) { let outputPath: string | undefined; try { - logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs; + logs = (await context.host.executeNgCommand(args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); diff --git a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts index 403d5e68f877..3fd7318c554b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/build_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/build_spec.ts @@ -29,8 +29,7 @@ describe('Build Tool', () => { it('should construct the command correctly with default configuration', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runBuild({}, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'my-app', '-c', 'development'], { cwd: '/test' }, ); @@ -39,8 +38,7 @@ describe('Build Tool', () => { it('should construct the command correctly with a specified project', async () => { addProjectToWorkspace(mockContext.workspace.projects, 'another-app'); await runBuild({ project: 'another-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'another-app', '-c', 'development'], { cwd: '/test' }, ); @@ -49,7 +47,7 @@ describe('Build Tool', () => { it('should construct the command correctly for a custom configuration', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runBuild({ configuration: 'myconfig' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['build', 'my-app', '-c', 'myconfig'], { + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['build', 'my-app', '-c', 'myconfig'], { cwd: '/test', }); }); @@ -61,14 +59,13 @@ describe('Build Tool', () => { 'some warning', 'Output location: dist/my-app', ]; - mockHost.runCommand.and.resolveTo({ + mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs, }); const { structuredContent } = await runBuild({ project: 'my-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'my-app', '-c', 'development'], { cwd: '/test' }, ); @@ -81,15 +78,14 @@ describe('Build Tool', () => { addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app'); const buildLogs = ['Some output before the crash.', 'Error: Something went wrong!']; const error = new CommandError('Build failed', buildLogs, 1); - mockHost.runCommand.and.rejectWith(error); + mockHost.executeNgCommand.and.rejectWith(error); const { structuredContent } = await runBuild( { project: 'my-failed-app', configuration: 'production' }, mockContext, ); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['build', 'my-failed-app', '-c', 'production'], { cwd: '/test' }, ); @@ -100,7 +96,7 @@ describe('Build Tool', () => { it('should handle builds where the output path is not found in logs', async () => { const buildLogs = ["Some logs that don't match any output path."]; - mockHost.runCommand.and.resolveTo({ logs: buildLogs }); + mockHost.executeNgCommand.and.resolveTo({ logs: buildLogs }); mockContext.workspace.extensions['defaultProject'] = 'my-app'; const { structuredContent } = await runBuild({}, mockContext); diff --git a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts index 0b617396efe0..735e82302a94 100644 --- a/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/devserver/devserver_spec.ts @@ -44,7 +44,7 @@ describe('Serve Tools', () => { mockContext = mock.context; // Customize host spies - mockHost.spawn.and.returnValue(mockProcess as unknown as ChildProcess); + mockHost.startNgProcess.and.returnValue(mockProcess as unknown as ChildProcess); mockHost.getAvailablePort.and.callFake(() => Promise.resolve(portCounter++)); // Setup default project @@ -57,7 +57,7 @@ describe('Serve Tools', () => { expect(startResult.structuredContent.message).toBe( `Development server for project 'my-app' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=12345'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'my-app', '--port=12345'], { stdio: 'pipe', cwd: '/test', }); @@ -74,7 +74,7 @@ describe('Serve Tools', () => { expect(startResult.structuredContent.message).toBe( `Development server for project 'my-app' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'my-app', '--port=54321'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'my-app', '--port=54321'], { stdio: 'pipe', cwd: '/test', }); @@ -130,17 +130,17 @@ describe('Serve Tools', () => { // Start server for project 2, returning a new mock process. const process2 = new MockChildProcess(); - mockHost.spawn.and.returnValue(process2 as unknown as ChildProcess); + mockHost.startNgProcess.and.returnValue(process2 as unknown as ChildProcess); const startResult2 = await startDevserver({ project: 'app-two' }, mockContext); expect(startResult2.structuredContent.message).toBe( `Development server for project 'app-two' started and watching for workspace changes.`, ); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-one', '--port=12345'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'app-one', '--port=12345'], { stdio: 'pipe', cwd: '/test', }); - expect(mockHost.spawn).toHaveBeenCalledWith('ng', ['serve', 'app-two', '--port=12346'], { + expect(mockHost.startNgProcess).toHaveBeenCalledWith(['serve', 'app-two', '--port=12346'], { stdio: 'pipe', cwd: '/test', }); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e.ts b/packages/angular/cli/src/commands/mcp/tools/e2e.ts index 2bd0441d2434..726308b12c87 100644 --- a/packages/angular/cli/src/commands/mcp/tools/e2e.ts +++ b/packages/angular/cli/src/commands/mcp/tools/e2e.ts @@ -62,7 +62,7 @@ export async function runE2e(input: E2eToolInput, host: Host, context: McpToolCo let logs: string[]; try { - logs = (await host.runCommand('ng', args, { cwd: workspacePath })).logs; + logs = (await host.executeNgCommand(args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); diff --git a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts index d2d3949a6451..318dd41aea52 100644 --- a/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts @@ -33,14 +33,14 @@ describe('E2E Tool', () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runE2e({}, mockHost, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); }); it('should construct the command correctly with a specified project', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); await runE2e({ project: 'my-app' }, mockHost, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); }); it('should error if project does not have e2e target', async () => { @@ -50,7 +50,7 @@ describe('E2E Tool', () => { expect(structuredContent.status).toBe('failure'); expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'"); - expect(mockHost.runCommand).not.toHaveBeenCalled(); + expect(mockHost.executeNgCommand).not.toHaveBeenCalled(); }); it('should error if no project was specified and the default project does not have e2e target', async () => { @@ -61,32 +61,32 @@ describe('E2E Tool', () => { expect(structuredContent.status).toBe('failure'); expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'"); - expect(mockHost.runCommand).not.toHaveBeenCalled(); + expect(mockHost.executeNgCommand).not.toHaveBeenCalled(); }); it('should handle a successful e2e run with a specified project', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E passed for my-app']; - mockHost.runCommand.and.resolveTo({ logs: e2eLogs }); + mockHost.executeNgCommand.and.resolveTo({ logs: e2eLogs }); const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app'], { cwd: '/test' }); + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'my-app'], { cwd: '/test' }); }); it('should handle a successful e2e run with the default project', async () => { mockContext.workspace.extensions['defaultProject'] = 'default-app'; addProjectToWorkspace(mockProjects, 'default-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E passed for default-app']; - mockHost.runCommand.and.resolveTo({ logs: e2eLogs }); + mockHost.executeNgCommand.and.resolveTo({ logs: e2eLogs }); const { structuredContent } = await runE2e({}, mockHost, mockContext); expect(structuredContent.status).toBe('success'); expect(structuredContent.logs).toEqual(e2eLogs); - expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'default-app'], { + expect(mockHost.executeNgCommand).toHaveBeenCalledWith(['e2e', 'default-app'], { cwd: '/test', }); }); @@ -94,7 +94,7 @@ describe('E2E Tool', () => { it('should handle a failed e2e run', async () => { addProjectToWorkspace(mockProjects, 'my-app', { e2e: { builder: 'mock-builder' } }); const e2eLogs = ['E2E failed']; - mockHost.runCommand.and.rejectWith(new CommandError('Failed', e2eLogs, 1)); + mockHost.executeNgCommand.and.rejectWith(new CommandError('Failed', e2eLogs, 1)); const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext); diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts index afd6316da83d..ebbc254d4d69 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file.ts @@ -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'; @@ -20,12 +21,13 @@ const supportedStrategies: ReadonlySet = new Set(['OnPush', 'Default', ' export async function migrateSingleFile( sourceFile: SourceFile, + host: Host, extras: RequestHandlerExtra, ): Promise { 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); diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts index 442dc2c68378..4f6337d8e434 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-single-file_spec.ts @@ -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 = { @@ -17,11 +18,13 @@ const fakeExtras = { } as unknown as RequestHandlerExtra; 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.' + @@ -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.' + @@ -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', @@ -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(); }); @@ -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(); }); @@ -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', @@ -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(); }); @@ -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(); }); diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-test-file.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-test-file.ts index e0c62eb23dc9..79b5acbec70e 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-test-file.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/migrate-test-file.ts @@ -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 { +export async function migrateTestFile( + sourceFile: SourceFile, + host: Host, +): Promise { 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 ( @@ -42,8 +44,8 @@ export async function migrateTestFile(sourceFile: SourceFile): Promise { - const angularJsonDir = findAngularJsonDir(startPath); +export async function searchForGlobalZoneless(startPath: string, host: Host): Promise { + 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. @@ -51,9 +53,10 @@ export async function searchForGlobalZoneless(startPath: string): Promise { + 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.', @@ -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(); }); @@ -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.', diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/ts-utils.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/ts-utils.ts index 08a3f6f0dcfd..0c79dc31f2e0 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/ts-utils.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/ts-utils.ts @@ -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; @@ -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(); diff --git a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts index cc33c648f189..25c12aa83378 100644 --- a/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts +++ b/packages/angular/cli/src/commands/mcp/tools/onpush-zoneless-migration/zoneless-migration.ts @@ -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'; @@ -49,19 +49,20 @@ change detection (a prerequisite for zoneless applications). ), }, factory: - () => + ({ host }) => ({ fileOrDirPath }, requestHandlerExtra) => - registerZonelessMigrationTool(fileOrDirPath, requestHandlerExtra), + registerZonelessMigrationTool(fileOrDirPath, host, requestHandlerExtra), }); export async function registerZonelessMigrationTool( fileOrDirPath: string, + host: Host, extras: RequestHandlerExtra, ) { 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 ` + @@ -85,7 +86,7 @@ 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; } @@ -93,7 +94,7 @@ export async function registerZonelessMigrationTool( } for (const file of componentTestFiles) { - const result = await migrateTestFile(file); + const result = await migrateTestFile(file, host); if (result !== null) { return result; } @@ -112,6 +113,7 @@ export async function registerZonelessMigrationTool( async function discoverAndCategorizeFiles( fileOrDirPath: string, + host: Host, extras: RequestHandlerExtra, ) { const filePaths: string[] = []; @@ -122,19 +124,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); @@ -147,8 +150,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, @@ -171,6 +174,7 @@ async function discoverAndCategorizeFiles( async function categorizeFile( sourceFile: SourceFile, + host: Host, extras: RequestHandlerExtra, categorizedFiles: { filesWithComponents: Set; @@ -202,9 +206,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); @@ -259,9 +263,9 @@ async function rankComponentFilesForMigration( return componentFiles; // Fallback to original order if the response fails } -async function getTestFilePath(filePath: string): Promise { +async function getTestFilePath(filePath: string, host: Host): Promise { const testFilePath = filePath.replace(/\.ts$/, '.spec.ts'); - if (existsSync(testFilePath)) { + if (host.existsSync(testFilePath)) { return testFilePath; } diff --git a/packages/angular/cli/src/commands/mcp/tools/test.ts b/packages/angular/cli/src/commands/mcp/tools/test.ts index 9c05a2f0f29b..72093c268a1b 100644 --- a/packages/angular/cli/src/commands/mcp/tools/test.ts +++ b/packages/angular/cli/src/commands/mcp/tools/test.ts @@ -65,7 +65,7 @@ export async function runTest(input: TestToolInput, context: McpToolContext) { let logs: string[]; try { - logs = (await context.host.runCommand('ng', args, { cwd: workspacePath })).logs; + logs = (await context.host.executeNgCommand(args, { cwd: workspacePath })).logs; } catch (e) { status = 'failure'; logs = getCommandErrorLogs(e); diff --git a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts index 487c986cdcd2..a56307dcf3cb 100644 --- a/packages/angular/cli/src/commands/mcp/tools/test_spec.ts +++ b/packages/angular/cli/src/commands/mcp/tools/test_spec.ts @@ -29,8 +29,7 @@ describe('Test Tool', () => { it('should construct the command correctly with defaults', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runTest({}, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], { cwd: '/test' }, ); @@ -39,8 +38,7 @@ describe('Test Tool', () => { it('should construct the command correctly with a specified project', async () => { addProjectToWorkspace(mockContext.workspace.projects, 'my-lib'); await runTest({ project: 'my-lib' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-lib', '--browsers', 'ChromeHeadless', '--watch', 'false'], { cwd: '/test' }, ); @@ -49,8 +47,7 @@ describe('Test Tool', () => { it('should construct the command correctly with filter', async () => { mockContext.workspace.extensions['defaultProject'] = 'my-app'; await runTest({ filter: 'AppComponent' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( [ 'test', 'my-app', @@ -67,14 +64,13 @@ describe('Test Tool', () => { it('should handle a successful test run and capture logs', async () => { const testLogs = ['Executed 10 of 10 SUCCESS', 'Total: 10 success']; - mockHost.runCommand.and.resolveTo({ + mockHost.executeNgCommand.and.resolveTo({ logs: testLogs, }); const { structuredContent } = await runTest({ project: 'my-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-app', '--browsers', 'ChromeHeadless', '--watch', 'false'], { cwd: '/test' }, ); @@ -86,7 +82,7 @@ describe('Test Tool', () => { addProjectToWorkspace(mockContext.workspace.projects, 'my-failed-app'); const testLogs = ['Executed 10 of 10 FAILED', 'Error: Some test failed']; const error = new CommandError('Test failed', testLogs, 1); - mockHost.runCommand.and.rejectWith(error); + mockHost.executeNgCommand.and.rejectWith(error); const { structuredContent } = await runTest({ project: 'my-failed-app' }, mockContext); @@ -106,8 +102,7 @@ describe('Test Tool', () => { await runTest({ project: 'my-vitest-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-vitest-app', '--headless', 'true', '--watch', 'false'], { cwd: '/test' }, ); @@ -123,8 +118,7 @@ describe('Test Tool', () => { await runTest({ project: 'my-default-vitest-app' }, mockContext); - expect(mockHost.runCommand).toHaveBeenCalledWith( - 'ng', + expect(mockHost.executeNgCommand).toHaveBeenCalledWith( ['test', 'my-default-vitest-app', '--headless', 'true', '--watch', 'false'], { cwd: '/test' }, );