mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 12:51:45 -04:00
Basic stuff that builds ontop of navigate support (https://github.com/lightpanda-io/browser/pull/3538) The first two already existed. And I added a few more than the last 3, but the last 3 demo better in a commit message: ```zig $ curl -X POST "http://localhost:9222/session" --data '{}' {"value":{"sessionId":"695a066e-4f79-4856-9af9-b92fdf18da7a",.... $ curl -X POST "http://localhost:9222/session/695a066e-4f79-4856-9af9-b92fdf18da7a/url" --data '{"url": "https://lightpanda.io"}' {"value":null} $ curl -X GET "http://localhost:9222/session/695a066e-4f79-4856-9af9-b92fdf18da7a/title" {"value":"Lightpanda | The headless browser"} $ curl -X GET "http://localhost:9222/session/695a066e-4f79-4856-9af9-b92fdf18da7a/source" {"value":"<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewpor.... $ curl -X GET "http://localhost:9222/session/695a066e-4f79-4856-9af9-b92fdf18da7a/screenshot" {"value":"iVBORw0KGgoAAAANSUhEUgAAB4AAAAQ4CAYAAADo08FDAAE/fUl ```
775 lines
28 KiB
Zig
775 lines
28 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 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 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));
|
|
|
|
const xpath_expr = if (p.locator.type == .xpath) XPathParser.parse(arena, selector) catch |err| {
|
|
return invalidSelector(cmd, "xpath", selector, err);
|
|
} else undefined;
|
|
|
|
// 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| {
|
|
switch (p.locator.type) {
|
|
.css => {
|
|
if (max == 1) {
|
|
const element = Selector.querySelector(root, selector, frame) catch |err| {
|
|
return invalidSelector(cmd, "css", selector, err);
|
|
};
|
|
if (element) |el| {
|
|
try remotes.append(arena, try serializer.domNode(el.asNode()));
|
|
}
|
|
} else {
|
|
const list = Selector.querySelectorAll(root, selector, frame) catch |err| {
|
|
return invalidSelector(cmd, "css", selector, err);
|
|
};
|
|
defer list.deinit(frame.page);
|
|
try appendNodes(&remotes, arena, &serializer, list._nodes, max);
|
|
}
|
|
},
|
|
.xpath => {
|
|
// TODO: maxNodeCount == 1 could stop at the first match like css
|
|
const result = xpath.evaluate(arena, xpath_expr, root, frame) catch |err| {
|
|
return invalidSelector(cmd, "xpath", selector, err);
|
|
};
|
|
switch (result) {
|
|
.node_set => |nodes| try appendNodes(&remotes, arena, &serializer, nodes, max),
|
|
else => return cmd.sendError("invalid selector", "xpath expression must select nodes"),
|
|
}
|
|
},
|
|
else => unreachable, // other types aren't currently supported (TODO) and were already rejected
|
|
}
|
|
|
|
if (remotes.items.len >= max) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return cmd.sendResult(.{ .nodes = remotes.items });
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
fn invalidSelector(cmd: *BiDi.Command, kind: []const u8, selector: []const u8, err: anyerror) !void {
|
|
log.debug(.bidi, "locateNodes", .{ .kind = kind, .selector = selector, .err = err });
|
|
return cmd.sendError("invalid selector", "invalid selector");
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 });
|
|
}
|