diff --git a/src/browser/Viewport.zig b/src/browser/Viewport.zig index 06c220975..959021ebc 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, // CSS px to device px // window.screen dimensions; null means the same as the viewport. screen_width: ?u32 = null, screen_height: ?u32 = null, diff --git a/src/browser/actions.zig b/src/browser/actions.zig index d40df49a9..c54518c0c 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -78,11 +78,10 @@ pub fn hover(node: *DOMNode, frame: *Frame) !void { } pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void { - const target_el: ?*Element = if (node) |n| + const target: *Element = if (node) |n| (n.is(Element) orelse return error.InvalidNodeType) else - null; - const target = if (target_el) |el| el.asEventTarget() else frame.document.asNode().asEventTarget(); + Frame.user_input.focusedElement(frame) orelse return error.ActionFailed; const canonical = canonicalKey(key); const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{ @@ -92,19 +91,11 @@ 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| { + _ = Frame.user_input.pressKey(frame, target, keydown_event, Frame.user_input.textForKey(keydown_event)) catch |err| { lp.log.err(.app, "press keydown failed", .{ .err = err }); return error.ActionFailed; }; - if (std.mem.eql(u8, canonical, "Enter") and !prevented) { - if (target_el) |el| implicitFormSubmit(el, frame) catch |err| { - // Don't skip keyup on a submit-listener throw — UIs that gate - // state on keyup (e.g. clearing a "submitting" flag) would hang. - lp.log.warn(.app, "implicit form submit failed", .{ .err = err }); - }; - } - const keyup_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keyup"), .{ .bubbles = true, .cancelable = true, @@ -112,7 +103,7 @@ pub fn press(node: ?*DOMNode, key: []const u8, frame: *Frame) !void { .key = canonical, }, frame); - frame._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| { + frame._event_manager.dispatch(target.asEventTarget(), keyup_event.asEvent()) catch |err| { lp.log.err(.app, "press keyup failed", .{ .err = err }); return error.ActionFailed; }; @@ -145,28 +136,6 @@ fn canonicalKey(key: []const u8) []const u8 { return key; } -fn implicitFormSubmit(el: *Element, frame: *Frame) !void { - const Input = Element.Html.Input; - const Button = Element.Html.Button; - - if (el.is(Input)) |input| { - const form = input.getForm(frame) orelse return; - const submitter: ?*Element = switch (input._input_type) { - .submit, .image => el, - // Non-text controls (checkbox, radio, file, ...) don't trigger - // implicit submission; only the text-like family does. - .text, .password, .email, .url, .tel, .search, .number, .date, .time, .@"datetime-local", .month, .week => null, - else => return, - }; - return form.requestSubmit(submitter, frame); - } - if (el.is(Button)) |button| { - if (!std.ascii.eqlIgnoreCase(button.getType(), "submit")) return; - const form = button.getForm(frame) orelse return; - return form.requestSubmit(el, frame); - } -} - pub fn selectOption(node: *DOMNode, value: []const u8, frame: *Frame) !void { const el = node.is(Element) orelse return error.InvalidNodeType; const select = el.is(Element.Html.Select) orelse return error.InvalidNodeType; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index bd39e063e..961a47ff0 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -798,26 +798,109 @@ 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); +pub fn triggerKeyDown(frame: *Frame, keydown: *KeyboardEvent, text: ?[]const u8) !bool { + const element = focusedElement(frame) orelse { + keydown.asEvent().deinit(frame.page); + return false; + }; + return pressKey(frame, element, keydown, text); +} + +pub fn triggerKeyUp(frame: *Frame, keyup: *KeyboardEvent) !void { + const element = focusedElement(frame) orelse { + keyup.asEvent().deinit(frame.page); return; }; + try frame._event_manager.dispatch(element.asEventTarget(), keyup.asEvent()); +} +/// Where a key event goes: `document.activeElement`, so with nothing focused +/// a key still fires on and Tab's focus navigation can run. +pub fn focusedElement(frame: *Frame) ?*Element { + return frame.window._document.getActiveElement(); +} + +/// 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 { if (comptime lp.IS_DEBUG) { log.debug(.frame, "frame keydown", .{ .url = frame.url, - .node = element, - .key = keyboard_event._key, + .node = target, + .key = keydown._key, .type = frame._type, }); } - try frame._event_manager.dispatch(element.asEventTarget(), event); + const event = keydown.asEvent(); + const t = text orelse return frame._event_manager.dispatchCancelable(target.asEventTarget(), event); + + // 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; + } + // 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 (is_enter and isButton(target)) { + return dispatchKeyboardClick(frame, target); + } + + 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); + } +} + +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 { @@ -833,55 +916,29 @@ 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 (enterFollowsLink(element)) { + 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) { + if (!input.acceptsTextEntry()) { return; } - return editKey(frame, keyboard_event, input, key); } 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()) { @@ -892,16 +949,18 @@ 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); } + } +} + +fn insertInto(frame: *Frame, ctl: anytype, text: []const u8) !void { + if (!ctl.acceptsTextEntry()) { return; } - - if (key.isPrintable()) { - if (try allowEdit(frame, event, ctl.asElement(), key.asString(), key.asString(), "insertText")) { - try ctl.innerInsert(key.asString(), frame); - } + if (try allowEdit(frame, ctl.asElement(), text, text, "insertText")) { + try ctl.innerInsert(text, frame); } } @@ -924,15 +983,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, @@ -982,23 +1035,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 { @@ -1011,15 +1047,19 @@ fn dispatchKeyboardClick(frame: *Frame, element: *Element) !void { try frame._event_manager.dispatch(element.asEventTarget(), event.asEvent()); } -// elements where enter on a keydown should dispatch a click-click event -fn enterActivates(element: *Element) bool { +// Which elements act on which key, and at which step: a link follows Enter on +// the keydown, a button clicks on Enter's keypress (typeChar) and on Space's +// keyup, as do checkboxes and radios for Space. +fn enterFollowsLink(element: *Element) bool { + const html_element = element.is(Element.Html) orelse return false; + return html_element._type == .anchor and element.getAttributeInterned("href") != null; +} + +fn isButton(element: *Element) bool { const html_element = element.is(Element.Html) orelse return false; if (html_element._type == .button) { return true; } - if (html_element._type == .anchor) { - return element.getAttributeInterned("href") != null; - } if (element.is(Element.Html.Input)) |input| { return switch (input._input_type) { .button, .submit, .reset, .image => true, @@ -1029,19 +1069,12 @@ fn enterActivates(element: *Element) bool { return false; } -// elements where space on a keyup should dispatch a click-click event fn spaceActivates(element: *Element) bool { - const html_element = element.is(Element.Html) orelse return false; - if (html_element._type == .button) { + if (isButton(element)) { return true; } - if (element.is(Element.Html.Input)) |input| { - return switch (input._input_type) { - .button, .submit, .reset, .image, .checkbox, .radio => true, - else => false, - }; - } - return false; + const input = element.is(Element.Html.Input) orelse return false; + return input._input_type == .checkbox or input._input_type == .radio; } // Sequential focus navigation: move `document.activeElement` to the next (Tab) @@ -1125,21 +1158,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 ea9a8e099..fe4b267b7 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -409,15 +409,7 @@ fn performKeySource(source: js.Object, frame: *Frame) !void { // longer does. setModifier(&frame.page.input_modifiers, key, is_down); - // 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(is_down, key, frame); } } @@ -502,7 +494,10 @@ fn setModifier(modifiers: *Modifiers, key: []const u8, pressed: bool) void { } } -fn dispatchKey(target: *EventTarget, typ: lp.String, key: []const u8, frame: *Frame) void { +// Key actions have no explicit target; they go to the focused element, +// resolved per action since a key's default action can move focus. +fn dispatchKey(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, @@ -517,7 +512,12 @@ 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()); + (if (is_down) + Frame.user_input.triggerKeyDown(frame, event, Frame.user_input.textForKey(event)) + else + Frame.user_input.triggerKeyUp(frame, 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/Window.zig b/src/browser/webapi/Window.zig index 3953e7bda..11eabdf57 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"); } @@ -926,6 +930,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; @@ -1230,7 +1238,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/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 2ae514394..5580069e0 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -627,6 +627,7 @@ const entry = text_entry.TextEntry(Input); pub const select = entry.select; pub const innerInsert = entry.innerInsert; +pub const acceptsTextEntry = entry.acceptsTextEntry; pub const innerDelete = entry.innerDelete; pub const moveCaret = entry.moveCaret; pub const CaretMove = entry.CaretMove; diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index b9e63f394..a4aa87934 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -140,6 +140,7 @@ const entry = text_entry.TextEntry(TextArea); pub const select = entry.select; pub const innerInsert = entry.innerInsert; +pub const acceptsTextEntry = entry.acceptsTextEntry; pub const innerDelete = entry.innerDelete; pub const moveCaret = entry.moveCaret; pub const CaretMove = entry.CaretMove; diff --git a/src/browser/webapi/element/text_entry.zig b/src/browser/webapi/element/text_entry.zig index b5b31a704..ca68facd6 100644 --- a/src/browser/webapi/element/text_entry.zig +++ b/src/browser/webapi/element/text_entry.zig @@ -29,6 +29,18 @@ const InputEvent = @import("../event/InputEvent.zig"); pub fn TextEntry(comptime T: type) type { return struct { + /// Whether typing edits the control's value. Checkbox and radio share + /// Input's value machinery but no text goes into them. + pub fn acceptsTextEntry(self: *const T) bool { + if (!@hasField(T, "_input_type")) { + return true; + } + return switch (self._input_type) { + .checkbox, .radio => false, + else => true, + }; + } + pub fn select(self: *T, frame: *Frame) !void { const len: u32 = @intCast(self.getValue().len); try setSelectionRange(self, 0, len, null, frame); 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 8b61c275c..f4565cd3e 100644 --- a/src/server/bidi/input.zig +++ b/src/server/bidi/input.zig @@ -642,7 +642,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/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 8fd444b41..492d604de 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 = "", // Many optional parameters are not implemented yet, see documentation url. const Type = enum { @@ -61,22 +62,41 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void { const bc = cmd.browser_context orelse return; const frame = bc.mainFrame() orelse return; + // 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 = params.key, + 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); - // result already sent + }; + + switch (params.type) { + .rawKeyDown => unreachable, + .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 => { + const event = try KeyboardEvent.initTrusted(comptime .wrap("keyup"), opts, frame); + try Frame.user_input.triggerKeyUp(frame, event); + }, + .char => { + const t = text orelse return; + if (bc.suppress_next_char) { + bc.suppress_next_char = false; + return; + } + const target = Frame.user_input.focusedElement(frame) orelse return; + const event = try KeyboardEvent.initTrusted(comptime .wrap("keypress"), opts, frame); + try Frame.user_input.typeChar(frame, target, event, t); + }, + } } // https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent @@ -1270,3 +1290,204 @@ test "cdp.input: dispatchKeyEvent caret movement keys move the text entry cursor try testing.expectEqualSlices(u8, step.expect, got); } } + +// 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(.{ .url = "mcp_actions.html" }); + const frame = bc.mainFrame().?; + + 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 = []; + \\for (const t of ['keydown', 'keypress', 'input', 'keyup']) { + \\ inp.addEventListener(t, (e) => window.events.push(t + ':' + (e.key ?? inp.value))); + \\} + , null); + + 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(',') === 'keydown:h,keypress:h,input:h,keyup: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()); + + // 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