mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 04:42:30 -04:00
WebDriver: add input (click, clear and sendKeys)
This commit is contained in:
9 files changed
+390
-25
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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');", "[]");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", "{}"));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user