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 @@ + +webdriver elements +
+

hello

+

world

+
+custom +first link +second link + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 4fc8cdaa4..d75d7bfbc 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -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), }; diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 5118fb774..8092447a4 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -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 diff --git a/src/server/Server.zig b/src/server/Server.zig index 9aa50b26c..206631dab 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -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

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(); 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 2a7f4d8d3..adad75d4b 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, 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-…": ""}: 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", "{}")); } diff --git a/src/server/http.zig b/src/server/http.zig index 655964532..b2883a914 100644 --- a/src/server/http.zig +++ b/src/server/http.zig @@ -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"), } };