diff --git a/src/Config.zig b/src/Config.zig index 8b3f0dfd..e95d3afc 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -236,7 +236,7 @@ pub const WaitUntil = enum { load, domcontentloaded, networkidle, - fixed, + done, }; pub const Fetch = struct { @@ -415,8 +415,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Defaults to 5000. \\ \\--wait_until Wait until the specified event. - \\ Supported events: load, domcontentloaded, networkidle, fixed. - \\ Defaults to 'load'. + \\ Supported events: load, domcontentloaded, networkidle, done. + \\ Defaults to 'done'. \\ ++ common_options ++ \\ diff --git a/src/Server.zig b/src/Server.zig index 0329dcd8..d9c8a455 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -302,15 +302,8 @@ pub const Client = struct { var ms_remaining = self.ws.timeout_ms; while (true) { - switch (cdp.pageWait(ms_remaining)) { - .cdp_socket => { - if (self.readSocket() == false) { - return; - } - last_message = milliTimestamp(.monotonic); - ms_remaining = self.ws.timeout_ms; - }, - .no_page => { + const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) { + error.NoPage => { const status = http.tick(ms_remaining) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; @@ -324,6 +317,18 @@ pub const Client = struct { } last_message = milliTimestamp(.monotonic); ms_remaining = self.ws.timeout_ms; + continue; + }, + else => return wait_err, + }; + + switch (result) { + .cdp_socket => { + if (self.readSocket() == false) { + return; + } + last_message = milliTimestamp(.monotonic); + ms_remaining = self.ws.timeout_ms; }, .done => { const now = milliTimestamp(.monotonic); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig new file mode 100644 index 00000000..7491a4f6 --- /dev/null +++ b/src/browser/Runner.zig @@ -0,0 +1,241 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); +const builtin = @import("builtin"); + +const log = @import("../log.zig"); +const App = @import("../App.zig"); + +const Page = @import("Page.zig"); +const Session = @import("Session.zig"); +const Browser = @import("Browser.zig"); +const Factory = @import("Factory.zig"); +const HttpClient = @import("HttpClient.zig"); + +const IS_DEBUG = builtin.mode == .Debug; + +const Runner = @This(); + +page: *Page, +session: *Session, +http_client: *HttpClient, + +pub const Opts = struct {}; + +pub fn init(session: *Session, _: Opts) !Runner { + const page = &(session.page orelse return error.NoPage); + + return .{ + .page = page, + .session = session, + .http_client = session.browser.http_client, + }; +} + +pub const WaitOpts = struct { + ms: u32, + until: lp.Config.WaitUntil = .done, +}; +pub fn wait(self: *Runner, opts: WaitOpts) !void { + _ = try self._wait(false, opts); +} + +pub const CDPWaitResult = enum { + done, + cdp_socket, +}; +pub fn waitCDP(self: *Runner, opts: WaitOpts) !CDPWaitResult { + return self._wait(true, opts); +} + +fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { + var timer = try std.time.Timer.start(); + var ms_remaining = opts.ms; + + const tick_opts = TickOpts{ + .ms = 200, + .until = opts.until, + }; + while (true) { + const tick_result = self._tick(is_cdp, tick_opts) catch |err| { + switch (err) { + error.JsError => {}, // already logged (with hopefully more context) + else => log.err(.browser, "session wait", .{ + .err = err, + .url = self.page.url, + }), + } + return err; + }; + + const next_ms = switch (tick_result) { + .ok => |next_ms| next_ms, + .done => return .done, + .cdp_socket => if (comptime is_cdp) return .cdp_socket else unreachable, + }; + + const ms_elapsed = timer.lap() / 1_000_000; + if (ms_elapsed >= ms_remaining) { + return .done; + } + ms_remaining -= @intCast(ms_elapsed); + if (next_ms > 0) { + std.Thread.sleep(std.time.ns_per_ms * next_ms); + } + } +} + +pub const TickOpts = struct { + ms: u32, + until: lp.Config.WaitUntil = .done, +}; + +pub const TickResult = union(enum) { + done, + ok: u32, +}; +pub fn tick(self: *Runner, opts: TickOpts) !TickResult { + return switch (try self._tick(false, opts)) { + .ok => |ms| .{ .ok = ms }, + .done => .done, + .cdp_socket => unreachable, + }; +} + +pub const CDPTickResult = union(enum) { + done, + cdp_socket, + ok: u32, +}; +pub fn tickCDP(self: *Runner, opts: TickOpts) !CDPTickResult { + return self._tick(true, opts); +} + +fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { + const page = self.page; + const http_client = self.http_client; + + switch (page._parse_state) { + .pre, .raw, .text, .image => { + // The main page hasn't started/finished navigating. + // There's no JS to run, and no reason to run the scheduler. + if (http_client.active == 0 and (comptime is_cdp) == false) { + // haven't started navigating, I guess. + return .done; + } + + // Either we have active http connections, or we're in CDP + // mode with an extra socket. Either way, we're waiting + // for http traffic + const http_result = try http_client.tick(@intCast(opts.ms)); + if ((comptime is_cdp) and http_result == .cdp_socket) { + return .cdp_socket; + } + return .{ .ok = 0 }; + }, + .html, .complete => { + const session = self.session; + if (session.queued_navigation.items.len != 0) { + try session.processQueuedNavigation(); + self.page = &session.page.?; // might have changed + return .{ .ok = 0 }; + } + const browser = session.browser; + + // The HTML page was parsed. We now either have JS scripts to + // download, or scheduled tasks to execute, or both. + + // scheduler.run could trigger new http transfers, so do not + // store http_client.active BEFORE this call and then use + // it AFTER. + try browser.runMacrotasks(); + + // Each call to this runs scheduled load events. + try page.dispatchLoad(); + + const http_active = http_client.active; + const total_network_activity = http_active + http_client.intercepted; + if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { + page.notifyNetworkAlmostIdle(); + } + if (page._notified_network_idle.check(total_network_activity == 0)) { + page.notifyNetworkIdle(); + } + + if (http_active == 0 and (comptime is_cdp == false)) { + // we don't need to consider http_client.intercepted here + // because is_cdp is true, and that can only be + // the case when interception isn't possible. + if (comptime IS_DEBUG) { + std.debug.assert(http_client.intercepted == 0); + } + + if (browser.hasBackgroundTasks()) { + // _we_ have nothing to run, but v8 is working on + // background tasks. We'll wait for them. + browser.waitForBackgroundTasks(); + } + + switch (opts.until) { + .done => {}, + .domcontentloaded => if (page._load_state == .load or page._load_state == .complete) { + return .done; + }, + .load => if (page._load_state == .complete) { + return .done; + }, + .networkidle => if (page._notified_network_idle == .done) { + return .done; + }, + } + + // We never advertise a wait time of more than 20, there can + // always be new background tasks to run. + if (browser.msToNextMacrotask()) |ms_to_next_task| { + return .{ .ok = @min(ms_to_next_task, 20) }; + } + return .done; + } + + // We're here because we either have active HTTP + // connections, or is_cdp == false (aka, there's + // an cdp_socket registered with the http client). + // We should continue to run tasks, so we minimize how long + // we'll poll for network I/O. + var ms_to_wait = @min(opts.ms, browser.msToNextMacrotask() orelse 200); + if (ms_to_wait > 10 and browser.hasBackgroundTasks()) { + // if we have background tasks, we don't want to wait too + // long for a message from the client. We want to go back + // to the top of the loop and run macrotasks. + ms_to_wait = 10; + } + const http_result = try http_client.tick(@intCast(@min(opts.ms, ms_to_wait))); + if ((comptime is_cdp) and http_result == .cdp_socket) { + return .cdp_socket; + } + return .{ .ok = 0 }; + }, + .err => |err| { + page._parse_state = .{ .raw_done = @errorName(err) }; + return err; + }, + .raw_done => return .done, + } +} diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 35f39115..5801038b 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -30,6 +30,7 @@ const Navigation = @import("webapi/navigation/Navigation.zig"); const History = @import("webapi/History.zig"); const Page = @import("Page.zig"); +pub const Runner = @import("Runner.zig"); const Browser = @import("Browser.zig"); const Factory = @import("Factory.zig"); const Notification = @import("../Notification.zig"); @@ -76,7 +77,15 @@ arena_pool: *ArenaPool, page: ?Page, -queued_navigation: std.ArrayList(*Page), +// Double buffer so that, as we process one list of queued navigations, new entries +// are added to the separate buffer. This ensures that we don't end up with +// endless navigation loops AND that we don't invalidate the list while iterating +// if a new entry gets appended +queued_navigation_1: std.ArrayList(*Page), +queued_navigation_2: std.ArrayList(*Page), +// pointer to either queued_navigation_1 or queued_navigation_2 +queued_navigation: *std.ArrayList(*Page), + // Temporary buffer for about:blank navigations during processing. // We process async navigations first (safe from re-entrance), then sync // about:blank navigations (which may add to queued_navigation). @@ -106,11 +115,14 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .navigation = .{ ._proto = undefined }, .storage_shed = .{}, .browser = browser, - .queued_navigation = .{}, + .queued_navigation = undefined, + .queued_navigation_1 = .{}, + .queued_navigation_2 = .{}, .queued_queued_navigation = .{}, .notification = notification, .cookie_jar = storage.Cookie.Jar.init(allocator), }; + self.queued_navigation = &self.queued_navigation_1; } pub fn deinit(self: *Session) void { @@ -258,12 +270,6 @@ pub fn currentPage(self: *Session) ?*Page { return &(self.page orelse return null); } -pub const WaitResult = enum { - done, - no_page, - cdp_socket, -}; - pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page { const page = self.currentPage() orelse return null; return findPageBy(page, "_frame_id", frame_id); @@ -284,208 +290,12 @@ fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page { return null; } -const WaitOpts = struct { - timeout_ms: u32 = 5000, - until: lp.Config.WaitUntil = .load, -}; - -pub fn wait(self: *Session, opts: WaitOpts) WaitResult { - var page = &(self.page orelse return .no_page); - while (true) { - const wait_result = self._wait(page, opts) catch |err| { - switch (err) { - error.JsError => {}, // already logged (with hopefully more context) - else => log.err(.browser, "session wait", .{ - .err = err, - .url = page.url, - }), - } - return .done; - }; - - switch (wait_result) { - .done => { - if (self.queued_navigation.items.len == 0) { - return .done; - } - self.processQueuedNavigation() catch return .done; - page = &self.page.?; // might have changed - }, - else => |result| return result, - } - } -} - -fn _wait(self: *Session, page: *Page, opts: WaitOpts) !WaitResult { - const wait_until = opts.until; - - var timer = try std.time.Timer.start(); - var ms_remaining = opts.timeout_ms; - - const browser = self.browser; - var http_client = browser.http_client; - - // I'd like the page to know NOTHING about cdp_socket / CDP, but the - // fact is that the behavior of wait changes depending on whether or - // not we're using CDP. - // If we aren't using CDP, as soon as we think there's nothing left - // to do, we can exit - we'de done. - // But if we are using CDP, we should wait for the whole `wait_ms` - // because the http_click.tick() also monitors the CDP socket. And while - // we could let CDP poll http (like it does for HTTP requests), the fact - // is that we know more about the timing of stuff (e.g. how long to - // poll/sleep) in the page. - const exit_when_done = http_client.cdp_client == null; - - while (true) { - switch (page._parse_state) { - .pre, .raw, .text, .image => { - // The main page hasn't started/finished navigating. - // There's no JS to run, and no reason to run the scheduler. - if (http_client.active == 0 and exit_when_done) { - // haven't started navigating, I guess. - if (wait_until != .fixed) { - return .done; - } - } - // Either we have active http connections, or we're in CDP - // mode with an extra socket. Either way, we're waiting - // for http traffic - if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) { - // exit_when_done is explicitly set when there isn't - // an extra socket, so it should not be possibl to - // get an cdp_socket message when exit_when_done - // is true. - if (IS_DEBUG) { - std.debug.assert(exit_when_done == false); - } - - // data on a socket we aren't handling, return to caller - return .cdp_socket; - } - }, - .html, .complete => { - if (self.queued_navigation.items.len != 0) { - return .done; - } - - // The HTML page was parsed. We now either have JS scripts to - // download, or scheduled tasks to execute, or both. - - // scheduler.run could trigger new http transfers, so do not - // store http_client.active BEFORE this call and then use - // it AFTER. - try browser.runMacrotasks(); - - // Each call to this runs scheduled load events. - try page.dispatchLoad(); - - const http_active = http_client.active; - const total_network_activity = http_active + http_client.intercepted; - if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { - page.notifyNetworkAlmostIdle(); - } - if (page._notified_network_idle.check(total_network_activity == 0)) { - page.notifyNetworkIdle(); - } - - if (http_active == 0 and exit_when_done) { - // we don't need to consider http_client.intercepted here - // because exit_when_done is true, and that can only be - // the case when interception isn't possible. - if (comptime IS_DEBUG) { - std.debug.assert(http_client.intercepted == 0); - } - - const is_event_done = switch (wait_until) { - .fixed => false, - .domcontentloaded => (page._load_state == .load or page._load_state == .complete), - .load => (page._load_state == .complete), - .networkidle => (page._notified_network_idle == .done), - }; - - var ms = blk: { - if (browser.hasBackgroundTasks()) { - // _we_ have nothing to run, but v8 is working on - // background tasks. We'll wait for them. - browser.waitForBackgroundTasks(); - break :blk 20; - } - - const next_task = browser.msToNextMacrotask(); - if (next_task == null and is_event_done) { - return .done; - } - break :blk next_task orelse 20; - }; - - if (ms > ms_remaining) { - if (is_event_done) { - return .done; - } - // Same as above, except we have a scheduled task, - // it just happens to be too far into the future - // compared to how long we were told to wait. - if (browser.hasBackgroundTasks()) { - // _we_ have nothing to run, but v8 is working on - // background tasks. We'll wait for them. - browser.waitForBackgroundTasks(); - } - // We're still wait for our wait_until. Not sure for what - // but let's keep waiting. Worst case, we'll timeout. - ms = 20; - } - - // We have a task to run in the not-so-distant future. - // You might think we can just sleep until that task is - // ready, but we should continue to run lowPriority tasks - // in the meantime, and that could unblock things. So - // we'll just sleep for a bit, and then restart our wait - // loop to see if anything new can be processed. - std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20)))); - } else { - // We're here because we either have active HTTP - // connections, or exit_when_done == false (aka, there's - // an cdp_socket registered with the http client). - // We should continue to run tasks, so we minimize how long - // we'll poll for network I/O. - var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200); - if (ms_to_wait > 10 and browser.hasBackgroundTasks()) { - // if we have background tasks, we don't want to wait too - // long for a message from the client. We want to go back - // to the top of the loop and run macrotasks. - ms_to_wait = 10; - } - if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) { - // data on a socket we aren't handling, return to caller - return .cdp_socket; - } - } - }, - .err => |err| { - page._parse_state = .{ .raw_done = @errorName(err) }; - return err; - }, - .raw_done => { - if (exit_when_done) { - return .done; - } - // we _could_ http_client.tick(ms_to_wait), but this has - // the same result, and I feel is more correct. - return .no_page; - }, - } - - const ms_elapsed = timer.lap() / 1_000_000; - if (ms_elapsed >= ms_remaining) { - return .done; - } - ms_remaining -= @intCast(ms_elapsed); - } +pub fn runner(self: *Session, opts: Runner.Opts) !Runner { + return Runner.init(self, opts); } pub fn scheduleNavigation(self: *Session, page: *Page) !void { - const list = &self.queued_navigation; + const list = self.queued_navigation; // Check if page is already queued for (list.items) |existing| { @@ -498,8 +308,13 @@ pub fn scheduleNavigation(self: *Session, page: *Page) !void { return list.append(self.arena, page); } -fn processQueuedNavigation(self: *Session) !void { - const navigations = &self.queued_navigation; +pub fn processQueuedNavigation(self: *Session) !void { + const navigations = self.queued_navigation; + if (self.queued_navigation == &self.queued_navigation_1) { + self.queued_navigation = &self.queued_navigation_2; + } else { + self.queued_navigation = &self.queued_navigation_1; + } if (self.page.?._queued_navigation != null) { // This is both an optimization and a simplification of sorts. If the @@ -515,7 +330,6 @@ fn processQueuedNavigation(self: *Session) !void { defer about_blank_queue.clearRetainingCapacity(); // First pass: process async navigations (non-about:blank) - // These cannot cause re-entrant navigation scheduling for (navigations.items) |page| { const qn = page._queued_navigation.?; @@ -530,7 +344,6 @@ fn processQueuedNavigation(self: *Session) !void { }; } - // Clear the queue after first pass navigations.clearRetainingCapacity(); // Second pass: process synchronous navigations (about:blank) @@ -540,15 +353,17 @@ fn processQueuedNavigation(self: *Session) !void { try self.processFrameNavigation(page, qn); } - // Safety: Remove any about:blank navigations that were queued during the - // second pass to prevent infinite loops + // Safety: Remove any about:blank navigations that were queued during + // processing to prevent infinite loops. New navigations have been queued + // in the other buffer. + const new_navigations = self.queued_navigation; var i: usize = 0; - while (i < navigations.items.len) { - const page = navigations.items[i]; + while (i < new_navigations.items.len) { + const page = new_navigations.items[i]; if (page._queued_navigation) |qn| { if (qn.is_about_blank) { - log.warn(.page, "recursive about blank", .{}); - _ = navigations.swapRemove(i); + log.warn(.page, "recursive about blank", .{}); + _ = self.queued_navigation.swapRemove(i); continue; } } diff --git a/src/browser/actions.zig b/src/browser/actions.zig index f62cfdbc..7facfc81 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -23,6 +23,7 @@ const Element = @import("webapi/Element.zig"); const Event = @import("webapi/Event.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig"); const Page = @import("Page.zig"); +const Session = @import("Session.zig"); const Selector = @import("webapi/selector/Selector.zig"); pub fn click(node: *DOMNode, page: *Page) !void { @@ -104,10 +105,13 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void { } } -pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, page: *Page) !*DOMNode { +pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Session) !*DOMNode { var timer = try std.time.Timer.start(); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = timeout_ms, .until = .load }); while (true) { + const page = runner.page; const element = Selector.querySelector(page.document.asNode(), selector, page) catch { return error.InvalidSelector; }; @@ -120,7 +124,14 @@ pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, page: *Page) !*D if (elapsed >= timeout_ms) { return error.Timeout; } - - _ = page._session.wait(.{ .timeout_ms = @min(100, timeout_ms - elapsed) }); + switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) { + .done => return error.Timeout, + .ok => |recommended_sleep_ms| { + if (recommended_sleep_ms > 0) { + // guanrateed to be <= 20ms + std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms); + } + }, + } } } diff --git a/src/browser/tests/animation/animation.html b/src/browser/tests/animation/animation.html index 97bfe077..886ac0b8 100644 --- a/src/browser/tests/animation/animation.html +++ b/src/browser/tests/animation/animation.html @@ -18,7 +18,7 @@ testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb)); - - diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index ed77165e..98a7969e 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -130,9 +130,10 @@ pub fn CDPT(comptime TypeProvider: type) type { // 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.WaitResult { - const session = &(self.browser.session orelse return .no_page); - return session.wait(.{ .timeout_ms = ms }); + 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 diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 2f8114f3..9cbf32a5 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -241,12 +241,12 @@ fn waitForSelector(cmd: anytype) !void { const params = (try cmd.params(Params)) orelse return error.InvalidParam; const bc = cmd.browser_context orelse return error.NoBrowserContext; - const page = bc.session.currentPage() orelse return error.PageNotLoaded; + _ = bc.session.currentPage() orelse return error.PageNotLoaded; const timeout_ms = params.timeout orelse 5000; const selector_z = try cmd.arena.dupeZ(u8, params.selector); - const node = lp.actions.waitForSelector(selector_z, timeout_ms, page) catch |err| { + const node = lp.actions.waitForSelector(selector_z, timeout_ms, bc.session) catch |err| { if (err == error.InvalidSelector) return error.InvalidParam; if (err == error.Timeout) return error.InternalError; return error.InternalError; @@ -316,7 +316,8 @@ test "cdp.lp: action tools" { const page = try bc.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = bc.session.wait(.{}); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); // Test Click const btn = page.document.getElementById("btn", page).?.asNode(); @@ -376,7 +377,8 @@ test "cdp.lp: waitForSelector" { const page = try bc.session.createPage(); const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = bc.session.wait(.{}); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); // 1. Existing element try ctx.processMessage(.{ diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 34f28c94..744104d1 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -136,7 +136,8 @@ const TestContext = struct { 0, ); try page.navigate(full_url, .{}); - _ = bc.session.wait(.{}); + var runner = try bc.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); } return bc; } diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 1add3dc4..67e8f1b9 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -108,7 +108,8 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { .reason = .address_bar, .kind = .{ .push = null }, }); - _ = session.wait(.{ .timeout_ms = opts.wait_ms, .until = opts.wait_until }); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = opts.wait_ms, .until = opts.wait_until }); const writer = opts.writer orelse return; if (opts.dump_mode) |mode| { diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 0805e100..15075642 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -106,7 +106,8 @@ pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void { defer try_catch.deinit(); try page.navigate(url, .{}); - _ = session.wait(.{}); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const caught = try_catch.caughtOrError(allocator, err); diff --git a/src/mcp/protocol.zig b/src/mcp/protocol.zig index 2bc6f9e8..1375cd00 100644 --- a/src/mcp/protocol.zig +++ b/src/mcp/protocol.zig @@ -27,6 +27,7 @@ pub const ErrorCode = enum(i64) { InvalidParams = -32602, InternalError = -32603, PageNotLoaded = -32604, + NotFound = -32605, }; pub const Notification = struct { diff --git a/src/mcp/tools.zig b/src/mcp/tools.zig index 06828b27..21215a7e 100644 --- a/src/mcp/tools.zig +++ b/src/mcp/tools.zig @@ -569,6 +569,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }}; try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); } + fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { const WaitParams = struct { selector: [:0]const u8, @@ -576,13 +577,13 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json }; const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector"); - const page = server.session.currentPage() orelse { + _ = server.session.currentPage() orelse { return server.sendError(id, .PageNotLoaded, "Page not loaded"); }; const timeout_ms = args.timeout orelse 5000; - const node = lp.actions.waitForSelector(args.selector, timeout_ms, page) catch |err| { + const node = lp.actions.waitForSelector(args.selector, timeout_ms, server.session) catch |err| { if (err == error.InvalidSelector) { return server.sendError(id, .InvalidParams, "Invalid selector"); } else if (err == error.Timeout) { @@ -624,25 +625,18 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void { return error.NavigationFailed; }; - _ = server.session.wait(.{}); + var runner = try session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); } -const testing = @import("../testing.zig"); const router = @import("router.zig"); +const testing = @import("../testing.zig"); test "MCP - evaluate error reporting" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage("about:blank", &out.writer); defer server.deinit(); - _ = try server.session.createPage(); - - const aa = testing.arena_allocator; // Call evaluate with a script that throws an error const msg = @@ -659,80 +653,74 @@ test "MCP - evaluate error reporting" { \\} ; - try router.handleMessage(server, aa, msg); + try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "result": { - \\ "isError": true, - \\ "content": [ - \\ { "type": "text" } - \\ ] - \\ } - \\} - , out_alloc.writer.buffered()); + try testing.expectJson(.{ .id = 1, .result = .{ + .isError = true, + .content = &.{.{ .type = "text" }}, + } }, out.written()); } test "MCP - Actions: click, fill, scroll" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; + const aa = testing.arena_allocator; - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(aa); + const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); + const page = &server.session.page.?; - // Test Click - const btn = page.document.getElementById("btn", page).?.asNode(); - const btn_id = (try server.node_registry.register(btn)).id; - var btn_id_buf: [12]u8 = undefined; - const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; - const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); - try router.handleMessage(server, aa, click_msg); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Clicked element") != null); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Page url: http://localhost:9582/src/browser/tests/mcp_actions.html") != null); - out_alloc.clearRetainingCapacity(); + { + // Test Click + const btn = page.document.getElementById("btn", page).?.asNode(); + const btn_id = (try server.node_registry.register(btn)).id; + var btn_id_buf: [12]u8 = undefined; + const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable; + const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" }); + try router.handleMessage(server, aa, click_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "Page url: http://localhost:9582/src/browser/tests/mcp_actions.html") != null); + out.clearRetainingCapacity(); + } - // Test Fill Input - const inp = page.document.getElementById("inp", page).?.asNode(); - const inp_id = (try server.node_registry.register(inp)).id; - var inp_id_buf: [12]u8 = undefined; - const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; - const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); - try router.handleMessage(server, aa, fill_msg); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Filled element") != null); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "with \\\"hello\\\"") != null); - out_alloc.clearRetainingCapacity(); + { + // Test Fill Input + const inp = page.document.getElementById("inp", page).?.asNode(); + const inp_id = (try server.node_registry.register(inp)).id; + var inp_id_buf: [12]u8 = undefined; + const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable; + const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" }); + try router.handleMessage(server, aa, fill_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"hello\\\"") != null); + out.clearRetainingCapacity(); + } - // Test Fill Select - const sel = page.document.getElementById("sel", page).?.asNode(); - const sel_id = (try server.node_registry.register(sel)).id; - var sel_id_buf: [12]u8 = undefined; - const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; - const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); - try router.handleMessage(server, aa, fill_sel_msg); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Filled element") != null); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "with \\\"opt2\\\"") != null); - out_alloc.clearRetainingCapacity(); + { + // Test Fill Select + const sel = page.document.getElementById("sel", page).?.asNode(); + const sel_id = (try server.node_registry.register(sel)).id; + var sel_id_buf: [12]u8 = undefined; + const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable; + const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" }); + try router.handleMessage(server, aa, fill_sel_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null); + try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"opt2\\\"") != null); + out.clearRetainingCapacity(); + } - // Test Scroll - const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); - const scrollbox_id = (try server.node_registry.register(scrollbox)).id; - var scroll_id_buf: [12]u8 = undefined; - const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; - const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); - try router.handleMessage(server, aa, scroll_msg); - try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Scrolled to x: 0, y: 50") != null); - out_alloc.clearRetainingCapacity(); + { + // Test Scroll + const scrollbox = page.document.getElementById("scrollbox", page).?.asNode(); + const scrollbox_id = (try server.node_registry.register(scrollbox)).id; + var scroll_id_buf: [12]u8 = undefined; + const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable; + const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" }); + try router.handleMessage(server, aa, scroll_msg); + try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled to x: 0, y: 50") != null); + out.clearRetainingCapacity(); + } // Evaluate assertions var ls: js.Local.Scope = undefined; @@ -743,108 +731,79 @@ test "MCP - Actions: click, fill, scroll" { try_catch.init(&ls.local); defer try_catch.deinit(); - const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null); + const result = try ls.local.exec( + \\ window.clicked === true && window.inputVal === 'hello' && + \\ window.changed === true && window.selChanged === 'opt2' && + \\ window.scrolled === true + , null); try testing.expect(result.isTrue()); } test "MCP - waitForSelector: existing element" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage( + "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html", + &out.writer, + ); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); - // waitForSelector on an element that already exists returns immediately const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#existing","timeout":2000}}} ; - try router.handleMessage(server, aa, msg); + try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "result": { - \\ "content": [ - \\ { "type": "text" } - \\ ] - \\ } - \\} - , out_alloc.writer.buffered()); + try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written()); } test "MCP - waitForSelector: delayed element" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage( + "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html", + &out.writer, + ); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); - // waitForSelector on an element added after 200ms via setTimeout const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#delayed","timeout":5000}}} ; - try router.handleMessage(server, aa, msg); + try router.handleMessage(server, testing.arena_allocator, msg); - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "result": { - \\ "content": [ - \\ { "type": "text" } - \\ ] - \\ } - \\} - , out_alloc.writer.buffered()); + try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written()); } test "MCP - waitForSelector: timeout" { defer testing.reset(); - const allocator = testing.allocator; - const app = testing.test_app; - - var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator); - defer out_alloc.deinit(); - - var server = try Server.init(allocator, app, &out_alloc.writer); + var out: std.io.Writer.Allocating = .init(testing.arena_allocator); + const server = try testLoadPage( + "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html", + &out.writer, + ); defer server.deinit(); - const aa = testing.arena_allocator; - const page = try server.session.createPage(); - const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html"; - try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); - _ = server.session.wait(.{}); - // waitForSelector with a short timeout on a non-existent element should error const msg = \\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}} ; - try router.handleMessage(server, aa, msg); - - try testing.expectJson( - \\{ - \\ "id": 1, - \\ "error": {} - \\} - , out_alloc.writer.buffered()); + try router.handleMessage(server, testing.arena_allocator, msg); + try testing.expectJson(.{ + .id = 1, + .@"error" = struct {}{}, + }, out.written()); +} + +fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server { + var server = try Server.init(testing.allocator, testing.test_app, writer); + errdefer server.deinit(); + + const page = try server.session.createPage(); + try page.navigate(url, .{}); + + var runner = try server.session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); + return server; } diff --git a/src/testing.zig b/src/testing.zig index bdfeb915..050c9849 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -415,7 +415,8 @@ fn runWebApiTest(test_file: [:0]const u8) !void { defer try_catch.deinit(); try page.navigate(url, .{}); - _ = test_session.wait(.{}); + var runner = try test_session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); test_browser.runMicrotasks(); @@ -439,7 +440,8 @@ pub fn pageTest(comptime test_file: []const u8) !*Page { ); try page.navigate(url, .{}); - _ = test_session.wait(.{}); + var runner = try test_session.runner(.{}); + try runner.wait(.{ .ms = 2000 }); return page; }