From 766c0d05d6910abc4e7f31428f266c65f8a98110 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 21 Aug 2026 07:34:15 +0800 Subject: [PATCH] bidi: protocol selection CLI, start of [classic] WebDriver In order to support Selenium the way people are used to, it looks like we need to support both WebDriver classic (WebDriver) and WebDriver BiDi (BiDi). Typical scripts look like a mix of the two, e.g. using WebDriver to control the browser and using BiDi to receive notifications. This commit: 1 - adds a --protocol (cdp|webdriver) CLI argument to the `serve` command to enable one or the other protocol (defaulting to CDP) 2 - adds basic WebDriver endpoint to let a Selenium client connect. This implementation is hackish and sits on top of our simple Handshake handler. The handshake handler is well past its original design. Serving /json/version and /metrics from it was one thing. But Driving the entire browser session? This will get a follow up PR. --- .github/workflows/e2e-test.yml | 3 +- src/Config.zig | 14 +++ src/server/Handshake.zig | 212 +++++++++++++++++++++++++++++---- src/server/Server.zig | 180 +++++++++++++++++++++++++--- src/server/bidi/BiDi.zig | 7 +- src/server/bidi/session.zig | 38 ++++-- src/server/bidi/testing.zig | 2 +- src/testing.zig | 3 +- 8 files changed, 409 insertions(+), 50 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 8e2a70b4d..7494f5e22 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -116,7 +116,8 @@ jobs: echo "value=$args" >> "$GITHUB_OUTPUT" - run: | - ./lightpanda serve --port 9222 ${{ steps.args.outputs.value }} & + ./lightpanda serve --port 9222 --protocol cdp ${{ steps.args.outputs.value }} & + ./lightpanda serve --port 9223 --protocol webdriver ${{ steps.args.outputs.value }} & - run: | go run runner/main.go diff --git a/src/Config.zig b/src/Config.zig index df3ccfbb0..6d206e2ba 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -386,6 +386,7 @@ const Commands = cli.Builder(.{ .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, .{ .name = "port", .type = u16, .default = 9222 }, .{ .name = "advertise_host", .type = ?[]const u8 }, + .{ .name = "protocol", .type = Protocol, .default = Protocol.cdp }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, .{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 }, .{ .name = "cdp_max_message_size", .type = u32, .default = 1024 * 1024 }, @@ -821,6 +822,19 @@ pub fn adblockLists(self: *const Config) ?std.mem.SplitIterator(u8, .scalar) { return std.mem.splitScalar(u8, paths, ','); } +pub const Protocol = enum { + cdp, + webdriver, +}; + +pub fn protocol(self: *const Config) Protocol { + return switch (self.mode) { + .serve => |opts| opts.protocol, + .mcp => .cdp, + else => unreachable, + }; +} + pub fn maxConnections(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.cdp_max_connections, diff --git a/src/server/Handshake.zig b/src/server/Handshake.zig index 76ad05e6b..581e27509 100644 --- a/src/server/Handshake.zig +++ b/src/server/Handshake.zig @@ -26,7 +26,9 @@ const lp = @import("lightpanda"); const App = @import("../App.zig"); const sys_net = @import("../sys/net.zig"); +const uuidv4 = @import("../id.zig").uuidv4; const header_parser = @import("../network/header_parser.zig"); +const bidi_session = @import("bidi/session.zig"); const log = lp.log; const posix = std.posix; @@ -35,27 +37,46 @@ const Handshake = @This(); pub const Driver = enum { cdp, bidi }; +// Which driver an upgraded socket is handed to. +pub const Route = union(Driver) { + cdp, + bidi: ?[36]u8, // the sessionId +}; + +// Which route families are served. +pub const Protocols = struct { + cdp: bool = false, + webdriver: bool = false, +}; + +// What every handshake on a server needs; built once by Server. +pub const Options = struct { + protocols: Protocols, + bidi_session_url: []const u8, + json_version_response: []const u8, +}; + app: *App, len: usize = 0, socket: posix.socket_t, // cdpMaxHTTPMessageSize is a u14, so this covers any configured limit. buf: [std.math.maxInt(u14) + 1]u8 = undefined, -json_version_response: []const u8, +options: *const Options, const Result = union(enum) { more, close, - upgrade: Driver, + upgrade: Route, }; // Runs the HTTP phase to completion. Returns the route to hand the // upgraded socket to, or null if the connection is done (plain HTTP // request served, error, timeout or disconnect). -pub fn run(app: *App, socket: posix.socket_t, json_version_response: []const u8) ?Driver { +pub fn run(app: *App, socket: posix.socket_t, options: *const Options) ?Route { var self = Handshake{ .app = app, .socket = socket, - .json_version_response = json_version_response, + .options = options, }; while (true) { @@ -97,13 +118,10 @@ fn processHttpRequest(self: *Handshake) !Result { return error.RequestTooLarge; } - // we're only expecting [body-less] GET requests. - if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) { - // we need more data, put any more data here - return .more; - } + // Wait for the whole header block; put any more data here. + const head_len = (std.mem.indexOf(u8, request, "\r\n\r\n") orelse return .more) + 4; - return self.handleHttpRequest(request) catch |err| { + return self.handleHttpRequest(request, head_len) catch |err| { switch (err) { error.NotFound => self.sendHttpError(404, "Not found"), error.ForbiddenOrigin => self.sendHttpError(403, "Origin not allowed"), @@ -123,35 +141,74 @@ fn processHttpRequest(self: *Handshake) !Result { }; } -fn handleHttpRequest(self: *Handshake, request: []u8) !Result { +fn handleHttpRequest(self: *Handshake, request: []u8, head_len: usize) !Result { if (request.len < 18) { // 18 is [generously] the smallest acceptable HTTP request return error.InvalidRequest; } + // The classic WebDriver session bootstrap is the only thing with a body. + if (std.mem.startsWith(u8, request, "POST ") or std.mem.startsWith(u8, request, "DELETE ")) { + if (!self.options.protocols.webdriver) { + return error.NotFound; + } + return self.handleWebDriverRequest(request, head_len); + } + if (std.mem.eql(u8, request[0..4], "GET ") == false) { return error.NotFound; } + // Everything else is a body-less GET: the header block is the request. + if (head_len != request.len) { + return .more; + } + const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse { return error.InvalidRequest; }; const url = request[4..url_end]; + if (std.mem.eql(u8, url, "/metrics") and self.app.config.metricsEndpointEnabled()) { + try self.sendMetrics(); + self.shutdown(); + return .close; + } + + if (self.options.protocols.webdriver) { + if (std.mem.eql(u8, url, "/session")) { + // /session is the path Firefox advertises its BiDi endpoint on + try self.upgrade(request); + return .{ .upgrade = .{ .bidi = null } }; + } + + if (std.mem.startsWith(u8, url, "/session/") and url.len == "/session/".len + 36) { + // The URL a POST /session handed out; the session id is the suffix. + var session_id: [36]u8 = undefined; + @memcpy(&session_id, url["/session/".len..]); + try self.upgrade(request); + return .{ .upgrade = .{ .bidi = session_id } }; + } + + if (std.mem.eql(u8, url, "/status")) { + // WebDriver's discovery endpoint; `ready` is whether a new session + // can be created, which the bootstrap never refuses. + return self.sendWebDriver("200 OK", .{ .ready = true, .message = "" }); + } + } + + if (!self.options.protocols.cdp) { + return error.NotFound; + } + if (std.mem.eql(u8, url, "/")) { try self.upgrade(request); return .{ .upgrade = .cdp }; } - if (std.mem.eql(u8, url, "/session")) { - // /session is the path Firefox advertises its BiDi endpoint on - try self.upgrade(request); - return .{ .upgrade = .bidi }; - } - if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) { - try self.send(self.json_version_response); + try self.send(self.options.json_version_response); // Chromedp (a Go driver) does an http request to /json/version // then to / (websocket upgrade) using a different connection. // Since we only allow 1 connection at a time, the 2nd one (the @@ -176,15 +233,126 @@ fn handleHttpRequest(self: *Handshake, request: []u8) !Result { return .close; } - if (std.mem.eql(u8, url, "/metrics") and self.app.config.metricsEndpointEnabled()) { - try self.sendMetrics(); - self.shutdown(); - return .close; + return error.NotFound; +} + +// TODO: Temporary solution that provides the bare minimum for Selenium to +// connect. Serve a few of the (classic) WebDriver HTTP API. It's obvious that +// Handshake.zig needs to become a more generic HTTP server/router, but that +// can be done after the experimental BiDi code lands. +fn handleWebDriverRequest(self: *Handshake, request: []const u8, head_len: usize) !Result { + // A malformed request line or header maps to a 400 in processHttpRequest. + const method, const path, _, var header_iterator = header_parser.parseRequest(request) catch { + return error.InvalidProtocol; + }; + + var content_length: usize = 0; + while (header_iterator.next() catch return error.InvalidRequest) |header| { + if (std.ascii.eqlIgnoreCase(header.key, "content-length")) { + content_length = std.fmt.parseInt(usize, header.value, 10) catch return error.InvalidRequest; + } } + const total_len = head_len + content_length; + if (request.len < total_len) { + return .more; + } + if (request.len > total_len) { + return error.InvalidRequest; + } + const body = request[head_len..total_len]; + + switch (method) { + .post => if (std.mem.eql(u8, path, "/session")) { + return self.newSession(body); + }, + .delete => if (std.mem.startsWith(u8, path, "/session/")) { + return self.sendWebDriver("200 OK", null); + }, + else => {}, + } return error.NotFound; } +fn newSession(self: *Handshake, body: []const u8) !Result { + const allocator = self.app.allocator; + + const Capability = struct { webSocketUrl: ?bool = null }; + const parsed = std.json.parseFromSlice(struct { + capabilities: ?struct { + alwaysMatch: ?Capability = null, + firstMatch: ?[]const Capability = null, + } = null, + }, allocator, body, .{ .ignore_unknown_fields = true }) catch { + return self.sendWebDriver("400 Bad Request", .{ + .@"error" = "invalid argument", + .message = "invalid JSON body", + .stacktrace = "", + }); + }; + defer parsed.deinit(); + + // Without the capability the client intends to drive the session over + // HTTP, which this server doesn't serve: tell it now rather than 404 + // its first real command. + if (!requestsWebSocketUrl(parsed.value.capabilities)) { + return self.sendWebDriver("500 Internal Server Error", .{ + .@"error" = "session not created", + .message = "only WebDriver BiDi sessions are supported; request the webSocketUrl capability", + .stacktrace = "", + }); + } + + var session_id: [36]u8 = undefined; + uuidv4(&session_id); + + const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.options.bidi_session_url, &session_id }); + defer allocator.free(url); + + return self.sendWebDriver("200 OK", .{ + .sessionId = &session_id, + .capabilities = bidi_session.Capabilities{ + .userAgent = self.app.config.http_headers.user_agent, + .webSocketUrl = url, + }, + }); +} + +fn requestsWebSocketUrl(capabilities: anytype) bool { + const caps = capabilities orelse return false; + if (caps.alwaysMatch) |always| { + if (always.webSocketUrl == true) { + return true; + } + } + for (caps.firstMatch orelse &.{}) |first| { + if (first.webSocketUrl == true) { + return true; + } + } + return false; +} + +// Answers a classic WebDriver request with {"value": value} and closes. +fn sendWebDriver(self: *Handshake, comptime status: []const u8, value: anytype) !Result { + const allocator = self.app.allocator; + + var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512); + defer aw.deinit(); + try std.json.Stringify.value(.{ .value = value }, .{}, &aw.writer); + const body = aw.written(); + + const response = try std.fmt.allocPrint(allocator, "HTTP/1.1 " ++ status ++ "\r\n" ++ + "Content-Length: {d}\r\n" ++ + "Connection: Close\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "{s}", .{ body.len, body }); + defer allocator.free(response); + try self.send(response); + self.shutdown(); + return .close; +} + fn upgrade(self: *Handshake, request: []u8) !void { // We need to make sure that we got all the necessary headers + values; // a bit per required header. diff --git a/src/server/Server.zig b/src/server/Server.zig index 460eeba51..86a05e8ad 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -72,6 +72,8 @@ const PSEUDO_POLLFDS = 2; app: *App, max_connections: usize, +protocols: Handshake.Protocols, +bidi_session_url: []const u8, json_version_response: []const u8, driver_mutex: std.Io.Mutex = .init, @@ -151,17 +153,27 @@ pub fn init(app: *App, address: sys_net.IpAddress) !*Server { pollfds[1] = .{ .fd = listener, .events = posix.POLL.IN, .revents = 0 }; log.note(.app, "server running", .{ .address = bound_address }); - const json_version_response = try buildJSONVersionResponse(app, bound_address.getPort()); + const port = bound_address.getPort(); + const json_version_response = try buildJSONVersionResponse(app, port); + errdefer allocator.free(json_version_response); + + const bidi_session_url = try std.fmt.allocPrint(allocator, "ws://{s}:{d}/session/", .{ app.config.advertiseHost(), port }); + errdefer allocator.free(bidi_session_url); self.* = .{ .app = app, .cdp_pool = .empty, .json_version_response = json_version_response, + .bidi_session_url = bidi_session_url, .max_connections = max_connections, .listener = listener, .pollfds = pollfds, .wakeup_pipe = pipe, .poll_snapshot = poll_snapshot, + .protocols = switch (app.config.protocol()) { + .cdp => .{ .cdp = true }, + .webdriver => .{ .webdriver = true }, + }, }; return self; } @@ -219,6 +231,7 @@ pub fn deinit(self: *Server) void { self.handshakes.deinit(allocator); self.cdp_pool.deinit(allocator); allocator.free(self.json_version_response); + allocator.free(self.bidi_session_url); allocator.free(self.pollfds); allocator.free(self.poll_snapshot); for (self.wakeup_pipe) |fd| { @@ -634,11 +647,11 @@ fn handleConnection(self: *Server, socket: posix.socket_t) void { const route = self.handshake(socket) orelse return; switch (route) { .cdp => self.serveCDP(socket, &active_conns_early_release), - .bidi => self.serveBiDi(socket, &active_conns_early_release), + .bidi => |session_id| self.serveBiDi(socket, session_id, &active_conns_early_release), } } -fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Driver { +fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Route { { self.driver_mutex.lockUncancelable(lp.io); defer self.driver_mutex.unlock(lp.io); @@ -654,7 +667,11 @@ fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Driver { } } } - return Handshake.run(self.app, socket, self.json_version_response); + return Handshake.run(self.app, socket, &.{ + .protocols = self.protocols, + .json_version_response = self.json_version_response, + .bidi_session_url = self.bidi_session_url, + }); } // The socket is an upgraded websocket speaking CDP. @@ -684,15 +701,17 @@ fn serveCDP(self: *Server, socket: posix.socket_t, active_conns_early_release: * self.serve(.init(.{ .cdp = cdp }), active_conns_early_release); } -// The socket is an upgraded websocket speaking WebDriver BiDi. -fn serveBiDi(self: *Server, socket: posix.socket_t, active_conns_early_release: *bool) void { +// The socket is an upgraded websocket speaking WebDriver BiDi. session_id is +// set when the client came through a classic POST /session, which already +// created the session it's about to use. +fn serveBiDi(self: *Server, socket: posix.socket_t, session_id: ?[36]u8, active_conns_early_release: *bool) void { const allocator = self.app.allocator; // heap-allocated: BiDi embeds a Browser const bidi = allocator.create(BiDi) catch @panic("OOM"); defer allocator.destroy(bidi); - bidi.init(self.app, socket) catch |err| { + bidi.init(self.app, socket, session_id) catch |err| { log.err(.app, "BiDi init", .{ .err = err }); return; }; @@ -1357,6 +1376,139 @@ test "server: 404" { "Not found", res); } +test "server: classic session bootstrap" { + // What Selenium does before it speaks BiDi: a classic POST /session + // that hands back the websocket URL, then a DELETE on quit. + const session_id = blk: { + var c = try createTestClient(); + defer c.deinit(); + + const body = "{\"capabilities\":{\"firstMatch\":[{}],\"alwaysMatch\":{\"browserName\":\"firefox\",\"webSocketUrl\":true}}}"; + const res = try c.httpRequest(std.fmt.comptimePrint("POST /session HTTP/1.1\r\n" ++ + "Content-Type: application/json;charset=UTF-8\r\n" ++ + "Content-Length: {d}\r\n\r\n" ++ + "{s}", .{ body.len, body })); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\r\nConnection: Close\r\n") != null); + + const json = res[std.mem.indexOf(u8, res, "\r\n\r\n").? + 4 ..]; + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); + defer parsed.deinit(); + + const value = parsed.value.object.get("value").?.object; + const id = value.get("sessionId").?.string; + try testing.expectEqual(36, id.len); + + const capabilities = value.get("capabilities").?.object; + try testing.expectEqual("Lightpanda", capabilities.get("browserName").?.string); + try testing.expectEqual(false, capabilities.get("acceptInsecureCerts").?.bool); + const ws_url = capabilities.get("webSocketUrl").?.string; + try testing.expectEqual("ws://127.0.0.1:9583/session/", ws_url[0 .. ws_url.len - 36]); + try testing.expectEqual(id, ws_url[ws_url.len - 36 ..]); + + break :blk id[0..36].*; + }; + + { + // The session already exists on the advertised URL: no session.new + // needed (or possible), everything else works as usual. + var c = try createTestClient(); + defer c.deinit(); + var path_buf: [64]u8 = undefined; + try c.handshake(try std.fmt.bufPrint(&path_buf, "/session/{s}", .{&session_id})); + + try c.bidiCommand("{\"id\":1,\"method\":\"session.status\"}"); + try assertBidiMessage(&c, .{ .type = "success", .id = 1, .result = .{ .ready = false, .message = "session already started" } }); + + try c.bidiCommand("{\"id\":2,\"method\":\"session.new\",\"params\":{\"capabilities\":{}}}"); + try assertBidiMessage(&c, .{ .type = "error", .id = 2, .@"error" = "session not created", .message = "session already exists" }); + + try c.bidiCommand("{\"id\":3,\"method\":\"browsingContext.getTree\"}"); + try assertBidiMessage(&c, .{ .type = "success", .id = 3, .result = .{ .contexts = .{} } }); + } + + { + 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} HTTP/1.1\r\nContent-Length: 0\r\n\r\n", .{&session_id})); + try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ + "Content-Length: 14\r\n" ++ + "Connection: Close\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "{\"value\":null}", res); + } +} + +test "server: classic session bootstrap errors" { + { + // the body can arrive after the headers + var c = try createTestClient(); + defer c.deinit(); + const body = "{\"capabilities\":{\"alwaysMatch\":{\"browserName\":\"firefox\"}}}"; + try sys_net.writeAll(c.socket, std.fmt.comptimePrint("POST /session HTTP/1.1\r\nContent-Length: {d}\r\n\r\n", .{body.len})); + lp.io.sleep(.fromMilliseconds(20), .awake) catch {}; + const res = try c.httpRequest(body); + 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\":\"session not created\",\"message\":\"only WebDriver BiDi sessions are supported; request the webSocketUrl capability\",\"stacktrace\":\"\"}}")); + } + + { + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequest("POST /session HTTP/1.1\r\nContent-Length: 8\r\n\r\nnot 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 JSON body\",\"stacktrace\":\"\"}}")); + } + + try assertHTTPError(404, "Not found", "POST /session/abc HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + try assertHTTPError(404, "Not found", "DELETE /session HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + // a websocket upgrade on /session/ needs a real session id + try assertHTTPError(404, "Not found", "GET /session/abc HTTP/1.1\r\n\r\n"); +} + +test "server: protocol gate" { + // The test server serves both; the CLI only ever enables one. + const protocols = &testing.test_cdp_server.?.protocols; + defer protocols.* = .{ .cdp = true, .webdriver = true }; + + protocols.* = .{ .cdp = true }; + try assertHTTPError(404, "Not found", "GET /status HTTP/1.1\r\n\r\n"); + try assertHTTPError(404, "Not found", "POST /session HTTP/1.1\r\nContent-Length: 2\r\n\r\n{}"); + try assertHTTPError(404, "Not found", "DELETE /session/x HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + try assertHTTPError(404, "Not found", "GET /session HTTP/1.1\r\n" ++ + "Connection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\nsec-websocket-key: k\r\n\r\n"); + { + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + } + + protocols.* = .{ .webdriver = true }; + try assertHTTPError(404, "Not found", "GET /json/version HTTP/1.1\r\n\r\n"); + try assertHTTPError(404, "Not found", "GET /json/list HTTP/1.1\r\n\r\n"); + try assertHTTPError(404, "Not found", "GET / HTTP/1.1\r\n" ++ + "Connection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\nsec-websocket-key: k\r\n\r\n"); + { + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequest("GET /status HTTP/1.1\r\n\r\n"); + try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ + "Content-Length: 37\r\n" ++ + "Connection: Close\r\n" ++ + "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ + "{\"value\":{\"ready\":true,\"message\":\"\"}}", res); + } + { + // /metrics is protocol-neutral + var c = try createTestClient(); + defer c.deinit(); + const res = try c.httpRequestAlloc("GET /metrics HTTP/1.1\r\n\r\n"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + } +} + test "server: get /json/version" { { // twice on the same connection @@ -1386,7 +1538,6 @@ test "server: get /json/protocol" { defer c.deinit(); const res = try c.httpRequestAlloc("GET /json/protocol HTTP/1.1\r\n\r\n"); - defer testing.allocator.free(res); try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); try testing.expect(std.mem.indexOf(u8, res, "Content-Type: application/json") != null); @@ -1578,24 +1729,23 @@ const TestClient = struct { try sys_net.writeAll(self.socket, req); var response: std.ArrayList(u8) = .empty; - defer response.deinit(testing.allocator); while (true) { const n = try posix.read(self.socket, &self.buf); if (n == 0) { - return response.toOwnedSlice(testing.allocator); + return response.items; } - try response.appendSlice(testing.allocator, self.buf[0..n]); + try response.appendSlice(testing.arena_allocator, self.buf[0..n]); } } - fn handshake(self: *TestClient, comptime path: []const u8) !void { - const request = - "GET " ++ path ++ " HTTP/1.1\r\n" ++ + fn handshake(self: *TestClient, path: []const u8) !void { + var request_buf: [256]u8 = undefined; + const request = try std.fmt.bufPrint(&request_buf, "GET {s} HTTP/1.1\r\n" ++ "Connection: upgrade\r\n" ++ "Upgrade: websocket\r\n" ++ "sec-websocket-version:13\r\n" ++ "sec-websocket-key: this is my key\r\n" ++ - "Custom: Header-Value\r\n\r\n"; + "Custom: Header-Value\r\n\r\n", .{path}); const res = try self.httpRequest(request); try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++ diff --git a/src/server/bidi/BiDi.zig b/src/server/bidi/BiDi.zig index 1caaa0e29..6a7d25e8f 100644 --- a/src/server/bidi/BiDi.zig +++ b/src/server/bidi/BiDi.zig @@ -53,8 +53,8 @@ message_arena: std.heap.ArenaAllocator, // Allocations that live for the whole session (subscriptions). session_arena: std.heap.ArenaAllocator, -// The active session, created by session.new -session_id: ?[36]u8 = null, +// The active session +session_id: ?[36]u8, browser: Browser, notification: *Notification, @@ -89,7 +89,7 @@ const InputMessage = struct { method: ?[]const u8 = null, }; -pub fn init(self: *BiDi, app: *App, socket: posix.socket_t) !void { +pub fn init(self: *BiDi, app: *App, socket: posix.socket_t, session_id: ?[36]u8) !void { const allocator = app.allocator; self.* = .{ .app = app, @@ -98,6 +98,7 @@ pub fn init(self: *BiDi, app: *App, socket: posix.socket_t) !void { .browser = undefined, .user_context = undefined, .notification = undefined, + .session_id = session_id, .node_registry = .init(allocator), .handles = .{ .allocator = allocator }, .message_arena = std.heap.ArenaAllocator.init(allocator), diff --git a/src/server/bidi/session.zig b/src/server/bidi/session.zig index 99af6fcce..bb93e0b22 100644 --- a/src/server/bidi/session.zig +++ b/src/server/bidi/session.zig @@ -67,18 +67,42 @@ fn new(cmd: *const BiDi.Command) !void { return cmd.sendResult(.{ .sessionId = &bidi.session_id.?, - .capabilities = .{ - .acceptInsecureCerts = false, - .browserName = "Lightpanda", - .browserVersion = lp.build_config.version, - .platformName = platform_name, - .setWindowRect = false, + .capabilities = Capabilities{ .userAgent = cmd.bidi.app.config.http_headers.user_agent, - .proxy = struct {}{}, }, }); } +pub const Capabilities = struct { + acceptInsecureCerts: bool = false, + browserName: []const u8 = "Lightpanda", + browserVersion: []const u8 = lp.build_config.version, + platformName: []const u8 = platform_name, + setWindowRect: bool = false, + userAgent: []const u8, + proxy: struct {} = .{}, + webSocketUrl: ?[]const u8 = null, // only reported for the classic handshake + + // ugh, are you kidding me? All this so we don't emit the webSocketUrl + // when it's null. + pub fn jsonStringify(self: *const Capabilities, jws: anytype) !void { + try jws.beginObject(); + inline for (std.meta.fields(Capabilities)) |field| { + const value = @field(self, field.name); + if (@typeInfo(field.type) == .optional) { + if (value) |v| { + try jws.objectField(field.name); + try jws.write(v); + } + } else { + try jws.objectField(field.name); + try jws.write(value); + } + } + try jws.endObject(); + } +}; + fn end(cmd: *const BiDi.Command) !void { try cmd.sendResult(struct {}{}); diff --git a/src/server/bidi/testing.zig b/src/server/bidi/testing.zig index 2db75fa84..68cfe0ce8 100644 --- a/src/server/bidi/testing.zig +++ b/src/server/bidi/testing.zig @@ -60,7 +60,7 @@ pub const TestContext = struct { pub fn bidi(self: *TestContext) *BiDi { if (!self.bidi_initialized) { - self.bidi_.init(base.test_app, self.bidi_socket) catch |err| @panic(@errorName(err)); + self.bidi_.init(base.test_app, self.bidi_socket, null) catch |err| @panic(@errorName(err)); self.bidi_initialized = true; } return &self.bidi_; diff --git a/src/testing.zig b/src/testing.zig index 8df8e8b4c..c1706cb36 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -511,7 +511,7 @@ const Server = @import("server/Server.zig"); const TestWSServer = @import("TestWSServer.zig"); const TestHTTPServer = @import("TestHTTPServer.zig"); -var test_cdp_server: ?*Server = null; +pub var test_cdp_server: ?*Server = null; var test_cdp_server_thread: ?std.Thread = null; var test_http_server: ?TestHTTPServer = null; var test_http_server_thread: ?std.Thread = null; @@ -613,6 +613,7 @@ fn serveCDP(wg: *lp.WaitGroup) !void { std.debug.print("CDP server error: {}", .{err}); return err; }; + test_cdp_server.?.protocols = .{ .cdp = true, .webdriver = true }; wg.finish(); test_cdp_server.?.run();