Merge branch 'main' into css-improvements

This commit is contained in:
Adrià Arrufat
2026-03-16 10:25:35 +09:00
21 changed files with 249 additions and 81 deletions

View File

@@ -1,18 +1,32 @@
<p align="center"> <p align="center">
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a> <a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
</p> </p>
<h1 align="center">Lightpanda Browser</h1> <h1 align="center">Lightpanda Browser</h1>
<p align="center">
<strong>The headless browser built from scratch for AI agents and automation.</strong><br>
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
</p>
<p align="center"><a href="https://lightpanda.io/">lightpanda.io</a></p> </div>
<div align="center"> <div align="center">
[![License](https://img.shields.io/github/license/lightpanda-io/browser)](https://github.com/lightpanda-io/browser/blob/main/LICENSE) [![License](https://img.shields.io/github/license/lightpanda-io/browser)](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
[![Twitter Follow](https://img.shields.io/twitter/follow/lightpanda_io)](https://twitter.com/lightpanda_io) [![Twitter Follow](https://img.shields.io/twitter/follow/lightpanda_io)](https://twitter.com/lightpanda_io)
[![GitHub stars](https://img.shields.io/github/stars/lightpanda-io/browser)](https://github.com/lightpanda-io/browser) [![GitHub stars](https://img.shields.io/github/stars/lightpanda-io/browser)](https://github.com/lightpanda-io/browser)
[![Discord](https://img.shields.io/discord/1391984864894521354?style=flat-square&label=discord)](https://discord.gg/K63XeymfB5)
</div> </div>
<div align="center">
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
](https://github.com/lightpanda-io/demo)
&emsp;
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg">
](https://github.com/lightpanda-io/demo)
</div>
_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: 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) - Exceptionally fast execution (11x faster than Chrome)
- Instant startup - Instant startup
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
](https://github.com/lightpanda-io/demo)
&emsp;
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg">
](https://github.com/lightpanda-io/demo)
</div>
_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:** [^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. 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.

View File

@@ -295,7 +295,7 @@ pub const Client = struct {
} }
var cdp = &self.mode.cdp; var cdp = &self.mode.cdp;
var last_message = timestamp(.monotonic); var last_message = milliTimestamp(.monotonic);
var ms_remaining = self.ws.timeout_ms; var ms_remaining = self.ws.timeout_ms;
while (true) { while (true) {
@@ -304,7 +304,7 @@ pub const Client = struct {
if (self.readSocket() == false) { if (self.readSocket() == false) {
return; return;
} }
last_message = timestamp(.monotonic); last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms; ms_remaining = self.ws.timeout_ms;
}, },
.no_page => { .no_page => {
@@ -319,16 +319,18 @@ pub const Client = struct {
if (self.readSocket() == false) { if (self.readSocket() == false) {
return; return;
} }
last_message = timestamp(.monotonic); last_message = milliTimestamp(.monotonic);
ms_remaining = self.ws.timeout_ms; ms_remaining = self.ws.timeout_ms;
}, },
.done => { .done => {
const elapsed = timestamp(.monotonic) - last_message; const now = milliTimestamp(.monotonic);
if (elapsed > ms_remaining) { const elapsed = now - last_message;
if (elapsed >= ms_remaining) {
log.info(.app, "CDP timeout", .{}); log.info(.app, "CDP timeout", .{});
return; return;
} }
ms_remaining -= @intCast(elapsed); ms_remaining -= @intCast(elapsed);
last_message = now;
}, },
} }
} }
@@ -501,6 +503,7 @@ fn buildJSONVersionResponse(
} }
pub const timestamp = @import("datetime.zig").timestamp; pub const timestamp = @import("datetime.zig").timestamp;
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
const testing = std.testing; const testing = std.testing;
test "server: buildJSONVersionResponse" { test "server: buildJSONVersionResponse" {

View File

@@ -1091,7 +1091,6 @@ pub fn iframeAddedCallback(self: *Page, iframe: *IFrame) !void {
log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err }); log.warn(.page, "iframe navigate failure", .{ .url = url, .err = err });
self._pending_loads -= 1; self._pending_loads -= 1;
iframe._window = null; iframe._window = null;
page_frame.deinit(true);
return error.IFrameLoadError; return error.IFrameLoadError;
}; };

View File

@@ -548,7 +548,9 @@ fn processQueuedNavigation(self: *Session) !void {
continue; 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 // Clear the queue after first pass
@@ -588,7 +590,8 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
errdefer iframe._window = null; 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 // we already notified the parent that we had loaded
parent._pending_loads += 1; parent._pending_loads += 1;
} }
@@ -598,7 +601,19 @@ fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !v
page.* = undefined; page.* = undefined;
try Page.init(page, frame_id, self, parent); 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; page.iframe = iframe;
iframe._window = page.window; iframe._window = page.window;

View File

@@ -197,18 +197,20 @@ pub fn trackTemp(self: *Context, global: v8.Global) !void {
} }
pub fn weakRef(self: *Context, obj: anytype) 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) { if (comptime IS_DEBUG) {
// should not be possible // should not be possible
std.debug.assert(false); std.debug.assert(false);
} }
return; 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 { 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) { if (comptime IS_DEBUG) {
// should not be possible // should not be possible
std.debug.assert(false); std.debug.assert(false);
@@ -216,11 +218,12 @@ pub fn safeWeakRef(self: *Context, obj: anytype) void {
return; return;
}; };
v8.v8__Global__ClearWeak(&fc.global); 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 { 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) { if (comptime IS_DEBUG) {
// should not be possible // should not be possible
std.debug.assert(false); std.debug.assert(false);

View File

@@ -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 { fn _reject(self: PromiseResolver, value: anytype) !void {
const local = self.local; const local = self.local;
const js_val = try local.zigValueToJs(value, .{}); const js_val = try local.zigValueToJs(value, .{});

View File

@@ -11,9 +11,9 @@
} }
{ {
// Empty XML is a parse error (no root element)
const parser = new DOMParser(); const parser = new DOMParser();
testing.expectError('Error', () => parser.parseFromString('', 'text/xml')); let d = parser.parseFromString('', 'text/xml');
testing.expectEqual('<parsererror>error</parsererror>', new XMLSerializer().serializeToString(d));
} }
} }
</script> </script>

View File

@@ -1,15 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<body onload="loaded()"></body> <body onload="loadEvent = event"></body>
<script src="../testing.js"></script> <script src="../testing.js"></script>
<script id=bodyOnLoad2> <script id=bodyOnLoad2>
let called = 0; // Per spec, the handler is compiled as: function(event) { loadEvent = event }
function loaded(e) { // Verify: handler fires, "event" parameter is a proper Event, and handler is a function.
called += 1; let loadEvent = null;
}
testing.eventually(() => { testing.eventually(() => {
testing.expectEqual(1, called); testing.expectEqual("function", typeof document.body.onload);
testing.expectTrue(loadEvent instanceof Event);
testing.expectEqual("load", loadEvent.type);
}); });
</script> </script>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<body onload="called++"></body>
<script src="../testing.js"></script>
<script id=bodyOnLoad3>
// Per spec, the handler is compiled as: function(event) { called++ }
// Verify: handler fires exactly once, and body.onload reflects to window.onload.
let called = 0;
testing.eventually(() => {
// The attribute handler should have fired exactly once.
testing.expectEqual(1, called);
// body.onload is a Window-reflecting handler per spec.
testing.expectEqual("function", typeof document.body.onload);
testing.expectEqual(document.body.onload, window.onload);
// Setting body.onload via property replaces the attribute handler.
let propertyCalled = false;
document.body.onload = function() { propertyCalled = true; };
testing.expectEqual(document.body.onload, window.onload);
// Setting onload to null removes the handler.
document.body.onload = null;
testing.expectEqual(null, document.body.onload);
testing.expectEqual(null, window.onload);
});
</script>

View File

@@ -86,15 +86,15 @@ pub fn parseFromString(
var parser = Parser.init(arena, doc_node, page); var parser = Parser.init(arena, doc_node, page);
parser.parseXML(html); parser.parseXML(html);
if (parser.err) |pe| { if (parser.err != null or doc_node.firstChild() == null) {
return pe.err; // Return a document with a <parsererror> 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("<parsererror xmlns=\"http://www.mozilla.org/newlayout/xml/parsererror.xml\">error</parsererror>");
return err_doc.asDocument();
} }
const first_child = doc_node.firstChild() orelse { const first_child = doc_node.firstChild().?;
// Empty XML or no root element - this is a parse error.
// TODO: Return a document with a <parsererror> element per spec.
return error.JsException;
};
// If first node is a `ProcessingInstruction`, skip it. // If first node is a `ProcessingInstruction`, skip it.
if (first_child.getNodeType() == 7) { if (first_child.getNodeType() == 7) {

View File

@@ -192,7 +192,8 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self._page.js.localScope(&ls); self._page.js.localScope(&ls);
defer ls.deinit(); 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 { fn httpShutdownCallback(ctx: *anyopaque) void {

View File

@@ -228,6 +228,13 @@ pub const Writer = struct {
try w.objectField("value"); try w.objectField("value");
switch (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), inline else => |v| try w.write(v),
} }
@@ -1212,4 +1219,25 @@ test "AXNode: writer" {
// Check childIds array exists // Check childIds array exists
const child_ids = doc_node.get("childIds").?.array.items; const child_ids = doc_node.get("childIds").?.array.items;
try testing.expect(child_ids.len > 0); 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;
} }

View File

@@ -168,13 +168,11 @@ pub fn CDPT(comptime TypeProvider: type) type {
if (is_startup) { if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| { dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch {}; command.sendError(-31999, @errorName(err), .{}) catch return err;
return err;
}; };
} else { } else {
dispatchCommand(&command, input.method) catch |err| { dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err), .{}) catch {}; command.sendError(-31998, @errorName(err), .{}) catch return err;
return err;
}; };
} }
} }
@@ -924,18 +922,20 @@ test "cdp: invalid json" {
// method is required // method is required
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{})); try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
try testing.expectError(error.InvalidMethod, ctx.processMessage(.{ try ctx.processMessage(.{
.method = "Target", .method = "Target",
})); });
try ctx.expectSentError(-31998, "InvalidMethod", .{}); try ctx.expectSentError(-31998, "InvalidMethod", .{});
try testing.expectError(error.UnknownDomain, ctx.processMessage(.{ try ctx.processMessage(.{
.method = "Unknown.domain", .method = "Unknown.domain",
})); });
try ctx.expectSentError(-31998, "UnknownDomain", .{});
try testing.expectError(error.UnknownMethod, ctx.processMessage(.{ try ctx.processMessage(.{
.method = "Target.over9000", .method = "Target.over9000",
})); });
try ctx.expectSentError(-31998, "UnknownMethod", .{});
} }
test "cdp: invalid sessionId" { test "cdp: invalid sessionId" {

View File

@@ -550,11 +550,12 @@ test "cdp.dom: getSearchResults unknown search id" {
var ctx = testing.context(); var ctx = testing.context();
defer ctx.deinit(); defer ctx.deinit();
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ try ctx.processMessage(.{
.id = 8, .id = 8,
.method = "DOM.getSearchResults", .method = "DOM.getSearchResults",
.params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 }, .params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 },
})); });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 8 });
} }
test "cdp.dom: search flow" { test "cdp.dom: search flow" {
@@ -604,11 +605,12 @@ test "cdp.dom: search flow" {
try ctx.expectSentResult(null, .{ .id = 16 }); try ctx.expectSentResult(null, .{ .id = 16 });
// make sure the delete actually did something // make sure the delete actually did something
try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{ try ctx.processMessage(.{
.id = 17, .id = 17,
.method = "DOM.getSearchResults", .method = "DOM.getSearchResults",
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 }, .params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
})); });
try ctx.expectSentError(-31998, "SearchResultNotFound", .{ .id = 17 });
} }
test "cdp.dom: querySelector unknown search id" { 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 ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 });
try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{ try ctx.processMessage(.{
.id = 4, .id = 4,
.method = "DOM.querySelector", .method = "DOM.querySelector",
.params = .{ .nodeId = 1, .selector = "a" }, .params = .{ .nodeId = 1, .selector = "a" },
})); });
try ctx.expectSentError(-31998, "NodeNotFoundForGivenId", .{ .id = 4 });
try ctx.processMessage(.{ try ctx.processMessage(.{
.id = 5, .id = 5,

View File

@@ -19,6 +19,7 @@
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
const CdpStorage = @import("storage.zig"); const CdpStorage = @import("storage.zig");
@@ -117,7 +118,12 @@ fn deleteCookies(cmd: anytype) !void {
path: ?[]const u8 = null, path: ?[]const u8 = null,
partitionKey: ?CdpStorage.CookiePartitionKey = null, partitionKey: ?CdpStorage.CookiePartitionKey = null,
})) orelse return error.InvalidParams; })) 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 bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const cookies = &bc.session.cookie_jar.cookies; const cookies = &bc.session.cookie_jar.cookies;

View File

@@ -633,7 +633,7 @@ test "cdp.page: getFrameTree" {
defer ctx.deinit(); 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 }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
} }

View File

@@ -128,7 +128,14 @@ pub const CdpCookie = struct {
}; };
pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { 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; return error.NotImplemented;
} }

View File

@@ -556,7 +556,7 @@ test "cdp.target: disposeBrowserContext" {
defer ctx.deinit(); 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 }); try ctx.expectSentError(-31998, "InvalidParams", .{ .id = 7 });
} }
@@ -609,7 +609,7 @@ test "cdp.target: createTarget" {
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); 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 }); try ctx.expectSentError(-31998, "UnknownBrowserContextId", .{ .id = 10 });
} }
@@ -626,13 +626,13 @@ test "cdp.target: closeTarget" {
defer ctx.deinit(); 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 }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
} }
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); 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 }); try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 });
} }
@@ -640,7 +640,7 @@ test "cdp.target: closeTarget" {
_ = try bc.session.createPage(); _ = try bc.session.createPage();
bc.target_id = "TID-000000000A".*; 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 }); try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
} }
@@ -657,13 +657,13 @@ test "cdp.target: attachToTarget" {
defer ctx.deinit(); 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 }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
} }
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); 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 }); try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 });
} }
@@ -671,7 +671,7 @@ test "cdp.target: attachToTarget" {
_ = try bc.session.createPage(); _ = try bc.session.createPage();
bc.target_id = "TID-000000000B".*; 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 }); 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 }); try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
} }
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); 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 }); try ctx.expectSentError(-31998, "TargetNotLoaded", .{ .id = 10 });
} }
@@ -715,7 +715,7 @@ test "cdp.target: getTargetInfo" {
_ = try bc.session.createPage(); _ = try bc.session.createPage();
bc.target_id = "TID-000000000C".*; 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 }); try ctx.expectSentError(-31998, "UnknownTargetId", .{ .id = 10 });
} }

View File

@@ -116,3 +116,24 @@ test "MCP.Server - Integration: synchronous smoke test" {
try testing.expectJson(.{ .id = 1 }, out_alloc.writer.buffered()); 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());
}

View File

@@ -238,6 +238,27 @@ test "MCP.protocol - request parsing" {
try testing.expectString("1.0.0", init_params.value.clientInfo.version); 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" { test "MCP.protocol - response formatting" {
defer testing.reset(); defer testing.reset();
const response = Response{ const response = Response{

View File

@@ -34,6 +34,7 @@ const log = @import("../log.zig");
const Method = enum { const Method = enum {
initialize, initialize,
ping,
@"notifications/initialized", @"notifications/initialized",
@"tools/list", @"tools/list",
@"tools/call", @"tools/call",
@@ -43,6 +44,7 @@ const Method = enum {
const method_map = std.StaticStringMap(Method).initComptime(.{ const method_map = std.StaticStringMap(Method).initComptime(.{
.{ "initialize", .initialize }, .{ "initialize", .initialize },
.{ "ping", .ping },
.{ "notifications/initialized", .@"notifications/initialized" }, .{ "notifications/initialized", .@"notifications/initialized" },
.{ "tools/list", .@"tools/list" }, .{ "tools/list", .@"tools/list" },
.{ "tools/call", .@"tools/call" }, .{ "tools/call", .@"tools/call" },
@@ -68,6 +70,7 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
switch (method) { switch (method) {
.initialize => try handleInitialize(server, req), .initialize => try handleInitialize(server, req),
.ping => try handlePing(server, req),
.@"notifications/initialized" => {}, .@"notifications/initialized" => {},
.@"tools/list" => try tools.handleList(server, arena, req), .@"tools/list" => try tools.handleList(server, arena, req),
.@"tools/call" => try tools.handleCall(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); 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"); const testing = @import("../testing.zig");
test "MCP.router - handleMessage - synchronous unit tests" { 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.buffered());
out_alloc.writer.end = 0; out_alloc.writer.end = 0;
// 2. Tools list // 2. Ping
try handleMessage(server, aa, 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); try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "\"name\":\"goto\"") != null);
out_alloc.writer.end = 0; out_alloc.writer.end = 0;
// 3. Method not found // 4. Method not found
try handleMessage(server, aa, 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; out_alloc.writer.end = 0;
// 4. Parse error // 5. Parse error
{ {
const filter: testing.LogFilter = .init(.mcp); const filter: testing.LogFilter = .init(.mcp);
defer filter.deinit(); defer filter.deinit();