mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-30 09:08:55 +00:00
Compare commits
21 Commits
cdp_naviga
...
puppeteer-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4760858d | ||
|
|
8723ecdd2d | ||
|
|
451178558a | ||
|
|
70dc0f6b95 | ||
|
|
d99599fa21 | ||
|
|
20e62a5551 | ||
|
|
e083d4a3d1 | ||
|
|
7a23686cbd | ||
|
|
25889ff918 | ||
|
|
b4e3f246ca | ||
|
|
8eeeeda8c1 | ||
|
|
75dc4d5b0e | ||
|
|
0d40aed1b7 | ||
|
|
78cb766298 | ||
|
|
f60e5cce6d | ||
|
|
81d4bdb157 | ||
|
|
cf5e4d7d1e | ||
|
|
9f81d7d3ff | ||
|
|
1f22462f13 | ||
|
|
273ea91378 | ||
|
|
886aa3abba |
5
.github/workflows/e2e-test.yml
vendored
5
.github/workflows/e2e-test.yml
vendored
@@ -107,8 +107,11 @@ jobs:
|
||||
export PROXY_USERNAME=username PROXY_PASSWORD=password
|
||||
./proxy/proxy & echo $! > PROXY.id
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
|
||||
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
||||
kill `cat LPD.pid`
|
||||
|
||||
./lightpanda serve --http-proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
|
||||
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
|
||||
kill `cat LPD.pid` `cat PROXY.id`
|
||||
|
||||
# e2e tests w/ web-bot-auth configuration on.
|
||||
|
||||
@@ -24,6 +24,7 @@ const log = @import("log.zig");
|
||||
const dump = @import("browser/dump.zig");
|
||||
|
||||
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
||||
const mcp = @import("mcp.zig");
|
||||
|
||||
pub const RunMode = enum {
|
||||
help,
|
||||
@@ -222,6 +223,7 @@ pub const Serve = struct {
|
||||
|
||||
pub const Mcp = struct {
|
||||
common: Common = .{},
|
||||
version: mcp.Version = .default,
|
||||
};
|
||||
|
||||
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
|
||||
\\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 ++
|
||||
\\
|
||||
\\version command
|
||||
@@ -640,10 +648,22 @@ fn parseMcpArgs(
|
||||
allocator: Allocator,
|
||||
args: *std.process.ArgIterator,
|
||||
) !Mcp {
|
||||
var mcp: Mcp = .{};
|
||||
var result: Mcp = .{};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -651,7 +671,7 @@ fn parseMcpArgs(
|
||||
return error.UnkownOption;
|
||||
}
|
||||
|
||||
return mcp;
|
||||
return result;
|
||||
}
|
||||
|
||||
fn parseFetchArgs(
|
||||
|
||||
@@ -74,8 +74,6 @@ const EventListeners = struct {
|
||||
page_network_idle: List = .{},
|
||||
page_network_almost_idle: List = .{},
|
||||
page_frame_created: List = .{},
|
||||
page_dom_content_loaded: List = .{},
|
||||
page_loaded: List = .{},
|
||||
http_request_fail: List = .{},
|
||||
http_request_start: List = .{},
|
||||
http_request_intercept: List = .{},
|
||||
@@ -93,8 +91,6 @@ const Events = union(enum) {
|
||||
page_network_idle: *const PageNetworkIdle,
|
||||
page_network_almost_idle: *const PageNetworkAlmostIdle,
|
||||
page_frame_created: *const PageFrameCreated,
|
||||
page_dom_content_loaded: *const PageDOMContentLoaded,
|
||||
page_loaded: *const PageLoaded,
|
||||
http_request_fail: *const RequestFail,
|
||||
http_request_start: *const RequestStart,
|
||||
http_request_intercept: *const RequestIntercept,
|
||||
@@ -141,18 +137,6 @@ pub const PageFrameCreated = struct {
|
||||
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 {
|
||||
transfer: *Transfer,
|
||||
};
|
||||
|
||||
@@ -821,16 +821,16 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
|
||||
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
|
||||
// since we still need it here.
|
||||
transfer._performing = true;
|
||||
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) {
|
||||
// In case of request w/o data, we need to call the header done
|
||||
// callback now.
|
||||
@@ -873,7 +873,6 @@ fn processMessages(self: *Client) !bool {
|
||||
var processed = false;
|
||||
while (self.handles.readMessage()) |msg| {
|
||||
const transfer = try Transfer.fromConnection(&msg.conn);
|
||||
|
||||
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
|
||||
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
|
||||
transfer.requestFailed(err, true);
|
||||
@@ -1068,6 +1067,24 @@ pub const Transfer = struct {
|
||||
if (self.req.shutdown_callback) |cb| {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
log.err(.http, "header_callback", .{ .err = err, .req = transfer });
|
||||
return err;
|
||||
};
|
||||
|
||||
transfer.req.notification.dispatch(.http_response_header_done, &.{
|
||||
.transfer = transfer,
|
||||
});
|
||||
|
||||
return proceed and transfer.aborted == false;
|
||||
}
|
||||
|
||||
@@ -1492,3 +1509,12 @@ pub const Transfer = struct {
|
||||
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;
|
||||
};
|
||||
}
|
||||
self.documentIsComplete();
|
||||
|
||||
session.notification.dispatch(.page_navigate, &.{
|
||||
.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.
|
||||
_ = session.browser.http_client.incrReqId();
|
||||
|
||||
self.documentIsComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -739,12 +738,6 @@ pub fn _documentIsLoaded(self: *Page) !void {
|
||||
self.document.asEventTarget(),
|
||||
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 {
|
||||
@@ -803,6 +796,19 @@ pub fn documentIsComplete(self: *Page) void {
|
||||
self._documentIsComplete() catch |err| {
|
||||
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 {
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
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)) {
|
||||
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" });
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,3 +148,13 @@
|
||||
|
||||
}
|
||||
</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();
|
||||
}
|
||||
</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('en-US', navigator.language);
|
||||
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', navigator.languages[1]);
|
||||
testing.expectEqual(true, navigator.onLine);
|
||||
testing.expectEqual(true, navigator.cookieEnabled);
|
||||
testing.expectEqual(true, navigator.hardwareConcurrency > 0);
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn fromError(err: anyerror) ?DOMException {
|
||||
error.TimeoutError => .{ ._code = .timeout_error },
|
||||
error.InvalidNodeType => .{ ._code = .invalid_node_type_error },
|
||||
error.DataClone => .{ ._code = .data_clone_error },
|
||||
error.InvalidAccessError => .{ ._code = .invalid_access_error },
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn getLanguages(_: *const Navigator) [1][]const u8 {
|
||||
return .{"en-US"};
|
||||
pub fn getLanguages(_: *const Navigator) [2][]const u8 {
|
||||
return .{ "en-US", "en" };
|
||||
}
|
||||
|
||||
pub fn getPlatform(_: *const Navigator) []const u8 {
|
||||
|
||||
@@ -29,6 +29,9 @@ const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig");
|
||||
|
||||
const Canvas = @This();
|
||||
_proto: *HtmlElement,
|
||||
_cached: ?DrawingContext = null,
|
||||
|
||||
const ContextType = enum { none, @"2d", webgl };
|
||||
|
||||
pub fn asElement(self: *Canvas) *Element {
|
||||
return self._proto._proto;
|
||||
@@ -68,17 +71,28 @@ const DrawingContext = union(enum) {
|
||||
};
|
||||
|
||||
pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
|
||||
if (std.mem.eql(u8, context_type, "2d")) {
|
||||
const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
|
||||
return .{ .@"2d" = ctx };
|
||||
if (self._cached) |cached| {
|
||||
const matches = switch (cached) {
|
||||
.@"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 ctx = try page._factory.create(WebGLRenderingContext{});
|
||||
return .{ .webgl = ctx };
|
||||
}
|
||||
const drawing_context: DrawingContext = blk: {
|
||||
if (std.mem.eql(u8, context_type, "2d")) {
|
||||
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.
|
||||
|
||||
@@ -363,6 +363,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
inspector_session: *js.Inspector.Session,
|
||||
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,
|
||||
|
||||
// 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_navigated, self, onPageNavigated);
|
||||
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 {
|
||||
@@ -604,16 +607,6 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
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 {
|
||||
const self: *Self = @ptrCast(@alignCast(ctx));
|
||||
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
|
||||
/// 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
|
||||
const ScriptOnNewDocument = struct {
|
||||
identifier: u32,
|
||||
source: []const u8,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
/// An isolated world has it's own instance of globals like Window.
|
||||
|
||||
@@ -37,6 +37,7 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
getFrameTree,
|
||||
setLifecycleEventsEnabled,
|
||||
addScriptToEvaluateOnNewDocument,
|
||||
removeScriptToEvaluateOnNewDocument,
|
||||
createIsolatedWorld,
|
||||
navigate,
|
||||
reload,
|
||||
@@ -51,6 +52,7 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
.getFrameTree => return getFrameTree(cmd),
|
||||
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
|
||||
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
|
||||
.removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd),
|
||||
.createIsolatedWorld => return createIsolatedWorld(cmd),
|
||||
.navigate => return navigate(cmd),
|
||||
.reload => return doReload(cmd),
|
||||
@@ -147,22 +149,55 @@ fn setLifecycleEventsEnabled(cmd: anytype) !void {
|
||||
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 {
|
||||
// const params = (try cmd.params(struct {
|
||||
// source: []const u8,
|
||||
// worldName: ?[]const u8 = null,
|
||||
// includeCommandLineAPI: bool = false,
|
||||
// runImmediately: bool = false,
|
||||
// })) orelse return error.InvalidParams;
|
||||
const params = (try cmd.params(struct {
|
||||
source: []const u8,
|
||||
worldName: ?[]const u8 = null,
|
||||
includeCommandLineAPI: bool = false,
|
||||
runImmediately: bool = false,
|
||||
})) 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(.{
|
||||
.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 {
|
||||
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.
|
||||
const session_id = bc.session_id orelse return;
|
||||
|
||||
const timestamp = event.timestamp;
|
||||
const frame_id = &id.toFrameId(event.frame_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;
|
||||
|
||||
// When we actually recreated the context we should have the inspector send
|
||||
// this event, see: resetContextGroup. Sending this event will tell the
|
||||
// client that the context ids they had are invalid and the context should
|
||||
// be dropped. The client will expect us to send new contextCreated events,
|
||||
// this event, see: resetContextGroup Sending this event will tell the
|
||||
// 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,
|
||||
// such that the client has new id's for the active contexts.
|
||||
// Only send executionContextsCleared for main frame navigations. For child
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
|
||||
@@ -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
|
||||
try cdp.sendEvent("Page.frameNavigated", .{
|
||||
.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.
|
||||
// see https://github.com/chromedp/chromedp/issues/1558
|
||||
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(
|
||||
"Page.domContentEventFired",
|
||||
.{ .timestamp = timestamp },
|
||||
.{ .session_id = session_id },
|
||||
);
|
||||
|
||||
// lifecycle DOMContentLoaded event
|
||||
// TODO: partially hard coded
|
||||
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{
|
||||
.timestamp = timestamp,
|
||||
.name = "DOMContentLoaded",
|
||||
@@ -532,23 +573,16 @@ pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMConte
|
||||
.loaderId = loader_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(
|
||||
"Page.loadEventFired",
|
||||
.{ .timestamp = timestamp },
|
||||
.{ .session_id = session_id },
|
||||
);
|
||||
|
||||
// lifecycle DOMContentLoaded event
|
||||
if (bc.page_life_cycle_events) {
|
||||
const loader_id = &id.toLoaderId(event.req_id);
|
||||
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
||||
.timestamp = timestamp,
|
||||
.name = "load",
|
||||
@@ -557,6 +591,7 @@ pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
// frameStoppedLoading
|
||||
return cdp.sendEvent("Page.frameStoppedLoading", .{
|
||||
.frameId = frame_id,
|
||||
}, .{ .session_id = session_id });
|
||||
@@ -861,3 +896,55 @@ test "cdp.page: reload" {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,13 +168,26 @@ const TestContext = struct {
|
||||
index: ?usize = null,
|
||||
};
|
||||
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
|
||||
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
|
||||
.whitespace = .indent_2,
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
const expected_json = blk: {
|
||||
// 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
|
||||
|
||||
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 (self.received.items, 0..) |received, i| {
|
||||
if (try compareExpectedToSent(serialized, received) == false) {
|
||||
if (try base.isEqualJson(expected_json, received) == false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -187,6 +200,15 @@ const TestContext = struct {
|
||||
}
|
||||
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);
|
||||
try self.read();
|
||||
}
|
||||
@@ -299,17 +321,3 @@ pub fn context() !TestContext {
|
||||
.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");
|
||||
|
||||
pub const protocol = @import("mcp/protocol.zig");
|
||||
pub const Version = protocol.Version;
|
||||
pub const router = @import("mcp/router.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 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" {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
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 {
|
||||
jsonrpc: []const u8 = "2.0",
|
||||
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 {
|
||||
const id = req.id orelse return;
|
||||
const result = protocol.InitializeResult{
|
||||
.protocolVersion = "2025-11-25",
|
||||
const version: protocol.Version = switch (server.app.config.mode) {
|
||||
.mcp => |opts| opts.version,
|
||||
else => .default,
|
||||
};
|
||||
const result: protocol.InitializeResult = .{
|
||||
.protocolVersion = @tagName(version),
|
||||
.capabilities = .{
|
||||
.resources = .{},
|
||||
.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"}}}
|
||||
);
|
||||
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.end = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user