| // Copyright 2026 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import type {JSONSchema7} from 'json-schema'; |
| |
| import * as Host from '../../core/host/host.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as Protocol from '../../generated/protocol.js'; |
| import * as Bindings from '../../models/bindings/bindings.js'; |
| import * as WebMCP from '../../models/web_mcp/web_mcp.js'; |
| import * as Workspace from '../../models/workspace/workspace.js'; |
| import { |
| findMenuItemWithLabel, |
| getContextMenuForElement, |
| getMenuForToolbarButton |
| } from '../../testing/ContextMenuHelpers.js'; |
| import {assertScreenshot, renderElementIntoDOM} from '../../testing/DOMHelpers.js'; |
| import {createTarget, describeWithEnvironment, updateHostConfig} from '../../testing/EnvironmentHelpers.js'; |
| import {StubStackTrace} from '../../testing/StackTraceHelpers.js'; |
| import {createViewFunctionStub} from '../../testing/ViewFunctionHelpers.js'; |
| import * as RenderCoordinator from '../../ui/components/render_coordinator/render_coordinator.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import * as ProtocolMonitor from '../protocol_monitor/protocol_monitor.js'; |
| |
| import * as Application from './application.js'; |
| |
| const {urlString} = Platform.DevToolsPath; |
| |
| const {DEFAULT_VIEW, WebMCPView, filterToolCalls} = Application.WebMCPView; |
| |
| function createTool( |
| name: string, description: string, frameId: Protocol.Page.FrameId, target: SDK.Target.Target, |
| backendNodeId?: Protocol.DOM.BackendNodeId, inputSchema: unknown = { |
| type: 'object' |
| }): WebMCP.WebMCPModel.Tool { |
| return new WebMCP.WebMCPModel.Tool({name, description, inputSchema, frameId, backendNodeId}, target); |
| } |
| const createDefaultViewInput = (): Application.WebMCPView.ViewInput => { |
| return { |
| filters: {text: ''}, |
| tools: [], |
| toolCalls: [], |
| filterButtons: WebMCPView.createFilterButtons(() => {}, () => {}), |
| onClearLogClick: () => {}, |
| onFilterChange: () => {}, |
| selectedTool: null, |
| onToolSelect: () => {}, |
| onRevealTool: () => {}, |
| selectedCall: null, |
| onCallSelect: () => {}, |
| onRunTool: () => {}, |
| onPaste: () => {}, |
| }; |
| }; |
| |
| describeWithEnvironment('WebMCPView (View)', () => { |
| it('calls onCallSelect with correct tab when clicking different columns', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '800px'; |
| target.style.height = '600px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const tool = createTool('testTool', 'Test tool', 'frame-1' as Protocol.Page.FrameId, sdkTarget); |
| const call: WebMCP.WebMCPModel.Call = { |
| invocationId: '1', |
| input: '{"foo": "bar"}', |
| tool, |
| result: |
| new WebMCP.WebMCPModel.Result(Protocol.WebMCP.InvocationStatus.Completed, {baz: 'qux'}, undefined, undefined), |
| cancel: () => {}, |
| }; |
| |
| const onCallSelect = sinon.spy(); |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| toolCalls: [call], |
| onCallSelect, |
| }, |
| {}, target); |
| |
| await UI.Widget.Widget.allUpdatesComplete; |
| await RenderCoordinator.done({waitForWork: true}); |
| const grid = target.querySelector('devtools-data-grid'); |
| assert.isNotNull(grid); |
| const shadowRoot = grid.shadowRoot; |
| assert.isNotNull(shadowRoot); |
| const rows = shadowRoot.querySelectorAll('tr'); |
| // First row is header (th), second is the call (td) |
| const callRow = Array.from(rows).find(r => r.querySelector('td') && r.textContent?.includes(call.tool.name)); |
| assert.isDefined(callRow, 'Should have a data row with tool name'); |
| const cells = callRow!.querySelectorAll('td'); |
| assert.isAtLeast(cells.length, 4, 'Should have at least 4 columns'); |
| |
| // Name column -> Details tab |
| cells[0].dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); |
| assert.isTrue(onCallSelect.called, 'onCallSelect should have been called'); |
| sinon.assert.calledWith(onCallSelect, call, Application.WebMCPView.TabId.DETAILS); |
| |
| // Status column -> Output tab |
| cells[1].click(); |
| sinon.assert.calledWith(onCallSelect, call, Application.WebMCPView.TabId.OUTPUT); |
| |
| // Input column -> Input tab |
| cells[2].click(); |
| sinon.assert.calledWith(onCallSelect, call, Application.WebMCPView.TabId.INPUT); |
| |
| // Output column -> Output tab |
| cells[3].click(); |
| sinon.assert.calledWith(onCallSelect, call, Application.WebMCPView.TabId.OUTPUT); |
| }); |
| |
| it('ignores shortcuts when details view is already open', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const tool = createTool('testTool', 'Test tool', 'frame-1' as Protocol.Page.FrameId, sdkTarget); |
| const call1: WebMCP.WebMCPModel.Call = {invocationId: '1', tool, input: '', cancel: () => {}}; |
| const call2: WebMCP.WebMCPModel.Call = {invocationId: '2', tool, input: '', cancel: () => {}}; |
| |
| // Stub the model to return our calls |
| const model = sdkTarget.model(WebMCP.WebMCPModel.WebMCPModel); |
| assert.isNotNull(model); |
| sinon.stub(model, 'toolCalls').get(() => [call1, call2]); |
| |
| const view = createViewFunctionStub(Application.WebMCPView.WebMCPView); |
| const presenter = new Application.WebMCPView.WebMCPView(undefined, view); |
| renderElementIntoDOM(presenter); |
| |
| // Opening for the first time: shortcut should work |
| let input = await view.nextInput; |
| input.onCallSelect(call1, Application.WebMCPView.TabId.DETAILS); |
| |
| input = await view.nextInput; |
| assert.strictEqual(input.selectedCall, call1); |
| assert.strictEqual(input.selectedTab, Application.WebMCPView.TabId.DETAILS); |
| |
| // Already open: shortcut on different call should change call but NOT tab. |
| // Note: selectedTab will be undefined in the input because it's reset after the initial request. |
| input.onCallSelect(call2, Application.WebMCPView.TabId.OUTPUT); |
| |
| input = await view.nextInput; |
| assert.strictEqual(input.selectedCall, call2); |
| assert.isUndefined(input.selectedTab); |
| }); |
| |
| it('calls onRevealTool when run tool button is clicked', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '800px'; |
| target.style.height = '600px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const tool = createTool('testTool', 'Test tool', 'frame-1' as Protocol.Page.FrameId, sdkTarget); |
| const call: WebMCP.WebMCPModel.Call = { |
| invocationId: '1', |
| input: '{"foo": "bar"}', |
| tool, |
| cancel: () => {}, |
| }; |
| |
| const onRevealTool = sinon.spy(); |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| toolCalls: [call], |
| onRevealTool, |
| }, |
| {}, target); |
| |
| await UI.Widget.Widget.allUpdatesComplete; |
| await RenderCoordinator.done({waitForWork: true}); |
| |
| const grid = target.querySelector('devtools-data-grid'); |
| assert.isNotNull(grid); |
| const shadowRoot = grid.shadowRoot; |
| assert.isNotNull(shadowRoot); |
| const button = shadowRoot.querySelector('.run-tool-action-button') as HTMLElement; |
| assert.isNotNull(button, 'Should find the run tool button'); |
| |
| button.click(); |
| assert.isTrue(onRevealTool.called, 'onRevealTool should have been called'); |
| }); |
| |
| it('renders empty when no tools are available', async () => { |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| DEFAULT_VIEW(createDefaultViewInput(), {}, target); |
| |
| const listElements = target.querySelectorAll('.tool-item'); |
| assert.lengthOf(listElements, 0); |
| |
| const emptyStateHeader = target.querySelector('.tool-list .empty-state-header'); |
| assert.isNotNull(emptyStateHeader); |
| assert.strictEqual(emptyStateHeader?.textContent, 'Available WebMCP Tools'); |
| |
| const callListElements = target.querySelectorAll('.call-item'); |
| assert.lengthOf(callListElements, 0); |
| |
| const callListEmptyHeader = target.querySelector('.call-log .empty-state-header'); |
| assert.isNotNull(callListEmptyHeader); |
| assert.strictEqual(callListEmptyHeader?.textContent, 'Tool Activity'); |
| |
| await assertScreenshot('application/webmcp-empty.png'); |
| }); |
| |
| it('renders tool calls with different statuses', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| const tools = [ |
| createTool('list_files', 'List files', 'frame-1' as Protocol.Page.FrameId, sdkTarget), |
| createTool('read_file', 'Read a file', 'frame-1' as Protocol.Page.FrameId, sdkTarget), |
| createTool('write_file', 'Write a file', 'frame-1' as Protocol.Page.FrameId, sdkTarget), |
| createTool('long_running_task', 'A long task', 'frame-1' as Protocol.Page.FrameId, sdkTarget), |
| ]; |
| const toolCalls: WebMCP.WebMCPModel.Call[] = [ |
| { |
| invocationId: '1', |
| input: '{"dir": "/tmp"}', |
| tool: tools[0], |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '2', |
| input: '{"path": "/tmp/test.txt"}', |
| tool: tools[1], |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Completed, 'File content here', undefined, undefined), |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '3', |
| input: '{"path": "/root/secret.txt"}', |
| tool: tools[2], |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Error, undefined, 'Permission denied', undefined), |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '4', |
| input: '{"timeout": 100}', |
| tool: tools[3], |
| result: |
| new WebMCP.WebMCPModel.Result(Protocol.WebMCP.InvocationStatus.Canceled, undefined, undefined, undefined), |
| cancel: () => {}, |
| }, |
| ]; |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| tools, |
| toolCalls, |
| }, |
| {}, target); |
| |
| const grid = target.querySelector('devtools-data-grid'); |
| assert.isNotNull(grid); |
| await assertScreenshot('application/webmcp-tool-calls.png'); |
| }); |
| |
| it('renders tool calls with action button visible on focus', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const tools = [ |
| createTool('list_files', 'List files', 'frame-1' as Protocol.Page.FrameId, sdkTarget), |
| ]; |
| const toolCalls: WebMCP.WebMCPModel.Call[] = [ |
| { |
| invocationId: '1', |
| input: '{"dir": "/tmp"}', |
| tool: tools[0], |
| cancel: () => {}, |
| }, |
| ]; |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| tools, |
| toolCalls, |
| selectedCall: toolCalls[0], |
| }, |
| {}, target); |
| |
| const grid = target.querySelector('devtools-data-grid'); |
| assert.isNotNull(grid); |
| |
| await RenderCoordinator.done({waitForWork: true}); |
| |
| const row = grid.shadowRoot?.querySelector('tbody tr.selected') as HTMLElement; |
| assert.isNotNull(row); |
| |
| // Focus the datagrid wrapper to trigger focus-within on host-context |
| const dataGridContainer = grid.shadowRoot?.querySelector('.data-grid') as HTMLElement; |
| assert.isNotNull(dataGridContainer); |
| dataGridContainer.focus(); |
| |
| const button = grid.shadowRoot?.querySelector('.run-tool-action-button') as HTMLElement; |
| assert.isNotNull(button); |
| |
| const icon = button.querySelector('devtools-icon') as HTMLElement; |
| assert.isNotNull(icon); |
| |
| const rect = icon.getBoundingClientRect(); |
| assert.isAbove(rect.width, 0, 'Icon width should be above 0'); |
| assert.isAbove(rect.height, 0, 'Icon height should be above 0'); |
| |
| await assertScreenshot('application/webmcp-tool-call-action-focus.png'); |
| }); |
| |
| it('renders a list of tools correctly', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const container = document.createElement('div'); |
| container.style.width = '600px'; |
| container.style.height = '400px'; |
| renderElementIntoDOM(container, {includeCommonStyles: true}); |
| |
| const tools = [ |
| createTool('calculator', 'Calculates math expressions', 'frame1' as Protocol.Page.FrameId, sdkTarget), |
| createTool('weather', 'Gets the current weather', 'frame1' as Protocol.Page.FrameId, sdkTarget) |
| ]; |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| tools, |
| }, |
| {}, container); |
| |
| await assertScreenshot('application/webmcp_view.png'); |
| }); |
| |
| it('shows a context menu when right-clicking a tool', async () => { |
| const copyTextStub = sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'copyText'); |
| const sdkTarget = createTarget(); |
| const container = document.createElement('div'); |
| renderElementIntoDOM(container); |
| |
| const tools = [ |
| createTool('test_tool', 'A test tool description', 'frame1' as Protocol.Page.FrameId, sdkTarget), |
| ]; |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| tools, |
| }, |
| {}, container); |
| |
| const toolItem = container.querySelector('.tool-item'); |
| assert.isNotNull(toolItem); |
| |
| const contextMenu = getContextMenuForElement(toolItem); |
| const copyNameItem = findMenuItemWithLabel(contextMenu.defaultSection(), 'Copy name'); |
| const copyDescItem = findMenuItemWithLabel(contextMenu.defaultSection(), 'Copy description'); |
| |
| assert.isDefined(copyNameItem); |
| assert.isDefined(copyDescItem); |
| |
| contextMenu.invokeHandler(copyNameItem.id()); |
| sinon.assert.calledWith(copyTextStub, 'test_tool'); |
| |
| contextMenu.invokeHandler(copyDescItem.id()); |
| sinon.assert.calledWith(copyTextStub, 'A test tool description'); |
| }); |
| it('renders a list of tools', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| const tools = [ |
| createTool('tool1', 'desc1', 'frame1' as Protocol.Page.FrameId, sdkTarget), |
| createTool('tool2', 'desc2', 'frame1' as Protocol.Page.FrameId, sdkTarget) |
| ]; |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| tools, |
| }, |
| {}, target); |
| |
| const listElements = target.querySelectorAll('.tool-item'); |
| assert.lengthOf(listElements, 2); |
| assert.strictEqual(listElements[0].querySelector('.tool-name')?.textContent, 'tool1'); |
| assert.strictEqual(listElements[0].querySelector('.tool-description')?.textContent, 'desc1'); |
| assert.isNull(target.querySelector('.tool-list .empty-state')); |
| }); |
| |
| it('highlights the selected tool', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| const tools = [ |
| createTool('tool1', 'desc1', 'frame1' as Protocol.Page.FrameId, sdkTarget), |
| createTool('tool2', 'desc2', 'frame1' as Protocol.Page.FrameId, sdkTarget) |
| ]; |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| tools, |
| selectedTool: {tool: tools[1]}, |
| }, |
| {}, target); |
| |
| const listElements = target.querySelectorAll('.tool-item'); |
| assert.lengthOf(listElements, 2); |
| assert.isFalse(listElements[0].classList.contains('selected')); |
| assert.isTrue(listElements[1].classList.contains('selected')); |
| await assertScreenshot('application/webmcp-tool-selected.png'); |
| }); |
| |
| it('renders a selected tool call details in a TabbedPane', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '600px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const tool = createTool('list_files', 'List files', 'frame-1' as Protocol.Page.FrameId, sdkTarget); |
| const selectedCall: WebMCP.WebMCPModel.Call = { |
| invocationId: '1', |
| input: '{"dir": "/tmp"}', |
| tool, |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Completed, 'File content here', undefined, undefined), |
| cancel: () => {}, |
| }; |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| toolCalls: [selectedCall], |
| selectedCall, |
| }, |
| {}, target); |
| |
| await assertScreenshot('application/webmcp-tool-call-details.png'); |
| }); |
| |
| it('renders a tool call with JS exception in a TabbedPane', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const workspace = Workspace.Workspace.WorkspaceImpl.instance(); |
| const targetManager = sdkTarget.targetManager(); |
| const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace); |
| const ignoreListManager = Workspace.IgnoreListManager.IgnoreListManager.instance({forceNew: true}); |
| Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({ |
| forceNew: true, |
| resourceMapping, |
| targetManager, |
| ignoreListManager, |
| workspace, |
| }); |
| Bindings.CSSWorkspaceBinding.CSSWorkspaceBinding.instance({forceNew: true, resourceMapping, targetManager}); |
| |
| const tool = createTool('list_files', 'List files', 'frame-1' as Protocol.Page.FrameId, sdkTarget); |
| const selectedCall: WebMCP.WebMCPModel.Call = { |
| invocationId: '1', |
| input: '{"dir": "/tmp"}', |
| tool, |
| result: new WebMCP.WebMCPModel.Result(Protocol.WebMCP.InvocationStatus.Error, undefined, undefined, undefined), |
| cancel: () => {}, |
| }; |
| |
| const errorObject = sinon.createStubInstance(SDK.RemoteObject.RemoteObject); |
| const runtimeModel = sinon.createStubInstance(SDK.RuntimeModel.RuntimeModel); |
| runtimeModel.target.returns(sdkTarget); |
| errorObject.runtimeModel.returns(runtimeModel); |
| |
| const mockExceptionDetails: WebMCP.WebMCPModel.ExceptionDetails = { |
| error: errorObject, |
| description: 'TypeError: Cannot read properties of undefined (reading \'foo\')', |
| frames: [ |
| {line: 'TypeError: Cannot read properties of undefined (reading \'foo\')'}, { |
| line: ' at doSomething (app.js:10:5)', |
| isCallFrame: true, |
| link: { |
| url: urlString`http://localhost/app.js`, |
| lineNumber: 9, |
| columnNumber: 4, |
| prefix: ' at doSomething (', |
| suffix: ')', |
| enclosedInBraces: false, |
| scriptId: '123' as Protocol.Runtime.ScriptId, |
| } |
| } |
| ], |
| }; |
| |
| assert.isDefined(selectedCall.result); |
| sinon.stub(selectedCall.result, 'exceptionDetails').get(() => Promise.resolve(mockExceptionDetails)); |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| toolCalls: [selectedCall], |
| selectedCall, |
| }, |
| {}, target); |
| |
| await UI.Widget.Widget.allUpdatesComplete; |
| |
| const tabbedPane = target.querySelector('devtools-tabbed-pane'); |
| const tabElement = tabbedPane?.shadowRoot?.getElementById('tab-webmcp.call-outputs'); |
| tabElement?.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); |
| |
| await UI.Widget.Widget.allUpdatesComplete; |
| await assertScreenshot('application/webmcp-tool-call-error-js-exception.png'); |
| }); |
| |
| it('renders filter bar with filters applied', async () => { |
| const container = document.createElement('div'); |
| container.style.width = '600px'; |
| container.style.height = '400px'; |
| renderElementIntoDOM(container, {includeCommonStyles: true}); |
| |
| const filterButtons = WebMCPView.createFilterButtons(() => {}, () => {}); |
| |
| // Simulate setting an active filter visually |
| filterButtons.toolTypes.setCount(1); |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| filters: {text: 'test', toolTypes: {imperative: true}}, |
| filterButtons, |
| }, |
| {}, container); |
| |
| await assertScreenshot('application/webmcp_filter_bar_applied.png'); |
| }); |
| |
| it('calls onClearLogClick when clear log button is clicked', async () => { |
| const target = document.createElement('div'); |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const onClearLogClick = sinon.spy(); |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| onClearLogClick, |
| }, |
| {}, target); |
| |
| const clearButton = target.querySelector('devtools-button[title="Clear log"]') as HTMLElement; |
| assert.isNotNull(clearButton); |
| clearButton.click(); |
| sinon.assert.calledOnce(onClearLogClick); |
| }); |
| |
| it('calls onRevealTool on context menu actions', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| const tool = createTool('list_files', 'List files', 'frame-1' as Protocol.Page.FrameId, sdkTarget); |
| const selectedCall: WebMCP.WebMCPModel.Call = { |
| invocationId: '1', |
| input: '{"dir": "/tmp"}', |
| tool, |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Completed, 'File content here', undefined, undefined), |
| cancel: () => {}, |
| }; |
| |
| const onRevealTool = sinon.spy(); |
| |
| DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| toolCalls: [selectedCall], |
| onRevealTool, |
| }, |
| {}, target); |
| |
| const dataGrid = target.querySelector('devtools-data-grid'); |
| assert.isNotNull(dataGrid); |
| |
| // Wait for the data grid to fully render its shadow DOM and internal legacy grid |
| await RenderCoordinator.done({waitForWork: true}); |
| |
| const nameCell = dataGrid.shadowRoot?.querySelector('.name-cell') as HTMLElement; |
| assert.isNotNull(nameCell); |
| const row = nameCell.closest('tr') as HTMLElement; |
| assert.isNotNull(row); |
| |
| // Select the row before requesting the context menu |
| row.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})); |
| const contextMenu = getContextMenuForElement(row); |
| |
| const revealToolItem = findMenuItemWithLabel(contextMenu.defaultSection(), 'Reveal tool'); |
| assert.exists(revealToolItem); |
| contextMenu.invokeHandler(revealToolItem.id()); |
| sinon.assert.calledWith(onRevealTool, tool); |
| |
| const editAndRunItem = findMenuItemWithLabel(contextMenu.defaultSection(), 'Edit and run'); |
| assert.exists(editAndRunItem); |
| contextMenu.invokeHandler(editAndRunItem.id()); |
| sinon.assert.calledWith(onRevealTool, tool, {dir: '/tmp'}); |
| }); |
| }); |
| |
| describeWithEnvironment('WebMCPView Presenter', () => { |
| let target: SDK.Target.Target; |
| async function setup() { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| target = createTarget(); |
| const model = target.model(WebMCP.WebMCPModel.WebMCPModel) as WebMCP.WebMCPModel.WebMCPModel; |
| |
| const viewStub = createViewFunctionStub(WebMCPView); |
| new WebMCPView(document.createElement('div'), viewStub); |
| await viewStub.nextInput; |
| |
| return {model, viewStub}; |
| } |
| |
| afterEach(() => { |
| target?.dispose('test'); |
| }); |
| it('passes tools to the view sorted by name', async () => { |
| const {model, viewStub} = await setup(); |
| model.toolsAdded({ |
| tools: [ |
| { |
| name: 'b-tool', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }, |
| { |
| name: 'a-tool', |
| description: 'desc2', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| } |
| ] |
| }); |
| const input = await viewStub.nextInput; |
| |
| assert.lengthOf(input.tools, 2); |
| assert.strictEqual(input.tools[0].name, 'a-tool'); |
| assert.strictEqual(input.tools[1].name, 'b-tool'); |
| }); |
| |
| it('updates selected tool', async () => { |
| const {model, viewStub} = await setup(); |
| const toolProtocol = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [toolProtocol]}); |
| const input = await viewStub.nextInput; |
| const tool = input.tools[0]; |
| |
| viewStub.input.onToolSelect(tool); |
| const nextInput = await viewStub.nextInput; |
| assert.strictEqual(nextInput.selectedTool?.tool, tool); |
| }); |
| |
| it('updates selected tool and parameters on reveal tool', async () => { |
| const {model, viewStub} = await setup(); |
| const toolProtocol = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [toolProtocol]}); |
| const input = await viewStub.nextInput; |
| const tool = input.tools[0]; |
| |
| viewStub.input.onRevealTool(tool, {foo: 'bar'}); |
| const nextInput = await viewStub.nextInput; |
| assert.strictEqual(nextInput.selectedTool?.tool, tool); |
| assert.deepEqual(nextInput.selectedTool?.parameters, {foo: 'bar'}); |
| }); |
| |
| it('invokes tool via onRunTool', async () => { |
| const {model, viewStub} = await setup(); |
| const toolProtocol = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [toolProtocol]}); |
| const input = await viewStub.nextInput; |
| const tool = input.tools[0]; |
| |
| viewStub.input.onToolSelect(tool); |
| const nextInput = await viewStub.nextInput; |
| |
| const invokeStub = sinon.stub(target.webMCPAgent(), 'invoke_invokeTool'); |
| |
| // Test with parameters |
| nextInput.onRunTool({ |
| data: { |
| command: 'tool1', |
| parameters: {arg1: 'value'}, |
| } as ProtocolMonitor.JSONEditor.Command |
| }); |
| sinon.assert.calledWith( |
| invokeStub, {toolName: 'tool1', frameId: 'frame1' as Protocol.Page.FrameId, input: {arg1: 'value'}}); |
| |
| // Test with missing parameters |
| nextInput.onRunTool({ |
| data: { |
| command: 'tool1', |
| } as ProtocolMonitor.JSONEditor.Command |
| }); |
| sinon.assert.calledWith(invokeStub, {toolName: 'tool1', frameId: 'frame1' as Protocol.Page.FrameId, input: {}}); |
| }); |
| it('updates when tools are removed', async () => { |
| const {model, viewStub} = await setup(); |
| const tool = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [tool]}); |
| await viewStub.nextInput; |
| assert.lengthOf(viewStub.input.tools, 1); |
| |
| model.toolsRemoved({tools: [tool]}); |
| const input = await viewStub.nextInput; |
| assert.lengthOf(input.tools, 0); |
| }); |
| |
| it('clears selected tool when it is removed', async () => { |
| const {model, viewStub} = await setup(); |
| const toolProtocol = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [toolProtocol]}); |
| const input = await viewStub.nextInput; |
| const tool = input.tools[0]; |
| |
| viewStub.input.onToolSelect(tool); |
| const nextInput = await viewStub.nextInput; |
| assert.strictEqual(nextInput.selectedTool?.tool, tool); |
| |
| model.toolsRemoved({tools: [toolProtocol]}); |
| const finalInput = await viewStub.nextInput; |
| assert.isNull(finalInput.selectedTool); |
| }); |
| |
| it('updates filter state when text filter is set', async () => { |
| const {viewStub} = await setup(); |
| viewStub.input.onFilterChange({text: 'test filter'}); |
| const input = await viewStub.nextInput; |
| assert.strictEqual(input.filters.text, 'test filter'); |
| }); |
| |
| it('updates filter state when drop down filters are set', async () => { |
| const {viewStub} = await setup(); |
| |
| const contextMenu = getMenuForToolbarButton(viewStub.input.filterButtons.toolTypes.button); |
| const imperativeItem = findMenuItemWithLabel(contextMenu.defaultSection(), 'Imperative'); |
| assert.isDefined(imperativeItem); |
| |
| contextMenu.invokeHandler(imperativeItem.id()); |
| |
| const input = await viewStub.nextInput; |
| assert.isTrue(input.filters.toolTypes?.imperative); |
| }); |
| |
| it('clears all filters when clear filters button is clicked', async () => { |
| const {viewStub} = await setup(); |
| viewStub.input.onFilterChange({text: 'test filter', toolTypes: {imperative: false}}); |
| await viewStub.nextInput; |
| |
| viewStub.input.onFilterChange({text: ''}); |
| const input = await viewStub.nextInput; |
| assert.strictEqual(input.filters.text, ''); |
| assert.isUndefined(input.filters.toolTypes); |
| assert.isUndefined(input.filters.statusTypes); |
| }); |
| describe('onPaste', () => { |
| it('successfully populates a command', async () => { |
| const {model, viewStub} = await setup(); |
| const toolProtocol = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object', properties: {arg1: {type: 'string'}}}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [toolProtocol]}); |
| const input = await viewStub.nextInput; |
| const tool = input.tools[0]; |
| |
| viewStub.input.onToolSelect(tool); |
| const nextInput = await viewStub.nextInput; |
| |
| sinon.stub(navigator.clipboard, 'readText').resolves('{"arg1": "value"}'); |
| |
| await nextInput.onPaste(); |
| const pasteInput = await viewStub.nextInput; |
| |
| assert.deepEqual(pasteInput.selectedTool?.parameters, {arg1: 'value'}); |
| }); |
| |
| it('fails to populate a command because the json doesn\'t parse', async () => { |
| const {model, viewStub} = await setup(); |
| const toolProtocol = { |
| name: 'tool1', |
| description: 'desc1', |
| inputSchema: {type: 'object'}, |
| frameId: 'frame1' as Protocol.Page.FrameId |
| }; |
| model.toolsAdded({tools: [toolProtocol]}); |
| const input = await viewStub.nextInput; |
| const tool = input.tools[0]; |
| |
| viewStub.input.onToolSelect(tool); |
| const nextInput = await viewStub.nextInput; |
| |
| sinon.stub(navigator.clipboard, 'readText').resolves('invalid json'); |
| |
| await nextInput.onPaste(); |
| |
| assert.isUndefined(viewStub.input.selectedTool?.parameters); |
| }); |
| }); |
| }); |
| |
| describe('filterToolCalls', () => { |
| const target = sinon.createStubInstance(SDK.Target.Target); |
| const tools = [ |
| createTool('list_files', 'desc', 'frame-1' as Protocol.Page.FrameId, target), |
| createTool('read_file', 'desc', 'frame-1' as Protocol.Page.FrameId, target), |
| createTool('write_file', 'desc', 'frame-1' as Protocol.Page.FrameId, target), |
| createTool( |
| 'long_running_task', |
| 'desc', |
| 'frame-1' as Protocol.Page.FrameId, |
| target, |
| 1 as Protocol.DOM.BackendNodeId, |
| ), |
| createTool( |
| 'declarative_success', |
| 'desc', |
| 'frame-1' as Protocol.Page.FrameId, |
| target, |
| 2 as Protocol.DOM.BackendNodeId, |
| ), |
| ]; |
| const mockCalls: WebMCP.WebMCPModel.Call[] = [ |
| { |
| invocationId: '1', |
| tool: tools[0], |
| input: '{"dir": "/tmp"}', |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '2', |
| tool: tools[1], |
| input: '{"path": "/tmp/test.txt"}', |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Completed, 'File content here', undefined, undefined), |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '3', |
| tool: tools[2], |
| input: '{"path": "/root/secret.txt"}', |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Error, undefined, 'Permission denied', undefined), |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '4', |
| tool: tools[3], |
| input: '{"timeout": 100}', |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '5', |
| tool: tools[3], |
| input: '{}', |
| result: new WebMCP.WebMCPModel.Result( |
| Protocol.WebMCP.InvocationStatus.Completed, 'Declarative success content', undefined, undefined), |
| cancel: () => {}, |
| }, |
| { |
| invocationId: '6', |
| tool: tools[0], |
| input: '{}', |
| result: new WebMCP.WebMCPModel.Result(Protocol.WebMCP.InvocationStatus.Canceled, undefined, undefined, undefined), |
| cancel: () => {}, |
| } |
| ]; |
| |
| it('filters by name/text', () => { |
| const result = filterToolCalls(mockCalls, {text: 'secret.txt'}); |
| assert.lengthOf(result, 1); |
| assert.strictEqual(result[0].invocationId, '3'); |
| }); |
| |
| it('filters by status', () => { |
| const result = filterToolCalls(mockCalls, { |
| text: '', |
| statusTypes: { |
| completed: true, |
| }, |
| }); |
| assert.lengthOf(result, 2); |
| assert.strictEqual(result[0].invocationId, '2'); |
| assert.strictEqual(result[1].invocationId, '5'); |
| |
| const resultPending = filterToolCalls(mockCalls, { |
| text: '', |
| statusTypes: { |
| pending: true, |
| }, |
| }); |
| assert.lengthOf(resultPending, 2); |
| assert.strictEqual(resultPending[0].invocationId, '1'); |
| assert.strictEqual(resultPending[1].invocationId, '4'); |
| const resultCanceled = filterToolCalls(mockCalls, { |
| text: '', |
| statusTypes: { |
| canceled: true, |
| }, |
| }); |
| assert.lengthOf(resultCanceled, 1); |
| assert.strictEqual(resultCanceled[0].invocationId, '6'); |
| }); |
| |
| it('filters by type', () => { |
| const resultDeclarative = filterToolCalls(mockCalls, { |
| text: '', |
| toolTypes: { |
| declarative: true, |
| }, |
| }); |
| assert.lengthOf(resultDeclarative, 2); |
| assert.strictEqual(resultDeclarative[0].invocationId, '4'); |
| assert.strictEqual(resultDeclarative[1].invocationId, '5'); |
| |
| const resultImperative = filterToolCalls(mockCalls, { |
| text: '', |
| toolTypes: { |
| imperative: true, |
| }, |
| }); |
| assert.lengthOf(resultImperative, 4); |
| assert.strictEqual(resultImperative[0].invocationId, '1'); |
| assert.strictEqual(resultImperative[1].invocationId, '2'); |
| assert.strictEqual(resultImperative[2].invocationId, '3'); |
| assert.strictEqual(resultImperative[3].invocationId, '6'); |
| }); |
| |
| it('filters by all three together', () => { |
| const result = filterToolCalls(mockCalls, { |
| text: 'success', |
| statusTypes: { |
| completed: true, |
| }, |
| toolTypes: { |
| declarative: true, |
| }, |
| }); |
| assert.lengthOf(result, 1); |
| assert.strictEqual(result[0].invocationId, '5'); |
| }); |
| }); |
| |
| describeWithEnvironment('ToolDetailsWidget', () => { |
| beforeEach(() => { |
| Workspace.IgnoreListManager.IgnoreListManager.instance({forceNew: true}); |
| }); |
| |
| it('renders a DOM node origin', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const container = document.createElement('div'); |
| container.style.width = '600px'; |
| container.style.height = '400px'; |
| renderElementIntoDOM(container, {includeCommonStyles: true}); |
| |
| const domNode = sinon.createStubInstance(SDK.DOMModel.DOMNode); |
| domNode.getAttribute.withArgs('id').returns('my-id'); |
| domNode.getAttribute.withArgs('class').returns('class1 class2'); |
| domNode.nodeNameInCorrectCase.returns('div'); |
| |
| const tool = createTool('my-tool', 'my description', 'frame1' as Protocol.Page.FrameId, sdkTarget); |
| sinon.stub(tool, 'node').get(() => ({ |
| resolvePromise: () => Promise.resolve(domNode), |
| })); |
| |
| const widget = new Application.WebMCPView.ToolDetailsWidget(); |
| widget.markAsRoot(); |
| widget.show(container); |
| widget.tool = tool; |
| await widget.updateComplete; |
| |
| await assertScreenshot('application/webmcp_tool_details_node.png'); |
| }); |
| |
| it('renders a stack trace origin', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const container = document.createElement('div'); |
| container.style.width = '600px'; |
| container.style.height = '400px'; |
| renderElementIntoDOM(container, {includeCommonStyles: true}); |
| |
| const tool = createTool('my-tool', 'my description', 'frame1' as Protocol.Page.FrameId, sdkTarget); |
| sinon.stub(tool, 'stackTrace') |
| .get(() => Promise.resolve(StubStackTrace.create(['http://example.com/script.js:myFunction:10:5']))); |
| |
| const widget = new Application.WebMCPView.ToolDetailsWidget(); |
| widget.markAsRoot(); |
| widget.show(container); |
| widget.tool = tool; |
| await widget.updateComplete; |
| |
| await assertScreenshot('application/webmcp_tool_details_stacktrace.png'); |
| }); |
| |
| it('renders a frame', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const container = document.createElement('div'); |
| container.style.width = '600px'; |
| container.style.height = '400px'; |
| renderElementIntoDOM(container, {includeCommonStyles: true}); |
| |
| const frame = sinon.createStubInstance(SDK.ResourceTreeModel.ResourceTreeFrame); |
| frame.displayName.returns('My Frame Name'); |
| |
| const tool = createTool('my-tool', 'my description', 'frame1' as Protocol.Page.FrameId, sdkTarget); |
| sinon.stub(tool, 'frame').get(() => frame); |
| |
| const widget = new Application.WebMCPView.ToolDetailsWidget(); |
| widget.markAsRoot(); |
| widget.show(container); |
| widget.tool = tool; |
| await widget.updateComplete; |
| |
| await assertScreenshot('application/webmcp_tool_details_frame.png'); |
| }); |
| it('renders an unregistered warning', async () => { |
| updateHostConfig({devToolsWebMCPSupport: {enabled: true}}); |
| const sdkTarget = createTarget(); |
| const container = document.createElement('div'); |
| container.style.width = '600px'; |
| container.style.height = '600px'; |
| renderElementIntoDOM(container, {includeCommonStyles: true}); |
| |
| const tool = createTool('my-tool', 'my description', 'frame1' as Protocol.Page.FrameId, sdkTarget); |
| |
| const widget = new Application.WebMCPView.ToolDetailsWidget(); |
| widget.markAsRoot(); |
| widget.show(container); |
| widget.tool = tool; |
| widget.isUnregistered = true; |
| await widget.updateComplete; |
| |
| const warning = container.querySelector('.call-to-action .explanation'); |
| assert.isNotNull(warning); |
| assert.include(warning.textContent || '', 'This tool has been unregistered'); |
| |
| await assertScreenshot('application/webmcp_tool_details_unregistered.png'); |
| }); |
| }); |
| |
| describeWithEnvironment('PayloadWidget (View)', () => { |
| const {PAYLOAD_DEFAULT_VIEW} = Application.WebMCPView; |
| |
| it('renders parsed JSON input', async () => { |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| PAYLOAD_DEFAULT_VIEW( |
| { |
| valueObject: {key1: 'value1', key2: ['a', 'b']}, |
| }, |
| {}, target); |
| |
| await assertScreenshot('application/webmcp_payload_parsed.png'); |
| }); |
| |
| it('renders unparsable input as raw source', async () => { |
| const target = document.createElement('div'); |
| target.style.width = '600px'; |
| target.style.height = '400px'; |
| renderElementIntoDOM(target, {includeCommonStyles: true}); |
| |
| PAYLOAD_DEFAULT_VIEW( |
| { |
| valueString: 'invalid json input', |
| }, |
| {}, target); |
| |
| await assertScreenshot('application/webmcp_payload_unparsable.png'); |
| }); |
| }); |
| |
| describeWithEnvironment('PayloadWidget', () => { |
| const {PayloadWidget} = Application.WebMCPView; |
| async function createWidget() { |
| const view = createViewFunctionStub(PayloadWidget); |
| const widget = new PayloadWidget(undefined, view); |
| widget.markAsRoot(); |
| renderElementIntoDOM(widget); |
| await view.nextInput; |
| return {view, widget}; |
| } |
| |
| it('renders nothing if no call is assigned', async () => { |
| const {view} = await createWidget(); |
| assert.isUndefined(view.input.valueObject); |
| assert.isUndefined(view.input.valueString); |
| }); |
| |
| it('passes valid JSON input to view', async () => { |
| const {view, widget} = await createWidget(); |
| widget.valueObject = {key: 'value'}; |
| |
| const nextInput = await view.nextInput; |
| assert.deepEqual(nextInput.valueObject as {key: string}, {key: 'value'}); |
| }); |
| |
| it('passes string input to view', async () => { |
| const {view, widget} = await createWidget(); |
| widget.valueString = 'invalid json'; |
| |
| const nextInput = await view.nextInput; |
| assert.strictEqual(nextInput.valueString, 'invalid json'); |
| }); |
| }); |
| |
| describe('parseToolSchema', () => { |
| const {parseToolSchema} = Application.WebMCPView; |
| const {ParameterType} = ProtocolMonitor.JSONEditor; |
| |
| it('parses empty schema', () => { |
| const parsed = parseToolSchema({}); |
| assert.deepEqual(parsed.parameters, []); |
| assert.strictEqual(parsed.typesByName.size, 0); |
| assert.strictEqual(parsed.enumsByName.size, 0); |
| }); |
| |
| it('parses primitive properties', () => { |
| const schema: JSONSchema7 = { |
| type: 'object', |
| properties: { |
| strProp: {type: 'string', description: 'A string'}, |
| numProp: {type: 'integer'}, |
| boolProp: {type: 'boolean'}, |
| }, |
| required: ['strProp'], |
| }; |
| const parsed = parseToolSchema(schema); |
| |
| assert.lengthOf(parsed.parameters, 3); |
| assert.deepEqual(parsed.parameters[0], { |
| name: 'strProp', |
| type: ParameterType.STRING, |
| description: 'A string', |
| optional: false, |
| isCorrectType: true, |
| }); |
| assert.deepEqual(parsed.parameters[1], { |
| name: 'numProp', |
| type: ParameterType.NUMBER, |
| description: '', |
| optional: true, |
| isCorrectType: true, |
| }); |
| }); |
| |
| it('parses nested objects', () => { |
| const schema: JSONSchema7 = { |
| type: 'object', |
| properties: { |
| objProp: { |
| type: 'object', |
| properties: { |
| nestedStr: {type: 'string'}, |
| }, |
| required: ['nestedStr'], |
| }, |
| emptyObj: { |
| type: 'object', |
| }, |
| }, |
| }; |
| const parsed = parseToolSchema(schema); |
| |
| assert.lengthOf(parsed.parameters, 2); |
| assert.strictEqual(parsed.parameters[0].name, 'objProp'); |
| assert.strictEqual(parsed.parameters[0].type, ParameterType.OBJECT); |
| assert.isDefined(parsed.parameters[0].typeRef); |
| assert.isUndefined(parsed.parameters[0].isKeyEditable); |
| |
| assert.strictEqual(parsed.parameters[1].name, 'emptyObj'); |
| assert.strictEqual(parsed.parameters[1].type, ParameterType.OBJECT); |
| assert.isTrue(parsed.parameters[1].isKeyEditable); |
| |
| const typeRef = parsed.parameters[0].typeRef; |
| assert.isDefined(typeRef); |
| const nestedType = parsed.typesByName.get(typeRef); |
| assert.isDefined(nestedType); |
| assert.lengthOf(nestedType, 1); |
| assert.strictEqual(nestedType[0].name, 'nestedStr'); |
| assert.isFalse(nestedType[0].optional); |
| }); |
| |
| it('parses arrays', () => { |
| const schema: JSONSchema7 = { |
| type: 'object', |
| properties: { |
| arrProp: { |
| type: 'array', |
| items: {type: 'string'}, |
| }, |
| objArrProp: { |
| type: 'array', |
| items: { |
| type: 'object', |
| properties: { |
| nestedNum: {type: 'number'}, |
| }, |
| }, |
| }, |
| }, |
| }; |
| const parsed = parseToolSchema(schema); |
| |
| assert.lengthOf(parsed.parameters, 2); |
| assert.strictEqual(parsed.parameters[0].name, 'arrProp'); |
| assert.strictEqual(parsed.parameters[0].type, ParameterType.ARRAY); |
| assert.strictEqual(parsed.parameters[0].typeRef, 'string'); |
| |
| assert.strictEqual(parsed.parameters[1].name, 'objArrProp'); |
| assert.strictEqual(parsed.parameters[1].type, ParameterType.ARRAY); |
| assert.isDefined(parsed.parameters[1].typeRef); |
| |
| const typeRef = parsed.parameters[1].typeRef; |
| assert.isDefined(typeRef); |
| const nestedType = parsed.typesByName.get(typeRef); |
| assert.isDefined(nestedType); |
| assert.lengthOf(nestedType, 1); |
| assert.strictEqual(nestedType[0].name, 'nestedNum'); |
| assert.strictEqual(nestedType[0].type, ParameterType.NUMBER); |
| }); |
| |
| it('parses enums', () => { |
| const schema: JSONSchema7 = { |
| type: 'object', |
| properties: { |
| enumProp: { |
| type: 'string', |
| enum: ['val1', 'val2'], |
| }, |
| enumArrProp: { |
| type: 'array', |
| items: { |
| type: 'string', |
| enum: ['arrVal1'], |
| }, |
| }, |
| }, |
| }; |
| const parsed = parseToolSchema(schema); |
| |
| assert.lengthOf(parsed.parameters, 2); |
| const typeRef0 = parsed.parameters[0].typeRef; |
| const typeRef1 = parsed.parameters[1].typeRef; |
| assert.isDefined(typeRef0); |
| assert.isDefined(typeRef1); |
| |
| const enum1 = parsed.enumsByName.get(typeRef0); |
| assert.deepEqual(enum1, {val1: 'val1', val2: 'val2'}); |
| |
| const enum2 = parsed.enumsByName.get(typeRef1); |
| assert.deepEqual(enum2, {arrVal1: 'arrVal1'}); |
| }); |
| |
| it('parses refs', () => { |
| const schema: JSONSchema7 = { |
| type: 'object', |
| properties: { |
| refProp: { |
| $ref: '#/definitions/MyObject', |
| }, |
| enumRefProp: { |
| $ref: '#/definitions/MyEnum', |
| }, |
| }, |
| definitions: { |
| MyObject: { |
| type: 'object', |
| properties: { |
| nestedProp: {type: 'string'}, |
| }, |
| }, |
| MyEnum: { |
| type: 'string', |
| enum: ['val1', 'val2'], |
| }, |
| }, |
| }; |
| const parsed = parseToolSchema(schema); |
| |
| assert.lengthOf(parsed.parameters, 2); |
| assert.strictEqual(parsed.parameters[0].name, 'refProp'); |
| assert.strictEqual(parsed.parameters[0].type, ParameterType.OBJECT); |
| assert.strictEqual(parsed.parameters[0].typeRef, 'MyObject'); |
| |
| assert.strictEqual(parsed.parameters[1].name, 'enumRefProp'); |
| assert.strictEqual(parsed.parameters[1].type, ParameterType.STRING); |
| assert.strictEqual(parsed.parameters[1].typeRef, 'MyEnum'); |
| |
| const myObjectParams = parsed.typesByName.get('MyObject'); |
| assert.isDefined(myObjectParams); |
| assert.lengthOf(myObjectParams, 1); |
| assert.strictEqual(myObjectParams[0].name, 'nestedProp'); |
| assert.strictEqual(myObjectParams[0].type, ParameterType.STRING); |
| |
| const myEnumRecord = parsed.enumsByName.get('MyEnum'); |
| assert.isDefined(myEnumRecord); |
| assert.deepEqual(myEnumRecord, {val1: 'val1', val2: 'val2'}); |
| }); |
| |
| it('parses unparsable types like anyOf as unknown by default', () => { |
| const schema: JSONSchema7 = { |
| type: 'object', |
| properties: { |
| anyOfProp: { |
| anyOf: [ |
| {type: 'string'}, |
| {type: 'number'}, |
| ], |
| }, |
| }, |
| }; |
| const parsed = parseToolSchema(schema); |
| |
| assert.lengthOf(parsed.parameters, 1); |
| assert.strictEqual(parsed.parameters[0].name, 'anyOfProp'); |
| assert.strictEqual(parsed.parameters[0].type, ParameterType.UNKNOWN); |
| assert.isUndefined(parsed.parameters[0].typeRef); |
| }); |
| }); |
| |
| describeWithEnvironment('WebMCPView JSON Editor', () => { |
| const createDefaultViewInput = (): Application.WebMCPView.ViewInput => { |
| return { |
| filters: {text: ''}, |
| tools: [], |
| toolCalls: [], |
| filterButtons: Application.WebMCPView.WebMCPView.createFilterButtons(() => {}, () => {}), |
| onClearLogClick: () => {}, |
| onFilterChange: () => {}, |
| selectedTool: null, |
| onToolSelect: () => {}, |
| onRevealTool: () => {}, |
| selectedCall: null, |
| onCallSelect: () => {}, |
| onRunTool: () => {}, |
| onPaste: () => {}, |
| }; |
| }; |
| |
| it('renders parameters based on tool schema', async () => { |
| const target = createTarget(); |
| const tool = createTool('testTool', 'Test tool', 'frameId' as Protocol.Page.FrameId, target, undefined, { |
| type: 'object', |
| properties: { |
| strProp: {type: 'string', description: 'A string'}, |
| }, |
| required: ['strProp'], |
| }); |
| |
| const targetEl = document.createElement('div'); |
| targetEl.style.width = '600px'; |
| targetEl.style.height = '400px'; |
| renderElementIntoDOM(targetEl, {includeCommonStyles: true}); |
| |
| Application.WebMCPView.DEFAULT_VIEW( |
| { |
| ...createDefaultViewInput(), |
| selectedTool: {tool}, |
| }, |
| {}, targetEl); |
| |
| // Wait for nested Lit renders |
| await UI.Widget.Widget.allUpdatesComplete; |
| |
| const devtoolsWidget = targetEl.querySelector('devtools-widget.json-editor-widget'); |
| assert.isNotNull(devtoolsWidget); |
| |
| const inputs = devtoolsWidget.shadowRoot?.querySelectorAll('devtools-suggestion-input'); |
| |
| assert.isDefined(inputs); |
| assert.isTrue(inputs.length > 0, 'Should render suggestion inputs for parameters'); |
| |
| await assertScreenshot('application/webmcp-json-editor.png'); |
| }); |
| }); |