From fb95e8b39e98c9641861d6922512e77013711cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 16 Sep 2026 18:52:20 +0200 Subject: [PATCH 1/5] cdp: window.devicePixelRatio from viewport scale and Input char text insertion - window.devicePixelRatio: turn from a constant property into an accessor mirroring innerWidth/innerHeight. Reads the page viewport scale (set via Emulation.setDeviceMetricsOverride's deviceScaleFactor, default 1.0) and remains [Replaceable] through a setter that delegates to replaceGlobalProperty. - Input.dispatchKeyEvent: - For `char` events, insert `params.text` into the focused element when not default-prevented by a keypress listener. Non-text keys without a `text` payload (e.g. Enter) leave the value untouched. - For `keyDown` events, fall back to `params.text` when `params.key` is omitted, allowing text-only keyDown dispatches from CDP clients to insert the character into the focused element. --- src/browser/Viewport.zig | 2 +- src/browser/webapi/Window.zig | 10 +++- src/server/cdp/domains/emulation.zig | 8 +-- src/server/cdp/domains/input.zig | 89 +++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 8 deletions(-) diff --git a/src/browser/Viewport.zig b/src/browser/Viewport.zig index 06c220975..7537d7900 100644 --- a/src/browser/Viewport.zig +++ b/src/browser/Viewport.zig @@ -20,7 +20,7 @@ const Viewport = @This(); width: u32, height: u32, -scale: f32 = 1.0, // for screenshot raster +scale: f32 = 1.0, // for screenshot raster and window.devicePixelRatio // window.screen dimensions; null means the same as the viewport. screen_width: ?u32 = null, screen_height: ?u32 = null, diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index f4f8a7838..c8ef3d7dd 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -222,6 +222,10 @@ fn setInnerHeight(self: *Window, value: js.Value) void { self.replaceGlobalProperty(value, "innerHeight"); } +fn setDevicePixelRatio(self: *Window, value: js.Value) void { + self.replaceGlobalProperty(value, "devicePixelRatio"); +} + fn setScrollX(self: *Window, value: js.Value) void { self.replaceGlobalProperty(value, "scrollX"); } @@ -923,6 +927,10 @@ pub fn getInnerHeight(_: *const Window, frame: *Frame) u32 { return frame.page.getViewport().height; } +fn getDevicePixelRatio(_: *const Window, frame: *Frame) f32 { + return frame.page.getViewport().scale; +} + pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = opts.offsets(y); const new_x: u32 = if (o.left) |left| @intCast(@max(0, left)) else self._scroll_pos.x; @@ -1227,7 +1235,7 @@ pub const JsApi = struct { // the attribute rather than throwing. pub const innerWidth = bridge.accessor(Window.getInnerWidth, Window.setInnerWidth, .{}); pub const innerHeight = bridge.accessor(Window.getInnerHeight, Window.setInnerHeight, .{}); - pub const devicePixelRatio = bridge.property(1, .{ .template = false, .readonly = false }); + pub const devicePixelRatio = bridge.accessor(Window.getDevicePixelRatio, Window.setDevicePixelRatio, .{}); pub const opener = bridge.accessor(Window.getOpener, Window.setOpener, .{}); pub const closed = bridge.accessor(Window.getClosed, null, .{}); diff --git a/src/server/cdp/domains/emulation.zig b/src/server/cdp/domains/emulation.zig index a12510a60..757a27e30 100644 --- a/src/server/cdp/domains/emulation.zig +++ b/src/server/cdp/domains/emulation.zig @@ -310,10 +310,10 @@ test "cdp.Emulation: setDeviceMetricsOverride screenWidth/screenHeight reach win try ctx.processMessage(.{ .id = 1, .method = "Emulation.setDeviceMetricsOverride", - .params = .{ .width = 1280, .height = 720, .deviceScaleFactor = 1, .mobile = false, .screenWidth = 2560, .screenHeight = 1440 }, + .params = .{ .width = 1280, .height = 720, .deviceScaleFactor = 2, .mobile = false, .screenWidth = 2560, .screenHeight = 1440 }, }); try ctx.expectSentResult(null, .{ .id = 1 }); - var v = try ls.local.exec("screen.width === 2560 && screen.height === 1440 && innerWidth === 1280 && innerHeight === 720", null); + var v = try ls.local.exec("screen.width === 2560 && screen.height === 1440 && innerWidth === 1280 && innerHeight === 720 && devicePixelRatio === 2", null); try testing.expect(v.toBool()); // 0 keeps the current value, as for width/height. @@ -322,11 +322,11 @@ test "cdp.Emulation: setDeviceMetricsOverride screenWidth/screenHeight reach win .method = "Emulation.setDeviceMetricsOverride", .params = .{ .width = 1024, .height = 0, .deviceScaleFactor = 0, .mobile = false, .screenWidth = 0, .screenHeight = 0 }, }); - v = try ls.local.exec("screen.width === 2560 && screen.height === 1440 && innerWidth === 1024 && innerHeight === 720", null); + v = try ls.local.exec("screen.width === 2560 && screen.height === 1440 && innerWidth === 1024 && innerHeight === 720 && devicePixelRatio === 2", null); try testing.expect(v.toBool()); try ctx.processMessage(.{ .id = 3, .method = "Emulation.clearDeviceMetricsOverride" }); - v = try ls.local.exec("screen.width === innerWidth && screen.height === innerHeight", null); + v = try ls.local.exec("screen.width === innerWidth && screen.height === innerHeight && devicePixelRatio === 1", null); try testing.expect(v.toBool()); } diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 0b0299571..c10a801c9 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -43,6 +43,7 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { key: []const u8 = "", code: ?[]const u8 = null, modifiers: u4 = 0, + text: ?[]const u8 = null, // Many optional parameters are not implemented yet, see documentation url. const Type = enum { @@ -61,6 +62,7 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return; const frame = bc.mainFrame() orelse return; + const key_str = if (params.key.len > 0) params.key else params.text orelse ""; const KeyboardEvent = @import("../../../browser/webapi/event/KeyboardEvent.zig"); const keyboard_event = try KeyboardEvent.initTrusted(switch (params.type) { .keyDown => comptime .wrap("keydown"), @@ -68,7 +70,7 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { .char => comptime .wrap("keypress"), .rawKeyDown => unreachable, }, .{ - .key = params.key, + .key = key_str, .code = params.code, .altKey = params.modifiers & 1 == 1, .ctrlKey = params.modifiers & 2 == 2, @@ -76,7 +78,14 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { .shiftKey = params.modifiers & 8 == 8, }, frame); try Frame.user_input.triggerKeyboard(frame, keyboard_event); - // result already sent + + if (params.type == .char and !keyboard_event.asEvent().getDefaultPrevented()) { + if (params.text) |text| { + if (text.len > 0) { + try Frame.user_input.insertText(frame, text); + } + } + } } // https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent @@ -781,3 +790,79 @@ test "cdp.input: dispatchKeyEvent caret movement keys move the text entry cursor try testing.expectEqualSlices(u8, step.expect, got); } } + +test "cdp.input: dispatchKeyEvent char fires keypress and inserts text" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const frame = page.frame().?; + try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ + .reason = .address_bar, + .kind = .{ .push = null }, + }); + try testing.waitForPage(bc); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + _ = try ls.local.compileAndRun( + \\const inp = document.getElementById('inp'); + \\inp.value = ''; + \\inp.focus(); + \\window.events = []; + \\inp.addEventListener('keypress', (e) => window.events.push('keypress:' + e.key)); + \\inp.addEventListener('input', (e) => window.events.push('input:' + inp.value)); + , null); + + try ctx.processMessage(.{ + .id = 1, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "char", .key = "h", .text = "h" }, + }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + try testing.expect((try ls.local.compileAndRun( + \\inp.value === 'h' && window.events.join(',') === 'keypress:h,input:h' + , null)).isTrue()); + + // With preventDefault on keypress, no character is inserted. + _ = try ls.local.compileAndRun( + \\inp.addEventListener('keypress', (e) => { if (e.key === 'x') e.preventDefault(); }); + , null); + + try ctx.processMessage(.{ + .id = 2, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "char", .key = "x", .text = "x" }, + }); + try ctx.expectSentResult(null, .{ .id = 2 }); + + try testing.expect((try ls.local.compileAndRun( + \\inp.value === 'h' && window.events.join(',') === 'keypress:h,input:h,keypress:x' + , null)).isTrue()); + + // A `char` event without a `text` field must not insert its `key`. + try ctx.processMessage(.{ + .id = 3, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "char", .key = "Enter" }, + }); + try ctx.expectSentResult(null, .{ .id = 3 }); + try testing.expect((try ls.local.compileAndRun( + \\inp.value === 'h' && window.events.join(',') === 'keypress:h,input:h,keypress:x,keypress:Enter' + , null)).isTrue()); + + // A `keyDown` sending only `text` falls back to using it as the key and inserts it. + try ctx.processMessage(.{ + .id = 4, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "keyDown", .text = "z" }, + }); + try ctx.expectSentResult(null, .{ .id = 4 }); + try testing.expect((try ls.local.compileAndRun( + \\inp.value === 'hz' && window.events.join(',') === 'keypress:h,input:h,keypress:x,keypress:Enter,keypress:z,input:hz' + , null)).isTrue()); +} From 681a3e75d366e6405e97fa9984a5953a87cdd22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Wed, 16 Sep 2026 18:55:09 +0200 Subject: [PATCH 2/5] test(cdp): split and simplify char and keyDown input tests --- src/server/cdp/domains/input.zig | 71 ++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index c10a801c9..7900911a9 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -791,7 +791,7 @@ test "cdp.input: dispatchKeyEvent caret movement keys move the text entry cursor } } -test "cdp.input: dispatchKeyEvent char fires keypress and inserts text" { +test "cdp.input: dispatchKeyEvent char text insertion" { var ctx = try testing.context(); defer ctx.deinit(); @@ -813,56 +813,73 @@ test "cdp.input: dispatchKeyEvent char fires keypress and inserts text" { \\inp.value = ''; \\inp.focus(); \\window.events = []; - \\inp.addEventListener('keypress', (e) => window.events.push('keypress:' + e.key)); - \\inp.addEventListener('input', (e) => window.events.push('input:' + inp.value)); + \\inp.addEventListener('keypress', (e) => window.events.push(e.key)); + \\inp.addEventListener('input', (e) => window.events.push(inp.value)); , null); + // Text is inserted and dispatches keypress and input. try ctx.processMessage(.{ .id = 1, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "h", .text = "h" }, }); try ctx.expectSentResult(null, .{ .id = 1 }); - try testing.expect((try ls.local.compileAndRun( - \\inp.value === 'h' && window.events.join(',') === 'keypress:h,input:h' + \\inp.value === 'h' && window.events.join(',') === 'h,h' , null)).isTrue()); + // A char event without a text field (e.g. Enter) must not insert its key name. + try ctx.processMessage(.{ + .id = 2, + .method = "Input.dispatchKeyEvent", + .params = .{ .type = "char", .key = "Enter" }, + }); + try ctx.expectSentResult(null, .{ .id = 2 }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'h'", null)).isTrue()); + // With preventDefault on keypress, no character is inserted. _ = try ls.local.compileAndRun( \\inp.addEventListener('keypress', (e) => { if (e.key === 'x') e.preventDefault(); }); , null); try ctx.processMessage(.{ - .id = 2, + .id = 3, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "x", .text = "x" }, }); - try ctx.expectSentResult(null, .{ .id = 2 }); - - try testing.expect((try ls.local.compileAndRun( - \\inp.value === 'h' && window.events.join(',') === 'keypress:h,input:h,keypress:x' - , null)).isTrue()); - - // A `char` event without a `text` field must not insert its `key`. - try ctx.processMessage(.{ - .id = 3, - .method = "Input.dispatchKeyEvent", - .params = .{ .type = "char", .key = "Enter" }, - }); try ctx.expectSentResult(null, .{ .id = 3 }); - try testing.expect((try ls.local.compileAndRun( - \\inp.value === 'h' && window.events.join(',') === 'keypress:h,input:h,keypress:x,keypress:Enter' - , null)).isTrue()); + try testing.expect((try ls.local.compileAndRun("inp.value === 'h'", null)).isTrue()); +} - // A `keyDown` sending only `text` falls back to using it as the key and inserts it. +test "cdp.input: dispatchKeyEvent keyDown text-only fallback" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const frame = page.frame().?; + try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ + .reason = .address_bar, + .kind = .{ .push = null }, + }); + try testing.waitForPage(bc); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + _ = try ls.local.compileAndRun( + \\const inp = document.getElementById('inp'); + \\inp.value = ''; + \\inp.focus(); + , null); + + // When keyDown omits `key`, `text` is used as the key and inserted. try ctx.processMessage(.{ - .id = 4, + .id = 1, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .text = "z" }, }); - try ctx.expectSentResult(null, .{ .id = 4 }); - try testing.expect((try ls.local.compileAndRun( - \\inp.value === 'hz' && window.events.join(',') === 'keypress:h,input:h,keypress:x,keypress:Enter,keypress:z,input:hz' - , null)).isTrue()); + try ctx.expectSentResult(null, .{ .id = 1 }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'z'", null)).isTrue()); } From 74bf43e7321b7f19fef12249e365e3c76f76c7d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Thu, 17 Sep 2026 09:53:47 +0200 Subject: [PATCH 3/5] input: type text in a char step, not in the keydown default action Follow Chrome's model. A keydown only runs its own default action (Tab, caret moves, Backspace, Enter activation). Text is typed by the char half of the press, which fires keypress and then beforeinput/textInput, so either can veto the edit. A keyDown carrying `text` runs the char step inline (Puppeteer, Playwright). A text-less keyDown followed by a `char` message runs it on the char (chromedp), so each character is typed once. A cancelled text-less keyDown drops the char that follows it, as Chrome does. BiDi, WebDriver and the MCP press action go through the same pressKey, deriving the text from the key. pressKey holds a ref on the keydown for the keypress it builds, so nothing reads the event after dispatch. Input.insertText fires beforeinput like a key press does. --- src/browser/actions.zig | 5 +- src/browser/frame/user_input.zig | 195 ++++++++++++--------- src/browser/webapi/WebDriver.zig | 18 +- src/browser/webapi/event/KeyboardEvent.zig | 2 +- src/server/bidi/input.zig | 6 +- src/server/cdp/CDP.zig | 4 + src/server/cdp/domains/input.zig | 193 ++++++++++++-------- 7 files changed, 261 insertions(+), 162 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index b62016442..c1b295b7b 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -143,7 +143,10 @@ pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void { .key = canonical, }, frame); - const prevented = frame._event_manager.dispatchCancelable(target, keydown_event.asEvent()) catch |err| { + const prevented = (if (target_el) |el| + Frame.user_input.pressKey(frame, el, keydown_event, Frame.user_input.textForKey(keydown_event)) + else + frame._event_manager.dispatchCancelable(target, keydown_event.asEvent())) catch |err| { lp.log.err(.app, "press keydown failed", .{ .err = err }); return error.ActionFailed; }; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index e150f3c28..507972949 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -624,26 +624,106 @@ fn followLink(frame: *Frame, target: *Node, element: *Element, href: []const u8, }, .{ .anchor = target_frame }); } -pub fn triggerKeyboard(frame: *Frame, keyboard_event: *KeyboardEvent) !void { - const event = keyboard_event.asEvent(); - // Dispatch to the effective active element. When nothing is explicitly - // focused this resolves to (matching `document.activeElement`), so - // the keydown still fires and its default action — e.g. sequential focus - // navigation on Tab — can run. - const element = frame.window._document.getActiveElement() orelse { - event.deinit(frame.page); - return; - }; +/// pressKey on the focused element. +pub fn triggerKeyDown(frame: *Frame, keydown: *KeyboardEvent, text: ?[]const u8) !bool { + const element = focusedElement(frame, keydown) orelse return false; + return pressKey(frame, element, keydown, text); +} +pub fn triggerKeyUp(frame: *Frame, keyup: *KeyboardEvent) !void { + const element = focusedElement(frame, keyup) orelse return; + try frame._event_manager.dispatch(element.asEventTarget(), keyup.asEvent()); +} + +// `document.activeElement`, so with nothing focused a key still fires on +// and Tab's focus navigation can run. +fn focusedElement(frame: *Frame, keyboard_event: *KeyboardEvent) ?*Element { + const element = frame.window._document.getActiveElement() orelse { + keyboard_event.asEvent().deinit(frame.page); + return null; + }; if (comptime lp.IS_DEBUG) { - log.debug(.frame, "frame keydown", .{ + log.debug(.frame, "frame key", .{ .url = frame.url, .node = element, .key = keyboard_event._key, .type = frame._type, }); } - try frame._event_manager.dispatch(element.asEventTarget(), event); + return element; +} + +/// Dispatches a trusted keydown on `target` then, unless cancelled, types +/// `text` (Chrome's WebKeyboardEvent.text; null when the client sends the +/// char as its own event, as chromedp does). Returns whether the keydown was +/// cancelled. +pub fn pressKey(frame: *Frame, target: *Element, keydown: *KeyboardEvent, text: ?[]const u8) !bool { + const event = keydown.asEvent(); + // dispatch drops the event; keypressFor still needs it. + event.acquireRef(); + defer event.releaseRef(frame.page); + + if (try frame._event_manager.dispatchCancelable(target.asEventTarget(), event)) { + return true; + } + const t = text orelse return false; + // logged like a default action's failure, not the key event's + typeChar(frame, target, try keypressFor(frame, keydown), t) catch |err| { + log.warn(.frame, "frame.keypress", .{ .err = err }); + }; + return false; +} + +/// The text a key press produces, following Chrome's WebKeyboardEvent.text: the +/// key itself when printable, "\r" for Enter, nothing when ctrl/meta turn the +/// press into a shortcut. +pub fn textForKey(keyboard_event: *const KeyboardEvent) ?[]const u8 { + if (keyboard_event.getCtrlKey() or keyboard_event.getMetaKey()) { + return null; + } + const key = keyboard_event.getKey(); + if (key == .Enter) { + return "\r"; + } + return if (key.isPrintable()) key.asString() else null; +} + +/// The char half of a key press (Chrome's WebInputEvent::kChar): fires +/// `keypress` on `target` and, unless a listener cancels it, performs the +/// text edit it stands for. +pub fn typeChar(frame: *Frame, target: *Element, keypress: *KeyboardEvent, text: []const u8) !void { + if (try frame._event_manager.dispatchCancelable(target.asEventTarget(), keypress.asEvent())) { + return; + } + const is_enter = text.len == 1 and (text[0] == '\r' or text[0] == '\n'); + + if (target.is(Element.Html.Input)) |input| { + if (is_enter) { + return frame.submitForm(input.asElement(), input.getForm(frame), .{}); + } + return insertInto(frame, input, text); + } + + if (target.is(Element.Html.TextArea)) |textarea| { + if (is_enter) { + if (try allowEdit(frame, textarea.asElement(), null, "\n", "insertLineBreak")) { + try textarea.innerInsert("\n", frame); + } + return; + } + return insertInto(frame, textarea, text); + } +} + +/// The keypress mirroring `keydown`'s key and modifiers. +fn keypressFor(frame: *Frame, keydown: *const KeyboardEvent) !*KeyboardEvent { + return KeyboardEvent.initTrusted(comptime .wrap("keypress"), .{ + .key = keydown.getKey().asString(), + .ctrlKey = keydown.getCtrlKey(), + .shiftKey = keydown.getShiftKey(), + .altKey = keydown.getAltKey(), + .metaKey = keydown.getMetaKey(), + }, frame); } pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void { @@ -659,30 +739,16 @@ pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void { return moveFocus(frame, keyboard_event.getShiftKey() == false); } - if (event.getIsTrusted()) { - if ((key.isPrintable() or key == .Enter) and keyboard_event.getCtrlKey() == false and keyboard_event.getMetaKey() == false) { - // Fire a keypress for a printable (or Enter) keydown when ctrl/meta - // aren't pressed - if (try dispatchKeypress(frame, target, keyboard_event)) { - return; - } - } - - if (key == .Enter) { - if (target.is(Element)) |element| { - if (enterActivates(element)) { - // Enter generates a button-like "click" for some elements - return dispatchKeyboardClick(frame, element); - } + if (key == .Enter and event.getIsTrusted()) { + if (target.is(Element)) |element| { + if (enterActivates(element)) { + // Enter generates a button-like "click" for some elements + return dispatchKeyboardClick(frame, element); } } } if (target.is(Element.Html.Input)) |input| { - if (key == .Enter) { - return frame.submitForm(input.asElement(), input.getForm(frame), .{}); - } - // Don't handle text input for radio/checkbox const input_type = input._input_type; if (input_type == .radio or input_type == .checkbox) { @@ -693,21 +759,13 @@ pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void { } if (target.is(Element.Html.TextArea)) |textarea| { - if (key == .Enter) { - if (try allowEdit(frame, event, textarea.asElement(), null, "\n", "insertLineBreak")) { - try textarea.innerInsert("\n", frame); - } - return; - } - return editKey(frame, keyboard_event, textarea, key); } } -// edit keys are handled by Input and TextArea the same +// edit keys other than text insertion (typeChar's) are handled by Input and +// TextArea the same fn editKey(frame: *Frame, keyboard_event: *KeyboardEvent, ctl: anytype, key: KeyboardEvent.Key) !void { - const event = keyboard_event.asEvent(); - if (caretMove(key, ctl)) |move| { // Word/paragraph motions (ctrl/alt/meta variants) aren't modeled. if (keyboard_event.getCtrlKey() or keyboard_event.getAltKey() or keyboard_event.getMetaKey()) { @@ -718,17 +776,22 @@ fn editKey(frame: *Frame, keyboard_event: *KeyboardEvent, ctl: anytype, key: Key if (key == .Backspace or key == .Delete) { const forward = key == .Delete; - if (try allowEdit(frame, event, ctl.asElement(), null, null, deleteInputType(forward))) { + if (!keyboard_event.asEvent().getIsTrusted() or try allowEdit(frame, ctl.asElement(), null, null, deleteInputType(forward))) { try ctl.innerDelete(forward, frame); } - return; } +} - if (key.isPrintable()) { - if (try allowEdit(frame, event, ctl.asElement(), key.asString(), key.asString(), "insertText")) { - try ctl.innerInsert(key.asString(), frame); +fn insertInto(frame: *Frame, ctl: anytype, text: []const u8) !void { + if (@TypeOf(ctl) == *Element.Html.Input) { + const input_type = ctl._input_type; + if (input_type == .radio or input_type == .checkbox) { + return; } } + if (try allowEdit(frame, ctl.asElement(), text, text, "insertText")) { + try ctl.innerInsert(text, frame); + } } // Caret movement a key's default action performs on `ctl`, if any. On a @@ -750,15 +813,9 @@ fn deleteInputType(forward: bool) []const u8 { return if (forward) "deleteContentForward" else "deleteContentBackward"; } -// pre-edit events for a key's default action, can cancel the edit (i.e. by -// returning false) -fn allowEdit(frame: *Frame, keydown: *Event, target: *Element, before_data: ?[]const u8, text_data: ?[]const u8, input_type: []const u8) !bool { - if (keydown.getIsTrusted() == false) { - // only trusted events fire these events, so for a untrusted event, the - // edit isn't cancelled. - return true; - } - +// pre-edit events for a trusted key's default action, can cancel the edit +// (i.e. by returning false) +fn allowEdit(frame: *Frame, target: *Element, before_data: ?[]const u8, text_data: ?[]const u8, input_type: []const u8) !bool { { const before = (try InputEvent.initTrusted(comptime .wrap("beforeinput"), .{ .bubbles = true, @@ -808,23 +865,6 @@ pub fn handleKeyup(frame: *Frame, target: *Node, event: *Event) !void { } } -// Dispatch keypress mirroring `keydown`'s key and modifiers; returns true when -// a listener canceled it. -fn dispatchKeypress(frame: *Frame, target: *Node, keydown: *KeyboardEvent) !bool { - const event = (try KeyboardEvent.initTrusted(comptime .wrap("keypress"), .{ - .bubbles = true, - .cancelable = true, - .composed = true, - .key = keydown.getKey().asString(), - .ctrlKey = keydown.getCtrlKey(), - .shiftKey = keydown.getShiftKey(), - .altKey = keydown.getAltKey(), - .metaKey = keydown.getMetaKey(), - }, frame)).asEvent(); - - return frame._event_manager.dispatchCancelable(target.asEventTarget(), event); -} - // keydown+enter or keyup+space trigger this syntthetic pointer event (under // specific conditions, see handleKeydown and handleKeyup). fn dispatchKeyboardClick(frame: *Frame, element: *Element) !void { @@ -951,21 +991,16 @@ fn focusOrderBefore(a: *Element, a_tab_index: i32, b: *Element, b_tab_index: i32 return a_tab_index < b_tab_index; } -// insertText is a shortcut to insert text into the active element. +/// Text input without a key press (IME, paste): beforeinput but no keypress. pub fn insertText(frame: *Frame, v: []const u8) !void { const html_element = frame.document._active_element orelse return; if (html_element.is(Element.Html.Input)) |input| { - const input_type = input._input_type; - if (input_type == .radio or input_type == .checkbox) { - return; - } - - return input.innerInsert(v, frame); + return insertInto(frame, input, v); } if (html_element.is(Element.Html.TextArea)) |textarea| { - return textarea.innerInsert(v, frame); + return insertInto(frame, textarea, v); } } diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index bbd850573..e98b0acf9 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -426,12 +426,7 @@ fn performKeySource(source: js.Object, frame: *Frame) !void { // Key actions have no explicit target; they go to the focused element, // or the document if nothing is focused. Resolved per action since a // key's default action can move focus. - const target = if (frame.document._active_element) |el| - el.asEventTarget() - else - frame.document.asNode().asEventTarget(); - - dispatchKey(target, if (is_down) comptime .wrap("keydown") else comptime .wrap("keyup"), key, frame); + dispatchKey(frame.document._active_element, is_down, key, frame); } } @@ -516,7 +511,8 @@ fn setModifier(modifiers: *Modifiers, key: []const u8, pressed: bool) void { } } -fn dispatchKey(target: *EventTarget, typ: lp.String, key: []const u8, frame: *Frame) void { +fn dispatchKey(focused: ?*Element, is_down: bool, key: []const u8, frame: *Frame) void { + const typ: lp.String = if (is_down) comptime .wrap("keydown") else comptime .wrap("keyup"); const modifiers = frame.page.input_modifiers; const event = KeyboardEvent.initTrusted(typ, .{ .bubbles = true, @@ -531,7 +527,13 @@ fn dispatchKey(target: *EventTarget, typ: lp.String, key: []const u8, frame: *Fr log.warn(.app, "webdriver key event", .{ .err = err }); return; }; - dispatch(target, event.asEvent(), frame, typ.str()); + const el = focused orelse return dispatch(frame.document.asNode().asEventTarget(), event.asEvent(), frame, typ.str()); + if (!is_down) { + return dispatch(el.asEventTarget(), event.asEvent(), frame, typ.str()); + } + _ = Frame.user_input.pressKey(frame, el, event, Frame.user_input.textForKey(event)) catch |err| { + log.warn(.app, "webdriver dispatch", .{ .err = err, .type = typ.str() }); + }; } fn readI32(obj: js.Object, key: []const u8, default: i32) i32 { diff --git a/src/browser/webapi/event/KeyboardEvent.zig b/src/browser/webapi/event/KeyboardEvent.zig index cd90cb28c..570728c4e 100644 --- a/src/browser/webapi/event/KeyboardEvent.zig +++ b/src/browser/webapi/event/KeyboardEvent.zig @@ -289,7 +289,7 @@ const KeyboardEventOptions = struct { shiftKey: bool = false, }; -const Options = Event.inheritOptions( +pub const Options = Event.inheritOptions( KeyboardEvent, KeyboardEventOptions, ); diff --git a/src/server/bidi/input.zig b/src/server/bidi/input.zig index b63cfef0a..07f9d94bf 100644 --- a/src/server/bidi/input.zig +++ b/src/server/bidi/input.zig @@ -625,7 +625,11 @@ fn dispatchKey(frame: *Frame, comptime typ: []const u8, info: *const KeyInfo, mo .metaKey = modifiers.meta, .shiftKey = modifiers.shift, }, frame); - try user_input.triggerKeyboard(frame, event); + if (comptime std.mem.eql(u8, typ, "keydown")) { + _ = try user_input.triggerKeyDown(frame, event, user_input.textForKey(event)); + } else { + try user_input.triggerKeyUp(frame, event); + } } fn setModifier(modifiers: *Modifiers, which: ?Modifier, down: bool) void { diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index 42772309d..24739794f 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -459,6 +459,10 @@ pub const BrowserContext = struct { session_id: ?[]const u8, attached_sessions: std.ArrayList(AttachedSession) = .empty, + // A cancelled text-less keyDown drops the char message that follows it + // (chromedp's keyDown/char/keyUp split), as Chrome does. + suppress_next_char: bool = false, + security_origin: []const u8, page_life_cycle_events: bool, secure_context_type: []const u8, diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 7900911a9..2821a0a9e 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -43,7 +43,7 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { key: []const u8 = "", code: ?[]const u8 = null, modifiers: u4 = 0, - text: ?[]const u8 = null, + text: []const u8 = "", // Many optional parameters are not implemented yet, see documentation url. const Type = enum { @@ -56,35 +56,41 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { try cmd.sendResult(null, .{}); - // rawKeyDown is a Chrome-internal event type not used for JS dispatch - if (params.type == .rawKeyDown) return; - const bc = cmd.browser_context orelse return; const frame = bc.mainFrame() orelse return; - const key_str = if (params.key.len > 0) params.key else params.text orelse ""; + // Chrome types text only for an event carrying it: a keyDown with `text` + // (Puppeteer, Playwright) or a `char` (chromedp, after a text-less keyDown). + const text: ?[]const u8 = if (params.text.len == 0) null else params.text; const KeyboardEvent = @import("../../../browser/webapi/event/KeyboardEvent.zig"); - const keyboard_event = try KeyboardEvent.initTrusted(switch (params.type) { - .keyDown => comptime .wrap("keydown"), - .keyUp => comptime .wrap("keyup"), - .char => comptime .wrap("keypress"), - .rawKeyDown => unreachable, - }, .{ - .key = key_str, + const opts: KeyboardEvent.Options = .{ + .key = if (params.key.len > 0) params.key else params.text, .code = params.code, .altKey = params.modifiers & 1 == 1, .ctrlKey = params.modifiers & 2 == 2, .metaKey = params.modifiers & 4 == 4, .shiftKey = params.modifiers & 8 == 8, - }, frame); - try Frame.user_input.triggerKeyboard(frame, keyboard_event); + }; - if (params.type == .char and !keyboard_event.asEvent().getDefaultPrevented()) { - if (params.text) |text| { - if (text.len > 0) { - try Frame.user_input.insertText(frame, text); + switch (params.type) { + // rawKeyDown is a Chrome-internal event type not used for JS dispatch + .rawKeyDown => {}, + .keyDown => { + const event = try KeyboardEvent.initTrusted(comptime .wrap("keydown"), opts, frame); + const prevented = try Frame.user_input.triggerKeyDown(frame, event, text); + bc.suppress_next_char = prevented and text == null; + }, + .keyUp => try Frame.user_input.triggerKeyUp(frame, try KeyboardEvent.initTrusted(comptime .wrap("keyup"), opts, frame)), + .char => { + const t = text orelse return; + if (bc.suppress_next_char) { + bc.suppress_next_char = false; + return; } - } + const target = frame.window._document.getActiveElement() orelse return; + const event = try KeyboardEvent.initTrusted(comptime .wrap("keypress"), opts, frame); + try Frame.user_input.typeChar(frame, target, event, t); + }, } } @@ -791,18 +797,13 @@ test "cdp.input: dispatchKeyEvent caret movement keys move the text entry cursor } } -test "cdp.input: dispatchKeyEvent char text insertion" { +// chromedp's SendKeys shape: a text-less keyDown, the char with the text, keyUp. +test "cdp.input: dispatchKeyEvent text-less keyDown then char types once" { var ctx = try testing.context(); defer ctx.deinit(); - const bc = try ctx.loadBrowserContext(.{}); - const page = try bc.session.createPage(); - const frame = page.frame().?; - try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{ - .reason = .address_bar, - .kind = .{ .push = null }, - }); - try testing.waitForPage(bc); + const bc = try ctx.loadBrowserContext(.{ .url = "mcp_actions.html" }); + const frame = bc.mainFrame().?; var ls: lp.js.Local.Scope = undefined; frame.js.localScope(&ls); @@ -813,56 +814,96 @@ test "cdp.input: dispatchKeyEvent char text insertion" { \\inp.value = ''; \\inp.focus(); \\window.events = []; - \\inp.addEventListener('keypress', (e) => window.events.push(e.key)); - \\inp.addEventListener('input', (e) => window.events.push(inp.value)); + \\for (const t of ['keydown', 'keypress', 'input', 'keyup']) { + \\ inp.addEventListener(t, (e) => window.events.push(t + ':' + (e.key ?? inp.value))); + \\} , null); - // Text is inserted and dispatches keypress and input. - try ctx.processMessage(.{ - .id = 1, - .method = "Input.dispatchKeyEvent", - .params = .{ .type = "char", .key = "h", .text = "h" }, - }); + try ctx.processMessage(.{ .id = 1, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "h", .code = "KeyH" } }); try ctx.expectSentResult(null, .{ .id = 1 }); + try testing.expect((try ls.local.compileAndRun("inp.value === '' && window.events.join(',') === 'keydown:h'", null)).isTrue()); + + try ctx.processMessage(.{ .id = 2, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "h", .text = "h" } }); + try ctx.expectSentResult(null, .{ .id = 2 }); + try ctx.processMessage(.{ .id = 3, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyUp", .key = "h", .code = "KeyH" } }); + try ctx.expectSentResult(null, .{ .id = 3 }); try testing.expect((try ls.local.compileAndRun( - \\inp.value === 'h' && window.events.join(',') === 'h,h' + \\inp.value === 'h' && window.events.join(',') === 'keydown:h,keypress:h,input:h,keyup:h' , null)).isTrue()); - // A char event without a text field (e.g. Enter) must not insert its key name. - try ctx.processMessage(.{ - .id = 2, - .method = "Input.dispatchKeyEvent", - .params = .{ .type = "char", .key = "Enter" }, - }); - try ctx.expectSentResult(null, .{ .id = 2 }); - try testing.expect((try ls.local.compileAndRun("inp.value === 'h'", null)).isTrue()); + // A char without text has nothing to type. + try ctx.processMessage(.{ .id = 4, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "Enter" } }); + try ctx.expectSentResult(null, .{ .id = 4 }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'h' && window.events.length === 4", null)).isTrue()); - // With preventDefault on keypress, no character is inserted. + // A keyDown with text but no key (Puppeteer's shape for some keys) types it. + try ctx.processMessage(.{ .id = 5, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .text = "z" } }); + try ctx.expectSentResult(null, .{ .id = 5 }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'hz'", null)).isTrue()); + + // Enter's char is "\r": a line break in a