diff --git a/src/browser/tests/webdriver/elements.html b/src/browser/tests/webdriver/elements.html new file mode 100644 index 000000000..e3b94c389 --- /dev/null +++ b/src/browser/tests/webdriver/elements.html @@ -0,0 +1,15 @@ + +
hello
+world
+ inside #box, not the rest of the page
+ {
+ const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}/elements", .{box});
+ const res = responseBody(try sessionCommand(&c, "POST", &session_id, path, "{\"using\":\"css selector\",\"value\":\".item\"}"));
+ const references = try elementReferences(res);
+ try testing.expectEqual(2, references.len);
+ try testing.expectEqual(msg, references[0]);
+ }
+ {
+ const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}/element", .{box});
+ const res = responseBody(try sessionCommand(&c, "POST", &session_id, path, "{\"using\":\"tag name\",\"value\":\"p\"}"));
+ const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, res, .{});
+ try testing.expectEqual(msg, parsed.object.get("value").?.object.get(http_command.element_key).?.string);
+ }
+
+ // nothing is focused, so the active element is the body
+ {
+ const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/element/active", ""));
+ const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{});
+ const active = parsed.object.get("value").?.object.get(http_command.element_key).?.string;
+ try testing.expectEqual("{\"value\":\"body\"}", try elementCommand(&c, &session_id, active, "/name"));
+ }
+
+ // a reference to a node that's been taken out of the document
+ {
+ const handle = blk: {
+ const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/window", ""));
+ break :blk try testing.arena_allocator.dupe(u8, body[10..46]);
+ };
+
+ var ws = try createTestClient();
+ defer ws.deinit();
+ var path_buf: [64]u8 = undefined;
+ try ws.handshake(try std.fmt.bufPrint(&path_buf, "/session/{s}", .{&session_id}));
+ try ws.bidiCommand(try std.fmt.allocPrint(testing.arena_allocator,
+ \\{{"id":1,"method":"script.evaluate","params":{{"expression":"document.getElementById('msg').remove()","awaitPromise":false,"target":{{"context":"{s}"}}}}}}
+ , .{handle}));
+ try expectWebsocketContains(&ws, "\"type\":\"success\"");
+
+ const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}/text", .{msg});
+ 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);
+ }
+}
+
+fn findElement(c: *TestClient, session_id: *const [36]u8, using: []const u8, value: []const u8) ![]const u8 {
+ const body = try std.fmt.allocPrint(testing.arena_allocator, "{{\"using\":\"{s}\",\"value\":\"{s}\"}}", .{ using, value });
+ const res = responseBody(try sessionCommand(c, "POST", session_id, "/element", body));
+ const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, res, .{});
+ const reference = parsed.object.get("value").?.object;
+ return reference.get(http_command.element_key).?.string;
+}
+
+// The raw response, so a test can assert on an error too.
+fn findElements(c: *TestClient, session_id: *const [36]u8, using: []const u8, value: []const u8) ![]const u8 {
+ const body = try std.fmt.allocPrint(testing.arena_allocator, "{{\"using\":\"{s}\",\"value\":\"{s}\"}}", .{ using, value });
+ return sessionCommand(c, "POST", session_id, "/elements", body);
+}
+
+fn elementReferences(body: []const u8) ![]const []const u8 {
+ const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{});
+ const values = parsed.object.get("value").?.array;
+ const references = try testing.arena_allocator.alloc([]const u8, values.items.len);
+ for (values.items, references) |value, *reference| {
+ reference.* = value.object.get(http_command.element_key).?.string;
+ }
+ return references;
+}
+
+fn elementCommand(c: *TestClient, session_id: *const [36]u8, id: []const u8, suffix: []const u8) ![]const u8 {
+ const path = try std.fmt.allocPrint(testing.arena_allocator, "/element/{s}{s}", .{ id, suffix });
+ return responseBody(try sessionCommand(c, "GET", session_id, path, ""));
+}
+
fn responseBody(res: []const u8) []const u8 {
return res[std.mem.indexOf(u8, res, "\r\n\r\n").? + 4 ..];
}
@@ -2100,14 +2256,15 @@ test "server: HTTP command errors" {
const session_id = try createHTTPSession("{\"capabilities\":{}}", false);
- // routing errors are the loop's, in W3C form
+ // routing errors are the loop's, in W3C form. A known path with the wrong
+ // method is an unknown command like any other.
{
var c = try createTestClient();
defer c.deinit();
var request_buf: [128]u8 = undefined;
const res = try c.httpRequest(try std.fmt.bufPrint(&request_buf, "DELETE /session/{s}/url HTTP/1.1\r\n\r\n", .{&session_id}));
- try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 405 Method Not Allowed\r\n"));
- try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"unknown method\",\"message\":\"unknown method\",\"stacktrace\":\"\"}}"));
+ try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 404 Not Found\r\n"));
+ try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"unknown command\",\"message\":\"unknown command\",\"stacktrace\":\"\"}}"));
}
{
var c = try createTestClient();
diff --git a/src/server/bidi/browsing_context.zig b/src/server/bidi/browsing_context.zig
index 48c2bd18d..aff470434 100644
--- a/src/server/bidi/browsing_context.zig
+++ b/src/server/bidi/browsing_context.zig
@@ -336,6 +336,7 @@ fn locateNodes(cmd: *BiDi.Command) !void {
return cmd.sendError("unsupported operation", "locator type is not supported");
},
};
+ const locator: Locator = if (p.locator.type == .css) .{ .css = selector } else .{ .xpath = selector };
const bidi = cmd.bidi;
const frame = bidi.user_context.session.currentFrame() orelse {
@@ -367,43 +368,17 @@ fn locateNodes(cmd: *BiDi.Command) !void {
defer ls.deinit();
var serializer = remote_value.Serializer.init(bidi, arena, frame, &ls.local, p.serializationOptions.options(false));
- const xpath_expr = if (p.locator.type == .xpath) XPathParser.parse(arena, selector) catch |err| {
- return invalidSelector(cmd, "xpath", selector, err);
- } else undefined;
-
// Serialized straight from each root's result, stopping at maxNodeCount.
const max = p.maxNodeCount orelse std.math.maxInt(u32);
var remotes: std.ArrayList(remote_value.Remote) = .empty;
for (roots) |root| {
- switch (p.locator.type) {
- .css => {
- if (max == 1) {
- const element = Selector.querySelector(root, selector, frame) catch |err| {
- return invalidSelector(cmd, "css", selector, err);
- };
- if (element) |el| {
- try remotes.append(arena, try serializer.domNode(el.asNode()));
- }
- } else {
- const list = Selector.querySelectorAll(root, selector, frame) catch |err| {
- return invalidSelector(cmd, "css", selector, err);
- };
- defer list.deinit(frame.page);
- try appendNodes(&remotes, arena, &serializer, list._nodes, max);
- }
- },
- .xpath => {
- // TODO: maxNodeCount == 1 could stop at the first match like css
- const result = xpath.evaluate(arena, xpath_expr, root, frame) catch |err| {
- return invalidSelector(cmd, "xpath", selector, err);
- };
- switch (result) {
- .node_set => |nodes| try appendNodes(&remotes, arena, &serializer, nodes, max),
- else => return cmd.sendError("invalid selector", "xpath expression must select nodes"),
- }
- },
- else => unreachable, // other types aren't currently supported (TODO) and were already rejected
- }
+ const remaining = max - @as(u32, @intCast(remotes.items.len));
+ const nodes = locator.locate(arena, root, remaining, frame) catch |err| switch (err) {
+ error.InvalidSelector => return cmd.sendError("invalid selector", "invalid selector"),
+ error.NodeSetExpected => return cmd.sendError("invalid selector", "xpath expression must select nodes"),
+ else => return err,
+ };
+ try appendNodes(&remotes, arena, &serializer, nodes, max);
if (remotes.items.len >= max) {
break;
@@ -413,6 +388,94 @@ fn locateNodes(cmd: *BiDi.Command) !void {
return cmd.sendResult(.{ .nodes = remotes.items });
}
+// What a locator selects, shared by browsingContext.locateNodes and the HTTP
+// session's element finders. The last three are WebDriver-only strategies.
+pub const Locator = union(enum) {
+ css: []const u8,
+ xpath: []const u8,
+ tag_name: []const u8, // webdriver-only
+ link_text: []const u8, // webdriver-only
+ partial_link_text: []const u8, // webdriver-only
+
+ pub fn locate(self: Locator, arena: std.mem.Allocator, root: *Node, max: u32, frame: *Frame) ![]const *Node {
+ if (max == 0) {
+ return &.{};
+ }
+
+ switch (self) {
+ .css => |selector| {
+ if (max == 1) {
+ const element = Selector.querySelector(root, selector, frame) catch |err| return badSelector("css", selector, err);
+ const found = element orelse return &.{};
+ const nodes = try arena.alloc(*Node, 1);
+ nodes[0] = found.asNode();
+ return nodes;
+ }
+ const list = Selector.querySelectorAll(root, selector, frame) catch |err| return badSelector("css", selector, err);
+ defer list.deinit(frame.page);
+ return arena.dupe(*Node, list._nodes[0..@min(list._nodes.len, max)]);
+ },
+ .xpath => |expression| {
+ // TODO: max == 1 could stop at the first match like css
+ const parsed = XPathParser.parse(arena, expression) catch |err| return badSelector("xpath", expression, err);
+ const result = xpath.evaluate(arena, parsed, root, frame) catch |err| return badSelector("xpath", expression, err);
+ const nodes = switch (result) {
+ .node_set => |nodes| nodes,
+ else => return error.NodeSetExpected,
+ };
+ return nodes[0..@min(nodes.len, max)];
+ },
+ .tag_name, .link_text, .partial_link_text => {},
+ }
+
+ const tag_name = switch (self) {
+ .tag_name => |name| name,
+ else => "a", // link_text or partial_link_test all sub-filter from
+ };
+ var elements = root.getElementsByTagName(tag_name, frame) catch |err| switch (err) {
+ error.InvalidTagName => return badSelector("tag name", tag_name, err),
+ else => return err,
+ };
+
+ var text: std.Io.Writer.Allocating = .init(arena);
+ var found: std.ArrayList(*Node) = .empty;
+ switch (elements) {
+ inline else => |*list| while (list.next()) |element| {
+ const matches = switch (self) {
+ .tag_name => true,
+ .link_text, .partial_link_text => |needle| blk: {
+ // an outside HTML has no rendered text
+ if (element.getTag() != .anchor) {
+ break :blk false;
+ }
+ text.clearRetainingCapacity();
+ try element.getInnerText(&text.writer, frame);
+ const rendered = std.mem.trim(u8, text.written(), &std.ascii.whitespace);
+ break :blk switch (self) {
+ .link_text => std.mem.eql(u8, rendered, needle),
+ else => std.mem.indexOf(u8, rendered, needle) != null,
+ };
+ },
+ else => unreachable, // css and xpath returned above
+ };
+
+ if (matches) {
+ try found.append(arena, element.asNode());
+ if (found.items.len == max) {
+ break;
+ }
+ }
+ },
+ }
+ return found.items;
+ }
+};
+
+fn badSelector(kind: []const u8, selector: []const u8, err: anyerror) error{InvalidSelector} {
+ log.debug(.bidi, "locate", .{ .kind = kind, .selector = selector, .err = err });
+ return error.InvalidSelector;
+}
+
fn appendNodes(remotes: *std.ArrayList(remote_value.Remote), arena: std.mem.Allocator, serializer: *remote_value.Serializer, nodes: []const *Node, max: u32) !void {
// Try to optimize this a little for the inherit inefficiency of ArrayList with Arena.
const take = @min(nodes.len, max - remotes.items.len);
@@ -422,11 +485,6 @@ fn appendNodes(remotes: *std.ArrayList(remote_value.Remote), arena: std.mem.Allo
}
}
-fn invalidSelector(cmd: *BiDi.Command, kind: []const u8, selector: []const u8, err: anyerror) !void {
- log.debug(.bidi, "locateNodes", .{ .kind = kind, .selector = selector, .err = err });
- return cmd.sendError("invalid selector", "invalid selector");
-}
-
pub fn requireContext(cmd: *BiDi.Command, context: []const u8) !?*Context {
if (cmd.bidi.browsing_context) |*ctx| {
if (std.mem.eql(u8, &ctx.id, context)) {
diff --git a/src/server/bidi/http_command.zig b/src/server/bidi/http_command.zig
index 3b391385a..933cd0164 100644
--- a/src/server/bidi/http_command.zig
+++ b/src/server/bidi/http_command.zig
@@ -23,12 +23,15 @@
const std = @import("std");
const lp = @import("lightpanda");
+const js = @import("../../browser/js/js.zig");
const Frame = @import("../../browser/Frame.zig");
+const Node = @import("../../browser/webapi/Node.zig");
const Method = @import("../http.zig").Connection.Method;
const BiDi = @import("BiDi.zig");
const input = @import("input.zig");
+const remote_value = @import("remote_value.zig");
const browsing_context = @import("browsing_context.zig");
const Allocator = std.mem.Allocator;
@@ -47,6 +50,19 @@ pub const Command = union(enum) {
take_screenshot,
perform_actions: PerformActions,
release_actions,
+ find_element: Find,
+ find_elements: Find,
+ find_element_from_element: FindFrom,
+ find_elements_from_element: FindFrom,
+ get_active_element,
+ get_element_text: ElementId,
+ get_element_tag_name: ElementId,
+ get_element_attribute: ElementName,
+ get_element_property: ElementName,
+ get_element_css_value: ElementName,
+ get_element_rect: ElementId,
+ is_element_enabled: ElementId,
+ is_element_selected: ElementId,
};
pub const NavigateTo = struct {
@@ -57,56 +73,219 @@ pub const PerformActions = struct {
actions: []const std.json.Value,
};
-const Route = struct {
- method: Method,
- // what follows /session/{id}
- path: []const u8,
- command: std.meta.Tag(Command),
+// A command's path parameters are its leading fields (see `parse`); the rest
+// come from the body.
+pub const ElementId = struct {
+ id: []const u8,
};
+pub const ElementName = struct {
+ id: []const u8,
+ name: []const u8,
+};
+
+pub const Find = struct {
+ using: Using,
+ value: []const u8,
+};
+
+pub const FindFrom = struct {
+ id: []const u8,
+ using: Using,
+ value: []const u8,
+};
+
+// The location strategies, whose wire values don't fit an enum literal. The
+// tags are browsing_context.Locator's, so one maps onto the other.
+pub const Using = enum {
+ css,
+ xpath,
+ tag_name,
+ link_text,
+ partial_link_text,
+
+ const wire = std.StaticStringMap(Using).initComptime(.{
+ .{ "css selector", .css },
+ .{ "xpath", .xpath },
+ .{ "tag name", .tag_name },
+ .{ "link text", .link_text },
+ .{ "partial link text", .partial_link_text },
+ });
+
+ pub fn jsonParse(arena: Allocator, source: anytype, opts: std.json.ParseOptions) !Using {
+ const name = try std.json.innerParse([]const u8, arena, source, opts);
+ return wire.get(name) orelse error.UnexpectedToken;
+ }
+};
+
+const Route = struct {
+ method: Method,
+ path: []const u8, // everything following /session/{id}
+ command: std.meta.Tag(Command),
+ segments: []const Segment,
+ parameters: []const []const u8, // parameter names, in order that we capture them
+
+ const Segment = union(enum) {
+ literal: []const u8,
+ parameter: []const u8, // {name}
+ };
+
+ fn init(comptime method: Method, comptime path: []const u8, comptime command: std.meta.Tag(Command)) Route {
+ comptime var segments: []const Segment = &.{};
+ comptime var parameters: []const []const u8 = &.{};
+ var it = std.mem.splitScalar(u8, path, '/');
+ while (it.next()) |segment| {
+ const parsed: Segment = if (segment.len > 1 and segment[0] == '{') blk: {
+ if (segment[segment.len - 1] != '}') {
+ @compileError(path ++ ": '" ++ segment ++ "' is missing its closing brace");
+ }
+ const name = segment[1 .. segment.len - 1];
+ parameters = parameters ++ [_][]const u8{name};
+ break :blk .{ .parameter = name };
+ } else .{ .literal = segment };
+
+ segments = segments ++ [_]Segment{parsed};
+ }
+
+ return .{
+ .method = method,
+ .path = path,
+ .command = command,
+ .segments = segments,
+ .parameters = parameters,
+ };
+ }
+
+ fn match(comptime self: Route, path: []const u8, captured: [][]const u8) bool {
+ var it = std.mem.splitScalar(u8, path, '/');
+ comptime var i: usize = 0;
+ inline for (self.segments) |segment| {
+ const part = it.next() orelse return false;
+ switch (segment) {
+ .literal => |literal| if (!std.mem.eql(u8, literal, part)) {
+ return false;
+ },
+ .parameter => {
+ if (part.len == 0) {
+ return false;
+ }
+ captured[i] = part;
+ comptime i += 1;
+ },
+ }
+ }
+ return it.next() == null;
+ }
+
+ fn build(
+ comptime self: Route,
+ arena: Allocator,
+ captured: []const []const u8,
+ body: []const u8,
+ ) ParseError!Command {
+ const name = @tagName(self.command);
+
+ const T = @FieldType(Command, name);
+ if (comptime self.parameters.len == 0) {
+ return @unionInit(Command, name, try parseBody(T, arena, body));
+ }
+
+ var value: T = undefined;
+ inline for (self.parameters, 0..) |parameter, i| {
+ // path is the connection's read buffer, reused once the request is parked
+ @field(value, parameter) = try arena.dupe(u8, captured[i]);
+ }
+ const parsed = try parseBody(Body(T, self.parameters), arena, body);
+ inline for (@typeInfo(@TypeOf(parsed)).@"struct".fields) |field| {
+ @field(value, field.name) = @field(parsed, field.name);
+ }
+ return @unionInit(Command, name, value);
+ }
+};
+
+// First match wins: literals need to come before parameters
const routes = [_]Route{
- .{ .method = .POST, .path = "/url", .command = .navigate_to },
- .{ .method = .GET, .path = "/url", .command = .get_current_url },
- .{ .method = .POST, .path = "/refresh", .command = .refresh },
- .{ .method = .GET, .path = "/title", .command = .get_title },
- .{ .method = .GET, .path = "/window", .command = .get_window_handle },
- .{ .method = .GET, .path = "/window/handles", .command = .get_window_handles },
- .{ .method = .GET, .path = "/source", .command = .get_page_source },
- .{ .method = .GET, .path = "/screenshot", .command = .take_screenshot },
- .{ .method = .POST, .path = "/actions", .command = .perform_actions },
- .{ .method = .DELETE, .path = "/actions", .command = .release_actions },
+ .init(.POST, "/url", .navigate_to),
+ .init(.GET, "/url", .get_current_url),
+ .init(.POST, "/refresh", .refresh),
+ .init(.GET, "/title", .get_title),
+ .init(.GET, "/window", .get_window_handle),
+ .init(.GET, "/window/handles", .get_window_handles),
+ .init(.GET, "/source", .get_page_source),
+ .init(.GET, "/screenshot", .take_screenshot),
+ .init(.POST, "/actions", .perform_actions),
+ .init(.DELETE, "/actions", .release_actions),
+ .init(.POST, "/element", .find_element),
+ .init(.POST, "/elements", .find_elements),
+ .init(.GET, "/element/active", .get_active_element),
+ .init(.POST, "/element/{id}/element", .find_element_from_element),
+ .init(.POST, "/element/{id}/elements", .find_elements_from_element),
+ .init(.GET, "/element/{id}/text", .get_element_text),
+ .init(.GET, "/element/{id}/name", .get_element_tag_name),
+ .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(.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),
};
pub const ParseError = error{
UnknownCommand,
- UnknownMethod,
InvalidArgument,
OutOfMemory,
};
// Loop. Everything the command references is allocated in `arena`.
pub fn parse(arena: Allocator, method: Method, path: []const u8, body: []const u8) ParseError!Command {
- var path_matched = false;
inline for (routes) |route| {
- if (std.mem.eql(u8, route.path, path)) {
- if (route.method == method) {
- const name = @tagName(route.command);
- return @unionInit(Command, name, try parseBody(@FieldType(Command, name), arena, body));
+ if (route.method == method) {
+ var captured: [route.parameters.len][]const u8 = undefined;
+ if (route.match(path, &captured)) {
+ return route.build(arena, &captured, body);
}
- path_matched = true;
}
}
- if (path_matched) {
- return error.UnknownMethod;
- }
+
+ // A known path with the wrong method is W3C's "unknown method" (405). It
+ // costs a second pass over the routes to tell apart, and no client cares:
+ // Selenium picks its error class off the body's code, never the status.
return error.UnknownCommand;
}
+// What's left of a command once its path parameters, which are its leading
+// fields, are taken out: the part that comes from the body.
+fn Body(comptime T: type, comptime parameters: []const []const u8) type {
+ const fields = @typeInfo(T).@"struct".fields;
+ for (parameters, fields[0..parameters.len]) |parameter, field| {
+ if (!std.mem.eql(u8, parameter, field.name)) {
+ @compileError(@typeName(T) ++ ": field '" ++ field.name ++ "' should be the path parameter '" ++ parameter ++ "'");
+ }
+ }
+
+ const rest = fields[parameters.len..];
+ var field_names: [rest.len][:0]const u8 = undefined;
+ var types: [rest.len]type = undefined;
+ var attrs: [rest.len]std.builtin.Type.StructField.Attributes = undefined;
+ for (rest, 0..) |field, i| {
+ field_names[i] = field.name;
+ types[i] = field.type;
+ attrs[i] = .{ .@"align" = field.alignment, .default_value_ptr = field.default_value_ptr };
+ }
+ return @Struct(.auto, null, &field_names, &types, &attrs);
+}
+
+// Both are split on '/', so a leading empty segment lines up on either side.
+
fn parseBody(comptime T: type, arena: Allocator, body: []const u8) ParseError!T {
if (T == void) {
// POSTs without parameters still send a body ("{}"); nothing to read
return {};
}
+ if (@typeInfo(T).@"struct".fields.len == 0) {
+ // everything the command takes came from the path
+ return .{};
+ }
return std.json.parseFromSliceLeaky(T, arena, body, .{
.ignore_unknown_fields = true,
// body is the connection's read buffer, reused once the request is parked
@@ -130,6 +309,19 @@ pub fn process(cmd: *BiDi.Command) !void {
.take_screenshot => return takeScreenshot(cmd),
.perform_actions => |p| return performActions(cmd, p),
.release_actions => return releaseActions(cmd),
+ .find_element => |p| return findElement(cmd, p.using, p.value, null),
+ .find_elements => |p| return findElements(cmd, p.using, p.value, null),
+ .find_element_from_element => |p| return findElement(cmd, p.using, p.value, p.id),
+ .find_elements_from_element => |p| return findElements(cmd, p.using, p.value, p.id),
+ .get_active_element => return getActiveElement(cmd),
+ .get_element_text => |p| return getElementText(cmd, p),
+ .get_element_tag_name => |p| return getElementTagName(cmd, p),
+ .get_element_attribute => |p| return getElementAttribute(cmd, p),
+ .get_element_property => |p| return getElementProperty(cmd, p),
+ .get_element_css_value => |p| return getElementCssValue(cmd, p),
+ .get_element_rect => |p| return getElementRect(cmd, p),
+ .is_element_enabled => |p| return isElementEnabled(cmd, p),
+ .is_element_selected => |p| return isElementSelected(cmd, p),
}
}
@@ -197,6 +389,223 @@ fn releaseActions(cmd: *BiDi.Command) !void {
return input.release(cmd);
}
+// POST /session/{id}/element, POST /session/{id}/element/{id}/element
+fn findElement(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]const u8) !void {
+ const frame = (try currentFrame(cmd)) orelse return;
+ const root = (try findRoot(cmd, frame, from)) orelse return;
+ const nodes = (try locate(cmd, root, using, value, 1, frame)) orelse return;
+ if (nodes.len == 0) {
+ return cmd.sendError("no such element", "no matching element");
+ }
+ return cmd.sendResult(try reference(cmd, nodes[0]));
+}
+
+// POST /session/{id}/elements, POST /session/{id}/element/{id}/elements
+fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]const u8) !void {
+ const frame = (try currentFrame(cmd)) orelse return;
+ const root = (try findRoot(cmd, frame, from)) orelse return;
+ const nodes = (try locate(cmd, root, using, value, std.math.maxInt(u32), frame)) orelse return;
+
+ const references = try cmd.arena.alloc(Reference, nodes.len);
+ for (nodes, references) |node, *ref| {
+ ref.* = try reference(cmd, node);
+ }
+ return cmd.sendResult(references);
+}
+
+// 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;
+ return element.asNode();
+}
+
+fn locate(cmd: *BiDi.Command, root: *Node, using: Using, value: []const u8, max: u32, frame: *Frame) !?[]const *Node {
+ const locator: browsing_context.Locator = switch (using) {
+ inline else => |tag| @unionInit(browsing_context.Locator, @tagName(tag), value),
+ };
+
+ return locator.locate(cmd.arena, root, max, frame) catch |err| switch (err) {
+ error.InvalidSelector, error.NodeSetExpected => {
+ try cmd.sendError("invalid selector", "invalid selector");
+ return null;
+ },
+ else => return err,
+ };
+}
+
+// GET /session/{id}/element/active
+fn getActiveElement(cmd: *BiDi.Command) !void {
+ const frame = (try currentFrame(cmd)) orelse return;
+ const element = frame.window._document.getActiveElement() orelse {
+ return cmd.sendError("no such element", "no active element");
+ };
+ return cmd.sendResult(try reference(cmd, element.asNode()));
+}
+
+// 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;
+
+ var aw: std.Io.Writer.Allocating = .init(cmd.arena);
+ element.getInnerText(&aw.writer, frame) catch |err| switch (err) {
+ error.NotHtmlElement => try element.asNode().getTextContent(&aw.writer),
+ else => return err,
+ };
+ return cmd.sendResult(aw.written());
+}
+
+// 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;
+ 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;
+
+ if (isBooleanAttribute(p.name)) {
+ // a boolean attribute is "true" or nothing at all, never its value
+ if (try element.hasAttribute(.wrap(p.name), frame)) {
+ return cmd.sendResult("true");
+ }
+ return cmd.sendDone();
+ }
+
+ // the String stays in a local: str() can point into the String itself
+ const value = (try element.getAttribute(.wrap(p.name), frame)) orelse return cmd.sendDone();
+ return cmd.sendResult(value.str());
+}
+
+// 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;
+
+ var scope: js.Local.Scope = undefined;
+ frame.js.localScope(&scope);
+ defer scope.deinit();
+
+ const object = (try scope.local.zigValueToJs(element, .{})).toObject();
+ const value = object.get(p.name) catch {
+ return cmd.sendError("javascript error", "the property threw");
+ };
+
+ // A node is an element reference; anything else is JSON as V8 writes it.
+ if (value.taggedOpaque()) |tagged| {
+ if (tagged.as(Node)) |node| {
+ return cmd.sendResult(try reference(cmd, node));
+ }
+ }
+ return cmd.sendResult(value);
+}
+
+// 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 style = try frame.window.getComputedStyle(element, null, frame);
+ return cmd.sendResult(style.asCSSStyleDeclaration().getPropertyValue(p.name, frame));
+}
+
+// GET /session/{id}/element/{id}/rect. Absolute, so the viewport rect plus
+// 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 rect = try element.getBoundingClientRect(frame);
+ const window = frame.window;
+ return cmd.sendResult(.{
+ .x = rect.getX() + @as(f64, @floatFromInt(window.getScrollX())),
+ .y = rect.getY() + @as(f64, @floatFromInt(window.getScrollY())),
+ .width = rect.getWidth(),
+ .height = rect.getHeight(),
+ });
+}
+
+// GET /session/{id}/element/{id}/enabled
+fn isElementEnabled(cmd: *BiDi.Command, p: ElementId) !void {
+ const element = (try requireElement(cmd, p.id)) 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;
+
+ if (element.is(Node.Element.Html.Input)) |input_element| {
+ return cmd.sendResult(switch (input_element._input_type) {
+ .checkbox, .radio => input_element.getChecked(),
+ else => false,
+ });
+ }
+ if (element.is(Node.Element.Html.Option)) |option| {
+ return cmd.sendResult(option.getSelected());
+ }
+ return cmd.sendResult(false);
+}
+
+// {"element-6066-…": "