mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-08 20:32:00 -04:00
webdriver: add element endpoints
Follows https://github.com/lightpanda-io/browser/pull/3552 to add various element endpoints. Total of 13 new endpoints were added, including a slightly fancier router to deal with variables in URLs. ``` $ curl -X POST "http://localhost:9222/session" --data '{}' {"value":{"sessionId":"248378f2-bc8d-4cb5-8aaf-3ec4193c5a9e","capabilities":{"acceptInsecureCerts":false,"browserName":"Lightpanda","browserVersion":"1.0.0-dev.9605+475ed9ae7","platformName":"linux","setWindowRect":false,"userAgent":"Lightpanda/1.0","proxy":{}}}}⏎ ~/c/p/browser2] $ curl -X POST "http://localhost:9222/session/248378f2-bc8d-4cb5-8aaf-3ec4193c5a9e/url" --data '{"url": "https://lightpanda.io"}' {"value":null} $ curl -X POST "http://localhost:9222/session/248378f2-bc8d-4cb5-8aaf-3ec4193c5a9e/element" --data '{"using":"css selector","value":"h1"}' {"value":{"element-6066-11e4-a52e-4f735466cecf":"1"}} $ curl -X GET "http://localhost:9222/session/248378f2-bc8d-4cb5-8aaf-3ec4193c5a9e/element/1/text" {"value":"The first browserfor machines, not humans"} ``` First two already existed, last two are new.
This commit is contained in:
7 files changed
+741
-71
No files matched your search
@@ -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>
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -2076,6 +2076,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 ..];
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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-…": "<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;
|
||||
@@ -238,14 +647,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", "{}"));
|
||||
}
|
||||
@@ -812,7 +812,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"),
|
||||
}
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user