mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
98960556a6
See associated pull request for more information.
369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license block that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {ApplicationRef, Component, input, linkedSignal, signal} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {form, provideExperimentalWebMcpForms, required} from '@angular/forms/signals';
|
|
import {cleanupWebMCPPolyfill, initializeWebMCPPolyfill} from '@mcp-b/webmcp-polyfill';
|
|
import type {ChromeModelContextExtensions, ModelContext} from '@mcp-b/webmcp-types';
|
|
import {REGISTER_WEBMCP_FORM, RegisterWebMcpForm} from '../../src/webmcp/tokens';
|
|
|
|
describe('Signal Forms WebMCP Integration', () => {
|
|
beforeEach(() => {
|
|
// Firefox throws a security error with this.
|
|
Object.defineProperty(globalThis, 'originAgentCluster', {
|
|
value: true,
|
|
configurable: true,
|
|
});
|
|
initializeWebMCPPolyfill({installTestingShim: true});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanupWebMCPPolyfill();
|
|
});
|
|
|
|
describe('with provideWebMcpForms() provided', () => {
|
|
beforeEach(() => {
|
|
TestBed.configureTestingModule({
|
|
providers: [provideExperimentalWebMcpForms()],
|
|
});
|
|
});
|
|
|
|
it('should infer schema and register form as a tool', async () => {
|
|
const model = signal({
|
|
name: 'John',
|
|
age: 30,
|
|
isActive: true,
|
|
hobbies: ['reading', 'coding'],
|
|
address: {
|
|
city: 'Sunnyvale',
|
|
zip: 94089,
|
|
},
|
|
});
|
|
|
|
const modelContext = (globalThis.document as any).modelContext;
|
|
const registerSpy = spyOn(modelContext, 'registerTool').and.callThrough();
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
form(model, {
|
|
experimentalWebMcpTool: {
|
|
name: 'testFormTool',
|
|
description: 'A test form tool',
|
|
},
|
|
});
|
|
});
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
expect(registerSpy).toHaveBeenCalledWith(
|
|
jasmine.objectContaining({
|
|
annotations: {
|
|
readOnlyHint: false,
|
|
untrustedContentHint: false,
|
|
},
|
|
}),
|
|
jasmine.anything(),
|
|
);
|
|
|
|
const registeredTools = await getModelContext().getTools();
|
|
expect(registeredTools[0].name).toBe('testFormTool');
|
|
expect(registeredTools[0].description).toBe('A test form tool');
|
|
expect(registeredTools[0].inputSchema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
name: {type: 'string'},
|
|
age: {type: 'number'},
|
|
isActive: {type: 'boolean'},
|
|
hobbies: {
|
|
type: 'array',
|
|
items: {type: 'string'},
|
|
},
|
|
address: {
|
|
type: 'object',
|
|
properties: {
|
|
city: {type: 'string'},
|
|
zip: {type: 'number'},
|
|
},
|
|
required: [],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
required: [],
|
|
additionalProperties: false,
|
|
});
|
|
});
|
|
|
|
it('should infer required validators in schema', async () => {
|
|
const model = signal({
|
|
name: 'John',
|
|
age: 30,
|
|
address: {
|
|
city: 'Sunnyvale',
|
|
zip: 94089,
|
|
},
|
|
});
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
form(
|
|
model,
|
|
(p) => {
|
|
required(p.name);
|
|
required(p.address.city);
|
|
},
|
|
{
|
|
experimentalWebMcpTool: {
|
|
name: 'requiredTestTool',
|
|
description: 'A test for required validators',
|
|
},
|
|
},
|
|
);
|
|
});
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
const registeredTools = await getModelContext().getTools();
|
|
const tool = registeredTools.find((t) => t.name === 'requiredTestTool')!;
|
|
expect(tool.inputSchema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
name: {type: 'string'},
|
|
age: {type: 'number'},
|
|
address: {
|
|
type: 'object',
|
|
properties: {
|
|
city: {type: 'string'},
|
|
zip: {type: 'number'},
|
|
},
|
|
required: ['city'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
required: ['name'],
|
|
additionalProperties: false,
|
|
});
|
|
});
|
|
|
|
it('should fill out and submit the form successfully', async () => {
|
|
const model = signal({
|
|
name: '',
|
|
age: 0,
|
|
});
|
|
|
|
const submitSpy = jasmine.createSpy('submitSpy').and.returnValue(Promise.resolve(undefined));
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
form(model, {
|
|
experimentalWebMcpTool: {
|
|
name: 'testFormSubmitTool',
|
|
description: 'A test form submit tool',
|
|
},
|
|
submission: {
|
|
action: submitSpy,
|
|
},
|
|
});
|
|
});
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
const result = await executeTool(
|
|
'testFormSubmitTool',
|
|
JSON.stringify({
|
|
name: 'Alice',
|
|
age: 25,
|
|
}),
|
|
);
|
|
|
|
// Should update raw data model.
|
|
expect(model()).toEqual({
|
|
name: 'Alice',
|
|
age: 25,
|
|
});
|
|
|
|
expect(submitSpy).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(result!)).toEqual({
|
|
content: [{type: 'text', text: 'Form submitted successfully.'}],
|
|
});
|
|
});
|
|
|
|
it('should return a failure message if form validation fails', async () => {
|
|
const model = signal({name: {first: ''}});
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
form(
|
|
model,
|
|
(p) => {
|
|
required(p.name.first, {message: 'First name is required'});
|
|
},
|
|
{
|
|
experimentalWebMcpTool: {
|
|
name: 'testFormInvalidTool',
|
|
description: 'A validation test tool',
|
|
},
|
|
submission: {
|
|
action: async () => undefined,
|
|
},
|
|
},
|
|
);
|
|
});
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
const result = await executeTool('testFormInvalidTool', JSON.stringify({name: {first: ''}}));
|
|
|
|
expect(JSON.parse(result!)).toEqual({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: jasmine.stringContaining('name.first: First name is required'),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should return a failure message if the submit action fails', async () => {
|
|
const model = signal({name: ''});
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
form(model, {
|
|
experimentalWebMcpTool: {
|
|
name: 'testFormSubmitFailTool',
|
|
description: 'A submit fail test tool',
|
|
},
|
|
submission: {
|
|
action: async () => {
|
|
return {
|
|
kind: 'submit-failed',
|
|
message: 'Database write failed',
|
|
};
|
|
},
|
|
},
|
|
});
|
|
});
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
const result = await executeTool('testFormSubmitFailTool', JSON.stringify({name: ''}));
|
|
|
|
expect(JSON.parse(result!)).toEqual({
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: jasmine.stringContaining('Database write failed'),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should throw an error if the submit action throws an error', async () => {
|
|
const model = signal({name: ''});
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
form(model, {
|
|
experimentalWebMcpTool: {
|
|
name: 'testFormSubmitErrorTool',
|
|
description: 'A submit error test tool',
|
|
},
|
|
submission: {
|
|
action: async () => {
|
|
throw new Error('Database connection lost');
|
|
},
|
|
},
|
|
});
|
|
});
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
|
|
await expectAsync(
|
|
executeTool('testFormSubmitErrorTool', JSON.stringify({name: ''})),
|
|
).toBeRejectedWithError(/Database connection lost/);
|
|
});
|
|
|
|
it('should throw an error if schema cannot be inferred accurately', async () => {
|
|
const registerWebMcpForm = TestBed.inject<RegisterWebMcpForm>(REGISTER_WEBMCP_FORM);
|
|
// 1. Null value
|
|
await expectAsync(
|
|
TestBed.runInInjectionContext(() => {
|
|
const promise = registerWebMcpForm(form(signal({value: null})), {
|
|
name: 'nullTool',
|
|
description: 'A null tool',
|
|
});
|
|
TestBed.inject(ApplicationRef).tick();
|
|
return promise;
|
|
}),
|
|
).toBeRejectedWithError(/Could not accurately infer WebMCP schema/);
|
|
expect((await getModelContext()!.getTools()).some((t) => t.name === 'nullTool')).toBeFalse();
|
|
|
|
// 2. Empty array value
|
|
await expectAsync(
|
|
TestBed.runInInjectionContext(() => {
|
|
const promise = registerWebMcpForm(form(signal({value: [] as string[]})), {
|
|
name: 'emptyArrayTool',
|
|
description: 'An empty array tool',
|
|
});
|
|
TestBed.inject(ApplicationRef).tick();
|
|
return promise;
|
|
}),
|
|
).toBeRejectedWithError(/Could not accurately infer WebMCP schema/);
|
|
expect(
|
|
(await getModelContext().getTools()).some((t) => t.name === 'emptyArrayTool'),
|
|
).toBeFalse();
|
|
|
|
// 3. Unsupported type (symbol)
|
|
await expectAsync(
|
|
TestBed.runInInjectionContext(() => {
|
|
const promise = registerWebMcpForm(form(signal({value: Symbol('test')})), {
|
|
name: 'symbolTool',
|
|
description: 'A symbol tool',
|
|
});
|
|
TestBed.inject(ApplicationRef).tick();
|
|
return promise;
|
|
}),
|
|
).toBeRejectedWithError(/Could not accurately infer WebMCP schema/);
|
|
expect((await getModelContext().getTools()).some((t) => t.name === 'symbolTool')).toBeFalse();
|
|
});
|
|
|
|
it('should not throw an error when reading the model', async () => {
|
|
@Component({
|
|
selector: 'app-root',
|
|
template: ``,
|
|
})
|
|
class App {
|
|
id = input.required<string>();
|
|
model = linkedSignal(() => ({id: this.id()}));
|
|
|
|
form = form(this.model, () => {}, {
|
|
experimentalWebMcpTool: {description: 'foo', name: 'foo'},
|
|
});
|
|
}
|
|
|
|
await TestBed.inject(ApplicationRef).whenStable();
|
|
expect(() => TestBed.createComponent(App)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
it('should throw an error if `experimentalWebMcpTool` is configured but `provideWebMcpForms` was not', () => {
|
|
const model = signal({name: ''});
|
|
|
|
TestBed.runInInjectionContext(() => {
|
|
expect(() => {
|
|
form(model, {
|
|
experimentalWebMcpTool: {
|
|
name: 'orphanTool',
|
|
description: 'An orphan tool with no registry provided',
|
|
},
|
|
});
|
|
}).toThrowError(/Cannot register form "orphanTool"/);
|
|
});
|
|
});
|
|
});
|
|
|
|
function getModelContext(): ModelContext & ChromeModelContextExtensions {
|
|
// Because we use the 3p types, we need to cast the document.
|
|
return globalThis.document.modelContext!;
|
|
}
|
|
|
|
async function executeTool(toolName: string, args: string) {
|
|
const toolsForSubmit = await getModelContext().getTools();
|
|
const testFormSubmitTool = toolsForSubmit.find((t) => t.name === toolName)!;
|
|
return getModelContext().executeTool!(testFormSubmitTool, args);
|
|
}
|