From 54b61fbc1e8fbd912ba256f1e78c95ed0f714f94 Mon Sep 17 00:00:00 2001 From: Onni Hakala Date: Sun, 27 Sep 2026 13:39:15 +0300 Subject: [PATCH 1/2] webapi: input select() must not throw for email/number select() delegated to setSelectionRange(), which throws InvalidStateError for input types where the selection API doesn't apply (email, number, date, ...). Per spec select() never throws. email and number still have selectable text, so they now keep the internal selection/caret: select() + Input.insertText replaces the value like Chrome. This is what Playwright's locator.fill() does, which failed with 'locator.fill: undefined' on type=email. --- src/browser/tests/element/html/input.html | 21 ++++++++++ src/browser/webapi/element/html/Input.zig | 3 +- src/browser/webapi/element/text_entry.zig | 47 +++++++++++++++++++---- src/server/cdp/domains/input.zig | 31 +++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index 493afa689..a56d553ca 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -590,3 +590,24 @@ testing.expectEqual(null, check.selectionEnd); } + + diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 5580069e0..cb955f367 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -173,7 +173,7 @@ pub fn setValue(self: *Input, value: []const u8, frame: *Frame) !void { } // move the text entry cursor position to the end of the text control - if (changed and self.selectionAvailable()) { + if (changed and self.tracksSelection()) { self._selection_start = @intCast(sanitized.len); self._selection_end = @intCast(sanitized.len); self._selection_direction = .none; @@ -628,6 +628,7 @@ const entry = text_entry.TextEntry(Input); pub const select = entry.select; pub const innerInsert = entry.innerInsert; pub const acceptsTextEntry = entry.acceptsTextEntry; +pub const tracksSelection = entry.tracksSelection; 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 ca68facd6..f1458a391 100644 --- a/src/browser/webapi/element/text_entry.zig +++ b/src/browser/webapi/element/text_entry.zig @@ -41,9 +41,30 @@ pub fn TextEntry(comptime T: type) type { }; } + /// Whether the control keeps a text selection/caret internally. Wider + /// than selectionAvailable(): email and number have selectable text + /// (and a caret) even though the selection APIs don't apply to them. + pub fn tracksSelection(self: *const T) bool { + if (self.selectionAvailable()) { + return true; + } + if (!@hasField(T, "_input_type")) { + return false; + } + return switch (self._input_type) { + .email, .number => true, + else => false, + }; + } + + // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-textarea/input-select + // Never throws: without selectable text it just returns. pub fn select(self: *T, frame: *Frame) !void { + if (tracksSelection(self) == false) { + return; + } const len: u32 = @intCast(self.getValue().len); - try setSelectionRange(self, 0, len, null, frame); + try applySelectionRange(self, 0, len, .none, frame); const event = try Event.init("select", .{ .bubbles = true }, frame.page); try frame._event_manager.dispatch(self.asElement().asEventTarget(), event); } @@ -82,12 +103,12 @@ pub fn TextEntry(comptime T: type) type { }, .none => { // nothing selected, insert at the caret. Controls without - // selection support keep no caret; append. + // a caret (e.g. date) append. const current_value = self.getValue(); - const caret = if (self.selectionAvailable()) @min(self._selection_start, current_value.len) else current_value.len; + const caret = if (tracksSelection(self)) @min(self._selection_start, current_value.len) else current_value.len; const new_value = try std.mem.concat(arena, u8, &.{ current_value[0..caret], str, current_value[caret..] }); try self.setUserValue(new_value, frame); - if (self.selectionAvailable()) { + if (tracksSelection(self)) { // the sanitized value can be shorter than what was inserted const new_pos: u32 = @intCast(@min(caret + str.len, self.getValue().len)); self._selection_start = new_pos; @@ -117,8 +138,8 @@ pub fn TextEntry(comptime T: type) type { start, end = range; }, .none => { - // Controls without selection support keep no caret; edit at the end. - const caret = if (self.selectionAvailable()) @min(self._selection_start, value_len) else value_len; + // Controls without a caret (e.g. date) edit at the end. + const caret = if (tracksSelection(self)) @min(self._selection_start, value_len) else value_len; if (forward) { if (caret >= value_len) { return; @@ -158,7 +179,7 @@ pub fn TextEntry(comptime T: type) type { // ArrowLeft/ArrowRight on a non-collapsed selection collapses it to // the corresponding edge without moving, like Chrome. pub fn moveCaret(self: *T, move: CaretMove, extend: bool, frame: *Frame) !void { - if (self.selectionAvailable() == false) { + if (tracksSelection(self) == false) { return; } const value = self.getValue(); @@ -266,6 +287,16 @@ pub fn TextEntry(comptime T: type) type { } else break :blk .none; }; + return applySelectionRange(self, selection_start, selection_end, direction, frame); + } + + fn applySelectionRange( + self: *T, + selection_start: u32, + selection_end: u32, + direction: Selection.SelectionDirection, + frame: *Frame, + ) !void { const len_u32: u32 = @intCast(self.getValue().len); var start: u32 = if (selection_start > len_u32) len_u32 else selection_start; const end: u32 = if (selection_end > len_u32) len_u32 else selection_end; @@ -285,7 +316,7 @@ pub fn TextEntry(comptime T: type) type { const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none }; fn howSelected(self: *const T) HowSelected { - if (self.selectionAvailable() == false) { + if (tracksSelection(self) == false) { return .none; } const value_len: u32 = @intCast(self.getValue().len); diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 5dcdfb190..e2d3e3ccb 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -200,6 +200,37 @@ test "cdp.input: insertText is a user edit for tooLong" { try testing.expect((try ls.local.compileAndRun("inp.validity.tooLong === false", null)).isTrue()); } +test "cdp.input: insertText replaces select()ed value of email and number inputs" { + 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(); + + // What Playwright's fill() does: select() then Input.insertText. + _ = try ls.local.compileAndRun( + \\const inp = document.getElementById('inp'); + \\inp.type = 'email'; + \\inp.value = 'old@example.com'; + \\inp.select(); + \\inp.focus(); + , null); + try ctx.processMessage(.{ .id = 1, .method = "Input.insertText", .params = .{ .text = "new@example.com" } }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'new@example.com'", null)).isTrue()); + + _ = try ls.local.compileAndRun("inp.type = 'number'; inp.value = '12'; inp.select();", null); + try ctx.processMessage(.{ .id = 2, .method = "Input.insertText", .params = .{ .text = "345" } }); + try testing.expect((try ls.local.compileAndRun("inp.value === '345'", null)).isTrue()); +} + test "cdp.input: dispatchMouseEvent mouseMoved fires hover events" { var ctx = try testing.context(); defer ctx.deinit(); From a844e38826cd33568952c4138fc50e00fe71bdd0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 28 Sep 2026 09:49:13 +0800 Subject: [PATCH 2/2] preserve correctsendKeys() behavior for webdriver --- src/browser/tests/element/html/input.html | 8 +++++++ src/browser/webapi/element/html/Input.zig | 3 ++- src/browser/webapi/element/html/TextArea.zig | 1 + src/browser/webapi/element/text_entry.zig | 22 +++++++++++++++----- src/server/bidi/http_command.zig | 12 ++--------- src/server/cdp/domains/input.zig | 5 +++++ 6 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html index a56d553ca..b3b70dc95 100644 --- a/src/browser/tests/element/html/input.html +++ b/src/browser/tests/element/html/input.html @@ -609,5 +609,13 @@ email.select(); testing.expectEqual(1, selects); testing.expectError('InvalidStateError', () => email.setSelectionRange(0, 1)); + + const date = document.createElement('input'); + date.type = 'date'; + date.value = '2026-09-28'; + let date_selects = 0; + date.addEventListener('select', () => date_selects++); + date.select(); + testing.expectEqual(0, date_selects); } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index cb955f367..418848f5b 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -628,9 +628,10 @@ const entry = text_entry.TextEntry(Input); pub const select = entry.select; pub const innerInsert = entry.innerInsert; pub const acceptsTextEntry = entry.acceptsTextEntry; -pub const tracksSelection = entry.tracksSelection; +const tracksSelection = entry.tracksSelection; pub const innerDelete = entry.innerDelete; pub const moveCaret = entry.moveCaret; +pub const caretToEnd = entry.caretToEnd; pub const CaretMove = entry.CaretMove; pub const getSelectionDirection = entry.getSelectionDirection; pub const setSelectionStart = entry.setSelectionStart; diff --git a/src/browser/webapi/element/html/TextArea.zig b/src/browser/webapi/element/html/TextArea.zig index a4aa87934..42431e682 100644 --- a/src/browser/webapi/element/html/TextArea.zig +++ b/src/browser/webapi/element/html/TextArea.zig @@ -143,6 +143,7 @@ pub const innerInsert = entry.innerInsert; pub const acceptsTextEntry = entry.acceptsTextEntry; pub const innerDelete = entry.innerDelete; pub const moveCaret = entry.moveCaret; +pub const caretToEnd = entry.caretToEnd; pub const CaretMove = entry.CaretMove; pub const getSelectionDirection = entry.getSelectionDirection; pub const setSelectionStart = entry.setSelectionStart; diff --git a/src/browser/webapi/element/text_entry.zig b/src/browser/webapi/element/text_entry.zig index f1458a391..72f466880 100644 --- a/src/browser/webapi/element/text_entry.zig +++ b/src/browser/webapi/element/text_entry.zig @@ -77,8 +77,10 @@ pub fn TextEntry(comptime T: type) type { // fully selected, replace the content. const new_value = try arena.dupe(u8, str); try self.setUserValue(new_value, frame); - self._selection_start = @intCast(new_value.len); - self._selection_end = @intCast(new_value.len); + // the sanitized value can be shorter than what was inserted + const new_len: u32 = @intCast(self.getValue().len); + self._selection_start = new_len; + self._selection_end = new_len; self._selection_direction = .none; try dispatchSelectionChangeEvent(self, frame); }, @@ -95,9 +97,9 @@ pub fn TextEntry(comptime T: type) type { ); try self.setUserValue(new_value, frame); - const new_pos = range[0] + str.len; - self._selection_start = @intCast(new_pos); - self._selection_end = @intCast(new_pos); + const new_pos: u32 = @intCast(@min(range[0] + str.len, self.getValue().len)); + self._selection_start = new_pos; + self._selection_end = new_pos; self._selection_direction = .none; try dispatchSelectionChangeEvent(self, frame); }, @@ -171,6 +173,16 @@ pub fn TextEntry(comptime T: type) type { try dispatchInputEvent(self, null, if (forward) "deleteContentForward" else "deleteContentBackward", frame); } + // Collapses the selection to the end of the value. Unlike + // setSelectionRange(), applies to email and number too. + pub fn caretToEnd(self: *T, frame: *Frame) !void { + if (tracksSelection(self) == false) { + return; + } + const len: u32 = @intCast(self.getValue().len); + try applySelectionRange(self, len, len, .none, frame); + } + pub const CaretMove = enum { backward, forward, line_start, line_end }; // Default action of the caret movement keys (ArrowLeft, ArrowRight, diff --git a/src/server/bidi/http_command.zig b/src/server/bidi/http_command.zig index 9a00fe4ae..a59e80921 100644 --- a/src/server/bidi/http_command.zig +++ b/src/server/bidi/http_command.zig @@ -689,9 +689,9 @@ fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void { return cmd.sendError("element not interactable", "element can't be focused"); } if (element.is(Html.Input)) |input_element| { - try caretToEnd(input_element, frame); + try input_element.caretToEnd(frame); } else if (element.is(Html.TextArea)) |textarea| { - try caretToEnd(textarea, frame); + try textarea.caretToEnd(frame); } } @@ -702,14 +702,6 @@ fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void { return browsing_context.answerAfterNavigation(cmd, ctx, frame); } -fn caretToEnd(ctl: anytype, frame: *Frame) !void { - if (ctl.selectionAvailable() == false) { - return; - } - const len: u32 = @intCast(ctl.getValue().len); - try ctl.setSelectionRange(len, len, null, frame); -} - fn requireInteractable(cmd: *BiDi.Command, element: *Node.Element, frame: *Frame) !bool { element.scrollIntoView(null, frame); if (element.checkVisibility(.{}, frame)) { diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index e2d3e3ccb..c590d7649 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -229,6 +229,11 @@ test "cdp.input: insertText replaces select()ed value of email and number inputs _ = try ls.local.compileAndRun("inp.type = 'number'; inp.value = '12'; inp.select();", null); try ctx.processMessage(.{ .id = 2, .method = "Input.insertText", .params = .{ .text = "345" } }); try testing.expect((try ls.local.compileAndRun("inp.value === '345'", null)).isTrue()); + + // The caret lands at the end of the sanitized value, not of the inserted text. + _ = try ls.local.compileAndRun("inp.type = 'text'; inp.value = 'ab'; inp.select();", null); + try ctx.processMessage(.{ .id = 3, .method = "Input.insertText", .params = .{ .text = "c\nd" } }); + try testing.expect((try ls.local.compileAndRun("inp.value === 'cd' && inp.selectionStart === 2", null)).isTrue()); } test "cdp.input: dispatchMouseEvent mouseMoved fires hover events" {