webdriver: execute

Adds the ability to execute JavaScript via WebDriver. The other webdriver
endpoints were able to re-use the existing BiDi code (e.g. navigating via
webdriver or bidi quickly ends up in the same function). But for execute, it's
completely different, from input parameters, to running the code, to the result
that's returned. So there's a dedicated handler for this: `execute.zig`. But
the rest of the infrastructure (the http waiting for a reply, the routing, the
parameter parsing, ... are all the same).
This commit is contained in:
Karl Seguin committed 2026-09-22 16:31:52 +08:00
1 parent 962a13007b
commit bbd99f47e8
5 files changed
+960 -31

No files matched your search

+226
View File
@@ -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));
+18
View File
@@ -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| {
+2
View File
@@ -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 {
+580
View File
@@ -0,0 +1,580 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
// 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();
},
}
}
+134 -31
View File
@@ -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-…": "<sharedId>"}: 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);