mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-30 17:18:57 +00:00
Compare commits
22 Commits
ci-cdp_nav
...
fix/cdp-cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b70a434e | ||
|
|
e354b7315f | ||
|
|
8723ecdd2d | ||
|
|
451178558a | ||
|
|
70dc0f6b95 | ||
|
|
d99599fa21 | ||
|
|
20e62a5551 | ||
|
|
e083d4a3d1 | ||
|
|
7a23686cbd | ||
|
|
25889ff918 | ||
|
|
b4e3f246ca | ||
|
|
8eeeeda8c1 | ||
|
|
75dc4d5b0e | ||
|
|
0d40aed1b7 | ||
|
|
78cb766298 | ||
|
|
f60e5cce6d | ||
|
|
81d4bdb157 | ||
|
|
cf5e4d7d1e | ||
|
|
9f81d7d3ff | ||
|
|
1f22462f13 | ||
|
|
273ea91378 | ||
|
|
886aa3abba |
3
.github/workflows/e2e-test.yml
vendored
3
.github/workflows/e2e-test.yml
vendored
@@ -66,8 +66,7 @@ jobs:
|
|||||||
name: demo-scripts
|
name: demo-scripts
|
||||||
needs: zig-build-release
|
needs: zig-build-release
|
||||||
|
|
||||||
#runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
runs-on: lpd-bench-hetzner
|
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const log = @import("log.zig");
|
|||||||
const dump = @import("browser/dump.zig");
|
const dump = @import("browser/dump.zig");
|
||||||
|
|
||||||
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
||||||
|
const mcp = @import("mcp.zig");
|
||||||
|
|
||||||
pub const RunMode = enum {
|
pub const RunMode = enum {
|
||||||
help,
|
help,
|
||||||
@@ -222,6 +223,7 @@ pub const Serve = struct {
|
|||||||
|
|
||||||
pub const Mcp = struct {
|
pub const Mcp = struct {
|
||||||
common: Common = .{},
|
common: Common = .{},
|
||||||
|
version: mcp.Version = .default,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DumpFormat = enum {
|
pub const DumpFormat = enum {
|
||||||
@@ -453,6 +455,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
|||||||
\\Starts an MCP (Model Context Protocol) server over stdio
|
\\Starts an MCP (Model Context Protocol) server over stdio
|
||||||
\\Example: {s} mcp
|
\\Example: {s} mcp
|
||||||
\\
|
\\
|
||||||
|
\\Options:
|
||||||
|
\\--version
|
||||||
|
\\ Override the reported MCP version.
|
||||||
|
\\ Valid: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25.
|
||||||
|
\\ Defaults to "2024-11-05".
|
||||||
|
\\
|
||||||
++ common_options ++
|
++ common_options ++
|
||||||
\\
|
\\
|
||||||
\\version command
|
\\version command
|
||||||
@@ -640,10 +648,22 @@ fn parseMcpArgs(
|
|||||||
allocator: Allocator,
|
allocator: Allocator,
|
||||||
args: *std.process.ArgIterator,
|
args: *std.process.ArgIterator,
|
||||||
) !Mcp {
|
) !Mcp {
|
||||||
var mcp: Mcp = .{};
|
var result: Mcp = .{};
|
||||||
|
|
||||||
while (args.next()) |opt| {
|
while (args.next()) |opt| {
|
||||||
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
|
if (std.mem.eql(u8, "--version", opt)) {
|
||||||
|
const str = args.next() orelse {
|
||||||
|
log.fatal(.mcp, "missing argument value", .{ .arg = opt });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
result.version = std.meta.stringToEnum(mcp.Version, str) orelse {
|
||||||
|
log.fatal(.mcp, "invalid protocol version", .{ .value = str });
|
||||||
|
return error.InvalidArgument;
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (try parseCommonArg(allocator, opt, args, &result.common)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +671,7 @@ fn parseMcpArgs(
|
|||||||
return error.UnkownOption;
|
return error.UnkownOption;
|
||||||
}
|
}
|
||||||
|
|
||||||
return mcp;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parseFetchArgs(
|
fn parseFetchArgs(
|
||||||
|
|||||||
@@ -74,8 +74,6 @@ const EventListeners = struct {
|
|||||||
page_network_idle: List = .{},
|
page_network_idle: List = .{},
|
||||||
page_network_almost_idle: List = .{},
|
page_network_almost_idle: List = .{},
|
||||||
page_frame_created: List = .{},
|
page_frame_created: List = .{},
|
||||||
page_dom_content_loaded: List = .{},
|
|
||||||
page_loaded: List = .{},
|
|
||||||
http_request_fail: List = .{},
|
http_request_fail: List = .{},
|
||||||
http_request_start: List = .{},
|
http_request_start: List = .{},
|
||||||
http_request_intercept: List = .{},
|
http_request_intercept: List = .{},
|
||||||
@@ -93,8 +91,6 @@ const Events = union(enum) {
|
|||||||
page_network_idle: *const PageNetworkIdle,
|
page_network_idle: *const PageNetworkIdle,
|
||||||
page_network_almost_idle: *const PageNetworkAlmostIdle,
|
page_network_almost_idle: *const PageNetworkAlmostIdle,
|
||||||
page_frame_created: *const PageFrameCreated,
|
page_frame_created: *const PageFrameCreated,
|
||||||
page_dom_content_loaded: *const PageDOMContentLoaded,
|
|
||||||
page_loaded: *const PageLoaded,
|
|
||||||
http_request_fail: *const RequestFail,
|
http_request_fail: *const RequestFail,
|
||||||
http_request_start: *const RequestStart,
|
http_request_start: *const RequestStart,
|
||||||
http_request_intercept: *const RequestIntercept,
|
http_request_intercept: *const RequestIntercept,
|
||||||
@@ -141,18 +137,6 @@ pub const PageFrameCreated = struct {
|
|||||||
timestamp: u64,
|
timestamp: u64,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const PageDOMContentLoaded = struct {
|
|
||||||
req_id: u32,
|
|
||||||
frame_id: u32,
|
|
||||||
timestamp: u64,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const PageLoaded = struct {
|
|
||||||
req_id: u32,
|
|
||||||
frame_id: u32,
|
|
||||||
timestamp: u64,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const RequestStart = struct {
|
pub const RequestStart = struct {
|
||||||
transfer: *Transfer,
|
transfer: *Transfer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -821,16 +821,16 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
|
|||||||
break :blk std.ascii.eqlIgnoreCase(hdr.value, "close");
|
break :blk std.ascii.eqlIgnoreCase(hdr.value, "close");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (msg.err != null and !is_conn_close_recv) {
|
|
||||||
transfer.requestFailed(transfer._callback_error orelse msg.err.?, true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// make sure the transfer can't be immediately aborted from a callback
|
// make sure the transfer can't be immediately aborted from a callback
|
||||||
// since we still need it here.
|
// since we still need it here.
|
||||||
transfer._performing = true;
|
transfer._performing = true;
|
||||||
defer transfer._performing = false;
|
defer transfer._performing = false;
|
||||||
|
|
||||||
|
if (msg.err != null and !is_conn_close_recv) {
|
||||||
|
transfer.requestFailed(transfer._callback_error orelse msg.err.?, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!transfer._header_done_called) {
|
if (!transfer._header_done_called) {
|
||||||
// In case of request w/o data, we need to call the header done
|
// In case of request w/o data, we need to call the header done
|
||||||
// callback now.
|
// callback now.
|
||||||
@@ -873,7 +873,6 @@ fn processMessages(self: *Client) !bool {
|
|||||||
var processed = false;
|
var processed = false;
|
||||||
while (self.handles.readMessage()) |msg| {
|
while (self.handles.readMessage()) |msg| {
|
||||||
const transfer = try Transfer.fromConnection(&msg.conn);
|
const transfer = try Transfer.fromConnection(&msg.conn);
|
||||||
|
|
||||||
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
|
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
|
||||||
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
|
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
|
||||||
transfer.requestFailed(err, true);
|
transfer.requestFailed(err, true);
|
||||||
@@ -1068,6 +1067,24 @@ pub const Transfer = struct {
|
|||||||
if (self.req.shutdown_callback) |cb| {
|
if (self.req.shutdown_callback) |cb| {
|
||||||
cb(self.ctx);
|
cb(self.ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self._performing or self.client.performing) {
|
||||||
|
// We're currently inside of a callback. This client, and libcurl
|
||||||
|
// generally don't expect a transfer to become deinitialized during
|
||||||
|
// a callback. We can flag the transfer as aborted (which is what
|
||||||
|
// we do when transfer.abort() is called in this condition) AND,
|
||||||
|
// since this "kill()"should prevent any future callbacks, the best
|
||||||
|
// we can do is null/noop them.
|
||||||
|
self.aborted = true;
|
||||||
|
self.req.start_callback = null;
|
||||||
|
self.req.shutdown_callback = null;
|
||||||
|
self.req.header_callback = Noop.headerCallback;
|
||||||
|
self.req.data_callback = Noop.dataCallback;
|
||||||
|
self.req.done_callback = Noop.doneCallback;
|
||||||
|
self.req.error_callback = Noop.errorCallback;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.deinit();
|
self.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,15 +1342,15 @@ pub const Transfer = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transfer.req.notification.dispatch(.http_response_header_done, &.{
|
|
||||||
.transfer = transfer,
|
|
||||||
});
|
|
||||||
|
|
||||||
const proceed = transfer.req.header_callback(transfer) catch |err| {
|
const proceed = transfer.req.header_callback(transfer) catch |err| {
|
||||||
log.err(.http, "header_callback", .{ .err = err, .req = transfer });
|
log.err(.http, "header_callback", .{ .err = err, .req = transfer });
|
||||||
return err;
|
return err;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
transfer.req.notification.dispatch(.http_response_header_done, &.{
|
||||||
|
.transfer = transfer,
|
||||||
|
});
|
||||||
|
|
||||||
return proceed and transfer.aborted == false;
|
return proceed and transfer.aborted == false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,3 +1509,12 @@ pub const Transfer = struct {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Noop = struct {
|
||||||
|
fn headerCallback(_: *Transfer) !bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
fn dataCallback(_: *Transfer, _: []const u8) !void {}
|
||||||
|
fn doneCallback(_: *anyopaque) !void {}
|
||||||
|
fn errorCallback(_: *anyopaque, _: anyerror) void {}
|
||||||
|
};
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
|||||||
return error.InjectBlankFailed;
|
return error.InjectBlankFailed;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
self.documentIsComplete();
|
||||||
|
|
||||||
session.notification.dispatch(.page_navigate, &.{
|
session.notification.dispatch(.page_navigate, &.{
|
||||||
.frame_id = self._frame_id,
|
.frame_id = self._frame_id,
|
||||||
@@ -518,8 +519,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
|
|||||||
|
|
||||||
// force next request id manually b/c we won't create a real req.
|
// force next request id manually b/c we won't create a real req.
|
||||||
_ = session.browser.http_client.incrReqId();
|
_ = session.browser.http_client.incrReqId();
|
||||||
|
|
||||||
self.documentIsComplete();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,12 +738,6 @@ pub fn _documentIsLoaded(self: *Page) !void {
|
|||||||
self.document.asEventTarget(),
|
self.document.asEventTarget(),
|
||||||
event,
|
event,
|
||||||
);
|
);
|
||||||
|
|
||||||
self._session.notification.dispatch(.page_dom_content_loaded, &.{
|
|
||||||
.frame_id = self._frame_id,
|
|
||||||
.req_id = self._req_id,
|
|
||||||
.timestamp = timestamp(.monotonic),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scriptsCompletedLoading(self: *Page) void {
|
pub fn scriptsCompletedLoading(self: *Page) void {
|
||||||
@@ -803,6 +796,19 @@ pub fn documentIsComplete(self: *Page) void {
|
|||||||
self._documentIsComplete() catch |err| {
|
self._documentIsComplete() catch |err| {
|
||||||
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
|
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (self._navigated_options) |no| {
|
||||||
|
// _navigated_options will be null in special short-circuit cases, like
|
||||||
|
// "navigating" to about:blank, in which case this notification has
|
||||||
|
// already been sent
|
||||||
|
self._session.notification.dispatch(.page_navigated, &.{
|
||||||
|
.frame_id = self._frame_id,
|
||||||
|
.req_id = self._req_id,
|
||||||
|
.opts = no,
|
||||||
|
.url = self.url,
|
||||||
|
.timestamp = timestamp(.monotonic),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _documentIsComplete(self: *Page) !void {
|
fn _documentIsComplete(self: *Page) !void {
|
||||||
@@ -821,12 +827,6 @@ fn _documentIsComplete(self: *Page) !void {
|
|||||||
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
|
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
|
||||||
}
|
}
|
||||||
|
|
||||||
self._session.notification.dispatch(.page_loaded, &.{
|
|
||||||
.frame_id = self._frame_id,
|
|
||||||
.req_id = self._req_id,
|
|
||||||
.timestamp = timestamp(.monotonic),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
|
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
|
||||||
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
|
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
|
||||||
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
|
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
|
||||||
@@ -879,19 +879,6 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self._navigated_options) |no| {
|
|
||||||
// _navigated_options will be null in special short-circuit cases, like
|
|
||||||
// "navigating" to about:blank, in which case this notification has
|
|
||||||
// already been sent
|
|
||||||
self._session.notification.dispatch(.page_navigated, &.{
|
|
||||||
.frame_id = self._frame_id,
|
|
||||||
.req_id = self._req_id,
|
|
||||||
.opts = no,
|
|
||||||
.url = self.url,
|
|
||||||
.timestamp = timestamp(.monotonic),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,13 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=identity>
|
||||||
|
{
|
||||||
|
const element = document.createElement('canvas');
|
||||||
|
const ctx = element.getContext('2d');
|
||||||
|
|
||||||
|
testing.expectTrue(ctx === element.getContext('2d'));
|
||||||
|
testing.expectEqual(null, element.getContext('webgl'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -85,3 +85,13 @@
|
|||||||
loseContext.restoreContext();
|
loseContext.restoreContext();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script id=identity>
|
||||||
|
{
|
||||||
|
const element = document.createElement('canvas');
|
||||||
|
const ctx = element.getContext('webgl');
|
||||||
|
|
||||||
|
testing.expectTrue(ctx === element.getContext('webgl'));
|
||||||
|
testing.expectEqual(null, element.getContext('2d'));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
testing.expectEqual(true, validPlatforms.includes(navigator.platform));
|
testing.expectEqual(true, validPlatforms.includes(navigator.platform));
|
||||||
testing.expectEqual('en-US', navigator.language);
|
testing.expectEqual('en-US', navigator.language);
|
||||||
testing.expectEqual(true, Array.isArray(navigator.languages));
|
testing.expectEqual(true, Array.isArray(navigator.languages));
|
||||||
testing.expectEqual(1, navigator.languages.length);
|
testing.expectEqual(2, navigator.languages.length);
|
||||||
testing.expectEqual('en-US', navigator.languages[0]);
|
testing.expectEqual('en-US', navigator.languages[0]);
|
||||||
|
testing.expectEqual('en', navigator.languages[1]);
|
||||||
testing.expectEqual(true, navigator.onLine);
|
testing.expectEqual(true, navigator.onLine);
|
||||||
testing.expectEqual(true, navigator.cookieEnabled);
|
testing.expectEqual(true, navigator.cookieEnabled);
|
||||||
testing.expectEqual(true, navigator.hardwareConcurrency > 0);
|
testing.expectEqual(true, navigator.hardwareConcurrency > 0);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ pub fn fromError(err: anyerror) ?DOMException {
|
|||||||
error.TimeoutError => .{ ._code = .timeout_error },
|
error.TimeoutError => .{ ._code = .timeout_error },
|
||||||
error.InvalidNodeType => .{ ._code = .invalid_node_type_error },
|
error.InvalidNodeType => .{ ._code = .invalid_node_type_error },
|
||||||
error.DataClone => .{ ._code = .data_clone_error },
|
error.DataClone => .{ ._code = .data_clone_error },
|
||||||
|
error.InvalidAccessError => .{ ._code = .invalid_access_error },
|
||||||
else => null,
|
else => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ pub fn getUserAgent(_: *const Navigator, page: *Page) []const u8 {
|
|||||||
return page._session.browser.app.config.http_headers.user_agent;
|
return page._session.browser.app.config.http_headers.user_agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getLanguages(_: *const Navigator) [1][]const u8 {
|
pub fn getLanguages(_: *const Navigator) [2][]const u8 {
|
||||||
return .{"en-US"};
|
return .{ "en-US", "en" };
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn getPlatform(_: *const Navigator) []const u8 {
|
pub fn getPlatform(_: *const Navigator) []const u8 {
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig");
|
|||||||
|
|
||||||
const Canvas = @This();
|
const Canvas = @This();
|
||||||
_proto: *HtmlElement,
|
_proto: *HtmlElement,
|
||||||
|
_cached: ?DrawingContext = null,
|
||||||
|
|
||||||
|
const ContextType = enum { none, @"2d", webgl };
|
||||||
|
|
||||||
pub fn asElement(self: *Canvas) *Element {
|
pub fn asElement(self: *Canvas) *Element {
|
||||||
return self._proto._proto;
|
return self._proto._proto;
|
||||||
@@ -68,17 +71,28 @@ const DrawingContext = union(enum) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
|
pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
|
||||||
if (std.mem.eql(u8, context_type, "2d")) {
|
if (self._cached) |cached| {
|
||||||
const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
|
const matches = switch (cached) {
|
||||||
return .{ .@"2d" = ctx };
|
.@"2d" => std.mem.eql(u8, context_type, "2d"),
|
||||||
|
.webgl => std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl"),
|
||||||
|
};
|
||||||
|
return if (matches) cached else null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) {
|
const drawing_context: DrawingContext = blk: {
|
||||||
const ctx = try page._factory.create(WebGLRenderingContext{});
|
if (std.mem.eql(u8, context_type, "2d")) {
|
||||||
return .{ .webgl = ctx };
|
const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
|
||||||
}
|
break :blk .{ .@"2d" = ctx };
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
if (std.mem.eql(u8, context_type, "webgl") or std.mem.eql(u8, context_type, "experimental-webgl")) {
|
||||||
|
const ctx = try page._factory.create(WebGLRenderingContext{});
|
||||||
|
break :blk .{ .webgl = ctx };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
self._cached = drawing_context;
|
||||||
|
return drawing_context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transfers control of the canvas to an OffscreenCanvas.
|
/// Transfers control of the canvas to an OffscreenCanvas.
|
||||||
|
|||||||
@@ -363,6 +363,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
|||||||
inspector_session: *js.Inspector.Session,
|
inspector_session: *js.Inspector.Session,
|
||||||
isolated_worlds: std.ArrayList(*IsolatedWorld),
|
isolated_worlds: std.ArrayList(*IsolatedWorld),
|
||||||
|
|
||||||
|
// Scripts registered via Page.addScriptToEvaluateOnNewDocument.
|
||||||
|
// Evaluated in each new document after navigation completes.
|
||||||
|
scripts_on_new_document: std.ArrayList(ScriptOnNewDocument) = .empty,
|
||||||
|
next_script_id: u32 = 1,
|
||||||
|
|
||||||
http_proxy_changed: bool = false,
|
http_proxy_changed: bool = false,
|
||||||
|
|
||||||
// Extra headers to add to all requests.
|
// Extra headers to add to all requests.
|
||||||
@@ -427,8 +432,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
|||||||
try notification.register(.page_navigate, self, onPageNavigate);
|
try notification.register(.page_navigate, self, onPageNavigate);
|
||||||
try notification.register(.page_navigated, self, onPageNavigated);
|
try notification.register(.page_navigated, self, onPageNavigated);
|
||||||
try notification.register(.page_frame_created, self, onPageFrameCreated);
|
try notification.register(.page_frame_created, self, onPageFrameCreated);
|
||||||
try notification.register(.page_dom_content_loaded, self, onPageDOMContentLoaded);
|
|
||||||
try notification.register(.page_loaded, self, onPageLoaded);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Self) void {
|
pub fn deinit(self: *Self) void {
|
||||||
@@ -604,16 +607,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
|||||||
return @import("domains/page.zig").pageFrameCreated(self, msg);
|
return @import("domains/page.zig").pageFrameCreated(self, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn onPageDOMContentLoaded(ctx: *anyopaque, msg: *const Notification.PageDOMContentLoaded) !void {
|
|
||||||
const self: *Self = @ptrCast(@alignCast(ctx));
|
|
||||||
return @import("domains/page.zig").pageDOMContentLoaded(self, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn onPageLoaded(ctx: *anyopaque, msg: *const Notification.PageLoaded) !void {
|
|
||||||
const self: *Self = @ptrCast(@alignCast(ctx));
|
|
||||||
return @import("domains/page.zig").pageLoaded(self, msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void {
|
pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void {
|
||||||
const self: *Self = @ptrCast(@alignCast(ctx));
|
const self: *Self = @ptrCast(@alignCast(ctx));
|
||||||
return @import("domains/page.zig").pageNetworkIdle(self, msg);
|
return @import("domains/page.zig").pageNetworkIdle(self, msg);
|
||||||
@@ -774,6 +767,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
|||||||
/// Clients create this to be able to create variables and run code without interfering with the
|
/// Clients create this to be able to create variables and run code without interfering with the
|
||||||
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
|
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
|
||||||
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
|
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
|
||||||
|
const ScriptOnNewDocument = struct {
|
||||||
|
identifier: u32,
|
||||||
|
source: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
/// in the isolated world by using its Context ID or the worldName.
|
/// in the isolated world by using its Context ID or the worldName.
|
||||||
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
|
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
|
||||||
/// An isolated world has it's own instance of globals like Window.
|
/// An isolated world has it's own instance of globals like Window.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ pub fn processMessage(cmd: anytype) !void {
|
|||||||
getFrameTree,
|
getFrameTree,
|
||||||
setLifecycleEventsEnabled,
|
setLifecycleEventsEnabled,
|
||||||
addScriptToEvaluateOnNewDocument,
|
addScriptToEvaluateOnNewDocument,
|
||||||
|
removeScriptToEvaluateOnNewDocument,
|
||||||
createIsolatedWorld,
|
createIsolatedWorld,
|
||||||
navigate,
|
navigate,
|
||||||
reload,
|
reload,
|
||||||
@@ -51,6 +52,7 @@ pub fn processMessage(cmd: anytype) !void {
|
|||||||
.getFrameTree => return getFrameTree(cmd),
|
.getFrameTree => return getFrameTree(cmd),
|
||||||
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
|
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
|
||||||
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
|
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
|
||||||
|
.removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd),
|
||||||
.createIsolatedWorld => return createIsolatedWorld(cmd),
|
.createIsolatedWorld => return createIsolatedWorld(cmd),
|
||||||
.navigate => return navigate(cmd),
|
.navigate => return navigate(cmd),
|
||||||
.reload => return doReload(cmd),
|
.reload => return doReload(cmd),
|
||||||
@@ -147,22 +149,55 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
|
|||||||
return cmd.sendResult(null, .{});
|
return cmd.sendResult(null, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: hard coded method
|
|
||||||
// With the command we receive a script we need to store and run for each new document.
|
|
||||||
// Note that the worldName refers to the name given to the isolated world.
|
|
||||||
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
|
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
|
||||||
// const params = (try cmd.params(struct {
|
const params = (try cmd.params(struct {
|
||||||
// source: []const u8,
|
source: []const u8,
|
||||||
// worldName: ?[]const u8 = null,
|
worldName: ?[]const u8 = null,
|
||||||
// includeCommandLineAPI: bool = false,
|
includeCommandLineAPI: bool = false,
|
||||||
// runImmediately: bool = false,
|
runImmediately: bool = false,
|
||||||
// })) orelse return error.InvalidParams;
|
})) orelse return error.InvalidParams;
|
||||||
|
|
||||||
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||||
|
|
||||||
|
if (params.runImmediately) {
|
||||||
|
log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const script_id = bc.next_script_id;
|
||||||
|
bc.next_script_id += 1;
|
||||||
|
|
||||||
|
const source_dupe = try bc.arena.dupe(u8, params.source);
|
||||||
|
try bc.scripts_on_new_document.append(bc.arena, .{
|
||||||
|
.identifier = script_id,
|
||||||
|
.source = source_dupe,
|
||||||
|
});
|
||||||
|
|
||||||
|
var id_buf: [16]u8 = undefined;
|
||||||
|
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{script_id}) catch "1";
|
||||||
return cmd.sendResult(.{
|
return cmd.sendResult(.{
|
||||||
.identifier = "1",
|
.identifier = id_str,
|
||||||
}, .{});
|
}, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn removeScriptToEvaluateOnNewDocument(cmd: anytype) !void {
|
||||||
|
const params = (try cmd.params(struct {
|
||||||
|
identifier: []const u8,
|
||||||
|
})) orelse return error.InvalidParams;
|
||||||
|
|
||||||
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||||
|
|
||||||
|
const target_id = std.fmt.parseInt(u32, params.identifier, 10) catch
|
||||||
|
return cmd.sendResult(null, .{});
|
||||||
|
|
||||||
|
for (bc.scripts_on_new_document.items, 0..) |script, i| {
|
||||||
|
if (script.identifier == target_id) {
|
||||||
|
_ = bc.scripts_on_new_document.orderedRemove(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmd.sendResult(null, .{});
|
||||||
|
}
|
||||||
|
|
||||||
fn close(cmd: anytype) !void {
|
fn close(cmd: anytype) !void {
|
||||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||||
|
|
||||||
@@ -385,6 +420,7 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
|
|||||||
// things, but no session.
|
// things, but no session.
|
||||||
const session_id = bc.session_id orelse return;
|
const session_id = bc.session_id orelse return;
|
||||||
|
|
||||||
|
const timestamp = event.timestamp;
|
||||||
const frame_id = &id.toFrameId(event.frame_id);
|
const frame_id = &id.toFrameId(event.frame_id);
|
||||||
const loader_id = &id.toLoaderId(event.req_id);
|
const loader_id = &id.toLoaderId(event.req_id);
|
||||||
|
|
||||||
@@ -436,9 +472,9 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
|
|||||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||||
|
|
||||||
// When we actually recreated the context we should have the inspector send
|
// When we actually recreated the context we should have the inspector send
|
||||||
// this event, see: resetContextGroup. Sending this event will tell the
|
// this event, see: resetContextGroup Sending this event will tell the
|
||||||
// client that the context ids they had are invalid and the context should
|
// client that the context ids they had are invalid and the context shouls
|
||||||
// be dropped. The client will expect us to send new contextCreated events,
|
// be dropped The client will expect us to send new contextCreated events,
|
||||||
// such that the client has new id's for the active contexts.
|
// such that the client has new id's for the active contexts.
|
||||||
// Only send executionContextsCleared for main frame navigations. For child
|
// Only send executionContextsCleared for main frame navigations. For child
|
||||||
// frames (iframes), clearing all contexts would destroy the main frame's
|
// frames (iframes), clearing all contexts would destroy the main frame's
|
||||||
@@ -448,18 +484,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
|
|||||||
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
|
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// frameNavigated event
|
|
||||||
try cdp.sendEvent("Page.frameNavigated", .{
|
|
||||||
.type = "Navigation",
|
|
||||||
.frame = Frame{
|
|
||||||
.id = frame_id,
|
|
||||||
.url = event.url,
|
|
||||||
.loaderId = loader_id,
|
|
||||||
.securityOrigin = bc.security_origin,
|
|
||||||
.secureContextType = bc.secure_context_type,
|
|
||||||
},
|
|
||||||
}, .{ .session_id = session_id });
|
|
||||||
|
|
||||||
{
|
{
|
||||||
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
|
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
|
||||||
|
|
||||||
@@ -493,6 +517,27 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument.
|
||||||
|
// Must run after the execution context is created but before the client
|
||||||
|
// receives frameNavigated/loadEventFired so polyfills are available for
|
||||||
|
// subsequent CDP commands.
|
||||||
|
if (bc.scripts_on_new_document.items.len > 0) {
|
||||||
|
var ls: js.Local.Scope = undefined;
|
||||||
|
page.js.localScope(&ls);
|
||||||
|
defer ls.deinit();
|
||||||
|
|
||||||
|
for (bc.scripts_on_new_document.items) |script| {
|
||||||
|
var try_catch: lp.js.TryCatch = undefined;
|
||||||
|
try_catch.init(&ls.local);
|
||||||
|
defer try_catch.deinit();
|
||||||
|
|
||||||
|
ls.local.eval(script.source, null) catch |err| {
|
||||||
|
const caught = try_catch.caughtOrError(arena, err);
|
||||||
|
log.warn(.cdp, "script on new doc", .{ .caught = caught });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// frameNavigated event
|
// frameNavigated event
|
||||||
try cdp.sendEvent("Page.frameNavigated", .{
|
try cdp.sendEvent("Page.frameNavigated", .{
|
||||||
.type = "Navigation",
|
.type = "Navigation",
|
||||||
@@ -509,22 +554,18 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
|
|||||||
// chromedp client expects to receive the events is this order.
|
// chromedp client expects to receive the events is this order.
|
||||||
// see https://github.com/chromedp/chromedp/issues/1558
|
// see https://github.com/chromedp/chromedp/issues/1558
|
||||||
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
|
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMContentLoaded) !void {
|
|
||||||
const session_id = bc.session_id orelse return;
|
|
||||||
const timestamp = event.timestamp;
|
|
||||||
var cdp = bc.cdp;
|
|
||||||
|
|
||||||
|
// domContentEventFired event
|
||||||
|
// TODO: partially hard coded
|
||||||
try cdp.sendEvent(
|
try cdp.sendEvent(
|
||||||
"Page.domContentEventFired",
|
"Page.domContentEventFired",
|
||||||
.{ .timestamp = timestamp },
|
.{ .timestamp = timestamp },
|
||||||
.{ .session_id = session_id },
|
.{ .session_id = session_id },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// lifecycle DOMContentLoaded event
|
||||||
|
// TODO: partially hard coded
|
||||||
if (bc.page_life_cycle_events) {
|
if (bc.page_life_cycle_events) {
|
||||||
const frame_id = &id.toFrameId(event.frame_id);
|
|
||||||
const loader_id = &id.toLoaderId(event.req_id);
|
|
||||||
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
||||||
.timestamp = timestamp,
|
.timestamp = timestamp,
|
||||||
.name = "DOMContentLoaded",
|
.name = "DOMContentLoaded",
|
||||||
@@ -532,23 +573,16 @@ pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMConte
|
|||||||
.loaderId = loader_id,
|
.loaderId = loader_id,
|
||||||
}, .{ .session_id = session_id });
|
}, .{ .session_id = session_id });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
|
|
||||||
const session_id = bc.session_id orelse return;
|
|
||||||
const timestamp = event.timestamp;
|
|
||||||
var cdp = bc.cdp;
|
|
||||||
|
|
||||||
const frame_id = &id.toFrameId(event.frame_id);
|
|
||||||
|
|
||||||
|
// loadEventFired event
|
||||||
try cdp.sendEvent(
|
try cdp.sendEvent(
|
||||||
"Page.loadEventFired",
|
"Page.loadEventFired",
|
||||||
.{ .timestamp = timestamp },
|
.{ .timestamp = timestamp },
|
||||||
.{ .session_id = session_id },
|
.{ .session_id = session_id },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// lifecycle DOMContentLoaded event
|
||||||
if (bc.page_life_cycle_events) {
|
if (bc.page_life_cycle_events) {
|
||||||
const loader_id = &id.toLoaderId(event.req_id);
|
|
||||||
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
||||||
.timestamp = timestamp,
|
.timestamp = timestamp,
|
||||||
.name = "load",
|
.name = "load",
|
||||||
@@ -557,6 +591,7 @@ pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
|
|||||||
}, .{ .session_id = session_id });
|
}, .{ .session_id = session_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// frameStoppedLoading
|
||||||
return cdp.sendEvent("Page.frameStoppedLoading", .{
|
return cdp.sendEvent("Page.frameStoppedLoading", .{
|
||||||
.frameId = frame_id,
|
.frameId = frame_id,
|
||||||
}, .{ .session_id = session_id });
|
}, .{ .session_id = session_id });
|
||||||
@@ -861,3 +896,55 @@ test "cdp.page: reload" {
|
|||||||
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
|
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "cdp.page: addScriptToEvaluateOnNewDocument" {
|
||||||
|
var ctx = try testing.context();
|
||||||
|
defer ctx.deinit();
|
||||||
|
|
||||||
|
var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
|
||||||
|
|
||||||
|
{
|
||||||
|
// Register a script — should return unique identifier "1"
|
||||||
|
try ctx.processMessage(.{ .id = 20, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test = 1" } });
|
||||||
|
try ctx.expectSentResult(.{
|
||||||
|
.identifier = "1",
|
||||||
|
}, .{ .id = 20 });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Register another script — should return identifier "2"
|
||||||
|
try ctx.processMessage(.{ .id = 21, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test2 = 2" } });
|
||||||
|
try ctx.expectSentResult(.{
|
||||||
|
.identifier = "2",
|
||||||
|
}, .{ .id = 21 });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Remove the first script — should succeed
|
||||||
|
try ctx.processMessage(.{ .id = 22, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "1" } });
|
||||||
|
try ctx.expectSentResult(null, .{ .id = 22 });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// Remove a non-existent identifier — should succeed silently
|
||||||
|
try ctx.processMessage(.{ .id = 23, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "999" } });
|
||||||
|
try ctx.expectSentResult(null, .{ .id = 23 });
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
try ctx.processMessage(.{ .id = 34, .method = "Page.reload" });
|
||||||
|
// wait for this event, which is sent after we've run the registered scripts
|
||||||
|
try ctx.expectSentEvent("Page.frameNavigated", .{
|
||||||
|
.frame = .{ .loaderId = "LID-0000000002" },
|
||||||
|
}, .{});
|
||||||
|
|
||||||
|
const page = bc.session.currentPage() orelse unreachable;
|
||||||
|
|
||||||
|
var ls: js.Local.Scope = undefined;
|
||||||
|
page.js.localScope(&ls);
|
||||||
|
defer ls.deinit();
|
||||||
|
|
||||||
|
const test_val = try ls.local.exec("window.__test2", null);
|
||||||
|
try testing.expectEqual(2, try test_val.toI32());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,15 +158,20 @@ fn createTarget(cmd: anytype) !void {
|
|||||||
else => return err,
|
else => return err,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (bc.target_id != null) {
|
|
||||||
return error.TargetAlreadyLoaded;
|
|
||||||
}
|
|
||||||
if (params.browserContextId) |param_browser_context_id| {
|
if (params.browserContextId) |param_browser_context_id| {
|
||||||
if (std.mem.eql(u8, param_browser_context_id, bc.id) == false) {
|
if (std.mem.eql(u8, param_browser_context_id, bc.id) == false) {
|
||||||
return error.UnknownBrowserContextId;
|
return error.UnknownBrowserContextId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a target already exists, close it first. Lightpanda only supports
|
||||||
|
// one page at a time, so we replace the existing target rather than
|
||||||
|
// rejecting the request. This unblocks automation frameworks (e.g.
|
||||||
|
// Stagehand) that call createTarget multiple times.
|
||||||
|
if (bc.target_id != null) {
|
||||||
|
try doCloseTarget(cmd, bc);
|
||||||
|
}
|
||||||
|
|
||||||
// if target_id is null, we should never have a page
|
// if target_id is null, we should never have a page
|
||||||
lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{});
|
lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{});
|
||||||
|
|
||||||
@@ -280,34 +285,9 @@ fn closeTarget(cmd: anytype) !void {
|
|||||||
return error.UnknownTargetId;
|
return error.UnknownTargetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// can't be null if we have a target_id
|
|
||||||
lp.assert(bc.session.page != null, "CDP.target.closeTarget null page", .{});
|
|
||||||
|
|
||||||
try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false });
|
try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false });
|
||||||
|
|
||||||
// could be null, created but never attached
|
try doCloseTarget(cmd, bc);
|
||||||
if (bc.session_id) |session_id| {
|
|
||||||
// Inspector.detached event
|
|
||||||
try cmd.sendEvent("Inspector.detached", .{
|
|
||||||
.reason = "Render process gone.",
|
|
||||||
}, .{ .session_id = session_id });
|
|
||||||
|
|
||||||
// detachedFromTarget event
|
|
||||||
try cmd.sendEvent("Target.detachedFromTarget", .{
|
|
||||||
.targetId = target_id,
|
|
||||||
.sessionId = session_id,
|
|
||||||
.reason = "Render process gone.",
|
|
||||||
}, .{});
|
|
||||||
|
|
||||||
bc.session_id = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
bc.session.removePage();
|
|
||||||
for (bc.isolated_worlds.items) |world| {
|
|
||||||
world.deinit();
|
|
||||||
}
|
|
||||||
bc.isolated_worlds.clearRetainingCapacity();
|
|
||||||
bc.target_id = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getTargetInfo(cmd: anytype) !void {
|
fn getTargetInfo(cmd: anytype) !void {
|
||||||
@@ -468,6 +448,41 @@ fn setAutoAttach(cmd: anytype) !void {
|
|||||||
try cmd.sendResult(null, .{});
|
try cmd.sendResult(null, .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close the current target in a browser context: send detach events,
|
||||||
|
/// remove the page, clean up isolated worlds, and clear the target_id.
|
||||||
|
/// Shared by closeTarget and createTarget (which auto-closes before
|
||||||
|
/// creating a replacement).
|
||||||
|
fn doCloseTarget(cmd: anytype, bc: anytype) !void {
|
||||||
|
// can't be null if we have a target_id
|
||||||
|
lp.assert(bc.session.page != null, "CDP.target.doCloseTarget null page", .{});
|
||||||
|
|
||||||
|
// could be null, created but never attached
|
||||||
|
if (bc.session_id) |session_id| {
|
||||||
|
try cmd.sendEvent("Inspector.detached", .{
|
||||||
|
.reason = "Render process gone.",
|
||||||
|
}, .{ .session_id = session_id });
|
||||||
|
|
||||||
|
try cmd.sendEvent("Target.detachedFromTarget", .{
|
||||||
|
.targetId = &bc.target_id.?,
|
||||||
|
.sessionId = session_id,
|
||||||
|
.reason = "Render process gone.",
|
||||||
|
}, .{});
|
||||||
|
|
||||||
|
bc.session_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try cmd.sendEvent("Target.targetDestroyed", .{
|
||||||
|
.targetId = &bc.target_id.?,
|
||||||
|
}, .{});
|
||||||
|
|
||||||
|
bc.session.removePage();
|
||||||
|
for (bc.isolated_worlds.items) |world| {
|
||||||
|
world.deinit();
|
||||||
|
}
|
||||||
|
bc.isolated_worlds.clearRetainingCapacity();
|
||||||
|
bc.target_id = null;
|
||||||
|
}
|
||||||
|
|
||||||
fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void {
|
fn doAttachtoTarget(cmd: anytype, target_id: []const u8) !void {
|
||||||
const bc = cmd.browser_context.?;
|
const bc = cmd.browser_context.?;
|
||||||
const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next();
|
const session_id = bc.session_id orelse cmd.cdp.session_id_gen.next();
|
||||||
@@ -646,6 +661,7 @@ test "cdp.target: closeTarget" {
|
|||||||
{
|
{
|
||||||
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } });
|
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } });
|
||||||
try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 });
|
try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 });
|
||||||
|
try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = "TID-000000000A" }, .{});
|
||||||
try testing.expectEqual(null, bc.session.page);
|
try testing.expectEqual(null, bc.session.page);
|
||||||
try testing.expectEqual(null, bc.target_id);
|
try testing.expectEqual(null, bc.target_id);
|
||||||
}
|
}
|
||||||
@@ -771,6 +787,53 @@ test "cdp.target: detachFromTarget" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "cdp.target: createTarget closes existing target (issue #1962)" {
|
||||||
|
var ctx = try testing.context();
|
||||||
|
defer ctx.deinit();
|
||||||
|
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||||
|
{
|
||||||
|
// Create first target
|
||||||
|
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
|
||||||
|
try testing.expectEqual(true, bc.target_id != null);
|
||||||
|
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 });
|
||||||
|
|
||||||
|
// Create second target — should succeed by auto-closing the first
|
||||||
|
try ctx.processMessage(.{ .id = 11, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
|
||||||
|
try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = "FID-0000000001" }, .{});
|
||||||
|
try testing.expectEqual(true, bc.target_id != null);
|
||||||
|
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 11 });
|
||||||
|
|
||||||
|
// Page should exist (new target is active)
|
||||||
|
try testing.expectEqual(true, bc.session.page != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "cdp.target: createTarget closes existing attached target (issue #1962)" {
|
||||||
|
var ctx = try testing.context();
|
||||||
|
defer ctx.deinit();
|
||||||
|
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
|
||||||
|
{
|
||||||
|
// Create and attach first target
|
||||||
|
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
|
||||||
|
try testing.expectEqual(true, bc.target_id != null);
|
||||||
|
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 10 });
|
||||||
|
|
||||||
|
try ctx.processMessage(.{ .id = 11, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
|
||||||
|
const session_id = bc.session_id.?;
|
||||||
|
try ctx.expectSentResult(.{ .sessionId = session_id }, .{ .id = 11 });
|
||||||
|
|
||||||
|
// Create second target — should close and detach the first
|
||||||
|
try ctx.processMessage(.{ .id = 12, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-9" } });
|
||||||
|
// Should have sent detach events for the old session
|
||||||
|
try ctx.expectSentEvent("Inspector.detached", .{ .reason = "Render process gone." }, .{ .session_id = session_id });
|
||||||
|
try ctx.expectSentEvent("Target.detachedFromTarget", .{ .sessionId = session_id, .reason = "Render process gone." }, .{});
|
||||||
|
try ctx.expectSentEvent("Target.targetDestroyed", .{ .targetId = "FID-0000000001" }, .{});
|
||||||
|
// New target should be created
|
||||||
|
try testing.expectEqual(true, bc.target_id != null);
|
||||||
|
try ctx.expectSentResult(.{ .targetId = bc.target_id.? }, .{ .id = 12 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test "cdp.target: detachFromTarget without session" {
|
test "cdp.target: detachFromTarget without session" {
|
||||||
var ctx = try testing.context();
|
var ctx = try testing.context();
|
||||||
defer ctx.deinit();
|
defer ctx.deinit();
|
||||||
|
|||||||
@@ -168,13 +168,26 @@ const TestContext = struct {
|
|||||||
index: ?usize = null,
|
index: ?usize = null,
|
||||||
};
|
};
|
||||||
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
|
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
|
||||||
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
|
const expected_json = blk: {
|
||||||
.whitespace = .indent_2,
|
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
|
||||||
.emit_null_optional_fields = false,
|
// We can't record that in an ArrayList(???), so we serialize it to JSON.
|
||||||
});
|
// Now, ideally, we could just take our expected structure, serialize it to
|
||||||
|
// json and check if the two are equal.
|
||||||
|
// Except serializing to JSON isn't deterministic.
|
||||||
|
// So we serialize the JSON then we deserialize to json.Value. And then we can
|
||||||
|
// compare our anytype expectation with the json.Value that we captured
|
||||||
|
|
||||||
|
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
|
||||||
|
.whitespace = .indent_2,
|
||||||
|
.emit_null_optional_fields = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
break :blk try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, serialized, .{});
|
||||||
|
};
|
||||||
|
|
||||||
for (0..5) |_| {
|
for (0..5) |_| {
|
||||||
for (self.received.items, 0..) |received, i| {
|
for (self.received.items, 0..) |received, i| {
|
||||||
if (try compareExpectedToSent(serialized, received) == false) {
|
if (try base.isEqualJson(expected_json, received) == false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +200,15 @@ const TestContext = struct {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (self.cdp_) |*cdp__| {
|
||||||
|
if (cdp__.browser_context) |*bc| {
|
||||||
|
if (bc.session.page != null) {
|
||||||
|
var runner = try bc.session.runner(.{});
|
||||||
|
_ = try runner.tick(.{ .ms = 1000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
std.Thread.sleep(5 * std.time.ns_per_ms);
|
std.Thread.sleep(5 * std.time.ns_per_ms);
|
||||||
try self.read();
|
try self.read();
|
||||||
}
|
}
|
||||||
@@ -299,17 +321,3 @@ pub fn context() !TestContext {
|
|||||||
.socket = pair[0],
|
.socket = pair[0],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zig makes this hard. When sendJSON is called, we're sending an anytype.
|
|
||||||
// We can't record that in an ArrayList(???), so we serialize it to JSON.
|
|
||||||
// Now, ideally, we could just take our expected structure, serialize it to
|
|
||||||
// json and check if the two are equal.
|
|
||||||
// Except serializing to JSON isn't deterministic.
|
|
||||||
// So we serialize the JSON then we deserialize to json.Value. And then we can
|
|
||||||
// compare our anytype expectation with the json.Value that we captured
|
|
||||||
|
|
||||||
fn compareExpectedToSent(expected: []const u8, actual: json.Value) !bool {
|
|
||||||
const expected_value = try std.json.parseFromSlice(json.Value, std.testing.allocator, expected, .{});
|
|
||||||
defer expected_value.deinit();
|
|
||||||
return base.isEqualJson(expected_value.value, actual);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
pub const protocol = @import("mcp/protocol.zig");
|
pub const protocol = @import("mcp/protocol.zig");
|
||||||
|
pub const Version = protocol.Version;
|
||||||
pub const router = @import("mcp/router.zig");
|
pub const router = @import("mcp/router.zig");
|
||||||
pub const Server = @import("mcp/Server.zig");
|
pub const Server = @import("mcp/Server.zig");
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ test "MCP.Server - Integration: synchronous smoke test" {
|
|||||||
|
|
||||||
try router.processRequests(server, &in_reader);
|
try router.processRequests(server, &in_reader);
|
||||||
|
|
||||||
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 1 }, out_alloc.writer.buffered());
|
try testing.expectJson(.{ .jsonrpc = "2.0", .id = 1, .result = .{ .protocolVersion = "2024-11-05" } }, out_alloc.writer.buffered());
|
||||||
}
|
}
|
||||||
|
|
||||||
test "MCP.Server - Integration: ping request returns an empty result" {
|
test "MCP.Server - Integration: ping request returns an empty result" {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub const Version = enum {
|
||||||
|
@"2024-11-05",
|
||||||
|
@"2025-03-26",
|
||||||
|
@"2025-06-18",
|
||||||
|
@"2025-11-25",
|
||||||
|
|
||||||
|
pub const default: Version = .@"2024-11-05";
|
||||||
|
};
|
||||||
|
|
||||||
pub const Request = struct {
|
pub const Request = struct {
|
||||||
jsonrpc: []const u8 = "2.0",
|
jsonrpc: []const u8 = "2.0",
|
||||||
id: ?std.json.Value = null,
|
id: ?std.json.Value = null,
|
||||||
|
|||||||
@@ -81,8 +81,12 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
|
|||||||
|
|
||||||
fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
fn handleInitialize(server: *Server, req: protocol.Request) !void {
|
||||||
const id = req.id orelse return;
|
const id = req.id orelse return;
|
||||||
const result = protocol.InitializeResult{
|
const version: protocol.Version = switch (server.app.config.mode) {
|
||||||
.protocolVersion = "2025-11-25",
|
.mcp => |opts| opts.version,
|
||||||
|
else => .default,
|
||||||
|
};
|
||||||
|
const result: protocol.InitializeResult = .{
|
||||||
|
.protocolVersion = @tagName(version),
|
||||||
.capabilities = .{
|
.capabilities = .{
|
||||||
.resources = .{},
|
.resources = .{},
|
||||||
.tools = .{},
|
.tools = .{},
|
||||||
@@ -121,7 +125,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
|
|||||||
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
|
\\{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
|
||||||
);
|
);
|
||||||
try testing.expectJson(
|
try testing.expectJson(
|
||||||
\\{ "jsonrpc": "2.0", "id": 1, "result": { "capabilities": { "tools": {} } } }
|
\\{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2024-11-05", "capabilities": { "tools": {} } } }
|
||||||
, out_alloc.writer.buffered());
|
, out_alloc.writer.buffered());
|
||||||
out_alloc.writer.end = 0;
|
out_alloc.writer.end = 0;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user