diff --git a/src/browser/actions.zig b/src/browser/actions.zig index c54518c0c..e7c3384e8 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -26,7 +26,7 @@ const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const Frame = @import("Frame.zig"); const Session = @import("Session.zig"); -fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { +pub fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame.page); frame._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| { lp.log.err(.app, "dispatch input event failed", .{ .err = err }); diff --git a/src/browser/tests/webdriver/elements.html b/src/browser/tests/webdriver/elements.html index e3b94c389..1a27b5c4e 100644 --- a/src/browser/tests/webdriver/elements.html +++ b/src/browser/tests/webdriver/elements.html @@ -13,3 +13,4 @@ + diff --git a/src/browser/tests/webdriver/input.html b/src/browser/tests/webdriver/input.html new file mode 100644 index 000000000..dcf516b56 --- /dev/null +++ b/src/browser/tests/webdriver/input.html @@ -0,0 +1,26 @@ + +webdriver input + + + + + + +
not focusable
+ + +
+ +
+ diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 609744fd6..42a5afce0 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -79,7 +79,7 @@ pub fn getSelected(self: *const Option) bool { return self._selected; } -fn setSelected(self: *Option, selected: bool, frame: *Frame) !void { +pub fn setSelected(self: *Option, selected: bool, frame: *Frame) !void { self.setSelectedness(selected); frame.domChanged(); } @@ -96,7 +96,7 @@ fn setSelectedness(self: *Option, selected: bool) void { } /// The toggles the option, any other selects it + const selected = if (select.getMultiple()) option.getSelected() == false else true; + if (option.getSelected() == selected) { + break :blk; + } + try option.setSelected(selected, frame); + try lp.actions.dispatchInputAndChangeEvents(select_element, frame); + } else if (element.isDisabled() == false) { + Frame.user_input.updateHoverTarget(frame, element, .{ .with_pointer = true }); + try Frame.user_input.triggerClick(frame, element, .{}); + } + return browsing_context.answerAfterNavigation(cmd, ctx, frame); +} + +// POST /session/{id}/element/{id}/clear +fn elementClear(cmd: *BiDi.Command, p: ElementId) !void { + const frame = (try currentFrame(cmd)) orelse return; + const element = (try requireElement(cmd, p.id, frame)) orelse return; + + if (isEditable(element) == false) { + return cmd.sendError("invalid element state", "element is not editable"); + } + if ((try requireInteractable(cmd, element, frame)) == false) { + return; + } + + if (element.is(Html.Input)) |input_element| { + try clearControl(input_element, frame); + } else if (element.is(Html.TextArea)) |textarea| { + try clearControl(textarea, frame); + } else { + try element.asNode().setTextContent("", frame); + } + return cmd.sendDone(); +} + +fn clearControl(ctl: anytype, frame: *Frame) !void { + if (ctl.getValue().len == 0) { + return; + } + + const element = ctl.asElement(); + // give it focus + try element.focus(frame); + // clear it + try ctl.setValue("", frame); + try lp.actions.dispatchInputAndChangeEvents(element, frame); + // remove focus + try element.blur(frame); +} + +fn isEditable(element: *Node.Element) bool { + if (element.is(Html.Input)) |input_element| { + switch (input_element._input_type) { + .hidden, .checkbox, .radio, .submit, .reset, .button, .image => return false, + else => {}, + } + } else if (element.is(Html.TextArea) == null) { + return element.isEditingHost(); + } + return element.isDisabled() == false and element.getAttributeSafe(comptime .wrap("readonly")) == null; +} + +// POST /session/{id}/element/{id}/value. +pub const SendKeys = struct { + id: []const u8, + text: []const u8, +}; +fn elementSendKeys(cmd: *BiDi.Command, p: SendKeys) !void { + const ctx = (try currentContext(cmd)) orelse return; + const frame = (try currentFrame(cmd)) orelse return; + const element = (try requireElement(cmd, p.id, frame)) orelse return; + + if (element.is(Html.Input)) |input_element| { + if (input_element._input_type == .file) { + return cmd.sendError("unsupported operation", "file upload is not supported"); + } + } + element.scrollIntoView(null, frame); + + const document = frame.window._document; + if (document.getActiveElement() != element) { + try element.focus(frame); + if (document.getActiveElement() != element) { + return cmd.sendError("element not interactable", "element can't be focused"); + } + if (element.is(Html.Input)) |input_element| { + try caretToEnd(input_element, frame); + } else if (element.is(Html.TextArea)) |textarea| { + try caretToEnd(textarea, frame); + } + } + + input.typeText(frame, p.text) catch |err| switch (err) { + error.InvalidUtf8 => return cmd.sendError("invalid argument", "text is not valid UTF-8"), + else => return err, + }; + 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)) { + return true; + } + try cmd.sendError("element not interactable", "element is not displayed"); + return false; +} + // POST /session/{id}/execute/sync, POST /session/{id}/execute/async fn executeScript(cmd: *BiDi.Command, p: execute.Script, mode: execute.Mode) !void { _ = (try currentContext(cmd)) orelse return; @@ -638,27 +793,26 @@ pub const ReferenceError = error{ StaleElement, }; -// A reference's element, or why it doesn't resolve. Shared with execute.zig, -// which resolves the references a script is called with. -pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8) ReferenceError!*Node.Element { - // ids are dropped on navigation, so a stale one is unknown by then +pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8, frame: *const Frame) ReferenceError!*Node.Element { + // ids are dropped on navigation const node = remote_value.nodeFromSharedId(registry, .{ .string = id }) catch return error.NoSuchElement; const element = node.is(Node.Element) orelse return error.NoSuchElement; - if (node.isConnected() == false) { + if (node.isConnected() == false or node.getDocument(frame) != frame.window._document) { + // disconnected or one from a document different than the current one return error.StaleElement; } return element; } // Answers the command and returns null when the reference doesn't resolve. -fn requireElement(cmd: *BiDi.Command, id: []const u8) !?*Node.Element { - return elementFromReference(&cmd.bidi.node_registry, id) catch |err| switch (err) { +fn requireElement(cmd: *BiDi.Command, id: []const u8, frame: *const Frame) !?*Node.Element { + return elementFromReference(&cmd.bidi.node_registry, id, frame) catch |err| switch (err) { error.NoSuchElement => { try cmd.sendError("no such element", "unknown element reference"); return null; }, error.StaleElement => { - try cmd.sendError("stale element reference", "element is no longer attached to the document"); + try cmd.sendError("stale element reference", "element is not in the current document"); return null; }, }; @@ -772,6 +926,16 @@ test "bidi.http_command: parse" { try testing.expectEqual(50, (try parse(arena, .POST, "/timeouts", "{\"script\":50}")).set_timeouts.script.ms); } + { + // Selenium sends the legacy `value` array next to `text` + const command = try parse(arena, .POST, "/element/7/value", "{\"text\":\"ab\",\"value\":[\"a\",\"b\"]}"); + try testing.expectEqual("7", command.element_send_keys.id); + try testing.expectEqual("ab", command.element_send_keys.text); + } + try testing.expectEqual("7", (try parse(arena, .POST, "/element/7/click", "{}")).element_click.id); + try testing.expectEqual("7", (try parse(arena, .POST, "/element/7/clear", "{}")).element_clear.id); + try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/element/7/value", "{}")); + try testing.expect(try parse(arena, .GET, "/timeouts", "") == .get_timeouts); try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/execute/sync", "{}")); diff --git a/src/server/bidi/input.zig b/src/server/bidi/input.zig index f4565cd3e..da63d1332 100644 --- a/src/server/bidi/input.zig +++ b/src/server/bidi/input.zig @@ -609,6 +609,56 @@ fn dispatch(bidi: *BiDi, frame: *Frame, source: *Source, action: *const Action) } } +// WebDriver's Element Send Keys, into whatever has focus. Every code point is a +// press and release, except a modifier, which stays down until it's typed +// again, U+E000 releases everything, or the text ends. It's a keyboard of its +// own: the actions' held keys don't apply. +pub fn typeText(frame: *Frame, text: []const u8) !void { + const view = std.unicode.Utf8View.init(text) catch return error.InvalidUtf8; + + var modifiers: Modifiers = .{}; + // there are 8 modifier code points, and each is held at most once + var held_buf: [8]u21 = undefined; + var held: std.ArrayList(u21) = .initBuffer(&held_buf); + + var it = view.iterator(); + while (it.nextCodepoint()) |c| { + // like chromedriver, a newline is the Enter key + const cp: u21 = if (c == '\n' or c == '\r') 0xE006 else c; + if (cp == 0xE000) { + try releaseHeld(frame, &held, &modifiers); + continue; + } + + const info = keyInfo(cp, modifiers.shift); + if (info.modifier == null) { + try dispatchKey(frame, "keydown", &info, &modifiers); + try dispatchKey(frame, "keyup", &info, &modifiers); + continue; + } + + if (std.mem.indexOfScalar(u21, held.items, cp)) |i| { + _ = held.orderedRemove(i); + setModifier(&modifiers, info.modifier, false); + try dispatchKey(frame, "keyup", &info, &modifiers); + } else { + held.appendAssumeCapacity(cp); + setModifier(&modifiers, info.modifier, true); + try dispatchKey(frame, "keydown", &info, &modifiers); + } + } + try releaseHeld(frame, &held, &modifiers); +} + +// in reverse press order +fn releaseHeld(frame: *Frame, held: *std.ArrayList(u21), modifiers: *Modifiers) !void { + while (held.pop()) |cp| { + const info = keyInfo(cp, modifiers.shift); + setModifier(modifiers, info.modifier, false); + try dispatchKey(frame, "keyup", &info, modifiers); + } +} + const Point = struct { x: f64, y: f64 }; fn resolveOrigin(bidi: *BiDi, frame: *Frame, source: *const Source, origin: Origin, x: f64, y: f64) !Point {