12 Commits

Author SHA1 Message Date
Adrià Arrufat
2fbf462f22 build: update v8 to v0.3.5 2026-03-26 10:19:04 +09:00
Karl Seguin
cf641ed458 Merge pull request #1990 from lightpanda-io/remove_cdp_generic
Remove cdp generic
2026-03-26 07:49:13 +08:00
Karl Seguin
0fc959dcc5 re-anble unreachable 2026-03-26 07:42:45 +08:00
Karl Seguin
077376ea04 Merge pull request #1985 from lightpanda-io/intersection_observer_root_document
Allow Document to be the root of an intersection observer
2026-03-26 07:41:40 +08:00
Karl Seguin
6ed8d1d201 Merge pull request #1981 from lightpanda-io/window_cross_origin
Window cross origin
2026-03-26 07:41:22 +08:00
Karl Seguin
5207bd4202 Merge pull request #1980 from lightpanda-io/frames_test
Improve async tests
2026-03-26 07:41:05 +08:00
Karl Seguin
11ed95290b Improve async tests
testing.async(...) is pretty lame. It works for simple cases, where the
microtask is very quickly resolved, but otherwise can't block the test from
exiting.

This adds an overload to testing.async and leverages the new Runner
https://github.com/lightpanda-io/browser/pull/1958 to "tick" until completion
(or timeout).

The overloaded version of testing.async() (called without a callback) will
increment a counter which is only decremented with the promise is resolved. The
test runner will now `tick` until the counter == 0.
2026-03-26 07:35:05 +08:00
Karl Seguin
ca41bb5fa2 fix import casing 2026-03-25 17:54:24 +08:00
Karl Seguin
0dd0495ab8 Removes CDPT (generic CDP)
CDPT used to be a generic so that we could inject Browser, Session, Page and
Client. At some point, it [thankfully] became a generic only to inject Client.

This commit removes the generic and bakes the *Server.Client instance in CDP.
It uses a socketpair for testing.

BrowserContext is still generic, but that's generic for a very different reason
and, while I'd like to remove that generic too, it belongs in a different PR.
2026-03-25 17:43:30 +08:00
Karl Seguin
ae080f32eb Allow Document to be the root of an intersection observer
We previously only supported an Element. null == viewport, but document means
the entire (scrollable) area, since we don't render anything, treating
document  as null seems ok?
2026-03-24 21:48:38 +08:00
Karl Seguin
b19f30d865 Start allowing some cross-origin scripting.
There are a few things allowed in cross origin scripting, the most important
being window.postMessage and window.parent.

This commit changes window-returning functions (e.g. window.top, window.parent
iframe.contentWindow) from always returning a *Window, to conditionally
returning a *Window or a *CrossOriginWindow. The CrossOriginWindow only allows
a few methods (e.g. postMessage).
2026-03-24 19:27:55 +08:00
Karl Seguin
35be9f897f Improve async tests
testing.async(...) is pretty lame. It works for simple cases, where the
microtask is very quickly resolved, but otherwise can't block the test from
exiting.

This adds an overload to testing.async and leverages the new Runner
https://github.com/lightpanda-io/browser/pull/1958 to "tick" until completion
(or timeout).

The overloaded version of testing.async() (called without a callback) will
increment a counter which is only decremented with the promise is resolved. The
test runner will now `tick` until the counter == 0.
2026-03-24 17:21:39 +08:00
25 changed files with 689 additions and 441 deletions

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.5.tar.gz",
.hash = "v8-0.0.0-xddH66Z5BABx8CmC6u6qNOjrT4_42uliDSnA7Yg0pcBe",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{

View File

@@ -27,7 +27,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig");
const App = @import("App.zig");
const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const CDP = @import("cdp/CDP.zig");
const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig");
@@ -212,7 +212,7 @@ pub const Client = struct {
http: *HttpClient,
ws: Net.WsConnection,
fn init(
pub fn init(
socket: posix.socket_t,
allocator: Allocator,
app: *App,
@@ -250,7 +250,7 @@ pub const Client = struct {
self.ws.shutdown();
}
fn deinit(self: *Client) void {
pub fn deinit(self: *Client) void {
switch (self.mode) {
.cdp => |*cdp| cdp.deinit(),
.http => {},
@@ -461,7 +461,7 @@ pub const Client = struct {
fn upgradeConnection(self: *Client, request: []u8) !void {
try self.ws.upgrade(request);
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
self.mode = .{ .cdp = try CDP.init(self) };
}
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {

View File

@@ -300,7 +300,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._performance = Performance.init(),
._screen = screen,
._visual_viewport = visual_viewport,
._cross_origin_wrapper = undefined,
});
self.window._cross_origin_wrapper = .{ .window = self.window };
self._style_manager = try StyleManager.init(self);
errdefer self._style_manager.deinit();
@@ -3587,12 +3589,7 @@ test "WebApi: Page" {
}
test "WebApi: Frames" {
// TOO FLAKY, disabled for now
// const filter: testing.LogFilter = .init(&.{.js});
// defer filter.deinit();
// try testing.htmlRunner("frames", .{});
try testing.htmlRunner("frames", .{});
}
test "WebApi: Integration" {

View File

@@ -118,24 +118,24 @@
}
</script>
<script id=link_click>
testing.async(async (restore) => {
let f6;
await new Promise((resolve) => {
let count = 0;
f6 = document.createElement('iframe');
f6.id = 'f6';
f6.addEventListener('load', () => {
if (++count == 2) {
resolve();
return;
}
f6.contentDocument.querySelector('#link').click();
});
f6.src = "support/with_link.html";
document.documentElement.appendChild(f6);
});
restore();
<script id=link_click type=module>
const state = await testing.async();
let count = 0;
let f6 = document.createElement('iframe');
f6.id = 'f6';
f6.addEventListener('load', () => {
if (++count == 2) {
state.resolve();
return;
}
f6.contentDocument.querySelector('#link').click();
});
f6.src = 'support/with_link.html';
document.documentElement.appendChild(f6);
await state.done(() => {
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
});
</script>

View File

@@ -7,7 +7,6 @@
{
let reply = null;
window.addEventListener('message', (e) => {
console.warn('reply')
reply = e.data;
});

View File

@@ -1,7 +1,6 @@
<!DOCTYPE html>
<script>
window.addEventListener('message', (e) => {
console.warn('Frame Message', e.data);
if (e.data === 'ping') {
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
}

View File

@@ -4,6 +4,7 @@
let eventuallies = [];
let async_capture = null;
let current_script_id = null;
let async_pending = 0;
function expectTrue(actual) {
expectEqual(true, actual);
@@ -64,6 +65,25 @@
}
async function async(cb) {
if (cb == undefined) {
let resolve = null
const promise = new Promise((r) => { resolve = r});
async_pending += 1;
return {
promise: promise,
resolve: resolve,
capture: {script_id: document.currentScript.id, stack: new Error().stack},
done: async function(cb) {
await this.promise;
async_pending -= 1;
async_capture = this.capture;
cb();
async_capture = false;
}
};
}
let capture = {script_id: document.currentScript.id, stack: new Error().stack};
await cb(() => { async_capture = capture; });
async_capture = null;
@@ -74,6 +94,10 @@
throw new Error('Failed');
}
if (async_pending > 0) {
return false;
}
for (let e of eventuallies) {
current_script_id = e.script_id;
e.callback();
@@ -97,6 +121,8 @@
throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`);
}
}
return true;
}
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe src=support/frame1.html></iframe>
<script id=post_message type=module>
const state = await testing.async();
{
const ALT_BASE = testing.BASE_URL.replace('127.0.0.1', 'localhost');
{
let iframe2 = document.createElement('iframe');
iframe2.src = ALT_BASE + 'window/support/frame1.html';
document.documentElement.appendChild(iframe2);
}
{
let iframe3 = document.createElement('iframe');
iframe3.src = ALT_BASE + 'window/support/frame2.html';
document.documentElement.appendChild(iframe3);
}
let captures = [];
window.addEventListener('message', (e) => {
captures.push(e.data);
if (captures.length == 3) {
state.resolve();
}
});
await state.done(() => {
const expected_urls = [
testing.BASE_URL + 'window/support/frame1.html',
ALT_BASE + 'window/support/frame1.html',
ALT_BASE + 'window/support/frame2.html',
];
// No strong order guarantee for messaages, and we don't care about the order
// so long as it's the correct data.
testing.expectEqual(expected_urls.sort(), captures.map((c) => {return c.url}).sort());
captures.forEach((c) => {
if (c.url.includes(testing.BASE_URL)) {
testing.expectEqual(false, c.document_is_undefined);
} else {
testing.expectEqual(true, c.document_is_undefined);
}
});
});
}
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<script>
window.parent.postMessage({
url: location.toString(),
document_is_undefined: window.parent.document === undefined,
}, '*')
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<script>
window.top.postMessage({
url: location.toString(),
document_is_undefined: window.top.document === undefined,
}, '*')
</script>

View File

@@ -25,6 +25,8 @@ const Allocator = std.mem.Allocator;
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
const DOMRect = @import("DOMRect.zig");
@@ -55,7 +57,7 @@ var zero_rect: DOMRect = .{
};
pub const ObserverInit = struct {
root: ?*Element = null,
root: ?*Node = null,
rootMargin: ?[]const u8 = null,
threshold: Threshold = .{ .scalar = 0.0 },
@@ -81,11 +83,25 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
.array => |arr| try arena.dupe(f64, arr),
};
const root: ?*Element = blk: {
const root_opt = opts.root orelse break :blk null;
switch (root_opt._type) {
.element => |el| break :blk el,
.document => {
// not strictly correct, `null` means the viewport, not the
// entire document, but since we don't render anything, this
// should be fine.
break :blk null;
},
else => return error.TypeError,
}
};
const self = try arena.create(IntersectionObserver);
self.* = .{
._arena = arena,
._callback = callback,
._root = opts.root,
._root = root,
._root_margin = root_margin,
._threshold = threshold,
};

View File

@@ -49,6 +49,10 @@ const IS_DEBUG = builtin.mode == .Debug;
const Allocator = std.mem.Allocator;
pub fn registerTypes() []const type {
return &.{ Window, CrossOriginWindow };
}
const Window = @This();
_proto: *EventTarget,
@@ -87,6 +91,8 @@ _scroll_pos: struct {
.y = 0,
.state = .done,
},
// A cross origin wrapper for this window
_cross_origin_wrapper: CrossOriginWindow,
pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto;
@@ -104,19 +110,19 @@ pub fn getWindow(self: *Window) *Window {
return self;
}
pub fn getTop(self: *Window) *Window {
pub fn getTop(self: *Window, page: *Page) Access {
var p = self._page;
while (p.parent) |parent| {
p = parent;
}
return p.window;
return Access.init(page.window, p.window);
}
pub fn getParent(self: *Window) *Window {
pub fn getParent(self: *Window, page: *Page) Access {
if (self._page.parent) |p| {
return p.window;
return Access.init(page.window, p.window);
}
return self;
return .{ .window = self };
}
pub fn getDocument(self: *Window) *Document {
@@ -606,6 +612,25 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.
}
}
pub const Access = union(enum) {
window: *Window,
cross_origin: *CrossOriginWindow,
pub fn init(callee: *Window, accessing: *Window) Access {
if (callee == accessing) {
// common enough that it's worth the check
return .{ .window = accessing };
}
if (callee._page.js.origin == accessing._page.js.origin) {
// two different windows, but same origin, return the full window
return .{ .window = accessing };
}
return .{ .cross_origin = &accessing._cross_origin_wrapper };
}
};
const ScheduleOpts = struct {
repeat: bool,
params: []js.Value.Temp,
@@ -892,6 +917,41 @@ pub const JsApi = struct {
}.prompt, .{});
};
const CrossOriginWindow = struct {
window: *Window,
pub fn postMessage(self: *CrossOriginWindow, message: js.Value.Temp, target_origin: ?[]const u8, page: *Page) !void {
return self.window.postMessage(message, target_origin, page);
}
pub fn getTop(self: *CrossOriginWindow, page: *Page) Access {
return self.window.getParent(page);
}
pub fn getParent(self: *CrossOriginWindow, page: *Page) Access {
return self.window.getParent(page);
}
pub fn getFramesLength(self: *const CrossOriginWindow) u32 {
return self.window.getFramesLength();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CrossOriginWindow);
pub const Meta = struct {
pub const name = "CrossOriginWindow";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const postMessage = bridge.function(CrossOriginWindow.postMessage, .{});
pub const top = bridge.accessor(CrossOriginWindow.getTop, null, .{});
pub const parent = bridge.accessor(CrossOriginWindow.getParent, null, .{});
pub const length = bridge.accessor(CrossOriginWindow.getFramesLength, null, .{});
};
};
const testing = @import("../../testing.zig");
test "WebApi: Window" {
try testing.htmlRunner("window", .{});

View File

@@ -39,8 +39,9 @@ pub fn asNode(self: *IFrame) *Node {
return self.asElement().asNode();
}
pub fn getContentWindow(self: *const IFrame) ?*Window {
return self._window;
pub fn getContentWindow(self: *const IFrame, page: *Page) ?Window.Access {
const frame_window = self._window orelse return null;
return Window.Access.init(page.window, frame_window);
}
pub fn getContentDocument(self: *const IFrame) ?*Document {

View File

@@ -22,297 +22,294 @@ const lp = @import("lightpanda");
const Allocator = std.mem.Allocator;
const json = std.json;
const log = @import("../log.zig");
const js = @import("../browser/js/js.zig");
const Incrementing = @import("id.zig").Incrementing;
const log = @import("../log.zig");
const App = @import("../App.zig");
const Notification = @import("../Notification.zig");
const Client = @import("../Server.zig").Client;
const js = @import("../browser/js/js.zig");
const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const Page = @import("../browser/Page.zig");
const Incrementing = @import("id.zig").Incrementing;
const Notification = @import("../Notification.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
const Mime = @import("../browser/Mime.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
pub const URL_BASE = "chrome://newtab/";
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const CDP = CDPT(struct {
const Client = *@import("../Server.zig").Client;
});
const SessionIdGen = Incrementing(u32, "SID");
const TargetIdGen = Incrementing(u32, "TID");
const SessionIdGen = Incrementing(u32, "SID");
const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it.
pub fn CDPT(comptime TypeProvider: type) type {
return struct {
// Used for sending message to the client and closing on error
client: TypeProvider.Client,
const CDP = @This();
allocator: Allocator,
// Used for sending message to the client and closing on error
client: *Client,
// The active browser
browser: Browser,
allocator: Allocator,
// when true, any target creation must be attached.
target_auto_attach: bool = false,
// The active browser
browser: Browser,
target_id_gen: TargetIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
// when true, any target creation must be attached.
target_auto_attach: bool = false,
browser_context: ?BrowserContext(Self),
target_id_gen: TargetIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
// Re-used arena for processing a message. We're assuming that we're getting
// 1 message at a time.
message_arena: std.heap.ArenaAllocator,
browser_context: ?BrowserContext(CDP),
// Used for processing notifications within a browser context.
notification_arena: std.heap.ArenaAllocator,
// Re-used arena for processing a message. We're assuming that we're getting
// 1 message at a time.
message_arena: std.heap.ArenaAllocator,
// Valid for 1 page navigation (what CDP calls a "renderer")
page_arena: std.heap.ArenaAllocator,
// Used for processing notifications within a browser context.
notification_arena: std.heap.ArenaAllocator,
// Valid for the entire lifetime of the BrowserContext. Should minimize
// (or altogether elimiate) our use of this.
browser_context_arena: std.heap.ArenaAllocator,
// Valid for 1 page navigation (what CDP calls a "renderer")
page_arena: std.heap.ArenaAllocator,
const Self = @This();
// Valid for the entire lifetime of the BrowserContext. Should minimize
// (or altogether elimiate) our use of this.
browser_context_arena: std.heap.ArenaAllocator,
pub fn init(app: *App, http_client: *HttpClient, client: TypeProvider.Client) !Self {
const allocator = app.allocator;
const browser = try Browser.init(app, .{
.env = .{ .with_inspector = true },
.http_client = http_client,
});
errdefer browser.deinit();
pub fn init(client: *Client) !CDP {
const app = client.app;
const allocator = app.allocator;
const browser = try Browser.init(app, .{
.env = .{ .with_inspector = true },
.http_client = client.http,
});
errdefer browser.deinit();
return .{
.client = client,
.browser = browser,
.allocator = allocator,
.browser_context = null,
.page_arena = std.heap.ArenaAllocator.init(allocator),
.message_arena = std.heap.ArenaAllocator.init(allocator),
.notification_arena = std.heap.ArenaAllocator.init(allocator),
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
};
}
pub fn deinit(self: *Self) void {
if (self.browser_context) |*bc| {
bc.deinit();
}
self.browser.deinit();
self.page_arena.deinit();
self.message_arena.deinit();
self.notification_arena.deinit();
self.browser_context_arena.deinit();
}
pub fn handleMessage(self: *Self, msg: []const u8) bool {
// if there's an error, it's already been logged
self.processMessage(msg) catch return false;
return true;
}
pub fn processMessage(self: *Self, msg: []const u8) !void {
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatch(arena.allocator(), self, msg);
}
// @newhttp
// A bit hacky right now. The main server loop doesn't unblock for
// scheduled task. So we run this directly in order to process any
// timeouts (or http events) which are ready to be processed.
pub fn pageWait(self: *Self, ms: u32) !Session.Runner.CDPWaitResult {
const session = &(self.browser.session orelse return error.NoPage);
var runner = try session.runner(.{});
return runner.waitCDP(.{ .ms = ms });
}
// Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget
// calls back into dispatch to capture the response.
pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true,
}) catch return error.InvalidJSON;
var command = Command(Self, @TypeOf(sender)){
.input = .{
.json = str,
.id = input.id,
.action = "",
.params = input.params,
.session_id = input.sessionId,
},
.cdp = self,
.arena = arena,
.sender = sender,
.browser_context = if (self.browser_context) |*bc| bc else null,
};
// See dispatchStartupCommand for more info on this.
var is_startup = false;
if (input.sessionId) |input_session_id| {
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
is_startup = true;
} else if (self.isValidSessionId(input_session_id) == false) {
return command.sendError(-32001, "Unknown sessionId", .{});
}
}
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err), .{}) catch return err;
};
}
}
// A CDP session isn't 100% fully driven by the driver. There's are
// independent actions that the browser is expected to take. For example
// Puppeteer expects the browser to startup a tab and thus have existing
// targets.
// To this end, we create a [very] dummy BrowserContext, Target and
// Session. There isn't actually a BrowserContext, just a special id.
// When messages are received with the "STARTUP" sessionId, we do
// "special" handling - the bare minimum we need to do until the driver
// switches to a real BrowserContext.
// (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
// Stagehand parses the response and error if we don't return a
// correct one for Page.getFrameTree on startup call.
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
// The Page.getFrameTree handles startup response gracefully.
return dispatchCommand(command, method);
}
return command.sendResult(null, .{});
}
fn dispatchCommand(command: anytype, method: []const u8) !void {
const domain = blk: {
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod;
};
command.input.action = method[i + 1 ..];
break :blk method[0..i];
};
switch (domain.len) {
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
else => {},
},
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
else => {},
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
else => {},
},
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
else => {},
},
else => {},
}
return error.UnknownDomain;
}
fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool {
const browser_context = &(self.browser_context orelse return false);
const session_id = browser_context.session_id orelse return false;
return std.mem.eql(u8, session_id, input_session_id);
}
pub fn createBrowserContext(self: *Self) ![]const u8 {
if (self.browser_context != null) {
return error.AlreadyExists;
}
const id = self.browser_context_id_gen.next();
self.browser_context = @as(BrowserContext(Self), undefined);
const browser_context = &self.browser_context.?;
try BrowserContext(Self).init(browser_context, id, self);
return id;
}
pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool {
const bc = &(self.browser_context orelse return false);
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;
return true;
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void {
return self.sendJSON(.{
.method = method,
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
.sessionId = opts.session_id,
});
}
pub fn sendJSON(self: *Self, message: anytype) !void {
return self.client.sendJSON(message, .{
.emit_null_optional_fields = false,
});
}
return .{
.client = client,
.browser = browser,
.allocator = allocator,
.browser_context = null,
.page_arena = std.heap.ArenaAllocator.init(allocator),
.message_arena = std.heap.ArenaAllocator.init(allocator),
.notification_arena = std.heap.ArenaAllocator.init(allocator),
.browser_context_arena = std.heap.ArenaAllocator.init(allocator),
};
}
pub fn deinit(self: *CDP) void {
if (self.browser_context) |*bc| {
bc.deinit();
}
self.browser.deinit();
self.page_arena.deinit();
self.message_arena.deinit();
self.notification_arena.deinit();
self.browser_context_arena.deinit();
}
pub fn handleMessage(self: *CDP, msg: []const u8) bool {
// if there's an error, it's already been logged
self.processMessage(msg) catch return false;
return true;
}
pub fn processMessage(self: *CDP, msg: []const u8) !void {
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatch(arena.allocator(), self, msg);
}
// @newhttp
// A bit hacky right now. The main server loop doesn't unblock for
// scheduled task. So we run this directly in order to process any
// timeouts (or http events) which are ready to be processed.
pub fn pageWait(self: *CDP, ms: u32) !Session.Runner.CDPWaitResult {
const session = &(self.browser.session orelse return error.NoPage);
var runner = try session.runner(.{});
return runner.waitCDP(.{ .ms = ms });
}
// Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget
// calls back into dispatch to capture the response.
pub fn dispatch(self: *CDP, arena: Allocator, sender: anytype, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true,
}) catch return error.InvalidJSON;
var command = Command(CDP, @TypeOf(sender)){
.input = .{
.json = str,
.id = input.id,
.action = "",
.params = input.params,
.session_id = input.sessionId,
},
.cdp = self,
.arena = arena,
.sender = sender,
.browser_context = if (self.browser_context) |*bc| bc else null,
};
// See dispatchStartupCommand for more info on this.
var is_startup = false;
if (input.sessionId) |input_session_id| {
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
is_startup = true;
} else if (self.isValidSessionId(input_session_id) == false) {
return command.sendError(-32001, "Unknown sessionId", .{});
}
}
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err), .{}) catch return err;
};
}
}
// A CDP session isn't 100% fully driven by the driver. There's are
// independent actions that the browser is expected to take. For example
// Puppeteer expects the browser to startup a tab and thus have existing
// targets.
// To this end, we create a [very] dummy BrowserContext, Target and
// Session. There isn't actually a BrowserContext, just a special id.
// When messages are received with the "STARTUP" sessionId, we do
// "special" handling - the bare minimum we need to do until the driver
// switches to a real BrowserContext.
// (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
// Stagehand parses the response and error if we don't return a
// correct one for Page.getFrameTree on startup call.
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
// The Page.getFrameTree handles startup response gracefully.
return dispatchCommand(command, method);
}
return command.sendResult(null, .{});
}
fn dispatchCommand(command: anytype, method: []const u8) !void {
const domain = blk: {
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod;
};
command.input.action = method[i + 1 ..];
break :blk method[0..i];
};
switch (domain.len) {
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
else => {},
},
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
else => {},
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
else => {},
},
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
else => {},
},
else => {},
}
return error.UnknownDomain;
}
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
const browser_context = &(self.browser_context orelse return false);
const session_id = browser_context.session_id orelse return false;
return std.mem.eql(u8, session_id, input_session_id);
}
pub fn createBrowserContext(self: *CDP) ![]const u8 {
if (self.browser_context != null) {
return error.AlreadyExists;
}
const id = self.browser_context_id_gen.next();
self.browser_context = @as(BrowserContext(CDP), undefined);
const browser_context = &self.browser_context.?;
try BrowserContext(CDP).init(browser_context, id, self);
return id;
}
pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
const bc = &(self.browser_context orelse return false);
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;
return true;
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts) !void {
return self.sendJSON(.{
.method = method,
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
.sessionId = opts.session_id,
});
}
pub fn sendJSON(self: *CDP, message: anytype) !void {
return self.client.sendJSON(message, .{
.emit_null_optional_fields = false,
});
}
pub fn BrowserContext(comptime CDP_T: type) type {
const Node = @import("Node.zig");
const AXNode = @import("AXNode.zig");
@@ -958,7 +955,7 @@ fn asUint(comptime T: type, comptime string: []const u8) T {
const testing = @import("testing.zig");
test "cdp: invalid json" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
@@ -983,7 +980,7 @@ test "cdp: invalid json" {
}
test "cdp: invalid sessionId" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -1008,7 +1005,7 @@ test "cdp: invalid sessionId" {
}
test "cdp: STARTUP sessionId" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -1021,13 +1018,13 @@ test "cdp: STARTUP sessionId" {
// we have a brower context but no session_id
_ = try ctx.loadBrowserContext(.{});
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 3, .index = 1, .session_id = "STARTUP" });
}
{
// we have a brower context with a different session_id
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 4, .index = 2, .session_id = "STARTUP" });
}
}

View File

@@ -112,7 +112,7 @@ fn resetPermissions(cmd: anytype) !void {
const testing = @import("../testing.zig");
test "cdp.browser: getVersion" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
try ctx.processMessage(.{
@@ -131,7 +131,7 @@ test "cdp.browser: getVersion" {
}
test "cdp.browser: getWindowForTarget" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
try ctx.processMessage(.{

View File

@@ -547,7 +547,7 @@ fn requestNode(cmd: anytype) !void {
const testing = @import("../testing.zig");
test "cdp.dom: getSearchResults unknown search id" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
try ctx.processMessage(.{
@@ -559,7 +559,7 @@ test "cdp.dom: getSearchResults unknown search id" {
}
test "cdp.dom: search flow" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
@@ -614,7 +614,7 @@ test "cdp.dom: search flow" {
}
test "cdp.dom: querySelector unknown search id" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
@@ -635,7 +635,7 @@ test "cdp.dom: querySelector unknown search id" {
}
test "cdp.dom: querySelector Node not found" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
@@ -663,7 +663,7 @@ test "cdp.dom: querySelector Node not found" {
}
test "cdp.dom: querySelector Nodes found" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" });
@@ -693,7 +693,7 @@ test "cdp.dom: querySelector Nodes found" {
}
test "cdp.dom: getBoxModel" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" });

View File

@@ -279,7 +279,7 @@ fn waitForSelector(cmd: anytype) !void {
const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
@@ -290,12 +290,12 @@ test "cdp.lp: getMarkdown" {
.method = "LP.getMarkdown",
});
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("markdown") != null);
}
test "cdp.lp: getInteractiveElements" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
@@ -306,13 +306,13 @@ test "cdp.lp: getInteractiveElements" {
.method = "LP.getInteractiveElements",
});
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("elements") != null);
try testing.expect(result.get("nodeIds") != null);
}
test "cdp.lp: getStructuredData" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
@@ -323,12 +323,12 @@ test "cdp.lp: getStructuredData" {
.method = "LP.getStructuredData",
});
const result = ctx.client.?.sent.items[0].object.get("result").?.object;
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("structuredData") != null);
}
test "cdp.lp: action tools" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
@@ -389,7 +389,7 @@ test "cdp.lp: action tools" {
}
test "cdp.lp: waitForSelector" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
@@ -405,9 +405,8 @@ test "cdp.lp: waitForSelector" {
.method = "LP.waitForSelector",
.params = .{ .selector = "#existing", .timeout = 2000 },
});
var result = ctx.client.?.sent.items[0].object.get("result").?.object;
var result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("backendNodeId") != null);
ctx.client.?.sent.clearRetainingCapacity();
// 2. Delayed element
try ctx.processMessage(.{
@@ -415,9 +414,8 @@ test "cdp.lp: waitForSelector" {
.method = "LP.waitForSelector",
.params = .{ .selector = "#delayed", .timeout = 5000 },
});
result = ctx.client.?.sent.items[0].object.get("result").?.object;
result = (try ctx.getSentMessage(1)).?.object.get("result").?.object;
try testing.expect(result.get("backendNodeId") != null);
ctx.client.?.sent.clearRetainingCapacity();
// 3. Timeout error
try ctx.processMessage(.{
@@ -425,6 +423,6 @@ test "cdp.lp: waitForSelector" {
.method = "LP.waitForSelector",
.params = .{ .selector = "#nonexistent", .timeout = 100 },
});
const err_obj = ctx.client.?.sent.items[0].object.get("error").?.object;
const err_obj = (try ctx.getSentMessage(2)).?.object.get("error").?.object;
try testing.expect(err_obj.get("code") != null);
}

View File

@@ -439,7 +439,7 @@ fn idFromRequestId(request_id: []const u8) !u64 {
const testing = @import("../testing.zig");
test "cdp.network setExtraHTTPHeaders" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "NID-A", .session_id = "NESI-A" });
@@ -465,7 +465,7 @@ test "cdp.Network: cookies" {
const ResCookie = CdpStorage.ResCookie;
const CdpCookie = CdpStorage.CdpCookie;
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });

View File

@@ -82,7 +82,7 @@ fn getFrameTree(cmd: anytype) !void {
.frame = .{
.id = "TID-STARTUP",
.loaderId = "LID-STARTUP",
.securityOrigin = @import("../cdp.zig").URL_BASE,
.securityOrigin = @import("../CDP.zig").URL_BASE,
.url = "about:blank",
.secureContextType = "Secure",
},
@@ -642,7 +642,7 @@ fn getLayoutMetrics(cmd: anytype) !void {
const testing = @import("../testing.zig");
test "cdp.page: getFrameTree" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -712,7 +712,7 @@ test "cdp.page: captureScreenshot" {
const filter: LogFilter = .init(&.{.not_implemented});
defer filter.deinit();
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } });
@@ -728,7 +728,7 @@ test "cdp.page: captureScreenshot" {
}
test "cdp.page: getLayoutMetrics" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });

View File

@@ -44,7 +44,7 @@ fn setIgnoreCertificateErrors(cmd: anytype) !void {
const testing = @import("../testing.zig");
test "cdp.Security: setIgnoreCertificateErrors" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" });

View File

@@ -243,7 +243,7 @@ pub fn writeCookie(cookie: *const Cookie, w: anytype) !void {
const testing = @import("../testing.zig");
test "cdp.Storage: cookies" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });

View File

@@ -512,7 +512,7 @@ const TargetInfo = struct {
const testing = @import("../testing.zig");
test "cdp.target: getBrowserContexts" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
// {
@@ -536,7 +536,7 @@ test "cdp.target: getBrowserContexts" {
}
test "cdp.target: createBrowserContext" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -554,7 +554,7 @@ test "cdp.target: createBrowserContext" {
}
test "cdp.target: disposeBrowserContext" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -585,7 +585,7 @@ test "cdp.target: disposeBrowserContext" {
test "cdp.target: createTarget" {
{
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about:blank" } });
@@ -595,7 +595,7 @@ test "cdp.target: createTarget" {
}
{
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
// active auto attach to get the Target.attachedToTarget event.
try ctx.processMessage(.{ .id = 9, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } });
@@ -607,7 +607,7 @@ test "cdp.target: createTarget" {
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{});
}
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
@@ -624,7 +624,7 @@ test "cdp.target: createTarget" {
}
test "cdp.target: closeTarget" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -655,7 +655,7 @@ test "cdp.target: closeTarget" {
}
test "cdp.target: attachToTarget" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -686,7 +686,7 @@ test "cdp.target: attachToTarget" {
}
test "cdp.target: getTargetInfo" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
{
@@ -737,7 +737,7 @@ test "cdp.target: getTargetInfo" {
}
test "cdp.target: issue#474: attach to just created target" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
@@ -752,7 +752,7 @@ test "cdp.target: issue#474: attach to just created target" {
}
test "cdp.target: detachFromTarget" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
@@ -775,19 +775,19 @@ test "cdp.target: detachFromTarget" {
}
test "cdp.target: detachFromTarget without session" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{
// detach when no session is attached should not send event
try ctx.processMessage(.{ .id = 10, .method = "Target.detachFromTarget" });
try ctx.expectSentResult(null, .{ .id = 10 });
try ctx.expectSentCount(0);
try ctx.expectSentCount(1);
}
}
test "cdp.target: setAutoAttach false sends detachedFromTarget" {
var ctx = testing.context();
var ctx = try testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{

View File

@@ -18,12 +18,14 @@
const std = @import("std");
const json = std.json;
const posix = std.posix;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Testing = @This();
const main = @import("cdp.zig");
const CDP = @import("CDP.zig");
const Server = @import("../Server.zig");
const base = @import("../testing.zig");
pub const allocator = base.allocator;
@@ -35,61 +37,27 @@ pub const expectEqualSlices = base.expectEqualSlices;
pub const pageTest = base.pageTest;
pub const newString = base.newString;
const Client = struct {
allocator: Allocator,
send_arena: ArenaAllocator,
sent: std.ArrayList(json.Value) = .{},
serialized: std.ArrayList([]const u8) = .{},
fn init(alloc: Allocator) Client {
return .{
.allocator = alloc,
.send_arena = ArenaAllocator.init(alloc),
};
}
pub fn sendAllocator(self: *Client) Allocator {
return self.send_arena.allocator();
}
pub fn sendJSON(self: *Client, message: anytype, opts: json.Stringify.Options) !void {
var opts_copy = opts;
opts_copy.whitespace = .indent_2;
const serialized = try json.Stringify.valueAlloc(self.allocator, message, opts_copy);
try self.serialized.append(self.allocator, serialized);
const value = try json.parseFromSliceLeaky(json.Value, self.allocator, serialized, .{});
try self.sent.append(self.allocator, value);
}
pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
const value = try json.parseFromSliceLeaky(json.Value, self.allocator, buf.items, .{});
try self.sent.append(self.allocator, value);
}
};
const TestCDP = main.CDPT(struct {
pub const Client = *Testing.Client;
});
const TestContext = struct {
client: ?Client = null,
cdp_: ?TestCDP = null,
arena: ArenaAllocator,
read_at: usize = 0,
read_buf: [1024 * 32]u8 = undefined,
cdp_: ?CDP = null,
client: Server.Client,
socket: posix.socket_t,
received: std.ArrayList(json.Value) = .empty,
received_raw: std.ArrayList([]const u8) = .empty,
pub fn deinit(self: *TestContext) void {
if (self.cdp_) |*c| {
c.deinit();
}
self.arena.deinit();
self.client.deinit();
posix.close(self.socket);
base.reset();
}
pub fn cdp(self: *TestContext) *TestCDP {
pub fn cdp(self: *TestContext) *CDP {
if (self.cdp_ == null) {
self.client = Client.init(self.arena.allocator());
// Don't use the arena here. We want to detect leaks in CDP.
// The arena is only for test-specific stuff
self.cdp_ = TestCDP.init(base.test_app, base.test_http, &self.client.?) catch unreachable;
self.cdp_ = CDP.init(&self.client) catch |err| @panic(@errorName(err));
}
return &self.cdp_.?;
}
@@ -100,7 +68,7 @@ const TestContext = struct {
session_id: ?[]const u8 = null,
url: ?[:0]const u8 = null,
};
pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*main.BrowserContext(TestCDP) {
pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*CDP.BrowserContext(CDP) {
var c = self.cdp();
if (c.browser_context) |bc| {
_ = c.disposeBrowserContext(bc.id);
@@ -130,7 +98,7 @@ const TestContext = struct {
}
const page = try bc.session.createPage();
const full_url = try std.fmt.allocPrintSentinel(
self.arena.allocator(),
base.arena_allocator,
"http://127.0.0.1:9582/src/browser/tests/{s}",
.{url},
0,
@@ -143,19 +111,20 @@ const TestContext = struct {
}
pub fn processMessage(self: *TestContext, msg: anytype) !void {
var json_message: []const u8 = undefined;
if (@typeInfo(@TypeOf(msg)) != .pointer) {
json_message = try std.json.Stringify.valueAlloc(self.arena.allocator(), msg, .{});
} else {
const json_message: []const u8 = blk: {
if (@typeInfo(@TypeOf(msg)) != .pointer) {
break :blk try std.json.Stringify.valueAlloc(base.arena_allocator, msg, .{});
}
// assume this is a string we want to send as-is, if it isn't, we'll
// get a compile error, so no big deal.
json_message = msg;
}
break :blk msg;
};
return self.cdp().processMessage(json_message);
}
pub fn expectSentCount(self: *TestContext, expected: usize) !void {
try expectEqual(expected, self.client.?.sent.items.len);
try self.read();
try expectEqual(expected, self.received.items.len);
}
const ExpectResultOpts = struct {
@@ -203,37 +172,135 @@ const TestContext = struct {
index: ?usize = null,
};
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
const serialized = try json.Stringify.valueAlloc(self.arena.allocator(), expected, .{
const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
.whitespace = .indent_2,
.emit_null_optional_fields = false,
});
for (self.client.?.sent.items, 0..) |sent, i| {
if (try compareExpectedToSent(serialized, sent) == false) {
continue;
}
if (opts.index) |expected_index| {
if (expected_index != i) {
return error.ErrorAtWrongIndex;
for (0..5) |_| {
for (self.received.items, 0..) |received, i| {
if (try compareExpectedToSent(serialized, received) == false) {
continue;
}
}
_ = self.client.?.sent.orderedRemove(i);
_ = self.client.?.serialized.orderedRemove(i);
return;
}
std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized});
for (self.client.?.serialized.items, 0..) |sent, i| {
std.debug.print("#{d}\n{s}\n\n", .{ i, sent });
if (opts.index) |expected_index| {
if (expected_index != i) {
std.debug.print("Expected message at index: {d}, was at index: {d}\n", .{ expected_index, i });
self.dumpReceived();
return error.ErrorAtWrongIndex;
}
}
return;
}
std.Thread.sleep(5 * std.time.ns_per_ms);
try self.read();
}
self.dumpReceived();
return error.ErrorNotFound;
}
fn dumpReceived(self: *const TestContext) void {
std.debug.print("CDP Message Received ({d})\n", .{self.received_raw.items.len});
for (self.received_raw.items, 0..) |received, i| {
std.debug.print("===Message: {d}===\n{s}\n\n", .{ i, received });
}
}
pub fn getSentMessage(self: *TestContext, index: usize) !?json.Value {
for (0..5) |_| {
if (index < self.received.items.len) {
return self.received.items[index];
}
std.Thread.sleep(5 * std.time.ns_per_ms);
try self.read();
}
return null;
}
fn read(self: *TestContext) !void {
while (true) {
const n = posix.read(self.socket, self.read_buf[self.read_at..]) catch |err| switch (err) {
error.WouldBlock => return,
else => return err,
};
if (n == 0) {
return;
}
self.read_at += n;
// Try to parse complete WebSocket frames
var pos: usize = 0;
while (pos < self.read_at) {
// Need at least 2 bytes for header
if (self.read_at - pos < 2) break;
const opcode = self.read_buf[pos] & 0x0F;
const payload_len_byte = self.read_buf[pos + 1] & 0x7F;
var header_size: usize = 2;
var payload_len: usize = payload_len_byte;
if (payload_len_byte == 126) {
if (self.read_at - pos < 4) break;
payload_len = std.mem.readInt(u16, self.read_buf[pos + 2 ..][0..2], .big);
header_size = 4;
}
// Skip 8-byte length case (127) - not needed
const frame_size = header_size + payload_len;
if (self.read_at - pos < frame_size) break;
// We have a complete frame - process text (1) or binary (2), skip others
if (opcode == 1 or opcode == 2) {
const payload = self.read_buf[pos + header_size ..][0..payload_len];
const parsed = try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, payload, .{});
try self.received.append(base.arena_allocator, parsed);
try self.received_raw.append(base.arena_allocator, try base.arena_allocator.dupe(u8, payload));
}
pos += frame_size;
}
// Move remaining partial data to beginning of buffer
if (pos > 0 and pos < self.read_at) {
std.mem.copyForwards(u8, &self.read_buf, self.read_buf[pos..self.read_at]);
self.read_at -= pos;
} else if (pos == self.read_at) {
self.read_at = 0;
}
}
}
};
pub fn context() TestContext {
pub fn context() !TestContext {
var pair: [2]posix.socket_t = undefined;
const rc = std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair);
if (rc != 0) {
return error.SocketPairFailed;
}
errdefer {
posix.close(pair[0]);
posix.close(pair[1]);
}
const timeout = std.mem.toBytes(posix.timeval{ .sec = 0, .usec = 5_000 });
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version", 2000);
return .{
.arena = ArenaAllocator.init(std.testing.allocator),
.client = client,
.socket = pair[0],
};
}

View File

@@ -324,7 +324,9 @@ pub const WsConnection = struct {
pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection {
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
if (builtin.is_test == false) {
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
}
var reader = try Reader(true).init(allocator);
errdefer reader.deinit();

View File

@@ -410,21 +410,46 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
{
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
try page.navigate(url, .{});
}
try page.navigate(url, .{});
var runner = try test_session.runner(.{});
try runner.wait(.{ .ms = 2000 });
test_browser.runMicrotasks();
var wait_ms: u32 = 2000;
var timer = try std.time.Timer.start();
while (true) {
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| {
const caught = try_catch.caughtOrError(arena_allocator, err);
std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught });
return err;
};
const js_val = ls.local.exec("testing.assertOk()", "testing.assertOk()") catch |err| {
const caught = try_catch.caughtOrError(arena_allocator, err);
std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught });
return err;
};
if (js_val.isTrue()) {
return;
}
switch (try runner.tick(.{ .ms = 20 })) {
.done => return error.TestNeverSignaledCompletion,
.ok => |next_ms| {
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
return error.TestTimedOut;
}
wait_ms -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
},
}
}
}
// Used by a few CDP tests - wouldn't be sad to see this go.
@@ -445,10 +470,6 @@ pub fn pageTest(comptime test_file: []const u8) !*Page {
return page;
}
test {
std.testing.refAllDecls(@This());
}
const log = @import("log.zig");
const TestHTTPServer = @import("TestHTTPServer.zig");