diff --git a/src/server/Server.zig b/src/server/Server.zig index c0b3f6f8a..44c8f47d8 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -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\":\"\\nbidi input")); + } + { + 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); } diff --git a/src/server/bidi/BiDi.zig b/src/server/bidi/BiDi.zig index 44affd688..badcd37bc 100644 --- a/src/server/bidi/BiDi.zig +++ b/src/server/bidi/BiDi.zig @@ -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), diff --git a/src/server/bidi/browsing_context.zig b/src/server/bidi/browsing_context.zig index 03c0fa847..48c2bd18d 100644 --- a/src/server/bidi/browsing_context.zig +++ b/src/server/bidi/browsing_context.zig @@ -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 }); } } diff --git a/src/server/bidi/http_command.zig b/src/server/bidi/http_command.zig index 0f3347b67..3b391385a 100644 --- a/src/server/bidi/http_command.zig +++ b/src/server/bidi/http_command.zig @@ -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-…": ""} +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", "{}")); diff --git a/src/server/bidi/input.zig b/src/server/bidi/input.zig index 7762dfaef..8b61c275c 100644 --- a/src/server/bidi/input.zig +++ b/src/server/bidi/input.zig @@ -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, }; },