Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions src/firefox/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,134 @@

import { By, Key, WebDriver, WebElement } from 'selenium-webdriver';

/**
* Key names accepted by press_key, mapped to the WebDriver unicode code points
* that selenium exposes as `Key.*`. Names are matched case-insensitively.
* `enter` and `return` are deliberately distinct, matching selenium: `return` is
* the main keyboard key, `enter` is the numpad one.
*/
const KEY_MAP: Record<string, string> = {
cancel: Key.CANCEL,
help: Key.HELP,
backspace: Key.BACK_SPACE,
tab: Key.TAB,
clear: Key.CLEAR,
return: Key.RETURN,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RETURN and ENTER are mapped to different keys in selenium, I think we should stick to that. https://github.com/SeleniumHQ/selenium/blob/trunk/javascript/selenium-webdriver/lib/input.js#L53-L54

Overall we are missing a few keys, might be good to add them.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the mapping and added the keys that are in selenium.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't realize that Selenium's Key.ENTER was the numpad one. I'm not sure what's the best approach between being faithful to selenium's Key definition or aliasing enter to Key.RETURN.

It probably doesn't change much in most cases, but maybe for now we should revert to what you had, and add numpadenter: Key.ENTER?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured: Return arrives as code=Enter, Enter as code=NumpadEnter. +1 to enter -> Key.RETURN plus numpadenter: Key.ENTER.

enter: Key.ENTER,
pause: Key.PAUSE,
escape: Key.ESCAPE,
esc: Key.ESCAPE,
space: Key.SPACE,
pageup: Key.PAGE_UP,
pagedown: Key.PAGE_DOWN,
end: Key.END,
home: Key.HOME,
arrowleft: Key.ARROW_LEFT,
left: Key.ARROW_LEFT,
arrowup: Key.ARROW_UP,
up: Key.ARROW_UP,
arrowright: Key.ARROW_RIGHT,
right: Key.ARROW_RIGHT,
arrowdown: Key.ARROW_DOWN,
down: Key.ARROW_DOWN,
insert: Key.INSERT,
delete: Key.DELETE,
semicolon: Key.SEMICOLON,
equals: Key.EQUALS,
numpad0: Key.NUMPAD0,
numpad1: Key.NUMPAD1,
numpad2: Key.NUMPAD2,
numpad3: Key.NUMPAD3,
numpad4: Key.NUMPAD4,
numpad5: Key.NUMPAD5,
numpad6: Key.NUMPAD6,
numpad7: Key.NUMPAD7,
numpad8: Key.NUMPAD8,
numpad9: Key.NUMPAD9,
multiply: Key.MULTIPLY,
add: Key.ADD,
separator: Key.SEPARATOR,
subtract: Key.SUBTRACT,
decimal: Key.DECIMAL,
divide: Key.DIVIDE,
f1: Key.F1,
f2: Key.F2,
f3: Key.F3,
f4: Key.F4,
f5: Key.F5,
f6: Key.F6,
f7: Key.F7,
f8: Key.F8,
f9: Key.F9,
f10: Key.F10,
f11: Key.F11,
f12: Key.F12,
};

const MODIFIER_MAP: Record<string, string> = {
ctrl: Key.CONTROL,
control: Key.CONTROL,
alt: Key.ALT,
shift: Key.SHIFT,
meta: Key.META,
cmd: Key.META,
command: Key.META,
win: Key.META,
super: Key.META,
};

export interface KeyCombo {
modifiers: string[];
key: string;
}

/**
* Parse a combination such as "ctrl+shift+t" into its modifiers and its single
* non-modifier key. Any number of modifiers is allowed, but exactly one key is:
* "ctrl+k+l" is rejected rather than silently dropping one of the two.
* Unrecognised names are rejected too, so that a mistyped key name does not turn
* into typed text.
* @param combo Key name, single character, or "+"-separated combination
*/
export function parseKeyCombo(combo: string): KeyCombo {
const trimmed = combo.trim();
// A lone character is taken literally so that "+" itself can be pressed.
const parts = [...trimmed].length === 1 ? [trimmed] : trimmed.split('+');

const modifiers: string[] = [];
let key: string | undefined;

for (const part of parts) {
const name = part.trim();
if (name === '') {
continue;
}

const modifier = MODIFIER_MAP[name.toLowerCase()];
if (modifier) {
modifiers.push(modifier);
continue;
}

const mapped = KEY_MAP[name.toLowerCase()];
if (mapped === undefined && [...name].length > 1) {
throw new Error(`press_key: unknown key "${name}" in "${combo}"`);
}
if (key !== undefined) {
throw new Error(
`press_key: "${combo}" has more than one non-modifier key. Use any number of modifiers but a single key, for example "ctrl+shift+t".`
);
}
key = mapped ?? name;
}

if (key === undefined) {
throw new Error(`press_key: no key specified in "${combo}"`);
}

return { modifiers, key };
}

export class DomInteractions {
constructor(
private driver: WebDriver,
Expand Down Expand Up @@ -288,6 +416,38 @@ export class DomInteractions {
await this.waitForEventsAfterAction();
}

/**
* Press a single key, optionally with modifiers.
* @param key Key name or combination, such as "Escape", "F5" or "ctrl+shift+t"
* @param uid Element UID to send the key to. Defaults to the focused element.
*/
async pressKey(key: string, uid?: string): Promise<void> {
const { modifiers, key: mainKey } = parseKeyCombo(key);

if (uid) {
if (!this.resolveUid) {
throw new Error('pressKey: resolveUid callback not set. Ensure snapshot is initialized.');
}
const el = await this.resolveUid(uid);
await el.sendKeys(Key.chord(...modifiers, mainKey));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using sendKeys when we have a uid and actions in the other case, could we try to focus the element corresponding to the uid and then use actions ?

Otherwise I'm worried we would get slightly different behaviors when using a uid or not.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, measured: Key.chord appends Key.NULL and Element Send Keys dispatches it, so press_key Escape with a uid yields Escape plus a phantom Unidentified keydown/keyup (actions path: Escape only). On <input type=file> it fails with File not found:, which handleUidError rewrites to "stale uid, take_snapshot", so the model loops. Focus-then-actions fixes both locally; a non-focusable uid then needs its own error, and the caret lands at the start of a contenteditable instead of the end.

} else {
// Without an element to target, drive the WebDriver actions keyboard
// source directly so the key reaches whatever currently has focus.
const actions = this.driver.actions({ async: true });
for (const modifier of modifiers) {
actions.keyDown(modifier);
}
actions.keyDown(mainKey);
actions.keyUp(mainKey);
for (const modifier of [...modifiers].reverse()) {
actions.keyUp(modifier);
}
await actions.perform();
}

await this.waitForEventsAfterAction();
}

/**
* Wait for events to propagate after user action
* Gives the page time to respond to interactions
Expand Down
7 changes: 7 additions & 0 deletions src/firefox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ export class FirefoxClient {
return await this.dom.uploadFileByUid(uid, filePath);
}

async pressKey(key: string, uid?: string): Promise<void> {
if (!this.dom) {
throw new Error('Not connected');
}
return await this.dom.pressKey(key, uid);
}

// ============================================================================
// Console
// ============================================================================
Expand Down
50 changes: 50 additions & 0 deletions src/tools/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,30 @@ export const uploadFileByUidTool = {
},
};

export const pressKeyTool = {
name: 'press_key',
description:
'Press a single key, optionally with modifiers, to submit, dismiss, navigate or trigger a shortcut. Not for entering text: use fill_by_uid instead.',
annotations: {
readOnlyHint: false,
},
inputSchema: {
type: 'object',
properties: {
key: {
type: 'string',
description:
'One key, optionally preceded by "+"-separated modifiers, such as "Escape", "F5" or "ctrl+shift+t". Modifiers: ctrl, alt, shift, meta. Named keys: Enter (numpad), Return, Tab, Backspace, Delete, Insert, Space, Escape, Home, End, PageUp, PageDown, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, F1-F12, Numpad0-Numpad9, Clear, Pause, Help, Cancel, Semicolon, Equals, Add, Subtract, Multiply, Divide, Decimal, Separator. Anything else must be a single character.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be confusing to mention F5 here. Most readers (and models) would probably interpret that as the "reload" shortcut, but it would only work from Chrome/privileged context, and will be misleading in other situations.

To be safe I would remove it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ctrl+shift+t has the same issue: from content neither triggers the browser action (checked: tab count unchanged, no reload). A page-level example like shift+Tab would be safer. On macOS ctrl+a also does not select all (meta+a does).

},
uid: {
type: 'string',
description: 'Element UID from snapshot (default: the focused element)',
},
},
required: ['key'],
},
};

// Handlers
export async function handleClickByUid(args: unknown): Promise<McpToolResponse> {
try {
Expand Down Expand Up @@ -327,6 +351,31 @@ export async function handleUploadFileByUid(args: unknown): Promise<McpToolRespo
}
}

export async function handlePressKey(args: unknown): Promise<McpToolResponse> {
try {
const { key, uid } = args as { key: string; uid?: string };

if (!key || typeof key !== 'string') {
throw new Error('key parameter is required and must be a string');
}

const { getFirefox } = await import('../index.js');
const firefox = await getFirefox();

try {
await firefox.pressKey(key, uid);
return successResponse(uid ? `press_key ${key} on ${uid}` : `press_key ${key}`);
} catch (error) {
if (uid) {
throw handleUidError(error as Error, uid);
}
throw error;
}
} catch (error) {
return errorResponse(error as Error);
}
}

export const module = defineModule({
name: 'input',
description: 'Interact with the page via UID-based clicks, typing, drag, and uploads.',
Expand All @@ -337,5 +386,6 @@ export const module = defineModule({
[dragByUidToUidTool, handleDragByUidToUid],
[fillFormByUidTool, handleFillFormByUid],
[uploadFileByUidTool, handleUploadFileByUid],
[pressKeyTool, handlePressKey],
],
});
114 changes: 114 additions & 0 deletions tests/firefox/key-combo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Unit tests for press_key combination parsing
*/

import { describe, it, expect } from 'vitest';
import { Key } from 'selenium-webdriver';
import { parseKeyCombo } from '../../src/firefox/dom.js';

describe('parseKeyCombo', () => {
describe('named keys', () => {
it('maps return and enter to the distinct selenium keys', () => {
expect(Key.RETURN).not.toBe(Key.ENTER);
expect(parseKeyCombo('Return')).toEqual({ modifiers: [], key: Key.RETURN });
expect(parseKeyCombo('Enter')).toEqual({ modifiers: [], key: Key.ENTER });
});

it('matches key names case-insensitively', () => {
expect(parseKeyCombo('escape').key).toBe(Key.ESCAPE);
expect(parseKeyCombo('Escape').key).toBe(Key.ESCAPE);
expect(parseKeyCombo('ESCAPE').key).toBe(Key.ESCAPE);
});

it('accepts short aliases', () => {
expect(parseKeyCombo('esc').key).toBe(Key.ESCAPE);
expect(parseKeyCombo('up').key).toBe(Key.ARROW_UP);
expect(parseKeyCombo('ArrowUp').key).toBe(Key.ARROW_UP);
expect(parseKeyCombo('down').key).toBe(Key.ARROW_DOWN);
expect(parseKeyCombo('left').key).toBe(Key.ARROW_LEFT);
expect(parseKeyCombo('right').key).toBe(Key.ARROW_RIGHT);
});

it('supports function, navigation and numpad keys', () => {
expect(parseKeyCombo('F1').key).toBe(Key.F1);
expect(parseKeyCombo('F12').key).toBe(Key.F12);
expect(parseKeyCombo('PageDown').key).toBe(Key.PAGE_DOWN);
expect(parseKeyCombo('Home').key).toBe(Key.HOME);
expect(parseKeyCombo('Backspace').key).toBe(Key.BACK_SPACE);
expect(parseKeyCombo('Space').key).toBe(Key.SPACE);
expect(parseKeyCombo('Numpad7').key).toBe(Key.NUMPAD7);
expect(parseKeyCombo('Subtract').key).toBe(Key.SUBTRACT);
});
});

describe('modifiers', () => {
it('separates modifiers from the key', () => {
expect(parseKeyCombo('ctrl+l')).toEqual({ modifiers: [Key.CONTROL], key: 'l' });
});

it('keeps every modifier of a combination, in order', () => {
expect(parseKeyCombo('ctrl+shift+t')).toEqual({
modifiers: [Key.CONTROL, Key.SHIFT],
key: 't',
});
});

it('accepts a modifier after the key', () => {
expect(parseKeyCombo('F4+alt')).toEqual({ modifiers: [Key.ALT], key: Key.F4 });
});

it('accepts modifier aliases', () => {
expect(parseKeyCombo('control+a').modifiers).toEqual([Key.CONTROL]);
for (const alias of ['meta', 'cmd', 'command', 'win', 'super']) {
expect(parseKeyCombo(`${alias}+a`).modifiers).toEqual([Key.META]);
}
});

it('combines modifiers with named keys', () => {
expect(parseKeyCombo('alt+ArrowLeft')).toEqual({
modifiers: [Key.ALT],
key: Key.ARROW_LEFT,
});
});

it('ignores whitespace around each part', () => {
expect(parseKeyCombo(' ctrl + shift + t ')).toEqual({
modifiers: [Key.CONTROL, Key.SHIFT],
key: 't',
});
});
});

describe('single characters', () => {
it('passes an unmapped single character through unchanged', () => {
expect(parseKeyCombo('a').key).toBe('a');
expect(parseKeyCombo('7').key).toBe('7');
});

it('preserves the case of a single character', () => {
expect(parseKeyCombo('A').key).toBe('A');
});

it('takes a lone separator as the plus key', () => {
expect(parseKeyCombo('+')).toEqual({ modifiers: [], key: '+' });
});
});

describe('rejected input', () => {
it('rejects more than one non-modifier key', () => {
expect(() => parseKeyCombo('ctrl+k+l')).toThrow(/more than one non-modifier key/);
expect(() => parseKeyCombo('a+b')).toThrow(/more than one non-modifier key/);
});

it('rejects an unknown key name rather than typing it', () => {
expect(() => parseKeyCombo('foobar')).toThrow(/unknown key "foobar"/);
expect(() => parseKeyCombo('ctrl+hello')).toThrow(/unknown key "hello"/);
});

it('rejects a combination with no key', () => {
expect(() => parseKeyCombo('ctrl+shift')).toThrow(/no key specified/);
expect(() => parseKeyCombo('')).toThrow(/no key specified/);
expect(() => parseKeyCombo(' ')).toThrow(/no key specified/);
});
});
});
Loading