diff --git a/src/server/Server.zig b/src/server/Server.zig index 206631dab..72c5194f0 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -2221,6 +2221,232 @@ test "server: HTTP element commands" { } } +test "server: HTTP execute script" { + 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 ++ "\"}"))); + + try testing.expectEqual( + "{\"value\":{\"script\":30000,\"pageLoad\":300000,\"implicit\":0}}", + responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")), + ); + + // the script is a function body, so `arguments` is bound and `return` works + try testing.expectEqual("{\"value\":5}", try executeSync(&c, &session_id, "return arguments[0] + arguments[1];", "[2,3]")); + try testing.expectEqual("{\"value\":\"hi\"}", try executeSync(&c, &session_id, "return 'hi';", "[]")); + try testing.expectEqual("{\"value\":true}", try executeSync(&c, &session_id, "return 1 < 2;", "[]")); + + // a whole number isn't 2e0, and what JSON can't hold is null + try testing.expectEqual("{\"value\":2}", try executeSync(&c, &session_id, "return 2.0;", "[]")); + try testing.expectEqual("{\"value\":1.5}", try executeSync(&c, &session_id, "return 1.5;", "[]")); + try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "return 0/0;", "[]")); + + // undefined, and a body that doesn't return at all + try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "return undefined;", "[]")); + try testing.expectEqual("{\"value\":null}", try executeSync(&c, &session_id, "var x = 1;", "[]")); + + try testing.expectEqual( + "{\"value\":{\"a\":1,\"b\":[true,null,\"x\"]}}", + try executeSync(&c, &session_id, "return {a: 1, b: [true, null, 'x']};", "[]"), + ); + + // a function has no own enumerable properties, so it clones to {} + try testing.expectEqual("{\"value\":{}}", try executeSync(&c, &session_id, "return function() {};", "[]")); + + // toJSON wins over the property walk + try testing.expectEqual( + "{\"value\":\"1970-01-01T00:00:00.000Z\"}", + try executeSync(&c, &session_id, "return new Date(0);", "[]"), + ); + + // an element comes back as a reference, and goes back in as the node + { + const body = try executeSync(&c, &session_id, "return document.getElementById('msg');", "[]"); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, testing.arena_allocator, body, .{}); + const reference = parsed.object.get("value").?.object.get(http_command.element_key).?.string; + + // the same node the find endpoints hand out + try testing.expectEqual(reference, try findElement(&c, &session_id, "css selector", "#msg")); + try testing.expectEqual("{\"value\":\"hello\"}", try elementCommand(&c, &session_id, reference, "/text")); + + const args = try std.fmt.allocPrint(testing.arena_allocator, "[{{\"" ++ http_command.element_key ++ "\":\"{s}\"}}]", .{reference}); + try testing.expectEqual("{\"value\":\"msg\"}", try executeSync(&c, &session_id, "return arguments[0].id;", args)); + } + + // a collection is an array of references, a non-element node is a bare {} + { + const body = try executeSync(&c, &session_id, "return document.querySelectorAll('.item');", "[]"); + try testing.expectEqual(2, (try elementReferences(body)).len); + try testing.expectEqual("{\"value\":[{}]}", try executeSync(&c, &session_id, "return [document.getElementById('msg').firstChild];", "[]")); + } + + // a reference nothing handed out + { + const res = try executeRaw(&c, &session_id, "sync", "return 1;", "[{\"" ++ http_command.element_key ++ "\":\"99\"}]"); + 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); + } + + // a throw fails the command; it isn't reported inside a successful result + { + const res = try executeRaw(&c, &session_id, "sync", "throw new Error('nope');", "[]"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: nope") != null); + } + + { + const res = try executeRaw(&c, &session_id, "sync", "return (", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "SyntaxError") != null); + } + + // a cycle is an error, not a collapsed value like a RemoteValue's + { + const res = try executeRaw(&c, &session_id, "sync", "var a = {}; a.self = a; return a;", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "circular reference") != null); + } + + // a returned promise is resolved before we answer + try testing.expectEqual("{\"value\":7}", try executeSync(&c, &session_id, "return Promise.resolve(7);", "[]")); + try testing.expectEqual( + "{\"value\":8}", + try executeSync(&c, &session_id, "return new Promise(function(r) { setTimeout(function() { r(8); }, 5); });", "[]"), + ); + { + const res = try executeRaw(&c, &session_id, "sync", "return Promise.reject(new Error('late'));", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: late") != null); + } + + // async: the callback is the last argument, and only its first call counts + try testing.expectEqual("{\"value\":42}", try executeAsync(&c, &session_id, "arguments[0](42);", "[]")); + try testing.expectEqual( + "{\"value\":42}", + try executeAsync(&c, &session_id, "var cb = arguments[arguments.length - 1]; cb(arguments[0] * 2);", "[21]"), + ); + try testing.expectEqual( + "{\"value\":\"late\"}", + try executeAsync(&c, &session_id, "var cb = arguments[0]; setTimeout(function() { cb('late'); cb('again'); }, 5);", "[]"), + ); + // what an async body returns is ignored + try testing.expectEqual("{\"value\":null}", try executeAsync(&c, &session_id, "arguments[0](); return 9;", "[]")); + + // only the first call counts; the rest are a no-op on a settled promise + try testing.expectEqual("{\"value\":1}", try executeAsync(&c, &session_id, "arguments[0](1); arguments[0](2);", "[]")); + + // The body is promise-called, so throwing rejects it and fails the + // command even though the callback already ran -- and that failure must + // not be a *second* answer on a connection we already handed back. + { + const res = try executeRaw(&c, &session_id, "async", "arguments[0](1); throw new Error('too late');", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: too late") != null); + } + try testing.expectEqual("{\"value\":2}", try executeSync(&c, &session_id, "return 2;", "[]")); + + // a throw before the callback still fails the command + { + const res = try executeRaw(&c, &session_id, "async", "throw new Error('early');", "[]"); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "Error: early") != null); + } + + // a script that never completes is answered by the script timeout + { + try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/timeouts", "{\"script\":50}"))); + try testing.expectEqual( + "{\"value\":{\"script\":50,\"pageLoad\":300000,\"implicit\":0}}", + responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")), + ); + + const res = try executeRaw(&c, &session_id, "async", "// never calls back", "[]"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 500 Internal Server Error\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"script timeout\"") != null); + + // a sync script whose promise never settles times out the same way + const promise = try executeRaw(&c, &session_id, "sync", "return new Promise(function() {});", "[]"); + try testing.expect(std.mem.indexOf(u8, promise, "\"error\":\"script timeout\"") != null); + + // A script that resolves AFTER it timed out: answering is not the + // promise settling, so the Pending has to outlive its own answer. + // Freeing it on the timeout leaves V8 holding our callbacks on a + // live promise and the late resolve lands in freed memory -- which a + // release build segfaults on, but the debug allocator here does not + // trap, so this covers the path rather than proving the invariant. + // selenium/http/demo.js in ../demo is what actually catches it. + const late = try executeRaw(&c, &session_id, "async", "var cb = arguments[0]; setTimeout(function() { window.__late = true; cb('way late'); }, 150);", "[]"); + try testing.expect(std.mem.indexOf(u8, late, "\"error\":\"script timeout\"") != null); + lp.io.sleep(.fromMilliseconds(400), .awake) catch {}; + // the assertion only means anything if the stale resolve actually ran + try testing.expectEqual("{\"value\":true}", try executeSync(&c, &session_id, "return window.__late === true;", "[]")); + try testing.expectEqual("{\"value\":\"alive\"}", try executeSync(&c, &session_id, "return 'alive';", "[]")); + } + + // null turns the script timeout off + { + try testing.expectEqual("{\"value\":null}", responseBody(try sessionCommand(&c, "POST", &session_id, "/timeouts", "{\"script\":null}"))); + try testing.expectEqual( + "{\"value\":{\"script\":null,\"pageLoad\":300000,\"implicit\":0}}", + responseBody(try sessionCommand(&c, "GET", &session_id, "/timeouts", "")), + ); + try testing.expectEqual("{\"value\":1}", try executeSync(&c, &session_id, "return 1;", "[]")); + } + + // Navigating out from under a running script answers it. The Pending + // stays alive past that answer -- V8 still holds its callback -- until + // the frame, and with it the context, is destroyed. + { + 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})); + + // the script never calls back, so its connection parks + var parked = try createTestClient(); + defer parked.deinit(); + try writeSessionCommand(&parked, "POST", &session_id, "/execute/async", "{\"script\":\"// never calls back\",\"args\":[]}"); + lp.io.sleep(.fromMilliseconds(50), .awake) catch {}; + + try ws.bidiCommand(try std.fmt.allocPrint(testing.arena_allocator, + \\{{"id":1,"method":"browsingContext.navigate","params":{{"context":"{s}","url":"about:blank","wait":"complete"}}}} + , .{handle})); + + const res = try parked.httpRequest(""); + try testing.expect(std.mem.indexOf(u8, res, "\"error\":\"javascript error\"") != null); + try testing.expect(std.mem.indexOf(u8, res, "document was unloaded") != null); + } +} + +// POST /execute/{sync,async}: the raw response, so a test can assert on an +// error too. +fn executeRaw(c: *TestClient, session_id: *const [36]u8, kind: []const u8, script: []const u8, args: []const u8) ![]const u8 { + const arena = testing.arena_allocator; + const quoted = try std.json.Stringify.valueAlloc(arena, script, .{}); + const body = try std.fmt.allocPrint(arena, "{{\"script\":{s},\"args\":{s}}}", .{ quoted, args }); + const path = try std.fmt.allocPrint(arena, "/execute/{s}", .{kind}); + return sessionCommand(c, "POST", session_id, path, body); +} + +fn executeSync(c: *TestClient, session_id: *const [36]u8, script: []const u8, args: []const u8) ![]const u8 { + return responseBody(try executeRaw(c, session_id, "sync", script, args)); +} + +fn executeAsync(c: *TestClient, session_id: *const [36]u8, script: []const u8, args: []const u8) ![]const u8 { + return responseBody(try executeRaw(c, session_id, "async", script, args)); +} + 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)); diff --git a/src/server/bidi/BiDi.zig b/src/server/bidi/BiDi.zig index badcd37bc..9724eed81 100644 --- a/src/server/bidi/BiDi.zig +++ b/src/server/bidi/BiDi.zig @@ -36,6 +36,7 @@ const Server = @import("../Server.zig"); const script = @import("script.zig"); const http_command = @import("http_command.zig"); +const execute = @import("execute.zig"); const remote_value = @import("remote_value.zig"); const posix = std.posix; @@ -84,6 +85,13 @@ handles: remote_value.Handles, // Commands awaiting promise resolution pending: std.ArrayList(*script.Pending) = .empty, +// The HTTP session's execute/sync and execute/async, awaiting a promise or +// the callback an async script was handed. +execute_pending: std.ArrayList(*execute.Pending) = .empty, + +// The HTTP session's timeouts. BiDi has no equivalent. +timeouts: Timeouts = .{}, + input_state: @import("input.zig").State = .{}, subscriptions: std.ArrayList(Subscription) = .empty, @@ -121,6 +129,12 @@ const InputMessage = struct { method: ?[]const u8 = null, }; +pub const Timeouts = struct { + script: ?u32 = 30_000, + pageLoad: u32 = 300_000, + implicit: u32 = 0, +}; + pub fn init(self: *BiDi, app: *App, inbox: *Inbox, origin: Origin) !void { const allocator = app.allocator; { @@ -176,11 +190,14 @@ pub fn deinit(self: *BiDi) void { // Cancel first, so that any completions during session teardown are still valid script.Pending.cancelAll(self); + execute.Pending.cancelAll(self); self.handles.deinit(); self.browser.closeSession(); // Now we can destroy script.Pending.destroyAll(self); + execute.Pending.destroyAll(self); self.pending.deinit(allocator); + self.execute_pending.deinit(allocator); self.input_state.deinit(allocator); self.node_registry.deinit(); @@ -262,6 +279,7 @@ const UserContext = struct { pub fn resetRealm(self: *BiDi) void { script.Pending.realmReset(self); + execute.Pending.realmReset(self); self.handles.releaseAll(); self.node_registry.reset(); if (self.browsing_context) |*ctx| { diff --git a/src/server/bidi/browsing_context.zig b/src/server/bidi/browsing_context.zig index aff470434..a1fcc1e99 100644 --- a/src/server/bidi/browsing_context.zig +++ b/src/server/bidi/browsing_context.zig @@ -32,6 +32,7 @@ const Notification = @import("../../Notification.zig"); const BiDi = @import("BiDi.zig"); const script = @import("script.zig"); +const execute = @import("execute.zig"); const remote_value = @import("remote_value.zig"); const log = lp.log; @@ -547,6 +548,7 @@ fn onFrameCreated(ptr: *anyopaque, frame: *Frame) !void { fn onFrameDestroyed(ptr: *anyopaque, frame: *const Frame) !void { const bidi: *BiDi = @ptrCast(@alignCast(ptr)); script.Pending.contextDestroyed(bidi, frame.js.id); + execute.Pending.contextDestroyed(bidi, frame.js.id); } fn onFrameNavigate(ptr: *anyopaque, msg: *const Notification.FrameNavigate) !void { diff --git a/src/server/bidi/execute.zig b/src/server/bidi/execute.zig new file mode 100644 index 000000000..2d7d46f53 --- /dev/null +++ b/src/server/bidi/execute.zig @@ -0,0 +1,580 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Unlike most WebDriver endpoints, we can't re-use the BiDi flow here. The +// flow is too different. WebDriver treats throws as command failures and +// serializs over JSON. BiDi treats throws as success with the error reported +// inside and serializes via RemoteValue. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const NodeRegistry = @import("../../NodeRegistry.zig"); + +const js = @import("../../browser/js/js.zig"); +const Frame = @import("../../browser/Frame.zig"); +const Node = @import("../../browser/webapi/Node.zig"); +const NodeList = @import("../../browser/webapi/collections/NodeList.zig"); +const HTMLCollection = @import("../../browser/webapi/collections/HTMLCollection.zig"); + +const BiDi = @import("BiDi.zig"); +const http_command = @import("http_command.zig"); + +const log = lp.log; +const Allocator = std.mem.Allocator; + +pub const Script = struct { + script: []const u8, + args: []const std.json.Value = &.{}, +}; + +pub const Mode = enum { + sync, + async, +}; + +// POST /session/{id}/execute/sync, POST /session/{id}/execute/async +pub fn run(cmd: *BiDi.Command, p: Script, mode: Mode) !void { + const bidi = cmd.bidi; + const frame = bidi.user_context.session.currentFrame() orelse { + return cmd.sendError("no such window", "no frame"); + }; + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + const local = &ls.local; + + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + + // `script` is a function *body*, not an expression + const function = local.compileFunction(p.script, &.{}, &.{}) catch |err| { + if (err == error.ExecutionTerminated or err == error.OutOfMemory) { + return err; + } + return cmd.sendError("javascript error", exceptionText(cmd.arena, &try_catch, err)); + }; + + const extra = @intFromBool(mode == .async); + const arguments = try cmd.arena.alloc(js.Value, p.args.len + extra); + for (p.args, arguments[0..p.args.len]) |argument, *js_argument| { + js_argument.* = fromJson(local, &bidi.node_registry, argument) catch |err| switch (err) { + error.NoSuchElement => return cmd.sendError("no such element", "unknown element reference"), + error.StaleElement => return cmd.sendError("stale element reference", "element is no longer attached to the document"), + error.InvalidArgument => return cmd.sendError("invalid argument", "cannot deserialize an argument"), + else => return err, + }; + } + + var deferred: ?js.Promise = null; + if (mode == .async) { + // It looks like we're supposed to give it a plain callback (which we + // could, via `local.newCallback`), but a promise makes managing the + // lifetime easier (because it can only be settled once). + const pair = (try local.exec(async_bridge, "webdriver.executeAsync")).toArray(); + deferred = (try pair.get(0)).toPromise(); + arguments[p.args.len] = try pair.get(1); + } + + const undef = try local.zigValueToJs({}, .{}); + const returned = function.callWithThisRethrow(js.Value, undef, arguments) catch |err| { + if (err == error.ExecutionTerminated or err == error.OutOfMemory) { + return err; + } + return cmd.sendError("javascript error", exceptionText(cmd.arena, &try_catch, err)); + }; + + // An async script's return value is ignored, the resolved value is what answers. + const value = if (deferred) |promise| promise.toValue() else returned; + if (value.isPromise() == false) { + return sendResult(cmd, frame, local, value); + } + + // this isn't jus the async path, we're also here if the sync script returned + // a promise. This is another advantage of using a profile as our async + // paramater: it gives us a single thing to handle here (a promise) rather + // than a promise (from a sync return) and a callback (if we used local.newCallback) + + const promise = value.toPromise(); + promise.markAsHandled(); + local.runMicrotasks(); + + switch (promise.state()) { + .fulfilled => return sendResult(cmd, frame, local, promise.result()), + .rejected => return sendRejection(cmd, promise.result()), + .pending => { + const pnd = try Pending.create(cmd, frame); + _ = try promise.thenAndCatch( + local.newCallback(Pending.onFulfilled, pnd), + local.newCallback(Pending.onRejected, pnd), + ); + }, + } +} + +// Returns [promise, resolve]. The script gets `resolve`; we wait on the promise. +const async_bridge = "(function(){var r; var p = new Promise(function(res){r = res;}); return [p, r];})()"; + +fn sendResult(cmd: *BiDi.Command, frame: *Frame, local: *const js.Local, value: js.Value) !void { + const cloned = clone(cmd.arena, &cmd.bidi.node_registry, frame, local, value) catch |err| switch (err) { + error.OutOfMemory, error.ExecutionTerminated => return err, + else => return cmd.sendError("javascript error", cloneErrorMessage(err)), + }; + return cmd.sendResult(cloned); +} + +fn sendRejection(cmd: *BiDi.Command, reason: js.Value) !void { + return cmd.sendError("javascript error", rejectionText(cmd.arena, reason)); +} + +// The exception stringified ("Error: nope"), not just its message, which is +// what a driver's users expect to read. +fn exceptionText(arena: Allocator, try_catch: *const js.TryCatch, err: anyerror) []const u8 { + const caught = try_catch.caughtOrError(arena, err); + const fallback = caught.exception orelse @errorName(err); + const thrown = try_catch.exceptionValue() orelse return fallback; + return thrown.toStringSliceWithAlloc(arena) catch fallback; +} + +fn rejectionText(arena: Allocator, reason: js.Value) []const u8 { + return reason.toStringSliceWithAlloc(arena) catch "promise rejected"; +} + +// Script with a value that'll come later, i.e. a sync script that returned +// a promise (thus, when the promise is resolved/rejected) or for an async +// script that will resolve the promise. +pub const Pending = struct { + bidi: *BiDi, + to: BiDi.Reply, + js_context_id: usize, + deadline: ?u64, // null if there isn't one + answered: bool = false, + + fn create(cmd: *BiDi.Command, frame: *Frame) !*Pending { + const bidi = cmd.bidi; + const allocator = bidi.app.allocator; + const timeout = bidi.timeouts.script; + + const self = try allocator.create(Pending); + errdefer allocator.destroy(self); + + self.* = .{ + .bidi = bidi, + .to = cmd.reply(), + .js_context_id = frame.js.id, + .deadline = if (timeout) |ms| lp.datetime.milliTimestamp(.boot) + ms else null, + }; + try bidi.execute_pending.append(allocator, self); + errdefer _ = bidi.execute_pending.pop(); + + if (timeout) |ms| { + // timeout defaults to 30 seconds and is likely not going to be + // needed, never block done for this. + try frame.js.scheduler.add(bidi, onTimeout, ms, .{ + .name = "webdriver.scriptTimeout", + .blocks_done = false, + }); + } + + // from this point on, we own the reply + _ = cmd.takeReply(); + return self; + } + + fn unregister(self: *Pending) void { + const bidi = self.bidi; + for (bidi.execute_pending.items, 0..) |pending, i| { + if (pending == self) { + _ = bidi.execute_pending.swapRemove(i); + break; + } + } + bidi.app.allocator.destroy(self); + } + + fn onFulfilled(self: *Pending, value: js.Value, exec: *const js.Execution) void { + defer self.unregister(); + self.answer(exec.js.local.?, value); + } + + fn onRejected(self: *Pending, reason: js.Value, _: *const js.Execution) void { + defer self.unregister(); + if (self.answered) { + return; + } + const arena = self.scratch() orelse return; + defer arena.release(); + self.fail("javascript error", rejectionText(arena.allocator(), reason)); + } + + // The ctx for this is *BiDi, not *Pending, because the *Pending will clean + // itself up once the promise is resolved/rejected, but the scheduled timeout + // will live on. To make this work with a *Pending, the Scheduler would need + // to be able to remove a task. Don't think we've needed that before, and + // hard to justify just for this case. So, what we can do it just scan + // the bidi's list of pending's to see if any have timed out. + fn onTimeout(ctx: *anyopaque) !?u32 { + // Ab + const bidi: *BiDi = @ptrCast(@alignCast(ctx)); + const now = lp.datetime.milliTimestamp(.boot); + + var soonest: ?u64 = null; + var i = bidi.execute_pending.items.len; + while (i > 0) { + i -= 1; + const pending = bidi.execute_pending.items[i]; + const deadline = pending.deadline orelse continue; + if (deadline > now) { + soonest = if (soonest) |s| @min(s, deadline) else deadline; + continue; + } + // Most important thing is here: we answer but don't free. The + // promise could still be resolved at some point in the future! + pending.fail("script timeout", "the script did not complete within the script timeout"); + pending.deadline = null; + } + + if (soonest) |deadline| { + return @intCast(deadline - now); + } + return null; + } + + fn answer(self: *Pending, local: *const js.Local, value: js.Value) void { + if (self.answered) { + return; + } + + const bidi = self.bidi; + const frame = bidi.user_context.session.currentFrame() orelse { + return self.fail("no such window", "no frame"); + }; + + const arena = self.scratch() orelse return; + defer arena.release(); + + const cloned = clone(arena.allocator(), &bidi.node_registry, frame, local, value) catch |err| { + return self.fail("javascript error", cloneErrorMessage(err)); + }; + + self.answered = true; + bidi.replyResult(self.to, cloned) catch |err| { + log.err(.bidi, "execute result", .{ .err = err, .reply = self.to }); + }; + } + + fn fail(self: *Pending, code: []const u8, message: []const u8) void { + if (self.answered) { + return; + } + self.answered = true; + self.bidi.replyError(self.to, code, message) catch |err| { + log.err(.bidi, "execute error", .{ .err = err, .reply = self.to }); + }; + } + + fn scratch(self: *Pending) ?*lp.Arena { + return self.bidi.app.arena_pool.acquire(.small, "webdriver execute") catch |err| { + self.fail("unknown error", @errorName(err)); + return null; + }; + } + + pub fn realmReset(bidi: *BiDi) void { + for (bidi.execute_pending.items) |pending| { + pending.fail("javascript error", "the document was unloaded while the script was running"); + } + } + + // A frame, and with it the JS context holding our callbacks, is gone. + pub fn contextDestroyed(bidi: *BiDi, js_context_id: usize) void { + var i = bidi.execute_pending.items.len; + while (i > 0) { + i -= 1; + const pending = bidi.execute_pending.items[i]; + if (pending.js_context_id == js_context_id) { + _ = bidi.execute_pending.swapRemove(i); + bidi.app.allocator.destroy(pending); + } + } + } + + // Teardown: completions are still reachable, but their reply isn't. + pub fn cancelAll(bidi: *BiDi) void { + for (bidi.execute_pending.items) |pending| { + pending.answered = true; + } + } + + pub fn destroyAll(bidi: *BiDi) void { + while (bidi.execute_pending.pop()) |pending| { + bidi.app.allocator.destroy(pending); + } + } +}; + +const CloneError = error{ + CyclicReference, + TooDeep, + OutOfMemory, + ExecutionTerminated, + TypeError, + JsException, + MethodNotFound, + DeadFunctionHandle, + InvalidArgument, +}; + +fn cloneErrorMessage(err: anyerror) []const u8 { + return switch (err) { + error.CyclicReference => "cannot serialize a circular reference", + error.TooDeep => "the result is nested too deeply to serialize", + else => "cannot serialize the script's result", + }; +} + +// W3C's "JSON clone" of a script's result. Not a RemoteValue: a client reads +// it as plain JSON, with an element the one exception. +const Value = union(enum) { + null, + boolean: bool, + number: f64, + string: []const u8, + element: http_command.Reference, + array: []const Value, + object: []const Property, + + pub const Property = struct { + name: []const u8, + value: Value, + }; + + pub fn jsonStringify(self: *const Value, w: anytype) !void { + switch (self.*) { + .null => try w.write(null), + .boolean => |v| try w.write(v), + .string => |v| try w.write(v), + .element => |v| try w.write(v), + .number => |v| { + // JSON has no NaN or Infinity, and a whole number must not go + // out as 3e0 -- a client that rejects non-conforming JSON is + // within its rights. + if (std.math.isFinite(v) == false) { + return w.write(null); + } + const max_safe_integer = 9007199254740991; + if (@trunc(v) == v and @abs(v) <= max_safe_integer) { + return w.write(@as(i64, @intFromFloat(v))); + } + try w.write(v); + }, + .array => |values| { + try w.beginArray(); + for (values) |*value| { + try w.write(value); + } + try w.endArray(); + }, + .object => |properties| { + try w.beginObject(); + for (properties) |*property| { + try w.objectField(property.name); + try w.write(&property.value); + } + try w.endObject(); + }, + } + } +}; + +fn clone( + arena: Allocator, + registry: *NodeRegistry, + frame: *Frame, + local: *const js.Local, + value: js.Value, +) CloneError!Value { + var cloner: Cloner = .{ .arena = arena, .registry = registry, .frame = frame, .local = local }; + return cloner.run(value); +} + +const Cloner = struct { + arena: Allocator, + registry: *NodeRegistry, + frame: *Frame, + local: *const js.Local, + // cyclical dependencies are an error (vs RemoteValue which collapses it) + seen: std.ArrayList(js.Object) = .empty, //cyclicli + + const max_depth = 64; + + fn run(self: *Cloner, value: js.Value) CloneError!Value { + if (value.isNullOrUndefined()) { + return .null; + } + if (value.isBoolean()) { + return .{ .boolean = value.toBool() }; + } + if (value.isNumber()) { + return .{ .number = try value.toF64() }; + } + if (value.isString() != null) { + return .{ .string = try value.toStringSliceWithAlloc(self.arena) }; + } + if (value.isObject() == false) { + // a symbol or a bigint + return .null; + } + + const object = value.toObject(); + if (self.isSeen(object)) { + return error.CyclicReference; + } + if (self.seen.items.len == max_depth) { + return error.TooDeep; + } + try self.seen.append(self.arena, object); + defer _ = self.seen.pop(); + + if (value.taggedOpaque()) |tao| { + if (try self.platform(tao)) |cloned| { + return cloned; + } + // self.platform() only handles a few select types. Everything else + // goes through a more generic path , e.g. self.properties() + } + + if (value.isArray()) { + return .{ .array = try self.items(value.toArray()) }; + } + + if (try object.getFunction("toJSON") != null) { + return self.run(try object.callMethod(js.Value, "toJSON", .{})); + } + + return .{ .object = try self.properties(object) }; + } + + fn platform(self: *Cloner, tao: *const js.TaggedOpaque) !?Value { + if (tao.as(Node)) |node| { + // Non-elements will be serialized via properties() + const element = node.is(Node.Element) orelse return null; + return .{ .element = try self.reference(element.asNode()) }; + } + + if (tao.as(NodeList)) |list| { + const values = try self.arena.alloc(Value, try list.length(self.frame)); + for (values, 0..) |*item, i| { + const node = (try list.getAtIndex(i, self.frame)) orelse unreachable; + item.* = try self.run(try self.local.zigValueToJs(node, .{})); + } + return .{ .array = values }; + } + + if (tao.as(HTMLCollection)) |collection| { + const values = try self.arena.alloc(Value, collection.length(self.frame)); + for (values, 0..) |*item, i| { + const element = collection.getAtIndex(i, self.frame) orelse unreachable; + item.* = .{ .element = try self.reference(element.asNode()) }; + } + return .{ .array = values }; + } + + return null; + } + + fn reference(self: *Cloner, node: *Node) !http_command.Reference { + return .init(self.arena, self.registry, node); + } + + fn items(self: *Cloner, array: js.Array) CloneError![]const Value { + const values = try self.arena.alloc(Value, array.len()); + for (values, 0..) |*value, i| { + value.* = try self.run(try array.get(@intCast(i))); + } + return values; + } + + fn properties(self: *Cloner, object: js.Object) CloneError![]const Value.Property { + var it = try object.iterator(); + var list: std.ArrayList(Value.Property) = try .initCapacity(self.arena, it.count); + while (try it.next()) |entry| { + list.appendAssumeCapacity(.{ + .name = try self.arena.dupe(u8, entry.name), + .value = try self.run(entry.value), + }); + } + return list.items; + } + + fn isSeen(self: *const Cloner, object: js.Object) bool { + const candidate = object.toValue(); + for (self.seen.items) |ancestor| { + if (ancestor.toValue().strictEquals(candidate)) { + return true; + } + } + return false; + } +}; + +fn fromJson( + local: *const js.Local, + registry: *const NodeRegistry, + value: std.json.Value, +) !js.Value { + switch (value) { + .null => return local.zigValueToJs(null, .{}), + .bool => |v| return local.zigValueToJs(v, .{}), + .integer => |v| return local.newNumber(@floatFromInt(v)), + .float => |v| return local.newNumber(v), + .number_string => |v| return local.newNumber(std.fmt.parseFloat(f64, v) catch return error.InvalidArgument), + .string => |v| return local.zigValueToJs(v, .{}), + .array => |v| { + var array = local.newArray(@intCast(v.items.len)); + for (v.items, 0..) |item, i| { + if (try array.set(@intCast(i), try fromJson(local, registry, item), .{}) == false) { + return error.InvalidArgument; + } + } + return array.toValue(); + }, + .object => |fields| { + if (fields.get(http_command.element_key)) |id| { + const shared_id = switch (id) { + .string => |s| s, + else => return error.NoSuchElement, + }; + const element = try http_command.elementFromReference(registry, shared_id); + return local.zigValueToJs(element.asNode(), .{}); + } + + const object = local.newObject(); + var it = fields.iterator(); + while (it.next()) |entry| { + const item = try fromJson(local, registry, entry.value_ptr.*); + if (try object.set(entry.key_ptr.*, item, .{}) == false) { + return error.InvalidArgument; + } + } + return object.toValue(); + }, + } +} diff --git a/src/server/bidi/http_command.zig b/src/server/bidi/http_command.zig index adad75d4b..306053378 100644 --- a/src/server/bidi/http_command.zig +++ b/src/server/bidi/http_command.zig @@ -26,11 +26,13 @@ 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 NodeRegistry = @import("../../NodeRegistry.zig"); const Method = @import("../http.zig").Connection.Method; const BiDi = @import("BiDi.zig"); const input = @import("input.zig"); +const execute = @import("execute.zig"); const remote_value = @import("remote_value.zig"); const browsing_context = @import("browsing_context.zig"); @@ -63,14 +65,10 @@ pub const Command = union(enum) { get_element_rect: ElementId, is_element_enabled: ElementId, is_element_selected: ElementId, -}; - -pub const NavigateTo = struct { - url: [:0]const u8, -}; - -pub const PerformActions = struct { - actions: []const std.json.Value, + execute_script: execute.Script, + execute_async_script: execute.Script, + get_timeouts, + set_timeouts: SetTimeouts, }; // A command's path parameters are its leading fields (see `parse`); the rest @@ -228,6 +226,10 @@ const routes = [_]Route{ .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), + .init(.POST, "/execute/sync", .execute_script), + .init(.POST, "/execute/async", .execute_async_script), + .init(.GET, "/timeouts", .get_timeouts), + .init(.POST, "/timeouts", .set_timeouts), }; pub const ParseError = error{ @@ -323,10 +325,17 @@ pub fn process(cmd: *BiDi.Command) !void { .get_element_rect => |p| return getElementRect(cmd, p), .is_element_enabled => |p| return isElementEnabled(cmd, p), .is_element_selected => |p| return isElementSelected(cmd, p), + .execute_script => |p| return executeScript(cmd, p, .sync), + .execute_async_script => |p| return executeScript(cmd, p, .async), + .get_timeouts => return getTimeouts(cmd), + .set_timeouts => |p| return setTimeouts(cmd, p), } } // POST /session/{id}/url. +pub const NavigateTo = struct { + url: [:0]const u8, +}; fn navigateTo(cmd: *BiDi.Command, p: NavigateTo) !void { const ctx = (try currentContext(cmd)) orelse return; return browsing_context.navigate(cmd, ctx, .{ .url = p.url, .wait = .complete }); @@ -379,6 +388,9 @@ fn takeScreenshot(cmd: *BiDi.Command) !void { } // POST /session/{id}/actions. +pub const PerformActions = struct { + actions: []const std.json.Value, +}; fn performActions(cmd: *BiDi.Command, p: PerformActions) !void { _ = (try currentContext(cmd)) orelse return; return input.perform(cmd, p.actions); @@ -398,7 +410,7 @@ fn findElement(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]con if (nodes.len == 0) { return cmd.sendError("no such element", "no matching element"); } - return cmd.sendResult(try reference(cmd, nodes[0])); + return cmd.sendResult(try Reference.initFromCommand(cmd, nodes[0])); } // POST /session/{id}/elements, POST /session/{id}/element/{id}/elements @@ -409,7 +421,7 @@ fn findElements(cmd: *BiDi.Command, using: Using, value: []const u8, from: ?[]co const references = try cmd.arena.alloc(Reference, nodes.len); for (nodes, references) |node, *ref| { - ref.* = try reference(cmd, node); + ref.* = try Reference.initFromCommand(cmd, node); } return cmd.sendResult(references); } @@ -441,7 +453,7 @@ fn getActiveElement(cmd: *BiDi.Command) !void { const element = frame.window._document.getActiveElement() orelse { return cmd.sendError("no such element", "no active element"); }; - return cmd.sendResult(try reference(cmd, element.asNode())); + return cmd.sendResult(try Reference.initFromCommand(cmd, element.asNode())); } // GET /session/{id}/element/{id}/text. @@ -500,7 +512,7 @@ fn getElementProperty(cmd: *BiDi.Command, p: ElementName) !void { if (value.isObject()) { if (value.taggedOpaque()) |tagged| { if (tagged.as(Node)) |node| { - return cmd.sendResult(try reference(cmd, node)); + return cmd.sendResult(try Reference.initFromCommand(cmd, node)); } } } @@ -552,11 +564,65 @@ fn isElementSelected(cmd: *BiDi.Command, p: ElementId) !void { return cmd.sendResult(false); } +// POST /session/{id}/execute/sync, POST /session/{id}/execute/async +fn executeScript(cmd: *BiDi.Command, p: execute.Script, mode: execute.Mode) !void { + _ = (try currentContext(cmd)) orelse return; + return execute.run(cmd, p, mode); +} + +// GET /session/{id}/timeouts +fn getTimeouts(cmd: *BiDi.Command) !void { + return cmd.sendResult(cmd.bidi.timeouts); +} + +// POST /session/{id}/timeouts +pub const SetTimeouts = struct { + script: ScriptTimeout = .absent, + pageLoad: ?u32 = null, + implicit: ?u32 = null, + + pub const ScriptTimeout = union(enum) { + absent, // not sent, keep whatever we have + disabled, // explicit null == no timeout + ms: u32, + + pub fn jsonParse(arena: Allocator, source: anytype, opts: std.json.ParseOptions) !ScriptTimeout { + const value = try std.json.innerParse(?u32, arena, source, opts); + return if (value) |ms| .{ .ms = ms } else .disabled; + } + }; +}; +fn setTimeouts(cmd: *BiDi.Command, p: SetTimeouts) !void { + const timeouts = &cmd.bidi.timeouts; + + switch (p.script) { + .absent => {}, + .disabled => timeouts.script = null, + .ms => |ms| timeouts.script = ms, + } + if (p.pageLoad) |ms| { + timeouts.pageLoad = ms; + } + if (p.implicit) |ms| { + timeouts.implicit = ms; + } + return cmd.sendDone(); +} + // {"element-6066-…": ""}: a WebDriver element reference is the // node registry's id, the same one BiDi hands out. -const Reference = struct { +pub const Reference = struct { shared_id: []const u8, + pub fn init(arena: Allocator, registry: *NodeRegistry, node: *Node) !Reference { + const registered = try registry.register(node); + return .{ .shared_id = try std.fmt.allocPrint(arena, "{d}", .{registered.id}) }; + } + + fn initFromCommand(cmd: *BiDi.Command, node: *Node) !Reference { + return .init(cmd.arena, &cmd.bidi.node_registry, node); + } + pub fn jsonStringify(self: Reference, jws: anytype) !void { try jws.beginObject(); try jws.objectField(element_key); @@ -565,29 +631,37 @@ const Reference = struct { } }; -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}) }; +pub const ReferenceError = error{ + // the id is unknown, or names something that isn't an element + NoSuchElement, + // the element is no longer in a document + StaleElement, +}; + +// A reference's element, or why it doesn't resolve. Shared with execute.zig, +// which resolves the references a script is called with. +pub fn elementFromReference(registry: *const NodeRegistry, id: []const u8) ReferenceError!*Node.Element { + // ids are dropped on navigation, so a stale one is unknown by then + const node = remote_value.nodeFromSharedId(registry, .{ .string = id }) catch return error.NoSuchElement; + const element = node.is(Node.Element) orelse return error.NoSuchElement; + if (node.isConnected() == false) { + return error.StaleElement; + } + return element; } // 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; + return elementFromReference(&cmd.bidi.node_registry, id) catch |err| switch (err) { + error.NoSuchElement => { + try cmd.sendError("no such element", "unknown element reference"); + return null; + }, + error.StaleElement => { + try cmd.sendError("stale element reference", "element is no longer attached to the document"); + 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 @@ -672,6 +746,35 @@ test "bidi.http_command: parse" { try testing.expectEqual("data-x", command.get_element_attribute.name); } + { + const command = try parse(arena, .POST, "/execute/sync", "{\"script\":\"return 1\",\"args\":[1,\"a\"]}"); + try testing.expectEqual("return 1", command.execute_script.script); + try testing.expectEqual(2, command.execute_script.args.len); + } + + { + // args defaults to empty + const command = try parse(arena, .POST, "/execute/async", "{\"script\":\"\"}"); + try testing.expectEqual(0, command.execute_async_script.args.len); + } + + { + // a partial update leaves the fields it doesn't name alone + const command = try parse(arena, .POST, "/timeouts", "{\"implicit\":5}"); + try testing.expectEqual(5, command.set_timeouts.implicit.?); + try testing.expect(command.set_timeouts.script == .absent); + try testing.expect(command.set_timeouts.pageLoad == null); + } + + { + // null is a value for script, not its absence + try testing.expect((try parse(arena, .POST, "/timeouts", "{\"script\":null}")).set_timeouts.script == .disabled); + try testing.expectEqual(50, (try parse(arena, .POST, "/timeouts", "{\"script\":50}")).set_timeouts.script.ms); + } + + try testing.expect(try parse(arena, .GET, "/timeouts", "") == .get_timeouts); + try testing.expectError(error.InvalidArgument, parse(arena, .POST, "/execute/sync", "{}")); + // 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);