Skip to content

Commit c61bfb9

Browse files
committed
feat(@angular/cli): add 'test' and 'e2e' MCP tools
This adds new tools for running unit and end-to-end tests via the MCP server.
1 parent 626a430 commit c61bfb9

File tree

5 files changed

+423
-1
lines changed

5 files changed

+423
-1
lines changed

packages/angular/cli/src/commands/mcp/mcp-server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import { DEVSERVER_START_TOOL } from './tools/devserver/devserver-start';
2020
import { DEVSERVER_STOP_TOOL } from './tools/devserver/devserver-stop';
2121
import { DEVSERVER_WAIT_FOR_BUILD_TOOL } from './tools/devserver/devserver-wait-for-build';
2222
import { DOC_SEARCH_TOOL } from './tools/doc-search';
23+
import { E2E_TOOL } from './tools/e2e';
2324
import { FIND_EXAMPLE_TOOL } from './tools/examples/index';
2425
import { MODERNIZE_TOOL } from './tools/modernize';
2526
import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration';
2627
import { LIST_PROJECTS_TOOL } from './tools/projects';
28+
import { TEST_TOOL } from './tools/test';
2729
import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry';
2830

2931
/**
@@ -48,7 +50,13 @@ const STABLE_TOOLS = [
4850
* The set of tools that are available but not enabled by default.
4951
* These tools are considered experimental and may have limitations.
5052
*/
51-
export const EXPERIMENTAL_TOOLS = [BUILD_TOOL, MODERNIZE_TOOL, ...DEVSERVER_TOOLS] as const;
53+
export const EXPERIMENTAL_TOOLS = [
54+
BUILD_TOOL,
55+
E2E_TOOL,
56+
MODERNIZE_TOOL,
57+
TEST_TOOL,
58+
...DEVSERVER_TOOLS,
59+
] as const;
5260

5361
/**
5462
* Experimental tools that are grouped together under a single name.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 { z } from 'zod';
10+
import { CommandError, type Host, LocalWorkspaceHost } from '../host';
11+
import { createStructuredContentOutput } from '../utils';
12+
import { type McpToolContext, type McpToolDeclaration, declareTool } from './tool-registry';
13+
14+
const e2eStatusSchema = z.enum(['success', 'failure']);
15+
type E2eStatus = z.infer<typeof e2eStatusSchema>;
16+
17+
const e2eToolInputSchema = z.object({
18+
project: z
19+
.string()
20+
.optional()
21+
.describe(
22+
'Which project to test in a monorepo context. If not provided, tests the default project.',
23+
),
24+
});
25+
26+
export type E2eToolInput = z.infer<typeof e2eToolInputSchema>;
27+
28+
const e2eToolOutputSchema = z.object({
29+
status: e2eStatusSchema.describe('E2E execution status.'),
30+
logs: z.array(z.string()).optional().describe('Output logs from `ng e2e`.'),
31+
});
32+
33+
export type E2eToolOutput = z.infer<typeof e2eToolOutputSchema>;
34+
35+
export async function runE2e(input: E2eToolInput, host: Host, context: McpToolContext) {
36+
const projectName = input.project;
37+
38+
if (context.workspace) {
39+
let targetProject;
40+
const projects = context.workspace.projects;
41+
42+
if (projectName) {
43+
targetProject = projects.get(projectName);
44+
} else {
45+
// Try to find default project
46+
const defaultProjectName = context.workspace.extensions['defaultProject'] as
47+
| string
48+
| undefined;
49+
if (defaultProjectName) {
50+
targetProject = projects.get(defaultProjectName);
51+
} else if (projects.size === 1) {
52+
targetProject = Array.from(projects.values())[0];
53+
}
54+
}
55+
56+
if (targetProject) {
57+
if (!targetProject.targets.has('e2e')) {
58+
return createStructuredContentOutput({
59+
status: 'failure',
60+
logs: [
61+
`No e2e target is defined for project '${projectName ?? 'default'}'. Please setup e2e testing first.`,
62+
],
63+
});
64+
}
65+
}
66+
}
67+
68+
// Build "ng"'s command line.
69+
const args = ['e2e'];
70+
if (input.project) {
71+
args.push(input.project);
72+
}
73+
74+
let status: E2eStatus = 'success';
75+
let logs: string[] = [];
76+
77+
try {
78+
logs = (await host.runCommand('ng', args)).logs;
79+
} catch (e) {
80+
status = 'failure';
81+
if (e instanceof CommandError) {
82+
logs = e.logs;
83+
} else if (e instanceof Error) {
84+
logs = [e.message];
85+
} else {
86+
logs = [String(e)];
87+
}
88+
}
89+
90+
const structuredContent: E2eToolOutput = {
91+
status,
92+
logs,
93+
};
94+
95+
return createStructuredContentOutput(structuredContent);
96+
}
97+
98+
export const E2E_TOOL: McpToolDeclaration<
99+
typeof e2eToolInputSchema.shape,
100+
typeof e2eToolOutputSchema.shape
101+
> = declareTool({
102+
name: 'e2e',
103+
title: 'E2E Tool',
104+
description: `
105+
<Purpose>
106+
Perform an end-to-end test with ng e2e.
107+
</Purpose>
108+
<Use Cases>
109+
* Running end-to-end tests for the project.
110+
</Use Cases>
111+
<Operational Notes>
112+
* This tool runs "ng e2e".
113+
* It will error if no "e2e" target is defined in the project, to avoid interactive setup prompts.
114+
</Operational Notes>
115+
`,
116+
isReadOnly: false,
117+
isLocalOnly: true,
118+
inputSchema: e2eToolInputSchema.shape,
119+
outputSchema: e2eToolOutputSchema.shape,
120+
factory: (context) => (input) => runE2e(input, LocalWorkspaceHost, context),
121+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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 { workspaces } from '@angular-devkit/core';
10+
import { AngularWorkspace } from '../../../utilities/config';
11+
import { CommandError, Host } from '../host';
12+
import type { MockHost } from '../testing/mock-host';
13+
import { runE2e } from './e2e';
14+
import type { McpToolContext } from './tool-registry';
15+
16+
describe('E2E Tool', () => {
17+
let mockHost: MockHost;
18+
let mockContext: McpToolContext;
19+
let mockProjects: workspaces.ProjectDefinitionCollection;
20+
let mockWorkspace: AngularWorkspace;
21+
22+
beforeEach(() => {
23+
mockHost = {
24+
runCommand: jasmine.createSpy<Host['runCommand']>('runCommand').and.resolveTo({ logs: [] }),
25+
} as unknown as MockHost;
26+
27+
mockProjects = new workspaces.ProjectDefinitionCollection();
28+
const mockWorkspaceDefinition: workspaces.WorkspaceDefinition = {
29+
projects: mockProjects,
30+
extensions: {},
31+
};
32+
33+
mockWorkspace = new AngularWorkspace(mockWorkspaceDefinition, '/test/angular.json');
34+
mockContext = {
35+
workspace: mockWorkspace,
36+
} as McpToolContext;
37+
});
38+
39+
function addProject(name: string, targets: Record<string, workspaces.TargetDefinition> = {}) {
40+
mockProjects.set(name, {
41+
root: `projects/${name}`,
42+
extensions: {},
43+
targets: new workspaces.TargetDefinitionCollection(targets),
44+
});
45+
}
46+
47+
it('should construct the command correctly with defaults', async () => {
48+
await runE2e({}, mockHost, mockContext);
49+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e']);
50+
});
51+
52+
it('should construct the command correctly with a specified project', async () => {
53+
addProject('my-app', { e2e: { builder: 'mock-builder' } });
54+
55+
await runE2e({ project: 'my-app' }, mockHost, mockContext);
56+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e', 'my-app']);
57+
});
58+
59+
it('should error if project does not have e2e target', async () => {
60+
addProject('my-app', { build: { builder: 'mock-builder' } });
61+
62+
const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext);
63+
64+
expect(structuredContent.status).toBe('failure');
65+
expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'my-app'");
66+
expect(mockHost.runCommand).not.toHaveBeenCalled();
67+
});
68+
69+
it('should error if default project does not have e2e target and no project specified', async () => {
70+
mockWorkspace.extensions['defaultProject'] = 'my-app';
71+
addProject('my-app', { build: { builder: 'mock-builder' } });
72+
73+
const { structuredContent } = await runE2e({}, mockHost, mockContext);
74+
75+
expect(structuredContent.status).toBe('failure');
76+
expect(structuredContent.logs?.[0]).toContain("No e2e target is defined for project 'default'");
77+
expect(mockHost.runCommand).not.toHaveBeenCalled();
78+
});
79+
80+
it('should proceed if no workspace context is available (fallback)', async () => {
81+
// If context.workspace is undefined, it should try to run ng e2e (which might fail or prompt, but tool runs it)
82+
const noWorkspaceContext = {} as McpToolContext;
83+
await runE2e({}, mockHost, noWorkspaceContext);
84+
expect(mockHost.runCommand).toHaveBeenCalledWith('ng', ['e2e']);
85+
});
86+
87+
it('should handle a successful e2e run', async () => {
88+
addProject('my-app', { e2e: { builder: 'mock-builder' } });
89+
const e2eLogs = ['E2E passed'];
90+
mockHost.runCommand.and.resolveTo({ logs: e2eLogs });
91+
92+
const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext);
93+
94+
expect(structuredContent.status).toBe('success');
95+
expect(structuredContent.logs).toEqual(e2eLogs);
96+
});
97+
98+
it('should handle a failed e2e run', async () => {
99+
addProject('my-app', { e2e: { builder: 'mock-builder' } });
100+
const e2eLogs = ['E2E failed'];
101+
mockHost.runCommand.and.rejectWith(new CommandError('Failed', e2eLogs, 1));
102+
103+
const { structuredContent } = await runE2e({ project: 'my-app' }, mockHost, mockContext);
104+
105+
expect(structuredContent.status).toBe('failure');
106+
expect(structuredContent.logs).toEqual(e2eLogs);
107+
});
108+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 { z } from 'zod';
10+
import { CommandError, type Host, LocalWorkspaceHost } from '../host';
11+
import { createStructuredContentOutput } from '../utils';
12+
import { type McpToolDeclaration, declareTool } from './tool-registry';
13+
14+
const testStatusSchema = z.enum(['success', 'failure']);
15+
type TestStatus = z.infer<typeof testStatusSchema>;
16+
17+
const testToolInputSchema = z.object({
18+
project: z
19+
.string()
20+
.optional()
21+
.describe('Which project to test in a monorepo context. If not provided, tests all projects.'),
22+
filter: z.string().optional().describe('Filter the executed tests by spec name.'),
23+
});
24+
25+
export type TestToolInput = z.infer<typeof testToolInputSchema>;
26+
27+
const testToolOutputSchema = z.object({
28+
status: testStatusSchema.describe('Test execution status.'),
29+
logs: z.array(z.string()).optional().describe('Output logs from `ng test`.'),
30+
});
31+
32+
export type TestToolOutput = z.infer<typeof testToolOutputSchema>;
33+
34+
export async function runTest(input: TestToolInput, host: Host) {
35+
// Build "ng"'s command line.
36+
const args = ['test'];
37+
if (input.project) {
38+
args.push(input.project);
39+
}
40+
41+
// This is ran by the agent so we want a non-watched, headless test.
42+
args.push('--browsers', 'ChromeHeadless');
43+
args.push('--watch', 'false');
44+
45+
if (input.filter) {
46+
args.push('--filter', input.filter);
47+
}
48+
49+
let status: TestStatus = 'success';
50+
let logs: string[] = [];
51+
52+
try {
53+
logs = (await host.runCommand('ng', args)).logs;
54+
} catch (e) {
55+
status = 'failure';
56+
if (e instanceof CommandError) {
57+
logs = e.logs;
58+
} else if (e instanceof Error) {
59+
logs = [e.message];
60+
} else {
61+
logs = [String(e)];
62+
}
63+
}
64+
65+
const structuredContent: TestToolOutput = {
66+
status,
67+
logs,
68+
};
69+
70+
return createStructuredContentOutput(structuredContent);
71+
}
72+
73+
export const TEST_TOOL: McpToolDeclaration<
74+
typeof testToolInputSchema.shape,
75+
typeof testToolOutputSchema.shape
76+
> = declareTool({
77+
name: 'test',
78+
title: 'Test Tool',
79+
description: `
80+
<Purpose>
81+
Perform a one-off, non-watched unit test execution with ng test.
82+
</Purpose>
83+
<Use Cases>
84+
* Running unit tests for the project.
85+
* Verifying code changes with tests.
86+
</Use Cases>
87+
<Operational Notes>
88+
* This tool runs "ng test" with "--watch false".
89+
* It supports filtering by spec name if the underlying builder supports it (e.g., 'unit-test' builder).
90+
</Operational Notes>
91+
`,
92+
isReadOnly: false,
93+
isLocalOnly: true,
94+
inputSchema: testToolInputSchema.shape,
95+
outputSchema: testToolOutputSchema.shape,
96+
factory: () => (input) => runTest(input, LocalWorkspaceHost),
97+
});

0 commit comments

Comments
 (0)