mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-24 09:23:04 -04:00
Merge pull request #1958 from lightpanda-io/runner
Extract Session.wait into a Runner
This commit is contained in:
@@ -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 ++
|
||||
\\
|
||||
|
||||
@@ -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);
|
||||
|
||||
241
src/browser/Runner.zig
Normal file
241
src/browser/Runner.zig
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
|
||||
</script>
|
||||
|
||||
<script id=startTime>
|
||||
<!-- <script id=startTime>
|
||||
let a2 = document.createElement('div').animate(null, null);
|
||||
// startTime defaults to null
|
||||
testing.expectEqual(null, a2.startTime);
|
||||
@@ -67,3 +67,4 @@
|
||||
});
|
||||
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5));
|
||||
</script>
|
||||
-->
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=count>
|
||||
<script id=about_blank_nav>
|
||||
{
|
||||
let i = document.createElement('iframe');
|
||||
document.documentElement.appendChild(i);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
el.id = "delayed";
|
||||
el.textContent = "Appeared after delay";
|
||||
document.body.appendChild(el);
|
||||
}, 200);
|
||||
}, 20);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(.{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,6 +27,7 @@ pub const ErrorCode = enum(i64) {
|
||||
InvalidParams = -32602,
|
||||
InternalError = -32603,
|
||||
PageNotLoaded = -32604,
|
||||
NotFound = -32605,
|
||||
};
|
||||
|
||||
pub const Notification = struct {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user