mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 04:42:30 -04:00
Merge pull request #3552 from lightpanda-io/webdriver-apis
WebDriver: add various basic WebDriver APIs
This commit is contained in:
5 files changed
+305
-40
No files matched your search
+98
-11
@@ -1977,14 +1977,14 @@ test "server: HTTP navigate" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
{
|
||||
const res = try sessionCommand(&c, &session_id, "/url", "not json");
|
||||
const res = try sessionCommand(&c, "POST", &session_id, "/url", "not json");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 400 Bad Request\r\n"));
|
||||
try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"invalid argument\",\"message\":\"invalid body\",\"stacktrace\":\"\"}}"));
|
||||
}
|
||||
|
||||
const url = "http://127.0.0.1:9582/src/browser/tests/cdp/dom2.html";
|
||||
{
|
||||
const res = try sessionCommand(&c, &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}");
|
||||
const res = try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}");
|
||||
try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: 14\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
@@ -2002,11 +2002,98 @@ test "server: HTTP navigate" {
|
||||
try testing.expect(std.mem.indexOf(u8, msg.data, "\"url\":\"" ++ url ++ "\"") != null);
|
||||
}
|
||||
|
||||
test "server: HTTP page 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();
|
||||
|
||||
// the browsing context is opened by the first command that needs it
|
||||
try testing.expectEqual("{\"value\":\"about:blank\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")));
|
||||
|
||||
const handle = blk: {
|
||||
const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/window", ""));
|
||||
try testing.expect(std.mem.startsWith(u8, body, "{\"value\":\""));
|
||||
break :blk try testing.arena_allocator.dupe(u8, body[10..46]);
|
||||
};
|
||||
{
|
||||
const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/window/handles", ""));
|
||||
try testing.expectEqual(try std.fmt.allocPrint(testing.arena_allocator, "{{\"value\":[\"{s}\"]}}", .{handle}), body);
|
||||
}
|
||||
|
||||
const url = "http://127.0.0.1:9582/src/browser/tests/bidi/input.html";
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"" ++ url ++ "\"}")));
|
||||
try testing.expectEqual("{\"value\":\"" ++ url ++ "\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")));
|
||||
try testing.expectEqual("{\"value\":\"bidi input\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/title", "")));
|
||||
{
|
||||
const body = responseBody(try sessionCommand(&c, "GET", &session_id, "/source", ""));
|
||||
try testing.expect(std.mem.startsWith(u8, body, "{\"value\":\"<!DOCTYPE html>\\n<html><head><title>bidi input</title>"));
|
||||
}
|
||||
{
|
||||
const res = try c.httpRequestAlloc(try std.fmt.allocPrint(testing.arena_allocator, "GET /session/{s}/screenshot HTTP/1.1\r\n\r\n", .{&session_id}));
|
||||
defer testing.allocator.free(res);
|
||||
// base64 of the PNG signature
|
||||
try testing.expect(std.mem.startsWith(u8, responseBody(res), "{\"value\":\"iVBORw0KGgo"));
|
||||
}
|
||||
|
||||
// a click on the button, then a held key. The element origin is
|
||||
// resolved after the pause parks the actions.
|
||||
{
|
||||
const actions = "{\"actions\":[" ++
|
||||
"{\"type\":\"pointer\",\"id\":\"mouse\",\"parameters\":{\"pointerType\":\"mouse\"},\"actions\":[" ++
|
||||
"{\"type\":\"pause\",\"duration\":20}," ++
|
||||
"{\"type\":\"pointerMove\",\"x\":0,\"y\":0,\"origin\":{\"" ++ http_command.element_key ++ "\":\"1\"}}," ++
|
||||
"{\"type\":\"pointerDown\",\"button\":0},{\"type\":\"pointerUp\",\"button\":0}]}," ++
|
||||
"{\"type\":\"key\",\"id\":\"kb\",\"actions\":[{\"type\":\"pause\"},{\"type\":\"pause\"},{\"type\":\"pause\"},{\"type\":\"keyDown\",\"value\":\"a\"}]}]}";
|
||||
|
||||
// an element reference is a sharedId, so take #btn's over BiDi
|
||||
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":"browsingContext.locateNodes","params":{{"context":"{s}","locator":{{"type":"css","value":"#btn"}}}}}}
|
||||
, .{handle}));
|
||||
try expectWebsocketContains(&ws, "\"sharedId\":\"1\"");
|
||||
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/actions", actions)));
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "DELETE", &session_id, "/actions", "")));
|
||||
|
||||
try ws.bidiCommand(try std.fmt.allocPrint(testing.arena_allocator,
|
||||
\\{{"id":2,"method":"script.evaluate","params":{{"expression":"window.events.join(' ')","awaitPromise":false,"target":{{"context":"{s}"}}}}}}
|
||||
, .{handle}));
|
||||
try expectWebsocketContains(&ws, "\"value\":\"mousemove@btn mousedown@btn mouseup@btn click@btn keydown:a@btn keyup:a@btn\"");
|
||||
}
|
||||
{
|
||||
const res = try sessionCommand(&c, "POST", &session_id, "/actions", "{\"actions\":[{\"type\":\"key\",\"id\":\"kb\",\"actions\":[{\"type\":\"keyDown\"}]}]}");
|
||||
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 argument\"") != null);
|
||||
}
|
||||
|
||||
// the reload lands on the same document
|
||||
try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/refresh", "{}")));
|
||||
try testing.expectEqual("{\"value\":\"" ++ url ++ "\"}", responseBody(try sessionCommand(&c, "GET", &session_id, "/url", "")));
|
||||
}
|
||||
|
||||
fn responseBody(res: []const u8) []const u8 {
|
||||
return res[std.mem.indexOf(u8, res, "\r\n\r\n").? + 4 ..];
|
||||
}
|
||||
|
||||
fn expectWebsocketContains(ws: *TestClient, expected: []const u8) !void {
|
||||
const msg = try ws.readWebsocketMessage() orelse return error.NoMessage;
|
||||
defer if (msg.cleanup_fragment) ws.reader.cleanup();
|
||||
if (std.mem.indexOf(u8, msg.data, expected) == null) {
|
||||
std.debug.print("expected {s} in {s}\n", .{ expected, msg.data });
|
||||
return error.UnexpectedMessage;
|
||||
}
|
||||
}
|
||||
|
||||
test "server: HTTP command errors" {
|
||||
{
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
const res = try sessionCommand(&c, "00000000-0000-4000-8000-000000000000", "/url", "{}");
|
||||
const res = try sessionCommand(&c, "POST", "00000000-0000-4000-8000-000000000000", "/url", "{}");
|
||||
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\":\"invalid session id\",\"message\":\"no such session\",\"stacktrace\":\"\"}}"));
|
||||
}
|
||||
@@ -2018,14 +2105,14 @@ test "server: HTTP command errors" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
var request_buf: [128]u8 = undefined;
|
||||
const res = try c.httpRequest(try std.fmt.bufPrint(&request_buf, "GET /session/{s}/url HTTP/1.1\r\n\r\n", .{&session_id}));
|
||||
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\":\"\"}}"));
|
||||
}
|
||||
{
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
const res = try sessionCommand(&c, &session_id, "/nope", "{}");
|
||||
const res = try sessionCommand(&c, "POST", &session_id, "/nope", "{}");
|
||||
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\":\"\"}}"));
|
||||
}
|
||||
@@ -2033,14 +2120,14 @@ test "server: HTTP command errors" {
|
||||
// a slow page keeps the navigate parked on the worker
|
||||
var slow = try createTestClient();
|
||||
defer slow.deinit();
|
||||
try writeSessionCommand(&slow, &session_id, "/url", "{\"url\":\"http://127.0.0.1:9582/src/browser/tests/hi.html?delay_ms=500\"}");
|
||||
try writeSessionCommand(&slow, "POST", &session_id, "/url", "{\"url\":\"http://127.0.0.1:9582/src/browser/tests/hi.html?delay_ms=500\"}");
|
||||
lp.io.sleep(.fromMilliseconds(50), .awake) catch {};
|
||||
|
||||
// one command at a time
|
||||
{
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
const res = try sessionCommand(&c, &session_id, "/url", "{\"url\":\"about:blank\"}");
|
||||
const res = try sessionCommand(&c, "POST", &session_id, "/url", "{\"url\":\"about:blank\"}");
|
||||
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n"));
|
||||
try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"unknown error\",\"message\":\"a command is already in progress\",\"stacktrace\":\"\"}}"));
|
||||
}
|
||||
@@ -2052,14 +2139,14 @@ test "server: HTTP command errors" {
|
||||
try testing.expect(std.mem.endsWith(u8, res, "{\"value\":{\"error\":\"invalid session id\",\"message\":\"session ended\",\"stacktrace\":\"\"}}"));
|
||||
}
|
||||
|
||||
fn sessionCommand(c: *TestClient, session_id: *const [36]u8, command: []const u8, body: []const u8) ![]const u8 {
|
||||
try writeSessionCommand(c, session_id, command, body);
|
||||
fn sessionCommand(c: *TestClient, method: []const u8, session_id: *const [36]u8, command: []const u8, body: []const u8) ![]const u8 {
|
||||
try writeSessionCommand(c, method, session_id, command, body);
|
||||
return c.httpRequest("");
|
||||
}
|
||||
|
||||
fn writeSessionCommand(c: *TestClient, session_id: *const [36]u8, command: []const u8, body: []const u8) !void {
|
||||
fn writeSessionCommand(c: *TestClient, method: []const u8, session_id: *const [36]u8, command: []const u8, body: []const u8) !void {
|
||||
var head_buf: [128]u8 = undefined;
|
||||
try sys_net.writeAll(c.socket, try std.fmt.bufPrint(&head_buf, "POST /session/{s}{s} HTTP/1.1\r\nContent-Length: {d}\r\n\r\n", .{ session_id, command, body.len }));
|
||||
try sys_net.writeAll(c.socket, try std.fmt.bufPrint(&head_buf, "{s} /session/{s}{s} HTTP/1.1\r\nContent-Length: {d}\r\n\r\n", .{ method, session_id, command, body.len }));
|
||||
try sys_net.writeAll(c.socket, body);
|
||||
}
|
||||
|
||||
|
||||
@@ -426,6 +426,11 @@ pub const Command = struct {
|
||||
return self.bidi.replyResult(self.reply(), result);
|
||||
}
|
||||
|
||||
pub fn sendDone(self: *Command) !void {
|
||||
self.answered = true;
|
||||
return self.bidi.replyDone(self.reply());
|
||||
}
|
||||
|
||||
pub fn sendError(self: *Command, code: []const u8, message: []const u8) !void {
|
||||
self.answered = true;
|
||||
return self.bidi.replyError(self.reply(), code, message);
|
||||
@@ -493,6 +498,14 @@ pub fn replyResult(self: *BiDi, reply: Reply, result: anytype) !void {
|
||||
}
|
||||
}
|
||||
|
||||
// A command's empty success: BiDi's {}, HTTP's null.
|
||||
pub fn replyDone(self: *BiDi, reply: Reply) !void {
|
||||
switch (reply) {
|
||||
.bidi => |id| return self.sendResult(id, struct {}{}),
|
||||
.http => return self.respondHTTP(null),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replyError(self: *BiDi, reply: Reply, code: []const u8, message: []const u8) !void {
|
||||
switch (reply) {
|
||||
.bidi => |id| return self.sendError(id, code, message),
|
||||
|
||||
@@ -200,13 +200,36 @@ pub const NavigateOpts = struct {
|
||||
};
|
||||
|
||||
pub fn navigate(cmd: *BiDi.Command, ctx: *Context, opts: NavigateOpts) !void {
|
||||
const bidi = cmd.bidi;
|
||||
const frame = bidi.user_context.session.currentFrame() orelse {
|
||||
const frame = cmd.bidi.user_context.session.currentFrame() orelse {
|
||||
return cmd.sendError("unknown error", "no frame");
|
||||
};
|
||||
const encoded_url = URL.resolveNavigation(frame.call_arena, opts.url, .{}) catch {
|
||||
return cmd.sendError("invalid argument", "invalid url");
|
||||
};
|
||||
return startNavigation(cmd, ctx, frame, encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, opts.wait);
|
||||
}
|
||||
|
||||
// Reloads the current document, replaying its method and body like CDP's
|
||||
// Page.reload.
|
||||
pub fn reload(cmd: *BiDi.Command, ctx: *Context, wait: NavigateOpts.Wait) !void {
|
||||
const frame = cmd.bidi.user_context.session.currentFrame() orelse {
|
||||
return cmd.sendError("unknown error", "no frame");
|
||||
};
|
||||
|
||||
// the frame's arena, which these live in, is gone once the reload commits
|
||||
const arena = cmd.arena;
|
||||
const url = try arena.dupeZ(u8, frame.url);
|
||||
var nav_opts: Frame.NavigateOpts = .{ .reason = .address_bar, .kind = .reload };
|
||||
if (frame._navigated_options) |prev| {
|
||||
nav_opts.method = prev.method;
|
||||
nav_opts.body = if (prev.body) |b| try arena.dupe(u8, b) else null;
|
||||
nav_opts.header = if (prev.header) |h| try arena.dupeZ(u8, h) else null;
|
||||
}
|
||||
return startNavigation(cmd, ctx, frame, url, nav_opts, wait);
|
||||
}
|
||||
|
||||
fn startNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *Frame, url: [:0]const u8, nav_opts: Frame.NavigateOpts, wait: NavigateOpts.Wait) !void {
|
||||
const bidi = cmd.bidi;
|
||||
|
||||
// A second navigate supersedes an in-flight one; answer the old command
|
||||
// so the client isn't left waiting on it forever.
|
||||
@@ -215,7 +238,7 @@ pub fn navigate(cmd: *BiDi.Command, ctx: *Context, opts: NavigateOpts) !void {
|
||||
// Set before starting: a navigation can reach its wait condition
|
||||
// synchronously (about:blank), which would fire the lifecycle callback
|
||||
// before we got a chance to record the pending command.
|
||||
switch (opts.wait) {
|
||||
switch (wait) {
|
||||
.none => {},
|
||||
.interactive => ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .interactive },
|
||||
.complete => ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .complete },
|
||||
@@ -225,13 +248,13 @@ pub fn navigate(cmd: *BiDi.Command, ctx: *Context, opts: NavigateOpts) !void {
|
||||
// navigated has nothing to preserve, so it navigates in place; a live
|
||||
// page goes through the pending-Page replacement machinery.
|
||||
const nav_result = if (frame._load_state == .waiting)
|
||||
frame.navigate(encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } })
|
||||
frame.navigate(url, nav_opts)
|
||||
else
|
||||
bidi.user_context.session.initiateRootNavigation(frame._frame_id, encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
||||
bidi.user_context.session.initiateRootNavigation(frame._frame_id, url, nav_opts);
|
||||
|
||||
nav_result catch |err| {
|
||||
log.warn(.bidi, "navigate", .{ .err = err });
|
||||
if (opts.wait != .none and ctx.pending_navigate == null) {
|
||||
if (wait != .none and ctx.pending_navigate == null) {
|
||||
// the lifecycle already answered it
|
||||
return;
|
||||
}
|
||||
@@ -239,8 +262,8 @@ pub fn navigate(cmd: *BiDi.Command, ctx: *Context, opts: NavigateOpts) !void {
|
||||
return cmd.sendError("unknown error", "navigation failed");
|
||||
};
|
||||
|
||||
if (opts.wait == .none) {
|
||||
return cmd.sendResult(.{ .navigation = &ctx.navigation_id, .url = encoded_url });
|
||||
if (wait == .none) {
|
||||
return cmd.sendResult(.{ .navigation = &ctx.navigation_id, .url = url });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,22 +21,42 @@
|
||||
// worker runs it as a BiDi.Command, which answers the parked request.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const Frame = @import("../../browser/Frame.zig");
|
||||
|
||||
const Method = @import("../http.zig").Connection.Method;
|
||||
|
||||
const BiDi = @import("BiDi.zig");
|
||||
const input = @import("input.zig");
|
||||
const browsing_context = @import("browsing_context.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// The key of a WebDriver element reference: {"element-6066-…": "<sharedId>"}
|
||||
pub const element_key = "element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
pub const Command = union(enum) {
|
||||
navigate_to: NavigateTo,
|
||||
get_current_url,
|
||||
refresh,
|
||||
get_title,
|
||||
get_window_handle,
|
||||
get_window_handles,
|
||||
get_page_source,
|
||||
take_screenshot,
|
||||
perform_actions: PerformActions,
|
||||
release_actions,
|
||||
};
|
||||
|
||||
pub const NavigateTo = struct {
|
||||
url: [:0]const u8,
|
||||
};
|
||||
|
||||
pub const PerformActions = struct {
|
||||
actions: []const std.json.Value,
|
||||
};
|
||||
|
||||
const Route = struct {
|
||||
method: Method,
|
||||
// what follows /session/{id}
|
||||
@@ -46,6 +66,15 @@ const Route = struct {
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
pub const ParseError = error{
|
||||
@@ -74,6 +103,10 @@ pub fn parse(arena: Allocator, method: Method, path: []const u8, body: []const u
|
||||
}
|
||||
|
||||
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 {};
|
||||
}
|
||||
return std.json.parseFromSliceLeaky(T, arena, body, .{
|
||||
.ignore_unknown_fields = true,
|
||||
// body is the connection's read buffer, reused once the request is parked
|
||||
@@ -88,26 +121,101 @@ fn parseBody(comptime T: type, arena: Allocator, body: []const u8) ParseError!T
|
||||
pub fn process(cmd: *BiDi.Command) !void {
|
||||
switch (cmd.input.http) {
|
||||
.navigate_to => |p| return navigateTo(cmd, p),
|
||||
.get_current_url => return getCurrentUrl(cmd),
|
||||
.refresh => return refresh(cmd),
|
||||
.get_title => return getTitle(cmd),
|
||||
.get_window_handle => return getWindowHandle(cmd),
|
||||
.get_window_handles => return getWindowHandles(cmd),
|
||||
.get_page_source => return getPageSource(cmd),
|
||||
.take_screenshot => return takeScreenshot(cmd),
|
||||
.perform_actions => |p| return performActions(cmd, p),
|
||||
.release_actions => return releaseActions(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /session/{id}/url. Answers once the page has loaded (the "normal"
|
||||
// page load strategy).
|
||||
// POST /session/{id}/url.
|
||||
fn navigateTo(cmd: *BiDi.Command, p: NavigateTo) !void {
|
||||
const ctx = currentContext(cmd.bidi) catch |err| switch (err) {
|
||||
error.CreatePage => return cmd.sendError("unknown error", "failed to create page"),
|
||||
else => return err,
|
||||
};
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
return browsing_context.navigate(cmd, ctx, .{ .url = p.url, .wait = .complete });
|
||||
}
|
||||
|
||||
// An HTTP session always has a top-level browsing context; it's opened on
|
||||
// first use.
|
||||
fn currentContext(bidi: *BiDi) !*browsing_context.Context {
|
||||
if (bidi.browsing_context) |*ctx| {
|
||||
// GET /session/{id}/url
|
||||
fn getCurrentUrl(cmd: *BiDi.Command) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
return cmd.sendResult(frame.url);
|
||||
}
|
||||
|
||||
// POST /session/{id}/refresh.
|
||||
fn refresh(cmd: *BiDi.Command) !void {
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
return browsing_context.reload(cmd, ctx, .complete);
|
||||
}
|
||||
|
||||
// GET /session/{id}/title
|
||||
fn getTitle(cmd: *BiDi.Command) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
return cmd.sendResult((try frame.getTitle()) orelse "");
|
||||
}
|
||||
|
||||
// GET /session/{id}/window. The handle is the BiDi context id
|
||||
fn getWindowHandle(cmd: *BiDi.Command) !void {
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
return cmd.sendResult(&ctx.id);
|
||||
}
|
||||
|
||||
// GET /session/{id}/window/handles
|
||||
fn getWindowHandles(cmd: *BiDi.Command) !void {
|
||||
const ctx = (try currentContext(cmd)) orelse return;
|
||||
return cmd.sendResult(&[_][]const u8{&ctx.id});
|
||||
}
|
||||
|
||||
// GET /session/{id}/source
|
||||
fn getPageSource(cmd: *BiDi.Command) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
var aw: std.Io.Writer.Allocating = .init(cmd.arena);
|
||||
try lp.dump.root(frame.window._document, .{ .shadow = .skip }, &aw.writer, frame);
|
||||
return cmd.sendResult(aw.written());
|
||||
}
|
||||
|
||||
// GET /session/{id}/screenshot.
|
||||
fn takeScreenshot(cmd: *BiDi.Command) !void {
|
||||
const frame = (try currentFrame(cmd)) orelse return;
|
||||
const opts: lp.screenshot.Opts = .fromViewport(cmd.bidi.browser.getViewport(), false);
|
||||
const shot = try lp.screenshot.preparePng(cmd.arena, .{ .root = frame.window._document.asNode() }, opts, frame);
|
||||
return cmd.sendResult(shot);
|
||||
}
|
||||
|
||||
// POST /session/{id}/actions.
|
||||
fn performActions(cmd: *BiDi.Command, p: PerformActions) !void {
|
||||
_ = (try currentContext(cmd)) orelse return;
|
||||
return input.perform(cmd, p.actions);
|
||||
}
|
||||
|
||||
// DELETE /session/{id}/actions
|
||||
fn releaseActions(cmd: *BiDi.Command) !void {
|
||||
_ = (try currentContext(cmd)) orelse return;
|
||||
return input.release(cmd);
|
||||
}
|
||||
|
||||
fn currentContext(cmd: *BiDi.Command) !?*browsing_context.Context {
|
||||
if (cmd.bidi.browsing_context) |*ctx| {
|
||||
return ctx;
|
||||
}
|
||||
return browsing_context.openContext(bidi);
|
||||
return browsing_context.openContext(cmd.bidi) catch |err| switch (err) {
|
||||
error.CreatePage => {
|
||||
try cmd.sendError("unknown error", "failed to create page");
|
||||
return null;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
fn currentFrame(cmd: *BiDi.Command) !?*Frame {
|
||||
_ = (try currentContext(cmd)) orelse return null;
|
||||
return cmd.bidi.user_context.session.currentFrame() orelse {
|
||||
try cmd.sendError("no such window", "no frame");
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
@@ -119,7 +227,24 @@ test "bidi.http_command: parse" {
|
||||
try testing.expectEqual("about:blank", command.navigate_to.url);
|
||||
}
|
||||
|
||||
try testing.expectError(error.UnknownMethod, parse(arena, .GET, "/url", ""));
|
||||
{
|
||||
// parameterless commands ignore the body
|
||||
const command = try parse(arena, .POST, "/refresh", "{}");
|
||||
try testing.expect(command == .refresh);
|
||||
}
|
||||
|
||||
{
|
||||
const command = try parse(arena, .POST, "/actions", "{\"actions\":[{\"type\":\"none\",\"id\":\"n\",\"actions\":[]}]}");
|
||||
try testing.expectEqual(1, command.perform_actions.actions.len);
|
||||
}
|
||||
|
||||
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", "{}"));
|
||||
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/actions", "{}"));
|
||||
try testing.expectError(error.UnknownCommand, parse(arena, .POST, "/nope", "{}"));
|
||||
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/url", "not json"));
|
||||
try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/url", "{}"));
|
||||
|
||||
@@ -25,6 +25,7 @@ const KeyboardEvent = @import("../../browser/webapi/event/KeyboardEvent.zig");
|
||||
|
||||
const BiDi = @import("BiDi.zig");
|
||||
const remote_value = @import("remote_value.zig");
|
||||
const http_command = @import("http_command.zig");
|
||||
const browsing_context = @import("browsing_context.zig");
|
||||
|
||||
const log = lp.log;
|
||||
@@ -52,13 +53,17 @@ fn performActions(cmd: *BiDi.Command) !void {
|
||||
if ((try browsing_context.requireContext(cmd, p.context)) == null) {
|
||||
return;
|
||||
}
|
||||
return perform(cmd, p.actions);
|
||||
}
|
||||
|
||||
// The core of performActions, shared with the HTTP session's Perform Actions.
|
||||
pub fn perform(cmd: *BiDi.Command, actions: []const std.json.Value) !void {
|
||||
const bidi = cmd.bidi;
|
||||
const arena = try bidi.app.arena_pool.acquire(.small, "bidi input.Pending");
|
||||
// run takes onwership of arena, so errdefer can lead to a double-free.
|
||||
// explicit release on error instead, then transfer to run.
|
||||
|
||||
const ticks = parseTicks(bidi, arena.allocator(), p.actions) catch |err| {
|
||||
const ticks = parseTicks(bidi, arena.allocator(), actions) catch |err| {
|
||||
arena.release();
|
||||
if (err == error.OutOfMemory) {
|
||||
return err;
|
||||
@@ -89,7 +94,11 @@ fn releaseActions(cmd: *BiDi.Command) !void {
|
||||
if ((try browsing_context.requireContext(cmd, p.context)) == null) {
|
||||
return;
|
||||
}
|
||||
return release(cmd);
|
||||
}
|
||||
|
||||
// The core of releaseActions, shared with the HTTP session's Release Actions.
|
||||
pub fn release(cmd: *BiDi.Command) !void {
|
||||
const bidi = cmd.bidi;
|
||||
const frame = bidi.user_context.session.currentFrame() orelse {
|
||||
return cmd.sendError("no such frame", "no frame");
|
||||
@@ -113,7 +122,7 @@ fn releaseActions(cmd: *BiDi.Command) !void {
|
||||
}
|
||||
}
|
||||
|
||||
return cmd.sendResult(struct {}{});
|
||||
return cmd.sendDone();
|
||||
}
|
||||
|
||||
fn dispatchFailed(cmd: *BiDi.Command, err: DispatchError) !void {
|
||||
@@ -297,7 +306,7 @@ const Pending = struct {
|
||||
}
|
||||
}
|
||||
|
||||
try bidi.replyResult(self.reply, struct {}{});
|
||||
try bidi.replyDone(self.reply);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -377,7 +386,7 @@ fn parseTicks(bidi: *BiDi, arena: Allocator, actions: []const std.json.Value) Pa
|
||||
|
||||
const parsed = try arena.alloc(Action, sa.actions.len);
|
||||
for (sa.actions, parsed) |raw, *action| {
|
||||
action.* = try parseAction(sa.type, raw);
|
||||
action.* = try parseAction(arena, sa.type, raw);
|
||||
}
|
||||
columns.appendAssumeCapacity(.{ .source = source, .actions = parsed });
|
||||
|
||||
@@ -398,7 +407,8 @@ fn parseTicks(bidi: *BiDi, arena: Allocator, actions: []const std.json.Value) Pa
|
||||
return ticks;
|
||||
}
|
||||
|
||||
fn parseAction(kind: Source.Kind, raw: std.json.Value) ParseError!Action {
|
||||
// `arena` outlives the command: an action can run after its json is gone.
|
||||
fn parseAction(arena: Allocator, kind: Source.Kind, raw: std.json.Value) ParseError!Action {
|
||||
const obj = switch (raw) {
|
||||
.object => |o| o,
|
||||
else => return error.InvalidActions,
|
||||
@@ -437,7 +447,7 @@ fn parseAction(kind: Source.Kind, raw: std.json.Value) ParseError!Action {
|
||||
.x = try numberField(obj, "x"),
|
||||
.y = try numberField(obj, "y"),
|
||||
.duration = try uintField(obj, "duration", 0),
|
||||
.origin = try originField(obj, .pointer),
|
||||
.origin = try originField(arena, obj, .pointer),
|
||||
} },
|
||||
.scroll => .{ .scroll = .{
|
||||
.x = try numberField(obj, "x"),
|
||||
@@ -445,7 +455,7 @@ fn parseAction(kind: Source.Kind, raw: std.json.Value) ParseError!Action {
|
||||
.delta_x = try numberField(obj, "deltaX"),
|
||||
.delta_y = try numberField(obj, "deltaY"),
|
||||
.duration = try uintField(obj, "duration", 0),
|
||||
.origin = try originField(obj, .wheel),
|
||||
.origin = try originField(arena, obj, .wheel),
|
||||
} },
|
||||
};
|
||||
}
|
||||
@@ -492,7 +502,7 @@ fn numberField(obj: std.json.ObjectMap, name: []const u8) ParseError!f64 {
|
||||
};
|
||||
}
|
||||
|
||||
fn originField(obj: std.json.ObjectMap, source: Source.Kind) ParseError!Origin {
|
||||
fn originField(arena: Allocator, obj: std.json.ObjectMap, source: Source.Kind) ParseError!Origin {
|
||||
const value = obj.get("origin") orelse return .viewport;
|
||||
switch (value) {
|
||||
.null => return .viewport,
|
||||
@@ -506,6 +516,13 @@ fn originField(obj: std.json.ObjectMap, source: Source.Kind) ParseError!Origin {
|
||||
return error.InvalidOrigin;
|
||||
},
|
||||
.object => |o| {
|
||||
// an HTTP session's element reference
|
||||
if (o.get(http_command.element_key)) |ref| {
|
||||
return switch (ref) {
|
||||
.string => |s| .{ .element = try arena.dupe(u8, s) },
|
||||
else => error.InvalidOrigin,
|
||||
};
|
||||
}
|
||||
const typ = o.get("type") orelse return error.InvalidOrigin;
|
||||
if (typ != .string or std.mem.eql(u8, typ.string, "element") == false) {
|
||||
return error.InvalidOrigin;
|
||||
@@ -515,7 +532,7 @@ fn originField(obj: std.json.ObjectMap, source: Source.Kind) ParseError!Origin {
|
||||
else => return error.InvalidOrigin,
|
||||
};
|
||||
return switch (element.get("sharedId") orelse return error.InvalidOrigin) {
|
||||
.string => |s| .{ .element = s },
|
||||
.string => |s| .{ .element = try arena.dupe(u8, s) },
|
||||
else => error.InvalidOrigin,
|
||||
};
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user