Merge pull request #3585 from lightpanda-io/webdriver-element

webdriver: add element endpoints
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-22 07:47:06 +08:00
commit 709567e2d8
7 files changed
+744 -71

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<title>webdriver elements</title>
<div id=box data-x="1" style="width: 40px; height: 20px">
<p class=item id=msg style="color: rgb(1, 2, 3)">hello</p>
<p class=item>world</p>
</div>
<my-widget>custom</my-widget>
<a href="/one">first link</a>
<a href="/two">second link</a>
<input id=check type=checkbox checked>
<input id=off disabled>
<select id=pick>
<option id=opt_a value=a selected>A</option>
<option id=opt_b value=b>B</option>
</select>
+1 -1
View File
@@ -1438,7 +1438,7 @@ pub fn getElementsByTagName(self: *Node, tag_name: []const u8, frame: *Frame) !G
const lower = std.ascii.lowerString(&frame.buf, tag_name);
if (Node.Element.Tag.parseForMatch(lower)) |known| {
// optimized for known tag names, comparis
// optimized for known tag names, comparison
return .{
.tag = collections.NodeLive(.tag).init(self, known, frame),
};
+1 -1
View File
@@ -676,7 +676,7 @@ pub fn matchMedia(_: *const Window, query: []const u8, frame: *Frame) !*MediaQue
return MediaQueryList.init(query, frame);
}
fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, frame: *Frame) !*CSSStyleProperties {
pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]const u8, frame: *Frame) !*CSSStyleProperties {
// :before/:after get their own cache entry and no warning: our answer
// (the element's own computed style) is a reasonable default for the
// common probes
+160 -3
View File
@@ -2094,6 +2094,162 @@ test "server: HTTP page commands" {
try testing.expectEqual("{\"value\":\"" ++ url ++ "\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")));
}
test "server: HTTP element commands" {
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/elements.html";
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
// a reference nothing ever handed out
{
const res = try sessionCommand(&c, "GET", &session_id, "/element/99/text", "");
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\":\"no such element\"") != null);
}
{
const res = try findElements(&c, &session_id, "css selector", "[");
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 selector\"") != null);
}
const msg = try findElement(&c, &session_id, "css selector", "#msg");
try testing.expectEqual("{\"value\":\"hello\"}", try elementCommand(&c, &session_id, msg, "/text"));
try testing.expectEqual("{\"value\":\"p\"}", try elementCommand(&c, &session_id, msg, "/name"));
try testing.expectEqual("{\"value\":\"msg\"}", try elementCommand(&c, &session_id, msg, "/property/id"));
try testing.expectEqual("{\"value\":\"P\"}", try elementCommand(&c, &session_id, msg, "/property/tagName"));
try testing.expectEqual("{\"value\":\"rgb(1, 2, 3)\"}", try elementCommand(&c, &session_id, msg, "/css/color"));
try testing.expectEqual("{\"value\":null}", try elementCommand(&c, &session_id, msg, "/attribute/nope"));
// the same node keeps its reference
try testing.expectEqual(msg, try findElement(&c, &session_id, "css selector", "#msg"));
const box = try findElement(&c, &session_id, "css selector", "#box");
try testing.expectEqual("{\"value\":\"1\"}", try elementCommand(&c, &session_id, box, "/attribute/data-x"));
{
const body = try elementCommand(&c, &session_id, box, "/rect");
try testing.expect(std.mem.startsWith(u8, body, "{\"value\":{\"x\":"));
try testing.expect(std.mem.indexOf(u8, body, "\"width\":40") != null);
try testing.expect(std.mem.indexOf(u8, body, "\"height\":20") != null);
}
// a boolean attribute is "true", never its value
const check = try findElement(&c, &session_id, "css selector", "#check");
try testing.expectEqual("{\"value\":\"true\"}", try elementCommand(&c, &session_id, check, "/attribute/checked"));
try testing.expectEqual("{\"value\":true}", try elementCommand(&c, &session_id, check, "/selected"));
try testing.expectEqual("{\"value\":true}", try elementCommand(&c, &session_id, check, "/enabled"));
try testing.expectEqual("{\"value\":false}", try elementCommand(&c, &session_id, msg, "/selected"));
const off = try findElement(&c, &session_id, "css selector", "#off");
try testing.expectEqual("{\"value\":false}", try elementCommand(&c, &session_id, off, "/enabled"));
{
const selected = try findElement(&c, &session_id, "css selector", "#opt_a");
try testing.expectEqual("{\"value\":true}", try elementCommand(&c, &session_id, selected, "/selected"));
const other = try findElement(&c, &session_id, "css selector", "#opt_b");
try testing.expectEqual("{\"value\":false}", try elementCommand(&c, &session_id, other, "/selected"));
}
// the strategies no selector engine covers
{
const link = try findElement(&c, &session_id, "link text", "first link");
try testing.expectEqual("{\"value\":\"first link\"}", try elementCommand(&c, &session_id, link, "/text"));
const partial = try findElement(&c, &session_id, "partial link text", "second");
try testing.expectEqual("{\"value\":\"second link\"}", try elementCommand(&c, &session_id, partial, "/text"));
// tag names match whatever case they're asked in
const res = try findElements(&c, &session_id, "tag name", "A");
try testing.expectEqual(2, (try elementReferences(responseBody(res))).len);
// a tag the Tag enum doesn't know takes the string-compare path
const custom = try findElement(&c, &session_id, "tag name", "my-widget");
try testing.expectEqual("{\"value\":\"custom\"}", try elementCommand(&c, &session_id, custom, "/text"));
}
{
const first = try findElement(&c, &session_id, "xpath", "//p[@class='item']");
try testing.expectEqual("{\"value\":\"hello\"}", try elementCommand(&c, &session_id, first, "/text"));
}
// scoped to an element: the two <p> 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 ..];
}
@@ -2118,14 +2274,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();
+96 -38
View File
@@ -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 <a>
};
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 <a> 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)) {
+471 -27
View File
@@ -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, if we park the connection, that
@@ -131,6 +310,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),
}
}
@@ -198,6 +390,226 @@ 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.
// taggedOpaque reads an internal field, so the value has to be an object.
if (value.isObject()) {
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-…": "<sharedId>"}: a WebDriver element reference is the
// node registry's id, the same one BiDi hands out.
const Reference = struct {
shared_id: []const u8,
pub fn jsonStringify(self: Reference, jws: anytype) !void {
try jws.beginObject();
try jws.objectField(element_key);
try jws.write(self.shared_id);
try jws.endObject();
}
};
fn reference(cmd: *BiDi.Command, node: *Node) !Reference {
const registered = try cmd.bidi.node_registry.register(node);
return .{ .shared_id = try std.fmt.allocPrint(cmd.arena, "{d}", .{registered.id}) };
}
// Answers the command and returns null when the reference doesn't resolve.
fn requireElement(cmd: *BiDi.Command, id: []const u8) !?*Node.Element {
const node = remote_value.nodeFromSharedId(&cmd.bidi.node_registry, .{ .string = id }) catch {
// ids are dropped on navigation, so a stale one is unknown by then
try cmd.sendError("no such element", "unknown element reference");
return null;
};
const element = node.is(Node.Element) orelse {
try cmd.sendError("no such element", "not an element");
return null;
};
if (node.isConnected() == false) {
try cmd.sendError("stale element reference", "element is no longer attached to the document");
return null;
}
return element;
}
// HTML's boolean attributes: present means "true", absent means null, and
// the attribute's own value never shows up.
const boolean_attributes = std.StaticStringMap(void).initComptime(.{
.{"allowfullscreen"}, .{"async"}, .{"autofocus"}, .{"autoplay"},
.{"checked"}, .{"controls"}, .{"default"}, .{"defer"},
.{"disabled"}, .{"formnovalidate"}, .{"hidden"}, .{"inert"},
.{"ismap"}, .{"itemscope"}, .{"loop"}, .{"multiple"},
.{"muted"}, .{"nomodule"}, .{"novalidate"}, .{"open"},
.{"playsinline"}, .{"readonly"}, .{"required"}, .{"reversed"},
.{"selected"},
});
fn isBooleanAttribute(name: []const u8) bool {
var buf: [16]u8 = undefined;
if (name.len > buf.len) {
return false;
}
return boolean_attributes.has(std.ascii.lowerString(buf[0..name.len], name));
}
fn currentContext(cmd: *BiDi.Command) !?*browsing_context.Context {
if (cmd.bidi.browsing_context) |*ctx| {
return ctx;
@@ -239,14 +651,46 @@ test "bidi.http_command: parse" {
try testing.expectEqual(1, command.perform_actions.actions.len);
}
{
const command = try parse(arena, .POST, "/element", "{\"using\":\"css selector\",\"value\":\"#a\"}");
try testing.expectEqual(.css, command.find_element.using);
try testing.expectEqual("#a", command.find_element.value);
}
{
// a path parameter and a body
const command = try parse(arena, .POST, "/element/7/elements", "{\"using\":\"link text\",\"value\":\"go\"}");
try testing.expectEqual("7", command.find_elements_from_element.id);
try testing.expectEqual(.link_text, command.find_elements_from_element.using);
try testing.expectEqual("go", command.find_elements_from_element.value);
}
{
// two path parameters, no body
const command = try parse(arena, .GET, "/element/7/attribute/data-x", "");
try testing.expectEqual("7", command.get_element_attribute.id);
try testing.expectEqual("data-x", command.get_element_attribute.name);
}
// a literal segment wins over the parameter that would also match it
try testing.expect(try parse(arena, .GET, "/element/active", "") == .get_active_element);
try testing.expect(try parse(arena, .GET, "/element/7/text", "") == .get_element_text);
try testing.expect(try parse(arena, .GET, "/url", "") == .get_current_url);
try testing.expect(try parse(arena, .GET, "/window/handles", "") == .get_window_handles);
try testing.expect(try parse(arena, .DELETE, "/actions", "") == .release_actions);
try testing.expectError(error.UnknownMethod, parse(arena, .PUT, "/url", ""));
try testing.expectError(error.UnknownMethod, parse(arena, .POST, "/title", "{}"));
// a known path with the wrong method is an unknown command too
try testing.expectError(error.UnknownCommand, parse(arena, .PUT, "/url", ""));
try testing.expectError(error.UnknownCommand, parse(arena, .POST, "/title", "{}"));
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/actions", "{}"));
try testing.expectError(error.UnknownCommand, parse(arena, .POST, "/nope", "{}"));
try testing.expectError(error.UnknownCommand, parse(arena, .POST, "/element/7/text", "{}"));
try testing.expectError(error.UnknownCommand, parse(arena, .GET, "/element//text", ""));
try testing.expectError(error.UnknownCommand, parse(arena, .GET, "/element/7/text/more", ""));
try testing.expectError(error.UnknownCommand, parse(arena, .GET, "/element/7", ""));
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/element", "{\"using\":\"nope\",\"value\":\"x\"}"));
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/element/7/element", "{\"using\":\"xpath\"}"));
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/url", "not json"));
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/url", "{}"));
}
-1
View File
@@ -852,7 +852,6 @@ fn serveSessionCommand(server: *Server, conn: *Connection, req: *Connection.Requ
switch (err) {
error.OutOfMemory => return err,
error.UnknownCommand => return serveWebDriverError(server, conn, req, "unknown command", "unknown command"),
error.UnknownMethod => return serveWebDriverError(server, conn, req, "unknown method", "unknown method"),
error.InvalidArgument => return serveWebDriverError(server, conn, req, "invalid argument", "invalid body"),
}
};