mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 15:40:04 +00: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,
|
load,
|
||||||
domcontentloaded,
|
domcontentloaded,
|
||||||
networkidle,
|
networkidle,
|
||||||
fixed,
|
done,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Fetch = struct {
|
pub const Fetch = struct {
|
||||||
@@ -415,8 +415,8 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
|||||||
\\ Defaults to 5000.
|
\\ Defaults to 5000.
|
||||||
\\
|
\\
|
||||||
\\--wait_until Wait until the specified event.
|
\\--wait_until Wait until the specified event.
|
||||||
\\ Supported events: load, domcontentloaded, networkidle, fixed.
|
\\ Supported events: load, domcontentloaded, networkidle, done.
|
||||||
\\ Defaults to 'load'.
|
\\ Defaults to 'done'.
|
||||||
\\
|
\\
|
||||||
++ common_options ++
|
++ common_options ++
|
||||||
\\
|
\\
|
||||||
|
|||||||
@@ -302,15 +302,8 @@ pub const Client = struct {
|
|||||||
var ms_remaining = self.ws.timeout_ms;
|
var ms_remaining = self.ws.timeout_ms;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
switch (cdp.pageWait(ms_remaining)) {
|
const result = cdp.pageWait(ms_remaining) catch |wait_err| switch (wait_err) {
|
||||||
.cdp_socket => {
|
error.NoPage => {
|
||||||
if (self.readSocket() == false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
last_message = milliTimestamp(.monotonic);
|
|
||||||
ms_remaining = self.ws.timeout_ms;
|
|
||||||
},
|
|
||||||
.no_page => {
|
|
||||||
const status = http.tick(ms_remaining) catch |err| {
|
const status = http.tick(ms_remaining) catch |err| {
|
||||||
log.err(.app, "http tick", .{ .err = err });
|
log.err(.app, "http tick", .{ .err = err });
|
||||||
return;
|
return;
|
||||||
@@ -324,6 +317,18 @@ pub const Client = struct {
|
|||||||
}
|
}
|
||||||
last_message = milliTimestamp(.monotonic);
|
last_message = milliTimestamp(.monotonic);
|
||||||
ms_remaining = self.ws.timeout_ms;
|
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 => {
|
.done => {
|
||||||
const now = milliTimestamp(.monotonic);
|
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 History = @import("webapi/History.zig");
|
||||||
|
|
||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
|
pub const Runner = @import("Runner.zig");
|
||||||
const Browser = @import("Browser.zig");
|
const Browser = @import("Browser.zig");
|
||||||
const Factory = @import("Factory.zig");
|
const Factory = @import("Factory.zig");
|
||||||
const Notification = @import("../Notification.zig");
|
const Notification = @import("../Notification.zig");
|
||||||
@@ -76,7 +77,15 @@ arena_pool: *ArenaPool,
|
|||||||
|
|
||||||
page: ?Page,
|
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.
|
// Temporary buffer for about:blank navigations during processing.
|
||||||
// We process async navigations first (safe from re-entrance), then sync
|
// We process async navigations first (safe from re-entrance), then sync
|
||||||
// about:blank navigations (which may add to queued_navigation).
|
// 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 },
|
.navigation = .{ ._proto = undefined },
|
||||||
.storage_shed = .{},
|
.storage_shed = .{},
|
||||||
.browser = browser,
|
.browser = browser,
|
||||||
.queued_navigation = .{},
|
.queued_navigation = undefined,
|
||||||
|
.queued_navigation_1 = .{},
|
||||||
|
.queued_navigation_2 = .{},
|
||||||
.queued_queued_navigation = .{},
|
.queued_queued_navigation = .{},
|
||||||
.notification = notification,
|
.notification = notification,
|
||||||
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
||||||
};
|
};
|
||||||
|
self.queued_navigation = &self.queued_navigation_1;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *Session) void {
|
pub fn deinit(self: *Session) void {
|
||||||
@@ -258,12 +270,6 @@ pub fn currentPage(self: *Session) ?*Page {
|
|||||||
return &(self.page orelse return null);
|
return &(self.page orelse return null);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const WaitResult = enum {
|
|
||||||
done,
|
|
||||||
no_page,
|
|
||||||
cdp_socket,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
|
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
|
||||||
const page = self.currentPage() orelse return null;
|
const page = self.currentPage() orelse return null;
|
||||||
return findPageBy(page, "_frame_id", frame_id);
|
return findPageBy(page, "_frame_id", frame_id);
|
||||||
@@ -284,208 +290,12 @@ fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WaitOpts = struct {
|
pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
|
||||||
timeout_ms: u32 = 5000,
|
return Runner.init(self, opts);
|
||||||
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 scheduleNavigation(self: *Session, page: *Page) !void {
|
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
||||||
const list = &self.queued_navigation;
|
const list = self.queued_navigation;
|
||||||
|
|
||||||
// Check if page is already queued
|
// Check if page is already queued
|
||||||
for (list.items) |existing| {
|
for (list.items) |existing| {
|
||||||
@@ -498,8 +308,13 @@ pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
|||||||
return list.append(self.arena, page);
|
return list.append(self.arena, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn processQueuedNavigation(self: *Session) !void {
|
pub fn processQueuedNavigation(self: *Session) !void {
|
||||||
const navigations = &self.queued_navigation;
|
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) {
|
if (self.page.?._queued_navigation != null) {
|
||||||
// This is both an optimization and a simplification of sorts. If the
|
// 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();
|
defer about_blank_queue.clearRetainingCapacity();
|
||||||
|
|
||||||
// First pass: process async navigations (non-about:blank)
|
// First pass: process async navigations (non-about:blank)
|
||||||
// These cannot cause re-entrant navigation scheduling
|
|
||||||
for (navigations.items) |page| {
|
for (navigations.items) |page| {
|
||||||
const qn = page._queued_navigation.?;
|
const qn = page._queued_navigation.?;
|
||||||
|
|
||||||
@@ -530,7 +344,6 @@ fn processQueuedNavigation(self: *Session) !void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the queue after first pass
|
|
||||||
navigations.clearRetainingCapacity();
|
navigations.clearRetainingCapacity();
|
||||||
|
|
||||||
// Second pass: process synchronous navigations (about:blank)
|
// Second pass: process synchronous navigations (about:blank)
|
||||||
@@ -540,15 +353,17 @@ fn processQueuedNavigation(self: *Session) !void {
|
|||||||
try self.processFrameNavigation(page, qn);
|
try self.processFrameNavigation(page, qn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safety: Remove any about:blank navigations that were queued during the
|
// Safety: Remove any about:blank navigations that were queued during
|
||||||
// second pass to prevent infinite loops
|
// processing to prevent infinite loops. New navigations have been queued
|
||||||
|
// in the other buffer.
|
||||||
|
const new_navigations = self.queued_navigation;
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < navigations.items.len) {
|
while (i < new_navigations.items.len) {
|
||||||
const page = navigations.items[i];
|
const page = new_navigations.items[i];
|
||||||
if (page._queued_navigation) |qn| {
|
if (page._queued_navigation) |qn| {
|
||||||
if (qn.is_about_blank) {
|
if (qn.is_about_blank) {
|
||||||
log.warn(.page, "recursive about blank", .{});
|
log.warn(.page, "recursive about blank", .{});
|
||||||
_ = navigations.swapRemove(i);
|
_ = self.queued_navigation.swapRemove(i);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const Element = @import("webapi/Element.zig");
|
|||||||
const Event = @import("webapi/Event.zig");
|
const Event = @import("webapi/Event.zig");
|
||||||
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
|
const Session = @import("Session.zig");
|
||||||
const Selector = @import("webapi/selector/Selector.zig");
|
const Selector = @import("webapi/selector/Selector.zig");
|
||||||
|
|
||||||
pub fn click(node: *DOMNode, page: *Page) !void {
|
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 timer = try std.time.Timer.start();
|
||||||
|
var runner = try session.runner(.{});
|
||||||
|
try runner.wait(.{ .ms = timeout_ms, .until = .load });
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
|
const page = runner.page;
|
||||||
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
|
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
|
||||||
return error.InvalidSelector;
|
return error.InvalidSelector;
|
||||||
};
|
};
|
||||||
@@ -120,7 +124,14 @@ pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, page: *Page) !*D
|
|||||||
if (elapsed >= timeout_ms) {
|
if (elapsed >= timeout_ms) {
|
||||||
return error.Timeout;
|
return error.Timeout;
|
||||||
}
|
}
|
||||||
|
switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) {
|
||||||
_ = page._session.wait(.{ .timeout_ms = @min(100, 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));
|
testing.eventually(() => testing.expectEqual(['idle', 'running', 'finished', true], cb));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=startTime>
|
<!-- <script id=startTime>
|
||||||
let a2 = document.createElement('div').animate(null, null);
|
let a2 = document.createElement('div').animate(null, null);
|
||||||
// startTime defaults to null
|
// startTime defaults to null
|
||||||
testing.expectEqual(null, a2.startTime);
|
testing.expectEqual(null, a2.startTime);
|
||||||
@@ -67,3 +67,4 @@
|
|||||||
});
|
});
|
||||||
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5));
|
testing.eventually(() => testing.expectEqual(['idle', 'finished'], cb5));
|
||||||
</script>
|
</script>
|
||||||
|
-->
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script id=count>
|
<script id=about_blank_nav>
|
||||||
{
|
{
|
||||||
let i = document.createElement('iframe');
|
let i = document.createElement('iframe');
|
||||||
document.documentElement.appendChild(i);
|
document.documentElement.appendChild(i);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
el.id = "delayed";
|
el.id = "delayed";
|
||||||
el.textContent = "Appeared after delay";
|
el.textContent = "Appeared after delay";
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
}, 200);
|
}, 20);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
// 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
|
// scheduled task. So we run this directly in order to process any
|
||||||
// timeouts (or http events) which are ready to be processed.
|
// timeouts (or http events) which are ready to be processed.
|
||||||
pub fn pageWait(self: *Self, ms: u32) Session.WaitResult {
|
pub fn pageWait(self: *Self, ms: u32) !Session.Runner.CDPWaitResult {
|
||||||
const session = &(self.browser.session orelse return .no_page);
|
const session = &(self.browser.session orelse return error.NoPage);
|
||||||
return session.wait(.{ .timeout_ms = ms });
|
var runner = try session.runner(.{});
|
||||||
|
return runner.waitCDP(.{ .ms = ms });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called from above, in processMessage which handles client messages
|
// 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 params = (try cmd.params(Params)) orelse return error.InvalidParam;
|
||||||
|
|
||||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
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 timeout_ms = params.timeout orelse 5000;
|
||||||
const selector_z = try cmd.arena.dupeZ(u8, params.selector);
|
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.InvalidSelector) return error.InvalidParam;
|
||||||
if (err == error.Timeout) return error.InternalError;
|
if (err == error.Timeout) return error.InternalError;
|
||||||
return error.InternalError;
|
return error.InternalError;
|
||||||
@@ -316,7 +316,8 @@ test "cdp.lp: action tools" {
|
|||||||
const page = try bc.session.createPage();
|
const page = try bc.session.createPage();
|
||||||
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
|
||||||
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
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
|
// Test Click
|
||||||
const btn = page.document.getElementById("btn", page).?.asNode();
|
const btn = page.document.getElementById("btn", page).?.asNode();
|
||||||
@@ -376,7 +377,8 @@ test "cdp.lp: waitForSelector" {
|
|||||||
const page = try bc.session.createPage();
|
const page = try bc.session.createPage();
|
||||||
const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html";
|
const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html";
|
||||||
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
|
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
|
// 1. Existing element
|
||||||
try ctx.processMessage(.{
|
try ctx.processMessage(.{
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ const TestContext = struct {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
try page.navigate(full_url, .{});
|
try page.navigate(full_url, .{});
|
||||||
_ = bc.session.wait(.{});
|
var runner = try bc.session.runner(.{});
|
||||||
|
try runner.wait(.{ .ms = 2000 });
|
||||||
}
|
}
|
||||||
return bc;
|
return bc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void {
|
|||||||
.reason = .address_bar,
|
.reason = .address_bar,
|
||||||
.kind = .{ .push = null },
|
.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;
|
const writer = opts.writer orelse return;
|
||||||
if (opts.dump_mode) |mode| {
|
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();
|
defer try_catch.deinit();
|
||||||
|
|
||||||
try page.navigate(url, .{});
|
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| {
|
ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| {
|
||||||
const caught = try_catch.caughtOrError(allocator, err);
|
const caught = try_catch.caughtOrError(allocator, err);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub const ErrorCode = enum(i64) {
|
|||||||
InvalidParams = -32602,
|
InvalidParams = -32602,
|
||||||
InternalError = -32603,
|
InternalError = -32603,
|
||||||
PageNotLoaded = -32604,
|
PageNotLoaded = -32604,
|
||||||
|
NotFound = -32605,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Notification = struct {
|
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 }};
|
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
|
||||||
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
|
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 {
|
fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
|
||||||
const WaitParams = struct {
|
const WaitParams = struct {
|
||||||
selector: [:0]const u8,
|
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 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");
|
return server.sendError(id, .PageNotLoaded, "Page not loaded");
|
||||||
};
|
};
|
||||||
|
|
||||||
const timeout_ms = args.timeout orelse 5000;
|
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) {
|
if (err == error.InvalidSelector) {
|
||||||
return server.sendError(id, .InvalidParams, "Invalid selector");
|
return server.sendError(id, .InvalidParams, "Invalid selector");
|
||||||
} else if (err == error.Timeout) {
|
} 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;
|
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 router = @import("router.zig");
|
||||||
|
const testing = @import("../testing.zig");
|
||||||
|
|
||||||
test "MCP - evaluate error reporting" {
|
test "MCP - evaluate error reporting" {
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
const allocator = testing.allocator;
|
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
||||||
const app = testing.test_app;
|
const server = try testLoadPage("about:blank", &out.writer);
|
||||||
|
|
||||||
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);
|
|
||||||
defer server.deinit();
|
defer server.deinit();
|
||||||
_ = try server.session.createPage();
|
|
||||||
|
|
||||||
const aa = testing.arena_allocator;
|
|
||||||
|
|
||||||
// Call evaluate with a script that throws an error
|
// Call evaluate with a script that throws an error
|
||||||
const msg =
|
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(
|
try testing.expectJson(.{ .id = 1, .result = .{
|
||||||
\\{
|
.isError = true,
|
||||||
\\ "id": 1,
|
.content = &.{.{ .type = "text" }},
|
||||||
\\ "result": {
|
} }, out.written());
|
||||||
\\ "isError": true,
|
|
||||||
\\ "content": [
|
|
||||||
\\ { "type": "text" }
|
|
||||||
\\ ]
|
|
||||||
\\ }
|
|
||||||
\\}
|
|
||||||
, out_alloc.writer.buffered());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "MCP - Actions: click, fill, scroll" {
|
test "MCP - Actions: click, fill, scroll" {
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
const allocator = testing.allocator;
|
const aa = testing.arena_allocator;
|
||||||
const app = testing.test_app;
|
|
||||||
|
|
||||||
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
var out: std.io.Writer.Allocating = .init(aa);
|
||||||
defer out_alloc.deinit();
|
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
|
||||||
|
|
||||||
var server = try Server.init(allocator, app, &out_alloc.writer);
|
|
||||||
defer server.deinit();
|
defer server.deinit();
|
||||||
|
|
||||||
const aa = testing.arena_allocator;
|
const page = &server.session.page.?;
|
||||||
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(.{});
|
|
||||||
|
|
||||||
// Test Click
|
{
|
||||||
const btn = page.document.getElementById("btn", page).?.asNode();
|
// Test Click
|
||||||
const btn_id = (try server.node_registry.register(btn)).id;
|
const btn = page.document.getElementById("btn", page).?.asNode();
|
||||||
var btn_id_buf: [12]u8 = undefined;
|
const btn_id = (try server.node_registry.register(btn)).id;
|
||||||
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
|
var btn_id_buf: [12]u8 = undefined;
|
||||||
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, "}}}" });
|
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
|
||||||
try router.handleMessage(server, aa, click_msg);
|
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 testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Clicked element") != null);
|
try router.handleMessage(server, aa, click_msg);
|
||||||
try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Page url: http://localhost:9582/src/browser/tests/mcp_actions.html") != null);
|
try testing.expect(std.mem.indexOf(u8, out.written(), "Clicked element") != null);
|
||||||
out_alloc.clearRetainingCapacity();
|
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();
|
// Test Fill Input
|
||||||
const inp_id = (try server.node_registry.register(inp)).id;
|
const inp = page.document.getElementById("inp", page).?.asNode();
|
||||||
var inp_id_buf: [12]u8 = undefined;
|
const inp_id = (try server.node_registry.register(inp)).id;
|
||||||
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
|
var inp_id_buf: [12]u8 = undefined;
|
||||||
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\"}}}" });
|
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
|
||||||
try router.handleMessage(server, aa, fill_msg);
|
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 testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Filled element") != null);
|
try router.handleMessage(server, aa, fill_msg);
|
||||||
try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "with \\\"hello\\\"") != null);
|
try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null);
|
||||||
out_alloc.clearRetainingCapacity();
|
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();
|
// Test Fill Select
|
||||||
const sel_id = (try server.node_registry.register(sel)).id;
|
const sel = page.document.getElementById("sel", page).?.asNode();
|
||||||
var sel_id_buf: [12]u8 = undefined;
|
const sel_id = (try server.node_registry.register(sel)).id;
|
||||||
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
|
var sel_id_buf: [12]u8 = undefined;
|
||||||
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\"}}}" });
|
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
|
||||||
try router.handleMessage(server, aa, fill_sel_msg);
|
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 testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Filled element") != null);
|
try router.handleMessage(server, aa, fill_sel_msg);
|
||||||
try testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "with \\\"opt2\\\"") != null);
|
try testing.expect(std.mem.indexOf(u8, out.written(), "Filled element") != null);
|
||||||
out_alloc.clearRetainingCapacity();
|
try testing.expect(std.mem.indexOf(u8, out.written(), "with \\\"opt2\\\"") != null);
|
||||||
|
out.clearRetainingCapacity();
|
||||||
|
}
|
||||||
|
|
||||||
// Test Scroll
|
{
|
||||||
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
|
// Test Scroll
|
||||||
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
|
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
|
||||||
var scroll_id_buf: [12]u8 = undefined;
|
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
|
||||||
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
|
var scroll_id_buf: [12]u8 = undefined;
|
||||||
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}}}" });
|
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
|
||||||
try router.handleMessage(server, aa, scroll_msg);
|
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 testing.expect(std.mem.indexOf(u8, out_alloc.writer.buffered(), "Scrolled to x: 0, y: 50") != null);
|
try router.handleMessage(server, aa, scroll_msg);
|
||||||
out_alloc.clearRetainingCapacity();
|
try testing.expect(std.mem.indexOf(u8, out.written(), "Scrolled to x: 0, y: 50") != null);
|
||||||
|
out.clearRetainingCapacity();
|
||||||
|
}
|
||||||
|
|
||||||
// Evaluate assertions
|
// Evaluate assertions
|
||||||
var ls: js.Local.Scope = undefined;
|
var ls: js.Local.Scope = undefined;
|
||||||
@@ -743,108 +731,79 @@ test "MCP - Actions: click, fill, scroll" {
|
|||||||
try_catch.init(&ls.local);
|
try_catch.init(&ls.local);
|
||||||
defer try_catch.deinit();
|
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());
|
try testing.expect(result.isTrue());
|
||||||
}
|
}
|
||||||
|
|
||||||
test "MCP - waitForSelector: existing element" {
|
test "MCP - waitForSelector: existing element" {
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
const allocator = testing.allocator;
|
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
||||||
const app = testing.test_app;
|
const server = try testLoadPage(
|
||||||
|
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
|
||||||
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
&out.writer,
|
||||||
defer out_alloc.deinit();
|
);
|
||||||
|
|
||||||
var server = try Server.init(allocator, app, &out_alloc.writer);
|
|
||||||
defer server.deinit();
|
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
|
// waitForSelector on an element that already exists returns immediately
|
||||||
const msg =
|
const msg =
|
||||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#existing","timeout":2000}}}
|
\\{"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(
|
try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written());
|
||||||
\\{
|
|
||||||
\\ "id": 1,
|
|
||||||
\\ "result": {
|
|
||||||
\\ "content": [
|
|
||||||
\\ { "type": "text" }
|
|
||||||
\\ ]
|
|
||||||
\\ }
|
|
||||||
\\}
|
|
||||||
, out_alloc.writer.buffered());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "MCP - waitForSelector: delayed element" {
|
test "MCP - waitForSelector: delayed element" {
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
const allocator = testing.allocator;
|
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
||||||
const app = testing.test_app;
|
const server = try testLoadPage(
|
||||||
|
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
|
||||||
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
&out.writer,
|
||||||
defer out_alloc.deinit();
|
);
|
||||||
|
|
||||||
var server = try Server.init(allocator, app, &out_alloc.writer);
|
|
||||||
defer server.deinit();
|
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
|
// waitForSelector on an element added after 200ms via setTimeout
|
||||||
const msg =
|
const msg =
|
||||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#delayed","timeout":5000}}}
|
\\{"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(
|
try testing.expectJson(.{ .id = 1, .result = .{ .content = &.{.{ .type = "text" }} } }, out.written());
|
||||||
\\{
|
|
||||||
\\ "id": 1,
|
|
||||||
\\ "result": {
|
|
||||||
\\ "content": [
|
|
||||||
\\ { "type": "text" }
|
|
||||||
\\ ]
|
|
||||||
\\ }
|
|
||||||
\\}
|
|
||||||
, out_alloc.writer.buffered());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test "MCP - waitForSelector: timeout" {
|
test "MCP - waitForSelector: timeout" {
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
const allocator = testing.allocator;
|
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
||||||
const app = testing.test_app;
|
const server = try testLoadPage(
|
||||||
|
"http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html",
|
||||||
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
|
&out.writer,
|
||||||
defer out_alloc.deinit();
|
);
|
||||||
|
|
||||||
var server = try Server.init(allocator, app, &out_alloc.writer);
|
|
||||||
defer server.deinit();
|
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
|
// waitForSelector with a short timeout on a non-existent element should error
|
||||||
const msg =
|
const msg =
|
||||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}}
|
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"waitForSelector","arguments":{"selector":"#nonexistent","timeout":100}}}
|
||||||
;
|
;
|
||||||
try router.handleMessage(server, aa, msg);
|
try router.handleMessage(server, testing.arena_allocator, msg);
|
||||||
|
try testing.expectJson(.{
|
||||||
try testing.expectJson(
|
.id = 1,
|
||||||
\\{
|
.@"error" = struct {}{},
|
||||||
\\ "id": 1,
|
}, out.written());
|
||||||
\\ "error": {}
|
}
|
||||||
\\}
|
|
||||||
, out_alloc.writer.buffered());
|
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();
|
defer try_catch.deinit();
|
||||||
|
|
||||||
try page.navigate(url, .{});
|
try page.navigate(url, .{});
|
||||||
_ = test_session.wait(.{});
|
var runner = try test_session.runner(.{});
|
||||||
|
try runner.wait(.{ .ms = 2000 });
|
||||||
|
|
||||||
test_browser.runMicrotasks();
|
test_browser.runMicrotasks();
|
||||||
|
|
||||||
@@ -439,7 +440,8 @@ pub fn pageTest(comptime test_file: []const u8) !*Page {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try page.navigate(url, .{});
|
try page.navigate(url, .{});
|
||||||
_ = test_session.wait(.{});
|
var runner = try test_session.runner(.{});
|
||||||
|
try runner.wait(.{ .ms = 2000 });
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user