diff --git a/src/tools/input.ts b/src/tools/input.ts index ad9c344b..53562eac 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -526,12 +526,20 @@ export const pressKey = definePageTool({ const [key, ...modifiers] = tokens; const result = await page.waitForEventsAfterAction(async () => { - for (const modifier of modifiers) { - await page.pptrPage.keyboard.down(modifier); - } - await page.pptrPage.keyboard.press(key); - for (const modifier of modifiers.toReversed()) { - await page.pptrPage.keyboard.up(modifier); + const heldModifiers: KeyInput[] = []; + try { + for (const modifier of modifiers) { + await page.pptrPage.keyboard.down(modifier); + heldModifiers.push(modifier); + } + await page.pptrPage.keyboard.press(key); + } finally { + // Release every modifier that was successfully pressed, even if a + // later key event throws. Otherwise a failed press leaves modifiers + // logically held down in the browser (see #2309). + for (const modifier of heldModifiers.toReversed()) { + await page.pptrPage.keyboard.up(modifier); + } } }); diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 0e7f7e3b..bac71ccc 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -9,6 +9,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {describe, it} from 'node:test'; +import sinon from 'sinon'; + import type {ParsedArguments} from '../../src/bin/chrome-devtools-mcp-cli-options.js'; import {McpResponse} from '../../src/McpResponse.js'; import {TextSnapshot} from '../../src/TextSnapshot.js'; @@ -1359,5 +1361,54 @@ describe('input', () => { ]); }); }); + + it('releases held modifiers when the main key press fails', async () => { + await withMcpContext(async (response, context) => { + const page = context.getSelectedMcpPage().pptrPage; + await page.setContent( + html``, + ); + context.getSelectedMcpPage().textSnapshot = await TextSnapshot.create( + context.getSelectedMcpPage(), + ); + + // Simulate the main key press failing mid-sequence (e.g. a CDP + // hiccup) after the modifiers have already been pressed down. + sinon + .stub(page.keyboard, 'press') + .throws(new Error('injected press failure')); + + try { + await assert.rejects( + pressKey.handler( + { + params: { + key: 'Control+Shift+C', + }, + page: context.getSelectedMcpPage(), + }, + response, + context, + ), + ); + } finally { + sinon.restore(); + } + + // The modifiers were pressed down; both must be released even though + // the main key press threw, otherwise the browser is left with the + // modifiers logically stuck down. + assert.deepStrictEqual(await page.evaluate('logs'), [ + 'dControl', + 'dShift', + 'uShift', + 'uControl', + ]); + }); + }); }); });