diff --git a/README.md b/README.md index 1853ed754..e32db77e0 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,7 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles -- **Input automation** (8 tools) +- **Input automation** (9 tools) - [`click`](docs/tool-reference.md#click) - [`drag`](docs/tool-reference.md#drag) - [`fill`](docs/tool-reference.md#fill) @@ -422,6 +422,7 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles - [`handle_dialog`](docs/tool-reference.md#handle_dialog) - [`hover`](docs/tool-reference.md#hover) - [`press_key`](docs/tool-reference.md#press_key) + - [`type_text`](docs/tool-reference.md#type_text) - [`upload_file`](docs/tool-reference.md#upload_file) - **Navigation automation** (6 tools) - [`close_page`](docs/tool-reference.md#close_page) diff --git a/docs/tool-reference.md b/docs/tool-reference.md index 9111d6fd0..f2b4e6234 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -1,8 +1,8 @@ -# Chrome DevTools MCP Tool Reference (~6916 cl100k_base tokens) +# Chrome DevTools MCP Tool Reference (~7094 cl100k_base tokens) -- **[Input automation](#input-automation)** (8 tools) +- **[Input automation](#input-automation)** (9 tools) - [`click`](#click) - [`drag`](#drag) - [`fill`](#fill) @@ -10,6 +10,7 @@ - [`handle_dialog`](#handle_dialog) - [`hover`](#hover) - [`press_key`](#press_key) + - [`type_text`](#type_text) - [`upload_file`](#upload_file) - **[Navigation automation](#navigation-automation)** (6 tools) - [`close_page`](#close_page) @@ -118,6 +119,17 @@ --- +### `type_text` + +**Description:** Type text using keyboard into a previously focused input + +**Parameters:** + +- **text** (string) **(required)**: The text to type +- **submitKey** (string) _(optional)_: Optional key to press after typing. E.g., "Enter", "Tab", "Escape" + +--- + ### `upload_file` **Description:** Upload a file through a provided element. diff --git a/src/tools/input.ts b/src/tools/input.ts index c309326e4..2c338a520 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -7,7 +7,7 @@ import {logger} from '../logger.js'; import type {McpContext, TextSnapshotNode} from '../McpContext.js'; import {zod} from '../third_party/index.js'; -import type {ElementHandle} from '../third_party/index.js'; +import type {ElementHandle, KeyInput} from '../third_party/index.js'; import {parseKey} from '../utils/keyboard.js'; import {ToolCategory} from './categories.js'; @@ -23,6 +23,13 @@ const includeSnapshotSchema = zod .optional() .describe('Whether to include a snapshot in the response. Default is false.'); +const submitKeySchema = zod + .string() + .optional() + .describe( + 'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"', + ); + function handleActionError(error: unknown, uid: string) { logger('failed to act using a locator', error); throw new Error( @@ -239,6 +246,31 @@ export const fill = defineTool({ }, }); +export const typeText = defineTool({ + name: 'type_text', + description: `Type text using keyboard into a previously focused input`, + annotations: { + category: ToolCategory.INPUT, + readOnlyHint: false, + }, + schema: { + text: zod.string().describe('The text to type'), + submitKey: submitKeySchema, + }, + handler: async (request, response, context) => { + await context.waitForEventsAfterAction(async () => { + const page = context.getSelectedPage(); + await page.keyboard.type(request.params.text); + if (request.params.submitKey) { + await page.keyboard.press(request.params.submitKey as KeyInput); + } + }); + response.appendResponseLine( + `Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`, + ); + }, +}); + export const drag = defineTool({ name: 'drag', description: `Drag an element onto another element`, diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 37f861add..b50d46b0d 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -19,6 +19,7 @@ import { uploadFile, pressKey, clickAt, + typeText, } from '../../src/tools/input.js'; import {parseKey} from '../../src/utils/keyboard.js'; import {serverHooks} from '../server.js'; @@ -355,7 +356,7 @@ describe('input', () => { it('fills out a textarea marked as combobox', async () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPage(); - await page.setContent(html``); await context.createTextSnapshot(); await fill.handler( { @@ -383,7 +384,7 @@ describe('input', () => { it('fills out a textarea with long text', async () => { await withMcpContext(async (response, context) => { const page = context.getSelectedPage(); - await page.setContent(html``); await context.createTextSnapshot(); page.setDefaultTimeout(1000); await fill.handler( @@ -411,6 +412,90 @@ describe('input', () => { }); }); + it('types text', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html``); + await page.click('textarea'); + await context.createTextSnapshot(); + await typeText.handler( + { + params: { + text: 'test', + }, + }, + response, + context, + ); + assert.strictEqual(response.responseLines[0], 'Typed text "test"'); + assert.strictEqual( + await page.evaluate(() => { + return document.body.querySelector('textarea')?.value; + }), + 'test', + ); + }); + }); + + it('types text with submit key', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html``); + await page.click('textarea'); + await context.createTextSnapshot(); + await typeText.handler( + { + params: { + text: 'test', + submitKey: 'Tab', + }, + }, + response, + context, + ); + assert.strictEqual( + response.responseLines[0], + 'Typed text "test + Tab"', + ); + assert.strictEqual( + await page.evaluate(() => { + return document.body.querySelector('textarea')?.value; + }), + 'test', + ); + assert.ok( + await page.evaluate(() => { + return ( + document.body.querySelector('textarea') !== document.activeElement + ); + }), + ); + }); + }); + + it('errors on invalid submit key', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedPage(); + await page.setContent(html``); + await page.click('textarea'); + await context.createTextSnapshot(); + try { + await typeText.handler( + { + params: { + text: 'test', + submitKey: 'XXX', + }, + }, + response, + context, + ); + } catch (err) { + assert.strictEqual(err.message, 'Unknown key: "XXX"'); + } + }); + }); + it('reproduction: fill isolation', async () => { await withMcpContext(async (_response, context) => { const page = context.getSelectedPage();