Files
browser/src/server/bidi/browsing_context.zig
T
Karl Seguin 18dcb8ac63 WebDriver: add various basic WebDriver APIs
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
```
2026-09-17 17:35:07 +08:00

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 });
}