Skip to content

Commit b5d01b5

Browse files
authored
feat: support type_text (#1026)
`type_text` is useful for pages that do not have a11y and, thus, uid is not known. It's also useful for testing user-like keyboard input and testing the focus state changes. The `fill` tools force the focus change and require an uid and therefore are not suitable for these tasks.
1 parent 25af426 commit b5d01b5

File tree

4 files changed

+136
-6
lines changed

4 files changed

+136
-6
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,14 +414,15 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
414414

415415
<!-- BEGIN AUTO GENERATED TOOLS -->
416416

417-
- **Input automation** (8 tools)
417+
- **Input automation** (9 tools)
418418
- [`click`](docs/tool-reference.md#click)
419419
- [`drag`](docs/tool-reference.md#drag)
420420
- [`fill`](docs/tool-reference.md#fill)
421421
- [`fill_form`](docs/tool-reference.md#fill_form)
422422
- [`handle_dialog`](docs/tool-reference.md#handle_dialog)
423423
- [`hover`](docs/tool-reference.md#hover)
424424
- [`press_key`](docs/tool-reference.md#press_key)
425+
- [`type_text`](docs/tool-reference.md#type_text)
425426
- [`upload_file`](docs/tool-reference.md#upload_file)
426427
- **Navigation automation** (6 tools)
427428
- [`close_page`](docs/tool-reference.md#close_page)

docs/tool-reference.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
<!-- AUTO GENERATED DO NOT EDIT - run 'npm run docs' to update-->
22

3-
# Chrome DevTools MCP Tool Reference (~6916 cl100k_base tokens)
3+
# Chrome DevTools MCP Tool Reference (~7094 cl100k_base tokens)
44

5-
- **[Input automation](#input-automation)** (8 tools)
5+
- **[Input automation](#input-automation)** (9 tools)
66
- [`click`](#click)
77
- [`drag`](#drag)
88
- [`fill`](#fill)
99
- [`fill_form`](#fill_form)
1010
- [`handle_dialog`](#handle_dialog)
1111
- [`hover`](#hover)
1212
- [`press_key`](#press_key)
13+
- [`type_text`](#type_text)
1314
- [`upload_file`](#upload_file)
1415
- **[Navigation automation](#navigation-automation)** (6 tools)
1516
- [`close_page`](#close_page)
@@ -118,6 +119,17 @@
118119

119120
---
120121

122+
### `type_text`
123+
124+
**Description:** Type text using keyboard into a previously focused input
125+
126+
**Parameters:**
127+
128+
- **text** (string) **(required)**: The text to type
129+
- **submitKey** (string) _(optional)_: Optional key to press after typing. E.g., "Enter", "Tab", "Escape"
130+
131+
---
132+
121133
### `upload_file`
122134

123135
**Description:** Upload a file through a provided element.

src/tools/input.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import {logger} from '../logger.js';
88
import type {McpContext, TextSnapshotNode} from '../McpContext.js';
99
import {zod} from '../third_party/index.js';
10-
import type {ElementHandle} from '../third_party/index.js';
10+
import type {ElementHandle, KeyInput} from '../third_party/index.js';
1111
import {parseKey} from '../utils/keyboard.js';
1212

1313
import {ToolCategory} from './categories.js';
@@ -23,6 +23,13 @@ const includeSnapshotSchema = zod
2323
.optional()
2424
.describe('Whether to include a snapshot in the response. Default is false.');
2525

26+
const submitKeySchema = zod
27+
.string()
28+
.optional()
29+
.describe(
30+
'Optional key to press after typing. E.g., "Enter", "Tab", "Escape"',
31+
);
32+
2633
function handleActionError(error: unknown, uid: string) {
2734
logger('failed to act using a locator', error);
2835
throw new Error(
@@ -239,6 +246,31 @@ export const fill = defineTool({
239246
},
240247
});
241248

249+
export const typeText = defineTool({
250+
name: 'type_text',
251+
description: `Type text using keyboard into a previously focused input`,
252+
annotations: {
253+
category: ToolCategory.INPUT,
254+
readOnlyHint: false,
255+
},
256+
schema: {
257+
text: zod.string().describe('The text to type'),
258+
submitKey: submitKeySchema,
259+
},
260+
handler: async (request, response, context) => {
261+
await context.waitForEventsAfterAction(async () => {
262+
const page = context.getSelectedPage();
263+
await page.keyboard.type(request.params.text);
264+
if (request.params.submitKey) {
265+
await page.keyboard.press(request.params.submitKey as KeyInput);
266+
}
267+
});
268+
response.appendResponseLine(
269+
`Typed text "${request.params.text}${request.params.submitKey ? ` + ${request.params.submitKey}` : ''}"`,
270+
);
271+
},
272+
});
273+
242274
export const drag = defineTool({
243275
name: 'drag',
244276
description: `Drag an element onto another element`,

tests/tools/input.test.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
uploadFile,
2020
pressKey,
2121
clickAt,
22+
typeText,
2223
} from '../../src/tools/input.js';
2324
import {parseKey} from '../../src/utils/keyboard.js';
2425
import {serverHooks} from '../server.js';
@@ -355,7 +356,7 @@ describe('input', () => {
355356
it('fills out a textarea marked as combobox', async () => {
356357
await withMcpContext(async (response, context) => {
357358
const page = context.getSelectedPage();
358-
await page.setContent(html`<textarea role="combobox" />`);
359+
await page.setContent(html`<textarea role="combobox"></textarea>`);
359360
await context.createTextSnapshot();
360361
await fill.handler(
361362
{
@@ -383,7 +384,7 @@ describe('input', () => {
383384
it('fills out a textarea with long text', async () => {
384385
await withMcpContext(async (response, context) => {
385386
const page = context.getSelectedPage();
386-
await page.setContent(html`<textarea />`);
387+
await page.setContent(html`<textarea></textarea>`);
387388
await context.createTextSnapshot();
388389
page.setDefaultTimeout(1000);
389390
await fill.handler(
@@ -411,6 +412,90 @@ describe('input', () => {
411412
});
412413
});
413414

415+
it('types text', async () => {
416+
await withMcpContext(async (response, context) => {
417+
const page = context.getSelectedPage();
418+
await page.setContent(html`<textarea></textarea>`);
419+
await page.click('textarea');
420+
await context.createTextSnapshot();
421+
await typeText.handler(
422+
{
423+
params: {
424+
text: 'test',
425+
},
426+
},
427+
response,
428+
context,
429+
);
430+
assert.strictEqual(response.responseLines[0], 'Typed text "test"');
431+
assert.strictEqual(
432+
await page.evaluate(() => {
433+
return document.body.querySelector('textarea')?.value;
434+
}),
435+
'test',
436+
);
437+
});
438+
});
439+
440+
it('types text with submit key', async () => {
441+
await withMcpContext(async (response, context) => {
442+
const page = context.getSelectedPage();
443+
await page.setContent(html`<textarea></textarea>`);
444+
await page.click('textarea');
445+
await context.createTextSnapshot();
446+
await typeText.handler(
447+
{
448+
params: {
449+
text: 'test',
450+
submitKey: 'Tab',
451+
},
452+
},
453+
response,
454+
context,
455+
);
456+
assert.strictEqual(
457+
response.responseLines[0],
458+
'Typed text "test + Tab"',
459+
);
460+
assert.strictEqual(
461+
await page.evaluate(() => {
462+
return document.body.querySelector('textarea')?.value;
463+
}),
464+
'test',
465+
);
466+
assert.ok(
467+
await page.evaluate(() => {
468+
return (
469+
document.body.querySelector('textarea') !== document.activeElement
470+
);
471+
}),
472+
);
473+
});
474+
});
475+
476+
it('errors on invalid submit key', async () => {
477+
await withMcpContext(async (response, context) => {
478+
const page = context.getSelectedPage();
479+
await page.setContent(html`<textarea></textarea>`);
480+
await page.click('textarea');
481+
await context.createTextSnapshot();
482+
try {
483+
await typeText.handler(
484+
{
485+
params: {
486+
text: 'test',
487+
submitKey: 'XXX',
488+
},
489+
},
490+
response,
491+
context,
492+
);
493+
} catch (err) {
494+
assert.strictEqual(err.message, 'Unknown key: "XXX"');
495+
}
496+
});
497+
});
498+
414499
it('reproduction: fill isolation', async () => {
415500
await withMcpContext(async (_response, context) => {
416501
const page = context.getSelectedPage();

0 commit comments

Comments
 (0)