diff --git a/README.md b/README.md
index 1a860fc7..456499ce 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,32 @@
-
Lightpanda Browser
+
+The headless browser built from scratch for AI agents and automation.
+Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
+
-lightpanda.io
-
+
[](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
[](https://twitter.com/lightpanda_io)
[](https://github.com/lightpanda-io/browser)
+[](https://discord.gg/K63XeymfB5)
+
+
+[

+](https://github.com/lightpanda-io/demo)
+
+[

+](https://github.com/lightpanda-io/demo)
+
+
+_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
+See [benchmark details](https://github.com/lightpanda-io/demo)._
Lightpanda is the open-source browser made for headless usage:
@@ -26,16 +40,6 @@ Fast web automation for AI agents, LLM training, scraping and testing:
- Exceptionally fast execution (11x faster than Chrome)
- Instant startup
-[
-](https://github.com/lightpanda-io/demo)
-
-[
-](https://github.com/lightpanda-io/demo)
-
-
-_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
-See [benchmark details](https://github.com/lightpanda-io/demo)._
-
[^1]: **Playwright support disclaimer:**
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
diff --git a/src/Server.zig b/src/Server.zig
index 23ddefb5..f899d43c 100644
--- a/src/Server.zig
+++ b/src/Server.zig
@@ -295,7 +295,7 @@ pub const Client = struct {
}
var cdp = &self.mode.cdp;
- var last_message = timestamp(.monotonic);
+ var last_message = milliTimestamp(.monotonic);
var ms_remaining = self.ws.timeout_ms;
while (true) {
@@ -304,7 +304,7 @@ pub const Client = struct {
if (self.readSocket() == false) {
return;
}
- last_message = timestamp(.monotonic);
+ last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.no_page => {
@@ -319,16 +319,18 @@ pub const Client = struct {
if (self.readSocket() == false) {
return;
}
- last_message = timestamp(.monotonic);
+ last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms;
},
.done => {
- const elapsed = timestamp(.monotonic) - last_message;
- if (elapsed > ms_remaining) {
+ const now = milliTimestamp(.monotonic);
+ const elapsed = now - last_message;
+ if (elapsed >= ms_remaining) {
log.info(.app, "CDP timeout", .{});
return;
}
ms_remaining -= @intCast(elapsed);
+ last_message = now;
},
}
}
@@ -501,6 +503,7 @@ fn buildJSONVersionResponse(
}
pub const timestamp = @import("datetime.zig").timestamp;
+pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
const testing = std.testing;
test "server: buildJSONVersionResponse" {
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index 0599bab5..cb62cb31 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -1091,7 +1091,6 @@ pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void {
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
self._pending_loads -= 1;
iframe._window = null;
- page_frame.deinit(true);
return error.IFrameLoadError;
};
diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index fea56a87..404a8bc4 100644
--- a/src/browser/Session.zig
+++ b/src/browser/Session.zig
@@ -548,7 +548,9 @@ fn processQueuedNavigation(self: *Session) !void {
continue;
}
- try self.processFrameNavigation(page, qn);
+ self.processFrameNavigation(page, qn) catch |err| {
+ log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
+ };
}
// Clear the queue after first pass
@@ -588,7 +590,8 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
errdefer iframe._window = null;
- if (page._parent_notified) {
+ const parent_notified = page._parent_notified;
+ if (parent_notified) {
// we already notified the parent that we had loaded
parent._pending_loads += 1;
}
@@ -598,7 +601,19 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
page.* = undefined;
try Page.init(page, frame_id, self, parent);
- errdefer page.deinit(true);
+ errdefer {
+ for (parent.frames.items, 0..) |frame, i| {
+ if (frame == page) {
+ parent.frames_sorted = false;
+ _ = parent.frames.swapRemove(i);
+ break;
+ }
+ }
+ if (parent_notified) {
+ parent._pending_loads -= 1;
+ }
+ page.deinit(true);
+ }
page.iframe = iframe;
iframe._window = page.window;
diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig
index 5c58c5cb..70af9d24 100644
--- a/src/browser/js/Context.zig
+++ b/src/browser/js/Context.zig
@@ -197,18 +197,20 @@ pub fn trackTemp(self: *Context, global: v8.Global) !void {
}
pub fn weakRef(self: *Context, obj: anytype) void {
- const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
+ const resolved = js.Local.resolveValue(obj);
+ const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
}
return;
};
- v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
+ v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
}
pub fn safeWeakRef(self: *Context, obj: anytype) void {
- const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
+ const resolved = js.Local.resolveValue(obj);
+ const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
@@ -216,11 +218,12 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
return;
};
v8.v8__Global__ClearWeak(&fc.global);
- v8.v8__Global__SetWeakFinalizer(&fc.global, fc, bridge.Struct(@TypeOf(obj)).JsApi.Meta.finalizer.from_v8, v8.kParameter);
+ v8.v8__Global__SetWeakFinalizer(&fc.global, fc, resolved.finalizer_from_v8, v8.kParameter);
}
pub fn strongRef(self: *Context, obj: anytype) void {
- const fc = self.origin.finalizer_callbacks.get(@intFromPtr(obj)) orelse {
+ const resolved = js.Local.resolveValue(obj);
+ const fc = self.origin.finalizer_callbacks.get(@intFromPtr(resolved.ptr)) orelse {
if (comptime IS_DEBUG) {
// should not be possible
std.debug.assert(false);
diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig
index f2aac0e0..67f04311 100644
--- a/src/browser/js/PromiseResolver.zig
+++ b/src/browser/js/PromiseResolver.zig
@@ -63,6 +63,20 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
};
}
+const RejectError = union(enum) {
+ generic: []const u8,
+ type_error: []const u8,
+};
+pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
+ const handle = switch (err) {
+ .type_error => |str| self.local.isolate.createTypeError(str),
+ .generic => |str| self.local.isolate.createError(str),
+ };
+ self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
+ log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
+ };
+}
+
fn _reject(self: PromiseResolver, value: anytype) !void {
const local = self.local;
const js_val = try local.zigValueToJs(value, .{});
diff --git a/src/browser/tests/domparser.html b/src/browser/tests/domparser.html
index 7930ec87..d8399055 100644
--- a/src/browser/tests/domparser.html
+++ b/src/browser/tests/domparser.html
@@ -11,9 +11,9 @@
}
{
- // Empty XML is a parse error (no root element)
const parser = new DOMParser();
- testing.expectError('Error', () => parser.parseFromString('', 'text/xml'));
+ let d = parser.parseFromString('', 'text/xml');
+ testing.expectEqual('error', new XMLSerializer().serializeToString(d));
}
}
diff --git a/src/browser/tests/window/body_onload2.html b/src/browser/tests/window/body_onload2.html
index 32327c93..0403b30c 100644
--- a/src/browser/tests/window/body_onload2.html
+++ b/src/browser/tests/window/body_onload2.html
@@ -1,15 +1,15 @@
-
+
-
diff --git a/src/browser/tests/window/body_onload3.html b/src/browser/tests/window/body_onload3.html
new file mode 100644
index 00000000..875be388
--- /dev/null
+++ b/src/browser/tests/window/body_onload3.html
@@ -0,0 +1,28 @@
+
+
+
+
+
diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig
index 9e27e9c7..10a94bca 100644
--- a/src/browser/webapi/DOMParser.zig
+++ b/src/browser/webapi/DOMParser.zig
@@ -86,15 +86,15 @@ pub fn parseFromString(
var parser = Parser.init(arena, doc_node, page);
parser.parseXML(html);
- if (parser.err) |pe| {
- return pe.err;
+ if (parser.err != null or doc_node.firstChild() == null) {
+ // Return a document with a element per spec.
+ const err_doc = try page._factory.document(XMLDocument{ ._proto = undefined });
+ var err_parser = Parser.init(arena, err_doc.asNode(), page);
+ err_parser.parseXML("error");
+ return err_doc.asDocument();
}
- const first_child = doc_node.firstChild() orelse {
- // Empty XML or no root element - this is a parse error.
- // TODO: Return a document with a element per spec.
- return error.JsException;
- };
+ const first_child = doc_node.firstChild().?;
// If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) {
diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig
index 9b0f2f98..ab98a8e5 100644
--- a/src/browser/webapi/net/Fetch.zig
+++ b/src/browser/webapi/net/Fetch.zig
@@ -192,7 +192,8 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self._page.js.localScope(&ls);
defer ls.deinit();
- ls.toLocal(self._resolver).reject("fetch error", @errorName(err));
+ // fetch() must reject with a TypeError on network errors per spec
+ ls.toLocal(self._resolver).rejectError("fetch error", .{ .type_error = @errorName(err) });
}
fn httpShutdownCallback(ctx: *anyopaque) void {
diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig
index 718d0bab..41a8e085 100644
--- a/src/cdp/AXNode.zig
+++ b/src/cdp/AXNode.zig
@@ -228,6 +228,13 @@ pub const Writer = struct {
try w.objectField("value");
switch (value) {
+ .integer => |v| {
+ // CDP spec requires integer values to be serialized as strings.
+ // 20 bytes is enough for the decimal representation of a 64-bit integer.
+ var buf: [20]u8 = undefined;
+ const s = try std.fmt.bufPrint(&buf, "{d}", .{v});
+ try w.write(s);
+ },
inline else => |v| try w.write(v),
}
@@ -1212,4 +1219,25 @@ test "AXNode: writer" {
// Check childIds array exists
const child_ids = doc_node.get("childIds").?.array.items;
try testing.expect(child_ids.len > 0);
+
+ // Find the h1 node and verify its level property is serialized as a string
+ for (nodes) |node_val| {
+ const obj = node_val.object;
+ const role_obj = obj.get("role") orelse continue;
+ const role_val = role_obj.object.get("value") orelse continue;
+ if (!std.mem.eql(u8, role_val.string, "heading")) continue;
+
+ const props = obj.get("properties").?.array.items;
+ for (props) |prop| {
+ const prop_obj = prop.object;
+ const name_str = prop_obj.get("name").?.string;
+ if (!std.mem.eql(u8, name_str, "level")) continue;
+ const level_value = prop_obj.get("value").?.object;
+ try testing.expectEqual("integer", level_value.get("type").?.string);
+ // CDP spec: integer values must be serialized as strings
+ try testing.expectEqual("1", level_value.get("value").?.string);
+ return;
+ }
+ }
+ return error.HeadingNodeNotFound;
}
diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig
index 08055d86..58ed11b9 100644
--- a/src/cdp/cdp.zig
+++ b/src/cdp/cdp.zig
@@ -168,13 +168,11 @@ pub fn CDPT(comptime TypeProvider: type) type {
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
- command.sendError(-31999, @errorName(err), .{}) catch {};
- return err;
+ command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
- command.sendError(-31998, @errorName(err), .{}) catch {};
- return err;
+ command.sendError(-31998, @errorName(err), .{}) catch return err;
};
}
}
@@ -924,18 +922,20 @@ test "cdp: invalid json" {
// method is required
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
- try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
+ try ctx.processMessage(.{
.method = "Target",
- }));
+ });
try ctx.expectSentError(-31998, "InvalidMethod", .{});
- try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
+ try ctx.processMessage(.{
.method = "Unknown.domain",
- }));
+ });
+ try ctx.expectSentError(-31998, "UnknownDomain", .{});
- try testing.expectError(error.UnknownMethod, ctx.processMessage(.{
+ try ctx.processMessage(.{
.method = "Target.over9000",
- }));
+ });
+ try ctx.expectSentError(-31998, "UnknownMethod", .{});
}
test "cdp: invalid sessionId" {
diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig
index 5150a8e5..386577db 100644
--- a/src/cdp/domains/dom.zig
+++ b/src/cdp/domains/dom.zig
@@ -550,11 +550,12 @@ test "cdp.dom: getSearchResults unknown search id" {
var ctx = testing.context();
defer ctx.deinit();
- try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{
+ try ctx.processMessage(.{
.id = 8,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 },
- }));
+ });
+ try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 8 });
}
test "cdp.dom: search flow" {
@@ -604,11 +605,12 @@ test "cdp.dom: search flow" {
try ctx.expectSentResult(null, .{ .id = 16 });
// make sure the delete actually did something
- try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{
+ try ctx.processMessage(.{
.id = 17,
.method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
- }));
+ });
+ try ctx.expectSentError(-31998, "SearchResultNotFound", .{ .id = 17 });
}
test "cdp.dom: querySelector unknown search id" {
@@ -645,11 +647,12 @@ test "cdp.dom: querySelector Node not found" {
});
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 });
- try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{
+ try ctx.processMessage(.{
.id = 4,
.method = "DOM.querySelector",
.params = .{ .nodeId = 1, .selector = "a" },
- }));
+ });
+ try ctx.expectSentError(-31998, "NodeNotFoundForGivenId", .{ .id = 4 });
try ctx.processMessage(.{
.id = 5,
diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig
index c04ee33b..5b9a49df 100644
--- a/src/cdp/domains/network.zig
+++ b/src/cdp/domains/network.zig
@@ -19,6 +19,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const Allocator = std.mem.Allocator;
+const log = @import("../../log.zig");
const CdpStorage = @import("storage.zig");
@@ -117,7 +118,12 @@ fn deleteCookies(cmd: anytype) !void {
path: ?[]const u8 = null,
partitionKey: ?CdpStorage.CookiePartitionKey = null,
})) orelse return error.InvalidParams;
- if (params.partitionKey != null) return error.NotImplemented;
+ // Silently ignore partitionKey since we don't support partitioned cookies (CHIPS).
+ // This allows Puppeteer's page.setCookie() to work, which sends deleteCookies
+ // with partitionKey as part of its cookie-setting workflow.
+ if (params.partitionKey != null) {
+ log.warn(.not_implemented, "partition key", .{ .src = "deleteCookies" });
+ }
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const cookies = &bc.session.cookie_jar.cookies;
diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig
index fabf37e5..fe21fcca 100644
--- a/src/cdp/domains/page.zig
+++ b/src/cdp/domains/page.zig
@@ -633,7 +633,7 @@ test "cdp.page: getFrameTree" {
defer ctx.deinit();
{
- try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig
index c7546ad6..e0f67456 100644
--- a/src/cdp/domains/storage.zig
+++ b/src/cdp/domains/storage.zig
@@ -128,7 +128,14 @@ pub const CdpCookie = struct {
};
pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void {
- if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) {
+ // Silently ignore partitionKey since we don't support partitioned cookies (CHIPS).
+ // This allows Puppeteer's page.setCookie() to work, which may send cookies with
+ // partitionKey as part of its cookie-setting workflow.
+ if (param.partitionKey != null) {
+ log.warn(.not_implemented, "partition key", .{ .src = "setCdpCookie" });
+ }
+ // Still reject unsupported features
+ if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null) {
return error.NotImplemented;
}
diff --git a/src/cdp/domains/target.zig b/src/cdp/domains/target.zig
index 80dc1512..1ff23849 100644
--- a/src/cdp/domains/target.zig
+++ b/src/cdp/domains/target.zig
@@ -556,7 +556,7 @@ test "cdp.target: disposeBrowserContext" {
defer ctx.deinit();
{
- try testing.expectError(error.InvalidParams, ctx.processMessage(.{ .id = 7, .method = "Target.disposeBrowserContext" }));
+ try ctx.processMessage(.{ .id = 7, .method = "Target.disposeBrowserContext" });
try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 7 });
}
@@ -609,7 +609,7 @@ test "cdp.target: createTarget" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
- try testing.expectError(error.UnknownBrowserContextId, ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .browserContextId = "BID-8" } });
try ctx.expectSentError(-31998, "UnknownBrowserContextId", .{ .id = 10 });
}
@@ -626,13 +626,13 @@ test "cdp.target: closeTarget" {
defer ctx.deinit();
{
- try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "X" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "X" } });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
- try testing.expectError(error.TargetNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } });
try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 });
}
@@ -640,7 +640,7 @@ test "cdp.target: closeTarget" {
_ = try bc.session.createPage();
bc.target_id = "TID-000000000A".*;
{
- try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } });
try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
}
@@ -657,13 +657,13 @@ test "cdp.target: attachToTarget" {
defer ctx.deinit();
{
- try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "X" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "X" } });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
- try testing.expectError(error.TargetNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } });
try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 });
}
@@ -671,7 +671,7 @@ test "cdp.target: attachToTarget" {
_ = try bc.session.createPage();
bc.target_id = "TID-000000000B".*;
{
- try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } });
try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
}
@@ -701,13 +701,13 @@ test "cdp.target: getTargetInfo" {
}
{
- try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "X" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "X" } });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
}
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
- try testing.expectError(error.TargetNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } });
try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 });
}
@@ -715,7 +715,7 @@ test "cdp.target: getTargetInfo" {
_ = try bc.session.createPage();
bc.target_id = "TID-000000000C".*;
{
- try testing.expectError(error.UnknownTargetId, ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } }));
+ try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } });
try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
}
diff --git a/src/mcp/Server.zig b/src/mcp/Server.zig
index 6f8b1f21..e3ef8097 100644
--- a/src/mcp/Server.zig
+++ b/src/mcp/Server.zig
@@ -116,3 +116,24 @@ test "MCP.Server - Integration: synchronous smoke test" {
try testing.expectJson(.{ .id = 1 }, out_alloc.writer.buffered());
}
+
+test "MCP.Server - Integration: ping request returns an empty result" {
+ defer testing.reset();
+ const allocator = testing.allocator;
+ const app = testing.test_app;
+
+ const input =
+ \\{"jsonrpc":"2.0","id":"ping-1","method":"ping"}
+ ;
+
+ var in_reader: std.io.Reader = .fixed(input);
+ var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
+ defer out_alloc.deinit();
+
+ var server = try Self.init(allocator, app, &out_alloc.writer);
+ defer server.deinit();
+
+ try router.processRequests(server, &in_reader);
+
+ try testing.expectJson(.{ .id = "ping-1", .result = .{} }, out_alloc.writer.buffered());
+}
diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig
index 1c488535..2bc6f9e8 100644
--- a/src/mcp/protocol.zig
+++ b/src/mcp/protocol.zig
@@ -238,6 +238,27 @@ test "MCP.protocol - request parsing" {
try testing.expectString("1.0.0", init_params.value.clientInfo.version);
}
+test "MCP.protocol - ping request parsing" {
+ defer testing.reset();
+ const raw_json =
+ \\{
+ \\ "jsonrpc": "2.0",
+ \\ "id": "123",
+ \\ "method": "ping"
+ \\}
+ ;
+
+ const parsed = try std.json.parseFromSlice(Request, testing.arena_allocator, raw_json, .{ .ignore_unknown_fields = true });
+ defer parsed.deinit();
+
+ const req = parsed.value;
+ try testing.expectString("2.0", req.jsonrpc);
+ try testing.expectString("ping", req.method);
+ try testing.expect(req.id.? == .string);
+ try testing.expectString("123", req.id.?.string);
+ try testing.expectEqual(null, req.params);
+}
+
test "MCP.protocol - response formatting" {
defer testing.reset();
const response = Response{
diff --git a/src/mcp/router.zig b/src/mcp/router.zig
index 3f4d32ee..70411814 100644
--- a/src/mcp/router.zig
+++ b/src/mcp/router.zig
@@ -34,6 +34,7 @@ const log = @import("../log.zig");
const Method = enum {
initialize,
+ ping,
@"notifications/initialized",
@"tools/list",
@"tools/call",
@@ -43,6 +44,7 @@ const Method = enum {
const method_map = std.StaticStringMap(Method).initComptime(.{
.{ "initialize", .initialize },
+ .{ "ping", .ping },
.{ "notifications/initialized", .@"notifications/initialized" },
.{ "tools/list", .@"tools/list" },
.{ "tools/call", .@"tools/call" },
@@ -68,6 +70,7 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
switch (method) {
.initialize => try handleInitialize(server, req),
+ .ping => try handlePing(server, req),
.@"notifications/initialized" => {},
.@"tools/list" => try tools.handleList(server, arena, req),
.@"tools/call" => try tools.handleCall(server, arena, req),
@@ -92,6 +95,11 @@ fn handleInitialize(server: *Server, req: protocol.Request) !void {
try server.sendResult(req.id.?, result);
}
+fn handlePing(server: *Server, req: protocol.Request) !void {
+ const id = req.id orelse return;
+ try server.sendResult(id, .{});
+}
+
const testing = @import("../testing.zig");
test "MCP.router - handleMessage - synchronous unit tests" {
@@ -116,22 +124,29 @@ test "MCP.router - handleMessage - synchronous unit tests" {
, out_alloc.writer.buffered());
out_alloc.writer.end = 0;
- // 2. Tools list
+ // 2. Ping
try handleMessage(server, aa,
- \\{"jsonrpc":"2.0","id":2,"method":"tools/list"}
+ \\{"jsonrpc":"2.0","id":2,"method":"ping"}
);
- try testing.expectJson(.{ .id = 2 }, out_alloc.writer.buffered());
+ try testing.expectJson(.{ .id = 2, .result = .{} }, out_alloc.writer.buffered());
+ out_alloc.writer.end = 0;
+
+ // 3. Tools list
+ try handleMessage(server, aa,
+ \\{"jsonrpc":"2.0","id":3,"method":"tools/list"}
+ );
+ try testing.expectJson(.{ .id = 3 }, out_alloc.writer.buffered());
try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "\"name\":\"goto\"") != null);
out_alloc.writer.end = 0;
- // 3. Method not found
+ // 4. Method not found
try handleMessage(server, aa,
- \\{"jsonrpc":"2.0","id":3,"method":"unknown_method"}
+ \\{"jsonrpc":"2.0","id":4,"method":"unknown_method"}
);
- try testing.expectJson(.{ .id = 3, .@"error" = .{ .code = -32601 } }, out_alloc.writer.buffered());
+ try testing.expectJson(.{ .id = 4, .@"error" = .{ .code = -32601 } }, out_alloc.writer.buffered());
out_alloc.writer.end = 0;
- // 4. Parse error
+ // 5. Parse error
{
const filter: testing.LogFilter = .init(.mcp);
defer filter.deinit();