From 6f4b8854e04888de8320058d940376a5b5654442 Mon Sep 17 00:00:00 2001 From: gcharang <21151592+gcharang@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:50:45 +0400 Subject: [PATCH] fix(extension): dispatch Enter with text semantics --- apps/extension/src/driver/cdp.test.ts | 124 +++++++++++++++++++++++ apps/extension/src/driver/cdp.ts | 34 ++++--- apps/extension/src/driver/keymap.test.ts | 12 ++- apps/extension/src/driver/keymap.ts | 16 +-- 4 files changed, 164 insertions(+), 22 deletions(-) diff --git a/apps/extension/src/driver/cdp.test.ts b/apps/extension/src/driver/cdp.test.ts index 0db2289..84eb986 100644 --- a/apps/extension/src/driver/cdp.test.ts +++ b/apps/extension/src/driver/cdp.test.ts @@ -32,6 +32,20 @@ function stubBrowserStorage(): void { }); } +function stubActionBrowser(): ReturnType { + const sendCommand = vi.fn().mockResolvedValue({}); + vi.stubGlobal("browser", { + storage: { + session: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + }, + }, + debugger: { sendCommand }, + }); + return sendCommand; +} + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); @@ -153,6 +167,116 @@ describe("CdpSession.resolveRefCheck", () => { }); }); +describe("CdpSession keyboard dispatch", () => { + it("submits typed text with Enter's carriage-return key event", async () => { + const sendCommand = stubActionBrowser(); + const session = await CdpSession.create(7, TEST_SCOPE); + session.refMap = new Map([[testRef(0, 1), 42]]); + + await expect(session.type("c-submit", testRef(0, 1), "secret", true)).resolves.toEqual({ + type: "action_result", + commandId: "c-submit", + ok: true, + }); + + expect(sendCommand.mock.calls).toEqual([ + [{ tabId: 7 }, "DOM.focus", { backendNodeId: 42 }], + [{ tabId: 7 }, "Input.insertText", { text: "secret" }], + [ + { tabId: 7 }, + "Input.dispatchKeyEvent", + { + type: "keyDown", + modifiers: 0, + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + text: "\r", + unmodifiedText: "\r", + }, + ], + [ + { tabId: 7 }, + "Input.dispatchKeyEvent", + { + type: "keyUp", + modifiers: 0, + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + }, + ], + ]); + }); + + it("does not dispatch a key event when type submit is false", async () => { + const sendCommand = stubActionBrowser(); + const session = await CdpSession.create(7, TEST_SCOPE); + session.refMap = new Map([[testRef(0, 1), 42]]); + + await session.type("c-no-submit", testRef(0, 1), "plain text", false); + + expect(sendCommand.mock.calls.map((call) => call[1])).toEqual([ + "DOM.focus", + "Input.insertText", + ]); + }); + + it("uses the same Enter payload for the explicit key command", async () => { + const sendCommand = stubActionBrowser(); + const session = await CdpSession.create(7, TEST_SCOPE); + session.refMap = new Map([[testRef(0, 1), 42]]); + + await session.key("c-enter", "Enter", testRef(0, 1)); + + expect(sendCommand.mock.calls.slice(1)).toEqual([ + [ + { tabId: 7 }, + "Input.dispatchKeyEvent", + { + type: "keyDown", + modifiers: 0, + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + text: "\r", + unmodifiedText: "\r", + }, + ], + [ + { tabId: 7 }, + "Input.dispatchKeyEvent", + { + type: "keyUp", + modifiers: 0, + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: 13, + }, + ], + ]); + }); + + it("uses rawKeyDown for keys without text", async () => { + const sendCommand = stubActionBrowser(); + const session = await CdpSession.create(7, TEST_SCOPE); + + await session.key("c-escape", "Escape"); + + expect(sendCommand.mock.calls[0]).toEqual([ + { tabId: 7 }, + "Input.dispatchKeyEvent", + { + type: "rawKeyDown", + modifiers: 0, + key: "Escape", + code: "Escape", + windowsVirtualKeyCode: 27, + }, + ]); + }); +}); + describe("CdpSession snapshot target identity", () => { function stubSnapshotBrowser( sendCommand: ReturnType, diff --git a/apps/extension/src/driver/cdp.ts b/apps/extension/src/driver/cdp.ts index e37ce00..d93fc02 100644 --- a/apps/extension/src/driver/cdp.ts +++ b/apps/extension/src/driver/cdp.ts @@ -414,6 +414,25 @@ export class CdpSession { } } + private async dispatchKey(parsed: ReturnType): Promise { + const base: Record = { + modifiers: parsed.modifiers, + key: parsed.key, + code: parsed.code, + windowsVirtualKeyCode: parsed.windowsVirtualKeyCode, + }; + const keyDown: Record = { + ...base, + type: parsed.text === undefined ? "rawKeyDown" : "keyDown", + }; + if (parsed.text !== undefined) { + keyDown.text = parsed.text; + keyDown.unmodifiedText = parsed.text; + } + await this.send("Input.dispatchKeyEvent", keyDown); + await this.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" }); + } + snapshotA11y(commandId: string): Promise { const deadlineAt = Date.now() + SNAPSHOT_DEADLINE_MS; return this.run( @@ -497,9 +516,7 @@ export class CdpSession { await this.focusOrClick(backendNodeId); await this.send("Input.insertText", { text }); if (submit === true) { - const enter = { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }; - await this.send("Input.dispatchKeyEvent", { type: "keyDown", ...enter }); - await this.send("Input.dispatchKeyEvent", { type: "keyUp", ...enter }); + await this.dispatchKey(parseKeys("Enter")); } return { type: "action_result", commandId, ok: true }; }); @@ -515,16 +532,7 @@ export class CdpSession { await this.optional(this.send("DOM.focus", { backendNodeId })); } const parsed = parseKeys(keys); - const base: Record = { - modifiers: parsed.modifiers, - key: parsed.key, - code: parsed.code, - windowsVirtualKeyCode: parsed.windowsVirtualKeyCode, - }; - const keyDown: Record = { ...base, type: "keyDown" }; - if (parsed.text !== undefined) keyDown.text = parsed.text; - await this.send("Input.dispatchKeyEvent", keyDown); - await this.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" }); + await this.dispatchKey(parsed); return { type: "action_result", commandId, ok: true }; }); } diff --git a/apps/extension/src/driver/keymap.test.ts b/apps/extension/src/driver/keymap.test.ts index a9cd4fe..28891ff 100644 --- a/apps/extension/src/driver/keymap.test.ts +++ b/apps/extension/src/driver/keymap.test.ts @@ -21,12 +21,13 @@ describe("parseKeys", () => { expect(parseKeys("OPTION+a").modifiers).toBe(1); }); - it("maps named keys to {key, code, windowsVirtualKeyCode}", () => { + it("maps named keys to their CDP fields", () => { expect(parseKeys("Enter")).toEqual({ modifiers: 0, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, + text: "\r", }); expect(parseKeys("Tab")).toMatchObject({ key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }); expect(parseKeys("Escape")).toMatchObject({ @@ -55,8 +56,13 @@ describe("parseKeys", () => { expect(parseKeys("Ctrl+a").text).toBeUndefined(); }); - it("does not set text for named or control keys", () => { - expect(parseKeys("Enter").text).toBeUndefined(); + it("carries a carriage return only for Enter without a command modifier", () => { + expect(parseKeys("Enter").text).toBe("\r"); + expect(parseKeys("Return").text).toBe("\r"); + expect(parseKeys("Shift+Enter").text).toBe("\r"); expect(parseKeys("Ctrl+Enter").text).toBeUndefined(); + expect(parseKeys("Alt+Enter").text).toBeUndefined(); + expect(parseKeys("Meta+Enter").text).toBeUndefined(); + expect(parseKeys("Escape").text).toBeUndefined(); }); }); diff --git a/apps/extension/src/driver/keymap.ts b/apps/extension/src/driver/keymap.ts index cac84fb..9b9a5f7 100644 --- a/apps/extension/src/driver/keymap.ts +++ b/apps/extension/src/driver/keymap.ts @@ -17,8 +17,9 @@ // // `modifiers` is the CDP bitmask Alt=1, Ctrl=2, Meta=4, Shift=8. `text` is set // only for a printable key with no command modifier (Ctrl/Alt/Meta) held — a -// shifted letter yields its uppercase text; named and control-combo keys carry -// none. +// shifted letter yields its uppercase text. Enter carries the carriage return +// Chromium needs for its default action; other named keys and control-combo +// keys carry none. export interface ParsedKey { modifiers: number; @@ -50,11 +51,12 @@ interface NamedKey { key: string; code: string; windowsVirtualKeyCode: number; + text?: string; } const NAMED_KEYS: Record = { - enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, - return: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 }, + enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" }, + return: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" }, tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9 }, escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }, esc: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }, @@ -89,14 +91,17 @@ export function parseKeys(spec: string): ParsedKey { modifiers |= bit; } + const hasCommandModifier = (modifiers & (MOD_ALT | MOD_CTRL | MOD_META)) !== 0; const named = NAMED_KEYS[keyToken.toLowerCase()]; if (named !== undefined) { - return { + const result: ParsedKey = { modifiers, key: named.key, code: named.code, windowsVirtualKeyCode: named.windowsVirtualKeyCode, }; + if (named.text !== undefined && !hasCommandModifier) result.text = named.text; + return result; } if (keyToken.length !== 1) { @@ -122,7 +127,6 @@ export function parseKeys(spec: string): ParsedKey { // Command modifiers (Alt/Ctrl/Meta) turn the keystroke into a shortcut rather // than text input, so only a plain (optionally Shift-ed) printable carries text. - const hasCommandModifier = (modifiers & (MOD_ALT | MOD_CTRL | MOD_META)) !== 0; if (!hasCommandModifier) result.text = key; return result;