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
2 changes: 1 addition & 1 deletion packages/angular/cli/src/commands/mcp/devserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
49 changes: 20 additions & 29 deletions packages/angular/cli/src/commands/mcp/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,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;
Expand All @@ -92,13 +90,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';
Expand All @@ -123,13 +119,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 {
Expand All @@ -150,7 +146,7 @@ function resolveCommand(
// Failed to resolve the CLI binary, fall back to assuming `ng` is on PATH.
}

return { command, args };
return defaultCommand;
}

/**
Expand All @@ -170,8 +166,7 @@ export const LocalWorkspaceHost: Host = {
return nodeGlob(pattern, { ...options, withFileTypes: true });
},

runCommand: async (
command: string,
executeNgCommand: async (
args: readonly string[],
options: {
timeout?: number;
Expand All @@ -180,7 +175,7 @@ export const LocalWorkspaceHost: Host = {
env?: Record<string, string>;
} = {},
): 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) => {
Expand Down Expand Up @@ -221,16 +216,15 @@ export const LocalWorkspaceHost: Host = {
});
},

spawn(
command: string,
startNgProcess(
args: readonly string[],
options: {
stdio?: 'pipe' | 'ignore';
cwd?: string;
env?: Record<string, string>;
} = {},
): ChildProcess {
const resolved = resolveCommand(command, args, options.cwd);
const resolved = resolveNgCommand(args, options.cwd);

return spawn(resolved.command, resolved.args, {
shell: false,
Expand Down Expand Up @@ -370,23 +364,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<Host['executeNgCommand']>[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<Host['startNgProcess']>[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);
},
};
}
4 changes: 2 additions & 2 deletions packages/angular/cli/src/commands/mcp/testing/mock-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 4 additions & 2 deletions packages/angular/cli/src/commands/mcp/testing/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ import { MockHost } from './mock-host';
*/
export function createMockHost(): MockHost {
return {
runCommand: jasmine.createSpy<Host['runCommand']>('runCommand').and.resolveTo({ logs: [] }),
executeNgCommand: jasmine
.createSpy<Host['executeNgCommand']>('executeNgCommand')
.and.resolveTo({ logs: [] }),
stat: jasmine.createSpy<Host['stat']>('stat'),
existsSync: jasmine.createSpy<Host['existsSync']>('existsSync'),
spawn: jasmine.createSpy<Host['spawn']>('spawn'),
startNgProcess: jasmine.createSpy<Host['startNgProcess']>('startNgProcess'),
getAvailablePort: jasmine
.createSpy<Host['getAvailablePort']>('getAvailablePort')
.and.resolveTo(0),
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/cli/src/commands/mcp/tools/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 8 additions & 12 deletions packages/angular/cli/src/commands/mcp/tools/build_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
);
Expand All @@ -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' },
);
Expand All @@ -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',
});
});
Expand All @@ -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' },
);
Expand All @@ -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' },
);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,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
Expand All @@ -52,7 +52,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',
});
Expand All @@ -69,7 +69,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',
});
Expand Down Expand Up @@ -125,17 +125,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',
});
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/cli/src/commands/mcp/tools/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 9 additions & 9 deletions packages/angular/cli/src/commands/mcp/tools/e2e_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -61,40 +61,40 @@ 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',
});
});

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);

Expand Down
2 changes: 1 addition & 1 deletion packages/angular/cli/src/commands/mcp/tools/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading