Merge pull request #3608 from lightpanda-io/webdriver-input

WebDriver: add input (click, clear and sendKeys)
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-24 06:17:35 +08:00
commit 6debc4dd71
9 files changed
+390 -25

No files matched your search

+1 -1
View File
@@ -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 });
@@ -13,3 +13,4 @@
<option id=opt_a value=a selected>A</option>
<option id=opt_b value=b>B</option>
</select>
<iframe id=child srcdoc="<p id=inner>inner</p>"></iframe>
+26
View File
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<title>webdriver input</title>
<button id=btn>go</button>
<button id=gone style="display: none">hidden</button>
<input id=name value="old">
<input id=ro value="fixed" readonly>
<input id=check type=checkbox>
<textarea id=area>text</textarea>
<div id=plain>not focusable</div>
<select id=pick>
<option id=opt_a value=a selected>A</option>
<option id=opt_b value=b>B</option>
</select>
<a id=next href="elements.html">next</a>
<form action="elements.html">
<input id=q name=q>
</form>
<script>
window.events = [];
for (const type of ['click', 'input', 'change', 'keydown', 'focus', 'blur']) {
document.addEventListener(type, (e) => {
const key = type === 'keydown' ? ':' + (e.shiftKey ? 'S-' : '') + e.key : '';
window.events.push(type + key + '@' + e.target.id);
}, true);
}
</script>
+2 -2
View File
@@ -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 <select> this option belongs to, directly or through an <optgroup>.
fn ownerSelect(self: *Option) ?*Select {
pub fn ownerSelect(self: *Option) ?*Select {
var node = self.asNode().parentNode();
while (node) |n| : (node = n.parentNode()) {
if (n.is(Select)) |select| return select;
+113
View File
@@ -2221,6 +2221,103 @@ test "server: HTTP element commands" {
}
}
test "server: HTTP element input" {
const session_id = try createHTTPSession("{\"capabilities\":{}}", false);
defer deleteHTTPSession(&session_id, true) catch |err| @panic(@errorName(err));
var c = try createTestClient();
defer c.deinit();
const url = "http://127.0.0.1:9582/src/browser/tests/webdriver/input.html";
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
const take_events = "var e = window.events.join(' '); window.events = []; return e;";
const btn = try findElement(&c, &session_id, "css selector", "#btn");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, btn, "/click", "{}")));
try testing.expectEqual("{\"value\":\"focus@btn click@btn\"}", try executeSync(&c, &session_id, take_events, "[]"));
{
const gone = try findElement(&c, &session_id, "css selector", "#gone");
const res = try elementPost(&c, &session_id, gone, "/click", "{}");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"element not interactable\"") != null);
}
// clear: focus, the edit's input and change, blur
const name = try findElement(&c, &session_id, "css selector", "#name");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/clear", "{}")));
try testing.expectEqual("{\"value\":\"\"}", try elementCommand(&c, &session_id, name, "/property/value"));
try testing.expectEqual("{\"value\":\"blur@btn focus@name input@name change@name blur@name\"}", try executeSync(&c, &session_id, take_events, "[]"));
// already empty: nothing happens at all
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/clear", "{}")));
try testing.expectEqual("{\"value\":\"\"}", try executeSync(&c, &session_id, take_events, "[]"));
for ([_][]const u8{ "#ro", "#check" }) |selector| {
const element = try findElement(&c, &session_id, "css selector", selector);
const res = try elementPost(&c, &session_id, element, "/clear", "{}");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"invalid element state\"") != null);
}
// a modifier stays down until it's typed again
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, name, "/value", "{\"text\":\"\\uE008a\\uE008b\"}")));
try testing.expectEqual("{\"value\":\"Ab\"}", try elementCommand(&c, &session_id, name, "/property/value"));
try testing.expectEqual(
"{\"value\":\"focus@name keydown:S-Shift@name keydown:S-A@name input@name keydown:b@name input@name\"}",
try executeSync(&c, &session_id, take_events, "[]"),
);
// focusing puts the caret at the end
{
const area = try findElement(&c, &session_id, "css selector", "#area");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, area, "/value", "{\"text\":\"!\"}")));
try testing.expectEqual("{\"value\":\"text!\"}", try elementCommand(&c, &session_id, area, "/property/value"));
}
{
const plain = try findElement(&c, &session_id, "css selector", "#plain");
const res = try elementPost(&c, &session_id, plain, "/value", "{\"text\":\"x\"}");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"element not interactable\"") != null);
}
// an option is clicked by selecting it
{
_ = try executeSync(&c, &session_id, take_events, "[]");
const opt_b = try findElement(&c, &session_id, "css selector", "#opt_b");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, opt_b, "/click", "{}")));
try testing.expectEqual("{\"value\":true}", try elementCommand(&c, &session_id, opt_b, "/selected"));
const opt_a = try findElement(&c, &session_id, "css selector", "#opt_a");
try testing.expectEqual("{\"value\":false}", try elementCommand(&c, &session_id, opt_a, "/selected"));
try testing.expectEqual("{\"value\":\"blur@area focus@pick click@opt_b input@pick change@pick\"}", try executeSync(&c, &session_id, take_events, "[]"));
}
// a click that navigates is answered once the new page has loaded
{
const next = try findElement(&c, &session_id, "css selector", "#next");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, next, "/click", "{}")));
try testing.expectEqual("{\"value\":\"webdriver elements\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/title", "")));
}
// and so is Enter in a form
{
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
const q = try findElement(&c, &session_id, "css selector", "#q");
try testing.expectEqual("{\"value\":null}", responseBody(try elementPost(&c, &session_id, q, "/value", "{\"text\":\"hi\\n\"}")));
try testing.expectEqual(
"{\"value\":\"http://127.0.0.1:9582/src/browser/tests/webdriver/elements.html?q=hi\"}",
responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")),
);
}
}
fn elementPost(c: *TestClient, session_id: *const [36]u8, id: []const u8, suffix: []const u8, body: []const u8) ![]const u8 {
const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}{s}", .{ id, suffix });
return sessionCommand(c, "POST", session_id, path, body);
}
test "server: HTTP execute script" {
const session_id = try createHTTPSession("{\"capabilities\":{}}", false);
defer deleteHTTPSession(&session_id, true) catch |err| @panic(@errorName(err));
@@ -2292,6 +2389,22 @@ test "server: HTTP execute script" {
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"no such element\"") != null);
}
// a node in another document is stale, connected or not
{
const body = try executeSync(&c, &session_id, "return document.getElementById('child').contentDocument.getElementById('inner');", "[]");
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{});
const reference = parsed.object.get("value").?.object.get(http_command.element_key).?.string;
const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}/text", .{reference});
const res = try sessionCommand(&c, "GET", &session_id, path, "");
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 404 Not Found\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"stale element reference\"") != null);
const args = try std.fmt.allocPrint(testing.arena_allocator, "[{{\"" ++ http_command.element_key ++ "\":\"{s}\"}}]", .{reference});
const arg_res = try executeRaw(&c, &session_id, "sync", "return 1;", args);
try testing.expect(std.mem.indexOf(u8, arg_res, "\"error\":\"stale element reference\"") != null);
}
// a throw fails the command; it isn't reported inside a successful result
{
const res = try executeRaw(&c, &session_id, "sync", "throw new Error('nope');", "[]");
+10
View File
@@ -268,6 +268,16 @@ fn startNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *Frame, url: [:0]co
}
}
// For commands that start a navigation, e.g. clicking a link.
pub fn answerAfterNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *const Frame) !void {
if (frame._queued_navigation == null) {
return cmd.sendDone();
}
// There was an already queued navigation, reject the previous pending command
try rejectPending(cmd.bidi, ctx, "navigation superseded");
ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .complete };
}
fn close(cmd: *BiDi.Command) !void {
const p = try cmd.params(struct {
context: []const u8,
+6 -5
View File
@@ -75,9 +75,9 @@ pub fn run(cmd: *BiDi.Command, p: Script, mode: Mode) !void {
const extra = @intFromBool(mode == .async);
const arguments = try cmd.arena.alloc(js.Value, p.args.len + extra);
for (p.args, arguments[0..p.args.len]) |argument, *js_argument| {
js_argument.* = fromJson(local, &bidi.node_registry, argument) catch |err| switch (err) {
js_argument.* = fromJson(local, &bidi.node_registry, argument, frame) catch |err| switch (err) {
error.NoSuchElement => return cmd.sendError("no such element", "unknown element reference"),
error.StaleElement => return cmd.sendError("stale element reference", "element is no longer attached to the document"),
error.StaleElement => return cmd.sendError("stale element reference", "element is not in the current document"),
error.InvalidArgument => return cmd.sendError("invalid argument", "cannot deserialize an argument"),
else => return err,
};
@@ -539,6 +539,7 @@ fn fromJson(
local: *const js.Local,
registry: *const NodeRegistry,
value: std.json.Value,
frame: *const Frame,
) !js.Value {
switch (value) {
.null => return local.zigValueToJs(null, .{}),
@@ -550,7 +551,7 @@ fn fromJson(
.array => |v| {
var array = local.newArray(@intCast(v.items.len));
for (v.items, 0..) |item, i| {
if (try array.set(@intCast(i), try fromJson(local, registry, item), .{}) == false) {
if (try array.set(@intCast(i), try fromJson(local, registry, item, frame), .{}) == false) {
return error.InvalidArgument;
}
}
@@ -562,14 +563,14 @@ fn fromJson(
.string => |s| s,
else => return error.NoSuchElement,
};
const element = try http_command.elementFromReference(registry, shared_id);
const element = try http_command.elementFromReference(registry, shared_id, frame);
return local.zigValueToJs(element.asNode(), .{});
}
const object = local.newObject();
var it = fields.iterator();
while (it.next()) |entry| {
const item = try fromJson(local, registry, entry.value_ptr.*);
const item = try fromJson(local, registry, entry.value_ptr.*, frame);
if (try object.set(entry.key_ptr.*, item, .{}) == false) {
return error.InvalidArgument;
}
+181 -17
View File
@@ -36,6 +36,7 @@ const execute = @import("execute.zig");
const remote_value = @import("remote_value.zig");
const browsing_context = @import("browsing_context.zig");
const Html = Node.Element.Html;
const Allocator = std.mem.Allocator;
// The key of a WebDriver element reference: {"element-6066-…": "<sharedId>"}
@@ -65,6 +66,9 @@ pub const Command = union(enum) {
get_element_rect: ElementId,
is_element_enabled: ElementId,
is_element_selected: ElementId,
element_click: ElementId,
element_clear: ElementId,
element_send_keys: SendKeys,
execute_script: execute.Script,
execute_async_script: execute.Script,
get_timeouts,
@@ -223,6 +227,9 @@ const routes = [_]Route{
.init(.GET, "/element/{id}/rect", .get_element_rect),
.init(.GET, "/element/{id}/enabled", .is_element_enabled),
.init(.GET, "/element/{id}/selected", .is_element_selected),
.init(.POST, "/element/{id}/click", .element_click),
.init(.POST, "/element/{id}/clear", .element_clear),
.init(.POST, "/element/{id}/value", .element_send_keys),
.init(.GET, "/element/{id}/attribute/{name}", .get_element_attribute),
.init(.GET, "/element/{id}/property/{name}", .get_element_property),
.init(.GET, "/element/{id}/css/{name}", .get_element_css_value),
@@ -325,6 +332,9 @@ pub fn process(cmd: *BiDi.Command) !void {
.get_element_rect => |p| return getElementRect(cmd, p),
.is_element_enabled => |p| return isElementEnabled(cmd, p),
.is_element_selected => |p| return isElementSelected(cmd, p),
.element_click => |p| return elementClick(cmd, p),
.element_clear => |p| return elementClear(cmd, p),
.element_send_keys => |p| return elementSendKeys(cmd, p),
.execute_script => |p| return executeScript(cmd, p, .sync),
.execute_async_script => |p| return executeScript(cmd, p, .async),
.get_timeouts => return getTimeouts(cmd),
@@ -429,7 +439,7 @@ fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]co
// The document, or the element a "from element" search starts at.
fn findRoot(cmd: *BiDi.Command, frame: *Frame, from: ?[]const u8) !?*Node {
const id = from orelse return frame.window._document.asNode();
const element = (try requireElement(cmd, id)) orelse return null;
const element = (try requireElement(cmd, id, frame)) orelse return null;
return element.asNode();
}
@@ -459,7 +469,7 @@ fn getActiveElement(cmd: *BiDi.Command) !void {
// GET /session/{id}/element/{id}/text.
fn getElementText(cmd: *BiDi.Command, p: ElementId) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
var aw: std.Io.Writer.Allocating = .init(cmd.arena);
element.getInnerText(&aw.writer, frame) catch |err| switch (err) {
@@ -471,14 +481,15 @@ fn getElementText(cmd: *BiDi.Command, p: ElementId) !void {
// GET /session/{id}/element/{id}/name. Lowercase, like every other driver.
fn getElementTagName(cmd: *BiDi.Command, p: ElementId) !void {
const element = (try requireElement(cmd, p.id)) orelse return;
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
return cmd.sendResult(element.getTagNameLower());
}
// GET /session/{id}/element/{id}/attribute/{name}
fn getElementAttribute(cmd: *BiDi.Command, p: ElementName) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
if (isBooleanAttribute(p.name)) {
// a boolean attribute is "true" or nothing at all, never its value
@@ -496,7 +507,7 @@ fn getElementAttribute(cmd: *BiDi.Command, p: ElementName) !void {
// GET /session/{id}/element/{id}/property/{name}
fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
var scope: js.Local.Scope = undefined;
frame.js.localScope(&scope);
@@ -522,7 +533,7 @@ fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void {
// GET /session/{id}/element/{id}/css/{name}
fn getElementCssValue(cmd: *BiDi.Command, p: ElementName) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
const style = try frame.window.getComputedStyle(element, null, frame);
return cmd.sendResult(style.asCSSStyleDeclaration().getPropertyValue(p.name, frame));
}
@@ -531,7 +542,7 @@ fn getElementCssValue(cmd: *BiDi.Command, p: ElementName) !void {
// however far the page is scrolled.
fn getElementRect(cmd: *BiDi.Command, p: ElementId) !void {
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
const rect = try element.getBoundingClientRect(frame);
const window = frame.window;
return cmd.sendResult(.{
@@ -544,13 +555,15 @@ fn getElementRect(cmd: *BiDi.Command, p: ElementId) !void {
// GET /session/{id}/element/{id}/enabled
fn isElementEnabled(cmd: *BiDi.Command, p: ElementId) !void {
const element = (try requireElement(cmd, p.id)) orelse return;
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
return cmd.sendResult(element.isDisabled() == false);
}
// GET /session/{id}/element/{id}/selected
fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void {
const element = (try requireElement(cmd, p.id)) orelse return;
const frame = (try currentFrame(cmd)) orelse return;
const element = (try requireElement(cmd, p.id, frame)) orelse return;
if (element.is(Node.Element.Html.Input)) |input_element| {
return cmd.sendResult(switch (input_element._input_type) {
@@ -564,6 +577,148 @@ fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void {
return cmd.sendResult(false);
}
// POST /session/{id}/element/{id}/click.
fn elementClick(cmd: *BiDi.Command, p: ElementId) !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("invalid argument", "a file input can't be clicked");
}
}
if ((try requireInteractable(cmd, element, frame)) == false) {
return;
}
if (element.is(Html.Option)) |option| blk: {
const select = option.ownerSelect() orelse break :blk;
const select_element = select.asElement();
if (select_element.isDisabled() or element.isDisabled()) {
break :blk;
}
Frame.user_input.updateHoverTarget(frame, element, .{ .with_pointer = true });
try Frame.user_input.triggerClick(frame, element, .{});
// a multiple <select> 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", "{}"));
+50
View File
@@ -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 {