Merge pull request #3542 from lightpanda-io/cdp-device-pixel-ratio-and-char-input

cdp: window.devicePixelRatio from viewport scale and Input char text insertion
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-18 06:29:58 +08:00
commit 798d054699
13 files changed
+411 -163

No files matched your search

+1 -1
View File
@@ -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,
+4 -35
View File
@@ -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;
+127 -99
View File
@@ -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 <body> (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 <body> 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);
}
}
+11 -11
View File
@@ -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 {
+9 -1
View File
@@ -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, .{});
@@ -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;
@@ -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;
+12
View File
@@ -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);
+1 -1
View File
@@ -289,7 +289,7 @@ const KeyboardEventOptions = struct {
shiftKey: bool = false,
};
const Options = Event.inheritOptions(
pub const Options = Event.inheritOptions(
KeyboardEvent,
KeyboardEventOptions,
);
+5 -1
View File
@@ -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 {
+4
View File
@@ -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,
+4 -4
View File
@@ -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());
}
+231 -10
View File
@@ -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 <textarea>, never a literal "\r".
_ = try ls.local.compileAndRun(
\\const ta = document.createElement('textarea');
\\document.body.appendChild(ta);
\\ta.value = 'one';
\\ta.focus();
, null);
try ctx.processMessage(.{ .id = 6, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "Enter", .code = "Enter" } });
try ctx.expectSentResult(null, .{ .id = 6 });
try ctx.processMessage(.{ .id = 7, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "Enter", .text = "\r" } });
try ctx.expectSentResult(null, .{ .id = 7 });
try ctx.processMessage(.{ .id = 8, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyUp", .key = "Enter", .code = "Enter" } });
try ctx.expectSentResult(null, .{ .id = 8 });
try testing.expect((try ls.local.compileAndRun("ta.value === 'one\\n'", null)).isTrue());
}
test "cdp.input: dispatchKeyEvent char honors keypress and beforeinput vetoes" {
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();
// keypress fires before beforeinput, so it sees the digits the latter vetoes.
_ = try ls.local.compileAndRun(
\\const inp = document.getElementById('inp');
\\inp.value = '';
\\inp.focus();
\\window.keypresses = [];
\\inp.addEventListener('keypress', (e) => {
\\ window.keypresses.push(e.key);
\\ if (e.key === 'x') e.preventDefault();
\\});
\\inp.addEventListener('beforeinput', (e) => {
\\ if (/[0-9]/.test(e.data)) e.preventDefault();
\\});
, null);
var id: u32 = 1;
for ("a1x2b") |c| {
const key: []const u8 = &.{c};
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = key } });
try ctx.expectSentResult(null, .{ .id = id });
id += 1;
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = key, .text = key } });
try ctx.expectSentResult(null, .{ .id = id });
id += 1;
}
try testing.expect((try ls.local.compileAndRun(
\\inp.value === 'ab' && window.keypresses.join('') === 'a1x2b'
, null)).isTrue());
}
test "cdp.input: dispatchKeyEvent cancelled keyDown suppresses its char" {
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.keypresses = [];
\\inp.addEventListener('keydown', (e) => { if (e.key === 'x') e.preventDefault(); });
\\inp.addEventListener('keypress', (e) => window.keypresses.push(e.key));
, null);
// The char in the same message (keyDown with text) and in the next one
// (chromedp) are both dropped.
try ctx.processMessage(.{ .id = 1, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "x", .text = "x" } });
try ctx.expectSentResult(null, .{ .id = 1 });
try ctx.processMessage(.{ .id = 2, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "x" } });
try ctx.expectSentResult(null, .{ .id = 2 });
try ctx.processMessage(.{ .id = 3, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "x", .text = "x" } });
try ctx.expectSentResult(null, .{ .id = 3 });
try testing.expect((try ls.local.compileAndRun("inp.value === '' && window.keypresses.length === 0", null)).isTrue());
try ctx.processMessage(.{ .id = 4, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "y" } });
try ctx.expectSentResult(null, .{ .id = 4 });
try ctx.processMessage(.{ .id = 5, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "y", .text = "y" } });
try ctx.expectSentResult(null, .{ .id = 5 });
try testing.expect((try ls.local.compileAndRun("inp.value === 'y' && window.keypresses.join('') === 'y'", null)).isTrue());
}
// Enter, as chromedp sends it: keyDown, a "\r" char, keyUp. Buttons click
// after their keypress; the form submits at most once.
test "cdp.input: dispatchKeyEvent Enter clicks buttons and submits 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(
\\document.body.insertAdjacentHTML('beforeend', '<form id=f>' +
\\ '<input id=text><input id=check type=checkbox><input id=submit type=submit>' +
\\ '<button id=button>go</button><input id=ibutton type=button><input id=reset type=reset></form>');
\\const form = document.getElementById('f');
\\form.addEventListener('submit', (e) => {
\\ e.preventDefault();
\\ window.events.push('submit');
\\});
\\// Only the focused control's keypress and click are recorded.
\\for (const t of ['keypress', 'click']) {
\\ form.addEventListener(t, (e) => {
\\ if (e.target === document.activeElement) window.events.push(t);
\\ }, true);
\\}
\\window.arm = (id) => {
\\ window.events = [];
\\ document.getElementById(id).focus();
\\};
, null);
const cases = [_]struct { id: []const u8, expect: []const u8 }{
.{ .id = "text", .expect = "keypress submit" },
.{ .id = "check", .expect = "keypress submit" },
.{ .id = "submit", .expect = "keypress click submit" },
.{ .id = "button", .expect = "keypress click submit" },
.{ .id = "ibutton", .expect = "keypress click" },
.{ .id = "reset", .expect = "keypress click" },
};
var id: u32 = 1;
for (cases) |c| {
var buf: [32]u8 = undefined;
_ = try ls.local.compileAndRun(try std.fmt.bufPrint(&buf, "arm('{s}')", .{c.id}), null);
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyDown", .key = "Enter", .code = "Enter" } });
try ctx.expectSentResult(null, .{ .id = id });
id += 1;
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "char", .key = "Enter", .text = "\r" } });
try ctx.expectSentResult(null, .{ .id = id });
id += 1;
try ctx.processMessage(.{ .id = id, .method = "Input.dispatchKeyEvent", .params = .{ .type = "keyUp", .key = "Enter", .code = "Enter" } });
try ctx.expectSentResult(null, .{ .id = id });
id += 1;
const got = try (try ls.local.compileAndRun("window.events.join(' ')", null)).toStringSlice();
try testing.expectEqualSlices(u8, c.expect, got);
}
}