mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 04:42:30 -04:00
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).
835 lines
31 KiB
Zig
835 lines
31 KiB
Zig
// 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/>.
|
|
|
|
const std = @import("std");
|
|
const lp = @import("lightpanda");
|
|
|
|
const uuidv4 = @import("../../id.zig").uuidv4;
|
|
|
|
const js = @import("../../browser/js/js.zig");
|
|
const URL = @import("../../browser/URL.zig");
|
|
const Node = @import("../../browser/webapi/Node.zig");
|
|
const Frame = @import("../../browser/Frame.zig");
|
|
const Selector = @import("../../browser/webapi/selector/Selector.zig");
|
|
const xpath = @import("../../browser/xpath/Evaluator.zig");
|
|
const XPathParser = @import("../../browser/xpath/Parser.zig");
|
|
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;
|
|
|
|
pub const Context = struct {
|
|
// The BiDi navigable id. Stable across navigations.
|
|
id: [36]u8,
|
|
|
|
frame_id: u32,
|
|
|
|
// Regenerated by onFrameNavigate for every root navigation, including
|
|
// ones the page itself initiates.
|
|
navigation_id: [36]u8,
|
|
|
|
// The window realm's id, reported by the script module. A navigation
|
|
// means a new document and so a new realm.
|
|
realm_id: [36]u8,
|
|
|
|
// A navigate command whose `wait` hasn't been satisfied yet.
|
|
pending_navigate: ?PendingNavigate = null,
|
|
|
|
// Whether script.realmCreated was sent or not. If sent, we'll send
|
|
// script.realmDestroyed on teardown.
|
|
realm_announced: bool = false,
|
|
};
|
|
|
|
const PendingNavigate = struct {
|
|
until: Until,
|
|
reply: BiDi.Reply,
|
|
|
|
pub const Until = enum { interactive, complete };
|
|
};
|
|
|
|
pub fn processMessage(cmd: *BiDi.Command, action: []const u8) !void {
|
|
const command = std.meta.stringToEnum(enum {
|
|
getTree,
|
|
create,
|
|
navigate,
|
|
close,
|
|
locateNodes,
|
|
}, action) orelse return error.UnknownCommand;
|
|
|
|
switch (command) {
|
|
.getTree => return getTree(cmd),
|
|
.create => return create(cmd),
|
|
.navigate => return bidiNavigate(cmd),
|
|
.close => return close(cmd),
|
|
.locateNodes => return locateNodes(cmd),
|
|
}
|
|
}
|
|
|
|
fn getTree(cmd: *BiDi.Command) !void {
|
|
const bidi = cmd.bidi;
|
|
const ctx = &(bidi.browsing_context orelse {
|
|
return cmd.sendResult(.{ .contexts = &[_]Info{} });
|
|
});
|
|
|
|
const url: []const u8 = if (bidi.user_context.session.currentFrame()) |frame| frame.url else "about:blank";
|
|
return cmd.sendResult(.{
|
|
.contexts = &[_]Info{.{
|
|
.url = url,
|
|
.context = &ctx.id,
|
|
.userContext = bidi.user_context.id(),
|
|
}},
|
|
});
|
|
}
|
|
|
|
fn create(cmd: *BiDi.Command) !void {
|
|
const p = try cmd.params(struct {
|
|
type: enum { tab, window },
|
|
userContext: ?[]const u8 = null,
|
|
referenceContext: ?[]const u8 = null,
|
|
background: bool = false,
|
|
});
|
|
|
|
const bidi = cmd.bidi;
|
|
if (bidi.browsing_context != null) {
|
|
log.warn(.not_implemented, "bidi.BrowingContext", .{ .feature = "multiple user contexts" });
|
|
return cmd.sendError("unsupported operation", "only a single browsing context is supported");
|
|
}
|
|
|
|
const requested = p.userContext orelse "default";
|
|
if (std.mem.eql(u8, requested, bidi.user_context.id()) == false) {
|
|
return cmd.sendError("no such user context", "unknown user context");
|
|
}
|
|
|
|
const ctx = openContext(bidi) catch |err| switch (err) {
|
|
error.CreatePage => return cmd.sendError("unknown error", "failed to create page"),
|
|
else => return err,
|
|
};
|
|
return cmd.sendResult(.{ .context = &ctx.id });
|
|
}
|
|
|
|
// The top-level browsing context, on a fresh about:blank page.
|
|
pub fn openContext(bidi: *BiDi) !*Context {
|
|
const page = bidi.user_context.session.createPage() catch |err| {
|
|
log.err(.bidi, "create page", .{ .err = err });
|
|
return error.CreatePage;
|
|
};
|
|
|
|
bidi.browsing_context = .{
|
|
.id = undefined,
|
|
.realm_id = undefined,
|
|
.frame_id = page.frame_id,
|
|
.navigation_id = undefined,
|
|
};
|
|
const ctx = &bidi.browsing_context.?;
|
|
uuidv4(&ctx.id);
|
|
uuidv4(&ctx.realm_id);
|
|
uuidv4(&ctx.navigation_id);
|
|
|
|
try bidi.sendEvent("browsingContext.contextCreated", .{
|
|
.context = &ctx.id,
|
|
.url = "about:blank",
|
|
.userContext = bidi.user_context.id(),
|
|
.originalOpener = null,
|
|
.children = null,
|
|
});
|
|
|
|
// A fresh page has no document; give it one so evaluation and getTree
|
|
// have something to work against before the first navigate.
|
|
if (page.frame()) |frame| {
|
|
frame.navigate("about:blank", .{
|
|
.reason = .address_bar,
|
|
.kind = .{ .push = null },
|
|
}) catch |err| {
|
|
log.warn(.bidi, "blank navigate", .{ .err = err });
|
|
};
|
|
try announceRealm(bidi, frame);
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
fn announceRealm(bidi: *BiDi, frame: *const Frame) !void {
|
|
const ctx = &(bidi.browsing_context orelse return);
|
|
if (ctx.realm_announced) {
|
|
return;
|
|
}
|
|
ctx.realm_announced = true;
|
|
|
|
const origin = (URL.getOrigin(bidi.message_arena.allocator(), frame.url) catch null) orelse "null";
|
|
return bidi.sendEvent("script.realmCreated", .{
|
|
.realm = &ctx.realm_id,
|
|
.origin = origin,
|
|
.type = "window",
|
|
.context = &ctx.id,
|
|
});
|
|
}
|
|
|
|
fn bidiNavigate(cmd: *BiDi.Command) !void {
|
|
const p = try cmd.params(struct {
|
|
url: [:0]const u8,
|
|
context: []const u8,
|
|
wait: NavigateOpts.Wait = .none,
|
|
});
|
|
|
|
const ctx = (try requireContext(cmd, p.context)) orelse return;
|
|
return navigate(cmd, ctx, .{ .url = p.url, .wait = p.wait });
|
|
}
|
|
|
|
pub const NavigateOpts = struct {
|
|
url: [:0]const u8,
|
|
wait: Wait,
|
|
|
|
pub const Wait = enum { none, interactive, complete };
|
|
};
|
|
|
|
pub fn navigate(cmd: *BiDi.Command, ctx: *Context, opts: NavigateOpts) !void {
|
|
const frame = cmd.bidi.user_context.session.currentFrame() orelse {
|
|
return cmd.sendError("unknown error", "no frame");
|
|
};
|
|
const encoded_url = URL.resolveNavigation(frame.call_arena, opts.url, .{}) catch {
|
|
return cmd.sendError("invalid argument", "invalid url");
|
|
};
|
|
return startNavigation(cmd, ctx, frame, encoded_url, .{ .reason = .address_bar, .kind = .{ .push = null } }, opts.wait);
|
|
}
|
|
|
|
// Reloads the current document, replaying its method and body like CDP's
|
|
// Page.reload.
|
|
pub fn reload(cmd: *BiDi.Command, ctx: *Context, wait: NavigateOpts.Wait) !void {
|
|
const frame = cmd.bidi.user_context.session.currentFrame() orelse {
|
|
return cmd.sendError("unknown error", "no frame");
|
|
};
|
|
|
|
// the frame's arena, which these live in, is gone once the reload commits
|
|
const arena = cmd.arena;
|
|
const url = try arena.dupeZ(u8, frame.url);
|
|
var nav_opts: Frame.NavigateOpts = .{ .reason = .address_bar, .kind = .reload };
|
|
if (frame._navigated_options) |prev| {
|
|
nav_opts.method = prev.method;
|
|
nav_opts.body = if (prev.body) |b| try arena.dupe(u8, b) else null;
|
|
nav_opts.header = if (prev.header) |h| try arena.dupeZ(u8, h) else null;
|
|
}
|
|
return startNavigation(cmd, ctx, frame, url, nav_opts, wait);
|
|
}
|
|
|
|
fn startNavigation(cmd: *BiDi.Command, ctx: *Context, frame: *Frame, url: [:0]const u8, nav_opts: Frame.NavigateOpts, wait: NavigateOpts.Wait) !void {
|
|
const bidi = cmd.bidi;
|
|
|
|
// A second navigate supersedes an in-flight one; answer the old command
|
|
// so the client isn't left waiting on it forever.
|
|
try rejectPending(bidi, ctx, "navigation superseded");
|
|
|
|
// Set before starting: a navigation can reach its wait condition
|
|
// synchronously (about:blank), which would fire the lifecycle callback
|
|
// before we got a chance to record the pending command.
|
|
switch (wait) {
|
|
.none => {},
|
|
.interactive => ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .interactive },
|
|
.complete => ctx.pending_navigate = .{ .reply = cmd.takeReply(), .until = .complete },
|
|
}
|
|
|
|
// Same fast path as CDP's Page.navigate: a root frame that never
|
|
// navigated has nothing to preserve, so it navigates in place; a live
|
|
// page goes through the pending-Page replacement machinery.
|
|
const nav_result = if (frame._load_state == .waiting)
|
|
frame.navigate(url, nav_opts)
|
|
else
|
|
bidi.user_context.session.initiateRootNavigation(frame._frame_id, url, nav_opts);
|
|
|
|
nav_result catch |err| {
|
|
log.warn(.bidi, "navigate", .{ .err = err });
|
|
if (wait != .none and ctx.pending_navigate == null) {
|
|
// the lifecycle already answered it
|
|
return;
|
|
}
|
|
ctx.pending_navigate = null;
|
|
return cmd.sendError("unknown error", "navigation failed");
|
|
};
|
|
|
|
if (wait == .none) {
|
|
return cmd.sendResult(.{ .navigation = &ctx.navigation_id, .url = url });
|
|
}
|
|
}
|
|
|
|
fn close(cmd: *BiDi.Command) !void {
|
|
const p = try cmd.params(struct {
|
|
context: []const u8,
|
|
});
|
|
|
|
const ctx = (try requireContext(cmd, p.context)) orelse return;
|
|
try destroy(cmd, ctx);
|
|
return cmd.sendResult(struct {}{});
|
|
}
|
|
|
|
// Closes the page behind `ctx` and reports it gone.
|
|
pub fn destroy(cmd: *BiDi.Command, ctx: *Context) !void {
|
|
const bidi = cmd.bidi;
|
|
try rejectPending(bidi, ctx, "browsing context closed");
|
|
|
|
// ctx points into bidi.browsing_context, which is about to be cleared
|
|
const gone = ctx.*;
|
|
|
|
// the input state map is keyed by top-level browsing context
|
|
bidi.input_state.reset(bidi.app.allocator);
|
|
|
|
// the frame's url doesn't survive closePage
|
|
const url: []const u8 = if (bidi.user_context.session.currentFrame()) |frame|
|
|
try cmd.arena.dupe(u8, frame.url)
|
|
else
|
|
"about:blank";
|
|
|
|
bidi.user_context.session.closePage(gone.frame_id);
|
|
bidi.browsing_context = null;
|
|
|
|
try cmd.sendEvent("browsingContext.contextDestroyed", .{
|
|
.context = &gone.id,
|
|
.url = url,
|
|
.userContext = bidi.user_context.id(),
|
|
.originalOpener = null,
|
|
.children = null,
|
|
});
|
|
}
|
|
|
|
fn locateNodes(cmd: *BiDi.Command) !void {
|
|
const p = try cmd.params(struct {
|
|
context: []const u8,
|
|
locator: struct {
|
|
type: enum { css, xpath, innerText, accessibility, context },
|
|
value: std.json.Value,
|
|
},
|
|
maxNodeCount: ?u32 = null,
|
|
serializationOptions: remote_value.SerializationOptions = .{},
|
|
startNodes: []const std.json.Value = &.{},
|
|
});
|
|
|
|
if ((try requireContext(cmd, p.context)) == null) {
|
|
return;
|
|
}
|
|
|
|
if (p.maxNodeCount == 0) {
|
|
return cmd.sendError("invalid argument", "maxNodeCount must be at least 1");
|
|
}
|
|
|
|
const selector = switch (p.locator.type) {
|
|
.css, .xpath => switch (p.locator.value) {
|
|
.string => |str| str,
|
|
else => return cmd.sendError("invalid argument", "locator value must be a string"),
|
|
},
|
|
.innerText, .accessibility, .context => {
|
|
log.warn(.not_implemented, "bidi.locateNodes", .{ .locator = @tagName(p.locator.type) });
|
|
return cmd.sendError("unsupported operation", "locator type is not supported");
|
|
},
|
|
};
|
|
const locator: Locator = if (p.locator.type == .css) .{ .css = selector } else .{ .xpath = selector };
|
|
|
|
const bidi = cmd.bidi;
|
|
const frame = bidi.user_context.session.currentFrame() orelse {
|
|
return cmd.sendError("no such frame", "no frame");
|
|
};
|
|
const arena = cmd.arena;
|
|
|
|
// The document is the default root
|
|
var roots: []const *Node = &.{frame.window._document.asNode()};
|
|
if (p.startNodes.len > 0) {
|
|
const start_nodes = try arena.alloc(*Node, p.startNodes.len);
|
|
for (p.startNodes, start_nodes) |ref, *node| {
|
|
const shared_id = switch (ref) {
|
|
.object => |o| o.get("sharedId") orelse .null,
|
|
else => .null,
|
|
};
|
|
node.* = remote_value.nodeFromSharedId(&bidi.node_registry, shared_id) catch {
|
|
return cmd.sendError("no such node", "unknown start node");
|
|
};
|
|
if (node.*.is(Node.Element) == null and node.*.is(Node.Document) == null and node.*.is(Node.DocumentFragment) == null) {
|
|
return cmd.sendError("invalid argument", "start nodes must be elements, documents or document fragments");
|
|
}
|
|
}
|
|
roots = start_nodes;
|
|
}
|
|
|
|
var ls: js.Local.Scope = undefined;
|
|
frame.js.localScope(&ls);
|
|
defer ls.deinit();
|
|
var serializer = remote_value.Serializer.init(bidi, arena, frame, &ls.local, p.serializationOptions.options(false));
|
|
|
|
// Serialized straight from each root's result, stopping at maxNodeCount.
|
|
const max = p.maxNodeCount orelse std.math.maxInt(u32);
|
|
var remotes: std.ArrayList(remote_value.Remote) = .empty;
|
|
for (roots) |root| {
|
|
const remaining = max - @as(u32, @intCast(remotes.items.len));
|
|
const nodes = locator.locate(arena, root, remaining, frame) catch |err| switch (err) {
|
|
error.InvalidSelector => return cmd.sendError("invalid selector", "invalid selector"),
|
|
error.NodeSetExpected => return cmd.sendError("invalid selector", "xpath expression must select nodes"),
|
|
else => return err,
|
|
};
|
|
try appendNodes(&remotes, arena, &serializer, nodes, max);
|
|
|
|
if (remotes.items.len >= max) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return cmd.sendResult(.{ .nodes = remotes.items });
|
|
}
|
|
|
|
// What a locator selects, shared by browsingContext.locateNodes and the HTTP
|
|
// session's element finders. The last three are WebDriver-only strategies.
|
|
pub const Locator = union(enum) {
|
|
css: []const u8,
|
|
xpath: []const u8,
|
|
tag_name: []const u8, // webdriver-only
|
|
link_text: []const u8, // webdriver-only
|
|
partial_link_text: []const u8, // webdriver-only
|
|
|
|
pub fn locate(self: Locator, arena: std.mem.Allocator, root: *Node, max: u32, frame: *Frame) ![]const *Node {
|
|
if (max == 0) {
|
|
return &.{};
|
|
}
|
|
|
|
switch (self) {
|
|
.css => |selector| {
|
|
if (max == 1) {
|
|
const element = Selector.querySelector(root, selector, frame) catch |err| return badSelector("css", selector, err);
|
|
const found = element orelse return &.{};
|
|
const nodes = try arena.alloc(*Node, 1);
|
|
nodes[0] = found.asNode();
|
|
return nodes;
|
|
}
|
|
const list = Selector.querySelectorAll(root, selector, frame) catch |err| return badSelector("css", selector, err);
|
|
defer list.deinit(frame.page);
|
|
return arena.dupe(*Node, list._nodes[0..@min(list._nodes.len, max)]);
|
|
},
|
|
.xpath => |expression| {
|
|
// TODO: max == 1 could stop at the first match like css
|
|
const parsed = XPathParser.parse(arena, expression) catch |err| return badSelector("xpath", expression, err);
|
|
const result = xpath.evaluate(arena, parsed, root, frame) catch |err| return badSelector("xpath", expression, err);
|
|
const nodes = switch (result) {
|
|
.node_set => |nodes| nodes,
|
|
else => return error.NodeSetExpected,
|
|
};
|
|
return nodes[0..@min(nodes.len, max)];
|
|
},
|
|
.tag_name, .link_text, .partial_link_text => {},
|
|
}
|
|
|
|
const tag_name = switch (self) {
|
|
.tag_name => |name| name,
|
|
else => "a", // link_text or partial_link_test all sub-filter from <a>
|
|
};
|
|
var elements = root.getElementsByTagName(tag_name, frame) catch |err| switch (err) {
|
|
error.InvalidTagName => return badSelector("tag name", tag_name, err),
|
|
else => return err,
|
|
};
|
|
|
|
var text: std.Io.Writer.Allocating = .init(arena);
|
|
var found: std.ArrayList(*Node) = .empty;
|
|
switch (elements) {
|
|
inline else => |*list| while (list.next()) |element| {
|
|
const matches = switch (self) {
|
|
.tag_name => true,
|
|
.link_text, .partial_link_text => |needle| blk: {
|
|
// an <a> outside HTML has no rendered text
|
|
if (element.getTag() != .anchor) {
|
|
break :blk false;
|
|
}
|
|
text.clearRetainingCapacity();
|
|
try element.getInnerText(&text.writer, frame);
|
|
const rendered = std.mem.trim(u8, text.written(), &std.ascii.whitespace);
|
|
break :blk switch (self) {
|
|
.link_text => std.mem.eql(u8, rendered, needle),
|
|
else => std.mem.indexOf(u8, rendered, needle) != null,
|
|
};
|
|
},
|
|
else => unreachable, // css and xpath returned above
|
|
};
|
|
|
|
if (matches) {
|
|
try found.append(arena, element.asNode());
|
|
if (found.items.len == max) {
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
}
|
|
return found.items;
|
|
}
|
|
};
|
|
|
|
fn badSelector(kind: []const u8, selector: []const u8, err: anyerror) error{InvalidSelector} {
|
|
log.debug(.bidi, "locate", .{ .kind = kind, .selector = selector, .err = err });
|
|
return error.InvalidSelector;
|
|
}
|
|
|
|
fn appendNodes(remotes: *std.ArrayList(remote_value.Remote), arena: std.mem.Allocator, serializer: *remote_value.Serializer, nodes: []const *Node, max: u32) !void {
|
|
// Try to optimize this a little for the inherit inefficiency of ArrayList with Arena.
|
|
const take = @min(nodes.len, max - remotes.items.len);
|
|
try remotes.ensureTotalCapacityPrecise(arena, remotes.items.len + take);
|
|
for (nodes[0..take]) |node| {
|
|
remotes.appendAssumeCapacity(try serializer.domNode(node));
|
|
}
|
|
}
|
|
|
|
pub fn requireContext(cmd: *BiDi.Command, context: []const u8) !?*Context {
|
|
if (cmd.bidi.browsing_context) |*ctx| {
|
|
if (std.mem.eql(u8, &ctx.id, context)) {
|
|
return ctx;
|
|
}
|
|
}
|
|
try cmd.sendError("no such frame", "unknown context");
|
|
return null;
|
|
}
|
|
|
|
// Answers `ctx`'s pending navigate command, if any, and clears it.
|
|
fn answerPending(bidi: *BiDi, ctx: *Context, url: []const u8) !void {
|
|
const pending = ctx.pending_navigate orelse return;
|
|
ctx.pending_navigate = null;
|
|
switch (pending.reply) {
|
|
.bidi => |id| return bidi.sendResult(id, .{
|
|
.url = url,
|
|
.navigation = &ctx.navigation_id,
|
|
}),
|
|
// WebDriver's Navigate To answers null
|
|
.http => return bidi.respondHTTP(null),
|
|
}
|
|
}
|
|
|
|
// Fails `ctx`'s pending navigate command, if any, and clears it. BiDi has no
|
|
// navigation-specific error code, so every reason is an "unknown error" and
|
|
// only the message distinguishes them.
|
|
fn rejectPending(bidi: *BiDi, ctx: *Context, message: []const u8) !void {
|
|
const pending = ctx.pending_navigate orelse return;
|
|
ctx.pending_navigate = null;
|
|
return bidi.replyError(pending.reply, "unknown error", message);
|
|
}
|
|
|
|
pub fn registerNotifications(bidi: *BiDi) !void {
|
|
const notification = bidi.notification;
|
|
try notification.register(.frame_loaded, bidi, onFrameLoaded);
|
|
try notification.register(.frame_remove, bidi, onFrameRemove);
|
|
try notification.register(.frame_created, bidi, onFrameCreated);
|
|
try notification.register(.frame_destroyed, bidi, onFrameDestroyed);
|
|
try notification.register(.frame_navigate, bidi, onFrameNavigate);
|
|
try notification.register(.frame_navigate_failed, bidi, onFrameNavigateFailed);
|
|
try notification.register(.frame_dom_content_loaded, bidi, onFrameDOMContentLoaded);
|
|
}
|
|
|
|
// The page whose nodes and persisted values these belong to is going away.
|
|
fn onFrameRemove(ptr: *anyopaque, _: Notification.FrameRemove) !void {
|
|
const bidi: *BiDi = @ptrCast(@alignCast(ptr));
|
|
bidi.resetRealm();
|
|
}
|
|
|
|
// A new root document, so a new window realm.
|
|
fn onFrameCreated(ptr: *anyopaque, frame: *Frame) !void {
|
|
const bidi: *BiDi = @ptrCast(@alignCast(ptr));
|
|
if (frame.parent != null or rootContext(bidi, frame._frame_id) == null) {
|
|
return;
|
|
}
|
|
return announceRealm(bidi, frame);
|
|
}
|
|
|
|
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 {
|
|
const bidi: *BiDi = @ptrCast(@alignCast(ptr));
|
|
const ctx = rootContext(bidi, msg.frame_id) orelse return;
|
|
|
|
if (msg.is_pending_root == false) {
|
|
bidi.resetRealm();
|
|
} else {
|
|
// pending root keeps its old page and thus its sharedIds and handles
|
|
// reachable until frameRemove commits the replacement.
|
|
}
|
|
|
|
uuidv4(&ctx.navigation_id);
|
|
try bidi.sendEvent("browsingContext.navigationStarted", .{
|
|
.context = &ctx.id,
|
|
.navigation = &ctx.navigation_id,
|
|
.timestamp = msg.timestamp,
|
|
.url = msg.url,
|
|
});
|
|
}
|
|
|
|
fn onFrameNavigateFailed(ptr: *anyopaque, msg: *const Notification.FrameNavigateFailed) !void {
|
|
const bidi: *BiDi = @ptrCast(@alignCast(ptr));
|
|
const ctx = rootContext(bidi, msg.frame_id) orelse return;
|
|
|
|
try bidi.sendEvent("browsingContext.navigationFailed", .{
|
|
.context = &ctx.id,
|
|
.navigation = &ctx.navigation_id,
|
|
.timestamp = msg.timestamp,
|
|
.url = msg.url,
|
|
});
|
|
|
|
try rejectPending(bidi, ctx, "navigation failed");
|
|
}
|
|
|
|
fn onFrameDOMContentLoaded(ptr: *anyopaque, msg: *const Notification.FrameDOMContentLoaded) !void {
|
|
return frameLifecycleEvent(ptr, "browsingContext.domContentLoaded", .interactive, msg.frame_id, msg.timestamp);
|
|
}
|
|
|
|
fn onFrameLoaded(ptr: *anyopaque, msg: *const Notification.FrameLoaded) !void {
|
|
return frameLifecycleEvent(ptr, "browsingContext.load", .complete, msg.frame_id, msg.timestamp);
|
|
}
|
|
|
|
fn frameLifecycleEvent(
|
|
ptr: *anyopaque,
|
|
comptime method: []const u8,
|
|
reached: PendingNavigate.Until,
|
|
frame_id: u32,
|
|
timestamp: u64,
|
|
) !void {
|
|
const bidi: *BiDi = @ptrCast(@alignCast(ptr));
|
|
const ctx = rootContext(bidi, frame_id) orelse return;
|
|
|
|
const url: []const u8 = if (bidi.user_context.session.currentFrame()) |frame| frame.url else "";
|
|
try bidi.sendEvent(method, .{
|
|
.context = &ctx.id,
|
|
.navigation = &ctx.navigation_id,
|
|
.timestamp = timestamp,
|
|
.url = url,
|
|
});
|
|
|
|
// The response comes after the matching event — drivers key off the
|
|
// event, and answering first would let a client see the command
|
|
// complete before the lifecycle it waited for.
|
|
if (ctx.pending_navigate) |pending| {
|
|
if (pending.until == reached) {
|
|
try answerPending(bidi, ctx, url);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only the root frame maps to the BiDi context; child-frame events are
|
|
// dropped until child navigables are supported.
|
|
fn rootContext(bidi: *BiDi, frame_id: u32) ?*Context {
|
|
if (bidi.browsing_context) |*ctx| {
|
|
if (ctx.frame_id == frame_id) {
|
|
return ctx;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// BrowsingContextInfo for getTree. Events send children: null instead of a
|
|
// (recursive) list, so they use ad-hoc structs.
|
|
const Info = struct {
|
|
context: []const u8,
|
|
url: []const u8,
|
|
userContext: []const u8,
|
|
originalOpener: ?[]const u8 = null,
|
|
children: []const Info = &.{},
|
|
};
|
|
|
|
const testing = @import("testing.zig");
|
|
test "bidi.browsing_context: locateNodes" {
|
|
testing.silenceLog(&.{.not_implemented});
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
const context_id = try ctx.createContext(.{ .url = "bidi/locate.html" });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 1,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "css", .value = ".item" } },
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .sharedId = "1", .value = .{ .nodeType = 1, .localName = "li", .attributes = .{ .class = "item" } } },
|
|
.{ .type = "node", .sharedId = "2", .value = .{ .localName = "li" } },
|
|
.{ .type = "node", .sharedId = "3", .value = .{ .localName = "li" } },
|
|
.{ .type = "node", .sharedId = "4", .value = .{ .localName = "span" } },
|
|
} }, .{ .id = 1 });
|
|
|
|
// maxNodeCount truncates in document order
|
|
try ctx.processMessage(.{
|
|
.id = 2,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "css", .value = ".item" }, .maxNodeCount = 2 },
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .sharedId = "1" },
|
|
.{ .type = "node", .sharedId = "2" },
|
|
} }, .{ .id = 2 });
|
|
|
|
// xpath, with serializationOptions
|
|
try ctx.processMessage(.{
|
|
.id = 3,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{
|
|
.context = context_id,
|
|
.locator = .{ .type = "xpath", .value = "//div[@id='other']" },
|
|
.serializationOptions = .{ .maxDomDepth = 1 },
|
|
},
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .value = .{
|
|
.localName = "div",
|
|
.children = .{.{ .type = "node", .sharedId = "4", .value = .{ .localName = "span" } }},
|
|
} },
|
|
} }, .{ .id = 3 });
|
|
|
|
// startNodes scope the search
|
|
try ctx.processMessage(.{
|
|
.id = 4,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{
|
|
.context = context_id,
|
|
.locator = .{ .type = "css", .value = ".item" },
|
|
.startNodes = .{.{ .sharedId = "5" }},
|
|
},
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .sharedId = "4" },
|
|
} }, .{ .id = 4 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 5,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{
|
|
.context = context_id,
|
|
.locator = .{ .type = "xpath", .value = ".//*[@class='item']" },
|
|
.startNodes = .{ .{ .sharedId = "5" }, .{ .sharedId = "1" } },
|
|
},
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .sharedId = "4" },
|
|
} }, .{ .id = 5 });
|
|
|
|
// maxNodeCount 1 takes the querySelector path; the first root has no match
|
|
try ctx.processMessage(.{
|
|
.id = 14,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{
|
|
.context = context_id,
|
|
.locator = .{ .type = "css", .value = ".item" },
|
|
.startNodes = .{ .{ .sharedId = "1" }, .{ .sharedId = "5" } },
|
|
.maxNodeCount = 1,
|
|
},
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .sharedId = "4" },
|
|
} }, .{ .id = 14 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 15,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "css", .value = ".item[" }, .maxNodeCount = 1 },
|
|
});
|
|
try ctx.expectSentError("invalid selector", null, .{ .id = 15 });
|
|
|
|
// An absolute xpath ignores the start node, as in any browser.
|
|
try ctx.processMessage(.{
|
|
.id = 13,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{
|
|
.context = context_id,
|
|
.locator = .{ .type = "xpath", .value = "//li" },
|
|
.startNodes = .{.{ .sharedId = "5" }},
|
|
},
|
|
});
|
|
try ctx.expectSentResult(.{ .nodes = .{
|
|
.{ .type = "node", .sharedId = "1" },
|
|
.{ .type = "node", .sharedId = "2" },
|
|
.{ .type = "node", .sharedId = "3" },
|
|
} }, .{ .id = 13 });
|
|
|
|
// errors
|
|
try ctx.processMessage(.{
|
|
.id = 6,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "css", .value = ".item[" } },
|
|
});
|
|
try ctx.expectSentError("invalid selector", null, .{ .id = 6 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 7,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "xpath", .value = "count(//li)" } },
|
|
});
|
|
try ctx.expectSentError("invalid selector", null, .{ .id = 7 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 8,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "xpath", .value = "//li[" } },
|
|
});
|
|
try ctx.expectSentError("invalid selector", null, .{ .id = 8 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 9,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "css", .value = ".item" }, .startNodes = .{.{ .sharedId = "999" }} },
|
|
});
|
|
try ctx.expectSentError("no such node", null, .{ .id = 9 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 10,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "css", .value = ".item" }, .maxNodeCount = 0 },
|
|
});
|
|
try ctx.expectSentError("invalid argument", null, .{ .id = 10 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 11,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = context_id, .locator = .{ .type = "innerText", .value = "a" } },
|
|
});
|
|
try ctx.expectSentError("unsupported operation", null, .{ .id = 11 });
|
|
|
|
try ctx.processMessage(.{
|
|
.id = 12,
|
|
.method = "browsingContext.locateNodes",
|
|
.params = .{ .context = "nope", .locator = .{ .type = "css", .value = ".item" } },
|
|
});
|
|
try ctx.expectSentError("no such frame", null, .{ .id = 12 });
|
|
}
|
|
|
|
test "bidi.browsing_context: window realm lifecycle" {
|
|
var ctx = try testing.context();
|
|
defer ctx.deinit();
|
|
|
|
try ctx.startSession();
|
|
try ctx.processMessage(.{ .id = 1, .method = "session.subscribe", .params = .{ .events = .{"script"} } });
|
|
try ctx.processMessage(.{ .id = 2, .method = "browsingContext.create", .params = .{ .type = "tab" } });
|
|
|
|
const bc = &(ctx.bidi().browsing_context orelse return error.NoBrowsingContext);
|
|
const context_id = try testing.arena.dupe(u8, &bc.id);
|
|
const realm1 = try testing.arena.dupe(u8, &bc.realm_id);
|
|
try ctx.expectSentEvent("script.realmCreated", .{ .realm = realm1, .type = "window", .context = context_id, .origin = "null" });
|
|
|
|
// a navigation is a new document and so a new realm
|
|
try ctx.processMessage(.{
|
|
.id = 3,
|
|
.method = "browsingContext.navigate",
|
|
.params = .{ .context = context_id, .url = testing.test_server ++ "bidi/values.html", .wait = "complete" },
|
|
});
|
|
try ctx.wait();
|
|
const realm2 = try testing.arena.dupe(u8, &bc.realm_id);
|
|
try testing.expect(std.mem.eql(u8, realm1, realm2) == false);
|
|
try ctx.expectSentEvent("script.realmDestroyed", .{ .realm = realm1 });
|
|
try ctx.expectSentEvent("script.realmCreated", .{ .realm = realm2, .type = "window", .context = context_id, .origin = "http://127.0.0.1:9582" });
|
|
try ctx.expectSentResult(.{ .navigation = &bc.navigation_id }, .{ .id = 3 });
|
|
}
|