mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-01 02:06:17 -04:00
A press/click that submits a form or follows a link commits a replacement Page inside the tool call itself (finalizeAction -> awaitQueuedNavigation), freeing the Frame that Session._tool_frame_override still pointed at; finalizeAction then dereferenced it via requireFrame -> currentFrame. Any script whose press/click navigated crashed on replay. Store a frame id instead of a pointer and resolve it at every currentFrame call. The replacement page keeps the frame id (see commitPendingPage), so the same handle stays routable across the swap.
1473 lines
60 KiB
Zig
1473 lines
60 KiB
Zig
// Copyright (C) 2023-2026 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 browser_tools = lp.tools;
|
|
const BrowserTool = browser_tools.Tool;
|
|
const CDPNode = @import("../cdp/Node.zig");
|
|
const Schema = @import("Schema.zig");
|
|
|
|
const v8 = lp.js.v8;
|
|
|
|
const Runtime = @This();
|
|
|
|
allocator: std.mem.Allocator,
|
|
app: *lp.App,
|
|
session: *lp.Session,
|
|
registry: *CDPNode.Registry,
|
|
env: lp.js.Env,
|
|
context: v8.Global,
|
|
has_context: bool,
|
|
call_arena: std.heap.ArenaAllocator,
|
|
primitive_data: [recorded_tool_count]PrimitiveData,
|
|
console_data: [std.enums.values(ConsoleMethod).len]ConsoleData,
|
|
/// Notified before each `console.*` line is written. The REPL uses it to
|
|
/// clear the live spinner so script output starts on a clean line instead
|
|
/// of colliding with the indicator; the line still goes to stdout/stderr.
|
|
console_observer: ?ConsoleObserver = null,
|
|
/// In-flight async `goto`s; emptied before each `runSource` returns.
|
|
pending_gotos: std.ArrayList(PendingGoto),
|
|
/// Restarted per `runSource`; backs `PendingGoto.deadline_ms`.
|
|
run_timer: std.time.Timer,
|
|
|
|
/// The runtime installs exactly the recorded browser tools as script
|
|
/// primitives — the same set the recorder writes — so every recorded call
|
|
/// replays. `buildArgs` adapts each tool's JS calling convention to the JSON
|
|
/// `browser_tools.call` expects.
|
|
const recorded_tool_count = blk: {
|
|
var n: usize = 0;
|
|
for (std.enums.values(BrowserTool)) |t| {
|
|
if (t.isRecorded()) n += 1;
|
|
}
|
|
break :blk n;
|
|
};
|
|
|
|
const PrimitiveData = struct {
|
|
runtime: *Runtime,
|
|
tool: BrowserTool,
|
|
};
|
|
|
|
/// An async `goto` not yet settled. The resolver and receiver (`this`) are
|
|
/// Globals so they survive the microtask drains between driver iterations.
|
|
const PendingGoto = struct {
|
|
frame_id: u32,
|
|
resolver: v8.Global,
|
|
receiver: v8.Global,
|
|
/// `run_timer` reading (ms) past which the navigation is abandoned.
|
|
deadline_ms: u64,
|
|
|
|
fn reset(self: *PendingGoto) void {
|
|
v8.v8__Global__Reset(&self.resolver);
|
|
v8.v8__Global__Reset(&self.receiver);
|
|
}
|
|
};
|
|
|
|
const ConsoleMethod = enum {
|
|
debug,
|
|
@"error",
|
|
info,
|
|
log,
|
|
warn,
|
|
|
|
fn writesStderr(self: ConsoleMethod) bool {
|
|
return switch (self) {
|
|
.@"error", .warn => true,
|
|
.debug, .info, .log => false,
|
|
};
|
|
}
|
|
};
|
|
|
|
const ConsoleData = struct {
|
|
runtime: *Runtime,
|
|
method: ConsoleMethod,
|
|
};
|
|
|
|
pub const ConsoleObserver = struct {
|
|
context: *anyopaque,
|
|
notify: *const fn (context: *anyopaque) void,
|
|
};
|
|
|
|
pub const InitError = error{
|
|
OutOfMemory,
|
|
RuntimeInitFailed,
|
|
TooManyContexts,
|
|
};
|
|
|
|
pub const RunError = error{
|
|
OutOfMemory,
|
|
};
|
|
|
|
pub fn init(
|
|
allocator: std.mem.Allocator,
|
|
app: *lp.App,
|
|
session: *lp.Session,
|
|
registry: *CDPNode.Registry,
|
|
) InitError!*Runtime {
|
|
const self = try allocator.create(Runtime);
|
|
errdefer allocator.destroy(self);
|
|
|
|
self.* = .{
|
|
.allocator = allocator,
|
|
.app = app,
|
|
.session = session,
|
|
.registry = registry,
|
|
.env = undefined,
|
|
.context = undefined,
|
|
.has_context = false,
|
|
.call_arena = .init(allocator),
|
|
.primitive_data = undefined,
|
|
.console_data = undefined,
|
|
.pending_gotos = .empty,
|
|
.run_timer = undefined,
|
|
};
|
|
errdefer self.call_arena.deinit();
|
|
|
|
// Separate isolate from the page. The full `Env` is used only as an isolate
|
|
// + terminate/microtask carrier; the agent context is bare (no WebAPIs).
|
|
self.env = lp.js.Env.init(app, .{}) catch return error.RuntimeInitFailed;
|
|
errdefer self.env.deinit();
|
|
|
|
try self.createContext();
|
|
errdefer self.resetContext();
|
|
|
|
return self;
|
|
}
|
|
|
|
pub fn deinit(self: *Runtime) void {
|
|
// Free leftover Globals while the isolate is still alive (a run terminated
|
|
// mid-flight can leave some).
|
|
for (self.pending_gotos.items) |*pending| pending.reset();
|
|
self.pending_gotos.deinit(self.allocator);
|
|
self.resetContext();
|
|
self.env.deinit();
|
|
self.call_arena.deinit();
|
|
const allocator = self.allocator;
|
|
allocator.destroy(self);
|
|
}
|
|
|
|
pub fn terminate(self: *Runtime) void {
|
|
self.env.terminate();
|
|
}
|
|
|
|
pub fn cancelTerminate(self: *Runtime) void {
|
|
self.env.cancelTerminate();
|
|
}
|
|
|
|
fn createContext(self: *Runtime) InitError!void {
|
|
var hs: lp.js.HandleScope = undefined;
|
|
hs.init(self.env.isolate);
|
|
defer hs.deinit();
|
|
|
|
const context = v8.v8__Context__New(self.env.isolate.handle, null, null) orelse
|
|
return error.RuntimeInitFailed;
|
|
v8.v8__Global__New(self.env.isolate.handle, context, &self.context);
|
|
self.has_context = true;
|
|
|
|
v8.v8__Context__Enter(context);
|
|
defer v8.v8__Context__Exit(context);
|
|
|
|
// The promise-reject callback every `Env` installs reads a browser `Context`
|
|
// from embedder slot 1; this bare context has none. Pin the slot to null so
|
|
// the callback no-ops instead of aligncasting garbage — the script body runs
|
|
// inside an async wrapper, so unhandled rejections happen routinely.
|
|
v8.v8__Context__SetAlignedPointerInEmbedderData(context, 1, null);
|
|
|
|
const global = v8.v8__Context__Global(context) orelse return error.RuntimeInitFailed;
|
|
|
|
// `Page` is the only global verb; `new Page()` attaches a method for each
|
|
// recorded tool, reusing these `primitive_data` entries — so fill them here.
|
|
var i: usize = 0;
|
|
for (std.enums.values(BrowserTool)) |t| {
|
|
if (!t.isRecorded()) continue;
|
|
self.primitive_data[i] = .{ .runtime = self, .tool = t };
|
|
i += 1;
|
|
}
|
|
try self.installFunction(context, global, "Page", pageConstructor, self);
|
|
try self.installConsole(context, global);
|
|
}
|
|
|
|
fn resetContext(self: *Runtime) void {
|
|
if (!self.has_context) return;
|
|
v8.v8__Global__Reset(&self.context);
|
|
self.env.isolate.notifyContextDisposed();
|
|
self.has_context = false;
|
|
}
|
|
|
|
const Callback = *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void;
|
|
|
|
/// Install `callback` (carrying `data` as its `External`) under `name` on `object`.
|
|
fn installFunction(
|
|
self: *Runtime,
|
|
context: *const v8.Context,
|
|
object: *const v8.Object,
|
|
name: []const u8,
|
|
callback: Callback,
|
|
data: *anyopaque,
|
|
) InitError!void {
|
|
const external = self.env.isolate.createExternal(data);
|
|
const func = v8.v8__Function__New__DEFAULT2(context, callback, external) orelse
|
|
return error.RuntimeInitFailed;
|
|
try self.setObjectProperty(context, object, name, @ptrCast(func));
|
|
}
|
|
|
|
fn installConsole(
|
|
self: *Runtime,
|
|
context: *const v8.Context,
|
|
global: *const v8.Object,
|
|
) InitError!void {
|
|
const console = v8.v8__Object__New(self.env.isolate.handle) orelse
|
|
return error.RuntimeInitFailed;
|
|
|
|
for (std.enums.values(ConsoleMethod), 0..) |method, i| {
|
|
self.console_data[i] = .{ .runtime = self, .method = method };
|
|
try self.installFunction(context, console, @tagName(method), consoleCallback, &self.console_data[i]);
|
|
}
|
|
|
|
try setObjectProperty(self, context, global, "console", @ptrCast(console));
|
|
}
|
|
|
|
fn setObjectProperty(
|
|
self: *Runtime,
|
|
context: *const v8.Context,
|
|
object: *const v8.Object,
|
|
name: []const u8,
|
|
value: *const v8.Value,
|
|
) InitError!void {
|
|
var out: v8.MaybeBool = undefined;
|
|
v8.v8__Object__Set(
|
|
object,
|
|
context,
|
|
@ptrCast(self.env.isolate.initStringHandle(name)),
|
|
value,
|
|
&out,
|
|
);
|
|
if (!out.has_value or !out.value) return error.RuntimeInitFailed;
|
|
}
|
|
|
|
/// Run script source in the agent context. Returns null on success; on a JS
|
|
/// compile/runtime exception returns a formatted error allocated in this
|
|
/// runtime's call arena and valid until deinit or the next run.
|
|
pub fn runSource(self: *Runtime, source: []const u8, name: []const u8) RunError!?[]const u8 {
|
|
_ = self.call_arena.reset(.retain_capacity);
|
|
self.run_timer = std.time.Timer.start() catch return try self.dupeError("internal: timer unavailable");
|
|
|
|
var hs: lp.js.HandleScope = undefined;
|
|
hs.init(self.env.isolate);
|
|
defer hs.deinit();
|
|
|
|
const context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.context, self.env.isolate.handle) orelse
|
|
return try self.dupeError("agent script context is not available"));
|
|
v8.v8__Context__Enter(context);
|
|
defer v8.v8__Context__Exit(context);
|
|
|
|
var try_catch: v8.TryCatch = undefined;
|
|
v8.v8__TryCatch__CONSTRUCT(&try_catch, self.env.isolate.handle);
|
|
defer v8.v8__TryCatch__DESTRUCT(&try_catch);
|
|
|
|
const script_name = self.env.isolate.initStringHandle(name);
|
|
// Wrap in an async IIFE so the source can use top-level `await` (e.g.
|
|
// `await page.goto(...)`). The wrapper evaluates to a Promise; a top-level
|
|
// `return <expr>` becomes that Promise's value, which we echo. (A bare
|
|
// trailing expression no longer auto-prints — `await` and a script
|
|
// completion value are mutually exclusive in JS.)
|
|
const wrapped = std.fmt.allocPrint(self.call_arena.allocator(), "(async () => {{\n{s}\n}})()", .{source}) catch
|
|
return try self.dupeError("out of memory");
|
|
const script_source = self.env.isolate.initStringHandle(wrapped);
|
|
|
|
var origin: v8.ScriptOrigin = undefined;
|
|
v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);
|
|
|
|
var compiler_source: v8.ScriptCompilerSource = undefined;
|
|
v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &compiler_source);
|
|
defer v8.v8__ScriptCompiler__Source__DESTRUCT(&compiler_source);
|
|
|
|
const script = v8.v8__ScriptCompiler__Compile(
|
|
context,
|
|
&compiler_source,
|
|
v8.kNoCompileOptions,
|
|
v8.kNoCacheNoReason,
|
|
) orelse return try self.formatCaught(context, &try_catch, "compile failed");
|
|
|
|
const completion = v8.v8__Script__Run(script, context) orelse
|
|
return try self.formatCaught(context, &try_catch, "script failed");
|
|
|
|
// `goto` only *starts* a navigation, so the root Promise is usually still
|
|
// pending; drive the in-flight ones to completion.
|
|
const root: *const v8.Promise = @ptrCast(completion);
|
|
self.driveAsync(context, &try_catch, root);
|
|
if (v8.v8__TryCatch__HasCaught(&try_catch)) {
|
|
return try self.formatCaught(context, &try_catch, "script failed");
|
|
}
|
|
|
|
// A still-pending root means the script awaited something we can't settle
|
|
// (no async navigation is in flight) — stay silent.
|
|
const state = promiseState(root);
|
|
if (state != v8.kFulfilled and state != v8.kRejected) return null;
|
|
const completion_value = v8.v8__Promise__Result(root) orelse return null;
|
|
if (state == v8.kRejected) return try self.formatRejection(context, completion_value);
|
|
self.printCompletion(context, completion_value);
|
|
return null;
|
|
}
|
|
|
|
/// `v8__Promise__State` returns the `c_uint` `PromiseState`, but the `k*`
|
|
/// constants are `c_int`; normalize so comparisons and switches type-check.
|
|
fn promiseState(promise: *const v8.Promise) c_int {
|
|
return @intCast(v8.v8__Promise__State(promise));
|
|
}
|
|
|
|
/// Format a script's rejection reason into a run-arena message. Prefers a thrown
|
|
/// Error's `.message` so the caller's own "Error:" label isn't doubled
|
|
/// (`Value.toString` on an Error yields "Error: <message>").
|
|
fn formatRejection(self: *Runtime, context: *const v8.Context, reason: *const v8.Value) RunError![]const u8 {
|
|
const value = self.errorMessage(context, reason) orelse reason;
|
|
const text = self.valueToString(self.call_arena.allocator(), context, value) catch
|
|
return try self.dupeError("script failed");
|
|
return try self.dupeError(text);
|
|
}
|
|
|
|
/// The `.message` of a thrown Error (a string), or null when `reason` is not an
|
|
/// Error-like object — in which case the caller stringifies the value itself.
|
|
fn errorMessage(self: *Runtime, context: *const v8.Context, reason: *const v8.Value) ?*const v8.Value {
|
|
if (!v8.v8__Value__IsObject(reason)) return null;
|
|
const key: *const v8.Value = @ptrCast(self.env.isolate.initStringHandle("message"));
|
|
const message = v8.v8__Object__Get(@ptrCast(reason), context, key) orelse return null;
|
|
return if (v8.v8__Value__IsString(message)) message else null;
|
|
}
|
|
|
|
/// Echo a script's output — the value it `return`s from the async wrapper, so a
|
|
/// script ending in `return page.extract(...)` prints without `console.log`.
|
|
/// `undefined` — no `return`, or a bare trailing expression — stays silent.
|
|
fn printCompletion(self: *Runtime, context: *const v8.Context, value: *const v8.Value) void {
|
|
if (v8.v8__Value__IsUndefined(value)) return;
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer arena_state.deinit();
|
|
const text = self.displayString(arena_state.allocator(), context, value) catch return;
|
|
self.writeConsoleLine(.log, text);
|
|
}
|
|
|
|
/// Unwrap a callback's info handle and its `External` payload (the `*T` passed
|
|
/// to `installFunction`). Returns null — so the callback no-ops — if either is
|
|
/// missing.
|
|
fn callbackData(comptime T: type, info_handle: ?*const v8.FunctionCallbackInfo) ?struct { *const v8.FunctionCallbackInfo, *T } {
|
|
const info = info_handle orelse return null;
|
|
const raw_data = v8.v8__FunctionCallbackInfo__Data(info) orelse return null;
|
|
const data: *T = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(raw_data)) orelse return null));
|
|
return .{ info, data };
|
|
}
|
|
|
|
fn primitiveCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
|
const info, const data = callbackData(PrimitiveData, info_handle) orelse return;
|
|
data.runtime.invoke(data.tool, info);
|
|
}
|
|
|
|
fn consoleCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
|
const info, const data = callbackData(ConsoleData, info_handle) orelse return;
|
|
data.runtime.invokeConsole(data.method, info);
|
|
}
|
|
|
|
fn pageConstructor(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
|
const info, const self = callbackData(Runtime, info_handle) orelse return;
|
|
self.construct(info);
|
|
}
|
|
|
|
fn closeCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
|
|
const info, const self = callbackData(Runtime, info_handle) orelse return;
|
|
self.invokeClose(info);
|
|
}
|
|
|
|
/// `new Page()`: attach a method for every recorded tool (including `goto`) plus
|
|
/// `close`. `__lpFrameId` is left unset until the first `goto` navigates the page.
|
|
fn construct(self: *Runtime, info: *const v8.FunctionCallbackInfo) void {
|
|
if (!v8.v8__FunctionCallbackInfo__IsConstructCall(info)) {
|
|
return self.throwTypeError("Page must be called with new");
|
|
}
|
|
const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse
|
|
return self.throwError("internal: missing callback context");
|
|
const this = v8.v8__FunctionCallbackInfo__This(info) orelse
|
|
return self.throwError("internal: missing receiver");
|
|
|
|
for (&self.primitive_data) |*data| {
|
|
self.installFunction(context, this, @tagName(data.tool), primitiveCallback, data) catch
|
|
return self.throwError("failed to construct page");
|
|
}
|
|
self.installFunction(context, this, "close", closeCallback, self) catch
|
|
return self.throwError("failed to construct page");
|
|
}
|
|
|
|
/// Property carrying a page handle's claim on a live frame. Numeric while the
|
|
/// page is navigated; absent/undefined once never-navigated or closed.
|
|
const frame_id_key = "__lpFrameId";
|
|
|
|
/// `page.close()`: stale the wrapper so later method calls error. A single
|
|
/// synchronous page has no popups to free; the active page is reclaimed on the
|
|
/// next `goto` or at script end.
|
|
fn invokeClose(self: *Runtime, info: *const v8.FunctionCallbackInfo) void {
|
|
const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse return;
|
|
const this = v8.v8__FunctionCallbackInfo__This(info) orelse return;
|
|
self.setObjectProperty(context, this, frame_id_key, self.env.isolate.initUndefined()) catch {};
|
|
}
|
|
|
|
fn invoke(self: *Runtime, tool: BrowserTool, info: *const v8.FunctionCallbackInfo) void {
|
|
// Owned, not shared: marshalling runs JS (`toJSON`) that can re-enter a
|
|
// primitive; a shared arena would let the nested call reset ours mid-flight.
|
|
var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse {
|
|
self.throwError("internal: missing callback context");
|
|
return;
|
|
};
|
|
|
|
const args = self.buildArgs(arena, context, tool, info) catch |err| switch (err) {
|
|
error.OutOfMemory => return self.throwError("out of memory"),
|
|
error.JsException => return,
|
|
error.InvalidArguments => return self.throwTypeError("invalid arguments"),
|
|
};
|
|
|
|
if (tool == .goto) return self.invokeGoto(arena, context, info, args);
|
|
|
|
// Route the call at the receiver's own frame: sibling pages may coexist, so
|
|
// `pages[0]` is not necessarily this handle's page.
|
|
const frame_id = self.receiverFrameId(context, info) orelse
|
|
return self.throwError("page is not navigated or has been closed; call page.goto(url) first");
|
|
if (self.session.findFrameByFrameId(frame_id) == null)
|
|
return self.throwError("page handle is no longer valid; the page was closed");
|
|
|
|
self.session.setToolFrameOverride(frame_id);
|
|
defer self.session.setToolFrameOverride(null);
|
|
|
|
const result = self.callTool(arena, tool, args) catch |err| switch (err) {
|
|
error.OutOfMemory => return self.throwError("out of memory"),
|
|
};
|
|
|
|
switch (result) {
|
|
.ok => |text| switch (tool) {
|
|
.extract => {
|
|
const normalized = self.normalizeExtractReturnJson(arena, text) catch |err| switch (err) {
|
|
error.OutOfMemory => return self.throwError("out of memory"),
|
|
};
|
|
self.setReturnJson(context, info, normalized);
|
|
},
|
|
else => self.setReturnString(info, text),
|
|
},
|
|
.fail => |message| self.throwError(message),
|
|
}
|
|
}
|
|
|
|
/// Start the receiver Page's navigation and return a *pending* Promise of the
|
|
/// page object; `driveAsync` settles it once the frame loads. This is what lets
|
|
/// `Promise.all([a.goto(x), b.goto(y)])` fetch both in parallel.
|
|
fn invokeGoto(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
info: *const v8.FunctionCallbackInfo,
|
|
args: ?std.json.Value,
|
|
) void {
|
|
const resolver = v8.v8__Promise__Resolver__New(context) orelse
|
|
return self.throwError("internal: resolver alloc failed");
|
|
const promise = v8.v8__Promise__Resolver__GetPromise(resolver) orelse
|
|
return self.throwError("internal: promise alloc failed");
|
|
self.setReturnValue(info, @ptrCast(promise));
|
|
|
|
const this = v8.v8__FunctionCallbackInfo__This(info) orelse
|
|
return self.rejectResolver(context, resolver, "navigation failed");
|
|
|
|
// Non-null on a re-goto: startGoto replaces only that page, not its siblings.
|
|
const receiver_frame_id = self.receiverFrameId(context, info);
|
|
// startGoto is browser-side work; run it under the browser's isolate.
|
|
const started = blk: {
|
|
self.session.browser.env.isolate.enter();
|
|
defer self.session.browser.env.isolate.exit();
|
|
break :blk browser_tools.startGoto(arena, self.session, self.registry, args, receiver_frame_id) catch
|
|
return self.rejectResolver(context, resolver, "navigation failed");
|
|
};
|
|
|
|
var pending: PendingGoto = .{
|
|
.frame_id = started.frame_id,
|
|
.resolver = undefined,
|
|
.receiver = undefined,
|
|
.deadline_ms = self.run_timer.read() / std.time.ns_per_ms + started.timeout_ms,
|
|
};
|
|
v8.v8__Global__New(self.env.isolate.handle, resolver, &pending.resolver);
|
|
v8.v8__Global__New(self.env.isolate.handle, this, &pending.receiver);
|
|
self.pending_gotos.append(self.allocator, pending) catch {
|
|
pending.reset();
|
|
return self.rejectResolver(context, resolver, "out of memory");
|
|
};
|
|
}
|
|
|
|
/// Drive every in-flight `goto` to completion: each pass ticks the browser over
|
|
/// the pending frames, settles those that finished, then drains microtasks so
|
|
/// awaiting continuations run (and may start more gotos). Ends when the root
|
|
/// Promise settles, no gotos remain, or the script is cancelled.
|
|
fn driveAsync(self: *Runtime, context: *const v8.Context, try_catch: *const v8.TryCatch, root: *const v8.Promise) void {
|
|
var runner = self.session.runner(.{});
|
|
|
|
var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer arena_state.deinit();
|
|
|
|
// Settle anything resolved synchronously, so a goto-free run still drains.
|
|
self.env.performIsolateMicrotasks();
|
|
|
|
while (true) {
|
|
const state = promiseState(root);
|
|
if (state == v8.kFulfilled or state == v8.kRejected) break;
|
|
if (v8.v8__TryCatch__HasCaught(try_catch)) break;
|
|
if (self.pending_gotos.items.len == 0) break;
|
|
if (self.session.isCancelled()) {
|
|
self.failAllPending(context, "navigation cancelled");
|
|
break;
|
|
}
|
|
|
|
_ = arena_state.reset(.retain_capacity);
|
|
const conditions = arena_state.allocator().alloc(lp.Session.Runner.WaitCondition, self.pending_gotos.items.len) catch {
|
|
self.failAllPending(context, "out of memory");
|
|
break;
|
|
};
|
|
for (self.pending_gotos.items, conditions) |pending, *condition| {
|
|
condition.* = .{ .frame_id = pending.frame_id, .until = .load };
|
|
}
|
|
|
|
// Browser-side tick: run under the browser's isolate, exit before the
|
|
// agent-side settling below (the Runtime owns a separate isolate).
|
|
var tick_failed = false;
|
|
{
|
|
self.session.browser.env.isolate.enter();
|
|
defer self.session.browser.env.isolate.exit();
|
|
_ = runner.tick(50, conditions) catch {
|
|
tick_failed = true;
|
|
};
|
|
}
|
|
if (tick_failed) {
|
|
self.failAllPending(context, "navigation failed");
|
|
break;
|
|
}
|
|
|
|
self.settleCompleted(context, conditions);
|
|
self.env.performIsolateMicrotasks();
|
|
}
|
|
|
|
// Reject anything still in flight (fire-and-forget goto, or a broken-out loop).
|
|
self.failAllPending(context, "navigation abandoned");
|
|
}
|
|
|
|
/// Settle each pending goto that finished or timed out, compacting survivors back
|
|
/// in place. `conditions[i]` lines up with `pending_gotos.items[i]` (same pass).
|
|
fn settleCompleted(self: *Runtime, context: *const v8.Context, conditions: []const lp.Session.Runner.WaitCondition) void {
|
|
const now_ms = self.run_timer.read() / std.time.ns_per_ms;
|
|
var write: usize = 0;
|
|
for (conditions, 0..) |condition, i| {
|
|
const pending = &self.pending_gotos.items[i];
|
|
const outcome: ?Outcome = switch (condition.status) {
|
|
.complete => .loaded,
|
|
.err => .failed,
|
|
.pending => if (now_ms >= pending.deadline_ms) .timed_out else null,
|
|
};
|
|
if (outcome) |o| {
|
|
self.settlePending(context, pending, o);
|
|
} else {
|
|
self.pending_gotos.items[write] = pending.*;
|
|
write += 1;
|
|
}
|
|
}
|
|
self.pending_gotos.shrinkRetainingCapacity(write);
|
|
}
|
|
|
|
const Outcome = enum { loaded, failed, timed_out };
|
|
|
|
/// Resolve or reject one pending goto's Promise and free its Globals.
|
|
fn settlePending(self: *Runtime, context: *const v8.Context, pending: *PendingGoto, outcome: Outcome) void {
|
|
const resolver: *const v8.PromiseResolver = @ptrCast(v8.v8__Global__Get(&pending.resolver, self.env.isolate.handle));
|
|
switch (outcome) {
|
|
.loaded => done: {
|
|
const frame = self.session.findFrameByFrameId(pending.frame_id);
|
|
if (frame == null or frame.?._last_navigate_error != null) {
|
|
self.rejectResolver(context, resolver, "navigation failed");
|
|
break :done;
|
|
}
|
|
const this: *const v8.Object = @ptrCast(v8.v8__Global__Get(&pending.receiver, self.env.isolate.handle));
|
|
self.bindFrameId(context, this, pending.frame_id) catch {
|
|
self.rejectResolver(context, resolver, "internal: page bind failed");
|
|
break :done;
|
|
};
|
|
self.resolveResolver(context, resolver, @ptrCast(this));
|
|
},
|
|
.failed => self.rejectResolver(context, resolver, "navigation failed"),
|
|
.timed_out => self.rejectResolver(context, resolver, "navigation timed out"),
|
|
}
|
|
pending.reset();
|
|
}
|
|
|
|
/// Reject every still-pending goto and clear the list, freeing all Globals.
|
|
fn failAllPending(self: *Runtime, context: *const v8.Context, message: []const u8) void {
|
|
for (self.pending_gotos.items) |*pending| {
|
|
const resolver: *const v8.PromiseResolver = @ptrCast(v8.v8__Global__Get(&pending.resolver, self.env.isolate.handle));
|
|
self.rejectResolver(context, resolver, message);
|
|
pending.reset();
|
|
}
|
|
self.pending_gotos.clearRetainingCapacity();
|
|
}
|
|
|
|
fn resolveResolver(_: *Runtime, context: *const v8.Context, resolver: *const v8.PromiseResolver, value: *const v8.Value) void {
|
|
var out: v8.MaybeBool = undefined;
|
|
v8.v8__Promise__Resolver__Resolve(resolver, context, value, &out);
|
|
}
|
|
|
|
fn rejectResolver(self: *Runtime, context: *const v8.Context, resolver: *const v8.PromiseResolver, message: []const u8) void {
|
|
var out: v8.MaybeBool = undefined;
|
|
v8.v8__Promise__Resolver__Reject(resolver, context, self.env.isolate.createError(message), &out);
|
|
}
|
|
|
|
/// Bind `__lpFrameId` on a page object to `frame_id` — the handle's claim on the
|
|
/// live frame, checked by `invoke` on every later method call.
|
|
fn bindFrameId(self: *Runtime, context: *const v8.Context, this: *const v8.Object, frame_id: u32) InitError!void {
|
|
const value = v8.v8__Integer__NewFromUnsigned(self.env.isolate.handle, frame_id) orelse
|
|
return error.RuntimeInitFailed;
|
|
return self.setObjectProperty(context, this, frame_id_key, @ptrCast(value));
|
|
}
|
|
|
|
/// Frame id a method's receiver was bound to (`this.__lpFrameId`), or null when
|
|
/// the page was never navigated or has been closed.
|
|
fn receiverFrameId(self: *Runtime, context: *const v8.Context, info: *const v8.FunctionCallbackInfo) ?u32 {
|
|
const this = v8.v8__FunctionCallbackInfo__This(info) orelse return null;
|
|
const key: *const v8.Value = @ptrCast(self.env.isolate.initStringHandle(frame_id_key));
|
|
const prop = v8.v8__Object__Get(this, context, key) orelse return null;
|
|
if (!v8.v8__Value__IsNumber(prop)) return null;
|
|
var out: v8.MaybeU32 = undefined;
|
|
v8.v8__Value__Uint32Value(prop, context, &out);
|
|
return if (out.has_value) out.value else null;
|
|
}
|
|
|
|
fn invokeConsole(self: *Runtime, method: ConsoleMethod, info: *const v8.FunctionCallbackInfo) void {
|
|
// Owned arena (see `invoke`): an argument's `toString` can re-enter a
|
|
// primitive mid-loop and must not reset the buffer we're accumulating.
|
|
var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse return;
|
|
const argc: usize = @intCast(v8.v8__FunctionCallbackInfo__Length(info));
|
|
|
|
var aw: std.Io.Writer.Allocating = .init(arena);
|
|
for (0..argc) |i| {
|
|
if (i > 0) aw.writer.writeByte(' ') catch return;
|
|
const value = v8.v8__FunctionCallbackInfo__INDEX(info, @intCast(i)) orelse continue;
|
|
const text = self.valueToString(arena, context, value) catch "<unprintable>";
|
|
aw.writer.writeAll(text) catch return;
|
|
}
|
|
|
|
self.writeConsoleLine(method, aw.written());
|
|
}
|
|
|
|
fn writeConsoleLine(self: *Runtime, method: ConsoleMethod, line: []const u8) void {
|
|
if (self.console_observer) |obs| obs.notify(obs.context);
|
|
var buf: [4096]u8 = undefined;
|
|
var file = if (method.writesStderr()) std.fs.File.stderr() else std.fs.File.stdout();
|
|
var writer = file.writer(&buf);
|
|
writer.interface.print("{s}\n", .{line}) catch return;
|
|
writer.interface.flush() catch return;
|
|
}
|
|
|
|
const PrimitiveResult = union(enum) {
|
|
ok: []const u8,
|
|
fail: []const u8,
|
|
};
|
|
|
|
fn callTool(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
tool: BrowserTool,
|
|
args: ?std.json.Value,
|
|
) error{OutOfMemory}!PrimitiveResult {
|
|
self.session.browser.env.isolate.enter();
|
|
defer self.session.browser.env.isolate.exit();
|
|
|
|
const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args) catch |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
error.FrameNotLoaded => return .{ .fail = "no page loaded - run page.goto(url) first" },
|
|
else => return .{ .fail = std.fmt.allocPrint(arena, "{s} failed: {s}", .{ @tagName(tool), @errorName(err) }) catch return error.OutOfMemory },
|
|
};
|
|
|
|
if (result.is_error) return .{ .fail = result.text };
|
|
return .{ .ok = result.text };
|
|
}
|
|
|
|
const BuildArgsError = error{
|
|
OutOfMemory,
|
|
JsException,
|
|
InvalidArguments,
|
|
};
|
|
|
|
fn buildArgs(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
tool: BrowserTool,
|
|
info: *const v8.FunctionCallbackInfo,
|
|
) BuildArgsError!?std.json.Value {
|
|
const argc: usize = @intCast(v8.v8__FunctionCallbackInfo__Length(info));
|
|
return switch (tool) {
|
|
.extract => try self.extractArgs(arena, context, info, argc),
|
|
else => try self.marshalArgs(arena, context, info, argc, Schema.positionalFor(tool)),
|
|
};
|
|
}
|
|
|
|
/// Marshals `tool(positionals…, options?)` into the args object `browser_tools.call`
|
|
/// expects: leading primitives bind to `positional` by index (a `null` omits its
|
|
/// field), a trailing object merges as options (conflict on a repeated field), and a
|
|
/// lone object passes through. Positionals stay opaque; the tool's parser types them.
|
|
fn marshalArgs(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
info: *const v8.FunctionCallbackInfo,
|
|
argc: usize,
|
|
positional: []const []const u8,
|
|
) BuildArgsError!std.json.Value {
|
|
const values = try arena.alloc(std.json.Value, argc);
|
|
for (values, 0..) |*v, i| v.* = try self.argJson(arena, context, info, @intCast(i));
|
|
|
|
if (argc == 1 and values[0] == .object) return values[0];
|
|
|
|
// A trailing object is the options bag; everything before it is positional.
|
|
var positional_count = argc;
|
|
var options: ?std.json.ObjectMap = null;
|
|
if (argc > 0 and values[argc - 1] == .object) {
|
|
options = values[argc - 1].object;
|
|
positional_count = argc - 1;
|
|
}
|
|
if (positional_count > positional.len) return error.InvalidArguments;
|
|
|
|
var obj: std.json.ObjectMap = .init(arena);
|
|
for (values[0..positional_count], positional[0..positional_count]) |v, field| {
|
|
switch (v) {
|
|
.null => {}, // omit the field — e.g. page/focused-level selector
|
|
.object, .array => return error.InvalidArguments,
|
|
else => try obj.put(field, v),
|
|
}
|
|
}
|
|
if (options) |opts| {
|
|
var it = opts.iterator();
|
|
while (it.next()) |entry| {
|
|
if (obj.contains(entry.key_ptr.*)) return error.InvalidArguments;
|
|
try obj.put(entry.key_ptr.*, entry.value_ptr.*);
|
|
}
|
|
}
|
|
return .{ .object = obj };
|
|
}
|
|
|
|
fn extractArgs(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
info: *const v8.FunctionCallbackInfo,
|
|
argc: usize,
|
|
) BuildArgsError!std.json.Value {
|
|
if (argc != 1) return error.InvalidArguments;
|
|
const value = try self.argJson(arena, context, info, 0);
|
|
const schema = switch (value) {
|
|
.string, .array => try extractSchemaString(arena, value),
|
|
.object => |obj| if (obj.get("schema")) |inner| blk: {
|
|
if (obj.count() != 1) return error.InvalidArguments;
|
|
break :blk try extractSchemaString(arena, inner);
|
|
} else try extractSchemaString(arena, value),
|
|
else => return error.InvalidArguments,
|
|
};
|
|
return try objectWith(arena, "schema", .{ .string = schema });
|
|
}
|
|
|
|
fn extractSchemaString(arena: std.mem.Allocator, value: std.json.Value) error{OutOfMemory}![]const u8 {
|
|
return switch (value) {
|
|
.string => |str| normalizeExtractSchemaString(arena, str),
|
|
.array => |arr| normalizeExtractSchemaString(
|
|
arena,
|
|
try std.json.Stringify.valueAlloc(arena, std.json.Value{ .array = arr }, .{}),
|
|
),
|
|
else => try std.json.Stringify.valueAlloc(arena, value, .{}),
|
|
};
|
|
}
|
|
|
|
fn normalizeExtractSchemaString(arena: std.mem.Allocator, schema: []const u8) error{OutOfMemory}![]const u8 {
|
|
const trimmed = std.mem.trim(u8, schema, &std.ascii.whitespace);
|
|
if (trimmed.len == 0 or trimmed[0] != '[') return schema;
|
|
return try std.fmt.allocPrint(arena, "{{\"__root\":{s}}}", .{schema});
|
|
}
|
|
|
|
fn argJson(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
info: *const v8.FunctionCallbackInfo,
|
|
index: u32,
|
|
) BuildArgsError!std.json.Value {
|
|
const value = v8.v8__FunctionCallbackInfo__INDEX(info, @intCast(index)) orelse return error.InvalidArguments;
|
|
return self.valueToJson(arena, context, value);
|
|
}
|
|
|
|
fn valueToJson(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
value: *const v8.Value,
|
|
) BuildArgsError!std.json.Value {
|
|
const json_string = v8.v8__JSON__Stringify(context, value, null) orelse return error.JsException;
|
|
const json = try self.stringToOwned(arena, json_string);
|
|
if (std.mem.eql(u8, json, "undefined")) return error.InvalidArguments;
|
|
return std.json.parseFromSliceLeaky(std.json.Value, arena, json, .{}) catch error.InvalidArguments;
|
|
}
|
|
|
|
fn objectWith(arena: std.mem.Allocator, key: []const u8, value: std.json.Value) error{OutOfMemory}!std.json.Value {
|
|
var obj: std.json.ObjectMap = .init(arena);
|
|
try obj.put(key, value);
|
|
return .{ .object = obj };
|
|
}
|
|
|
|
/// Unwraps only the `__root` sentinel that `normalizeExtractSchemaString` injects
|
|
/// for array schemas; a real single-field object schema keeps its shape.
|
|
fn normalizeExtractReturnJson(_: *Runtime, arena: std.mem.Allocator, value: []const u8) error{OutOfMemory}![]const u8 {
|
|
if (value.len == 0) return value;
|
|
|
|
const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, value, .{}) catch |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
else => return value,
|
|
};
|
|
if (parsed != .object or parsed.object.count() != 1) return value;
|
|
|
|
var it = parsed.object.iterator();
|
|
const entry = it.next() orelse return value;
|
|
if (!std.mem.eql(u8, entry.key_ptr.*, "__root")) return value;
|
|
return try std.json.Stringify.valueAlloc(arena, entry.value_ptr.*, .{});
|
|
}
|
|
|
|
fn setReturnString(self: *Runtime, info: *const v8.FunctionCallbackInfo, value: []const u8) void {
|
|
self.setReturnValue(info, @ptrCast(self.env.isolate.initStringHandle(value)));
|
|
}
|
|
|
|
fn setReturnJson(self: *Runtime, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, value: []const u8) void {
|
|
if (value.len == 0) {
|
|
self.setReturnValue(info, self.env.isolate.initUndefined());
|
|
return;
|
|
}
|
|
const json = self.env.isolate.initStringHandle(value);
|
|
const parsed = v8.v8__JSON__Parse(context, json) orelse {
|
|
self.throwError("extract returned invalid JSON");
|
|
return;
|
|
};
|
|
self.setReturnValue(info, parsed);
|
|
}
|
|
|
|
fn setReturnValue(_: *Runtime, info: *const v8.FunctionCallbackInfo, value: *const v8.Value) void {
|
|
var rv: v8.ReturnValue = undefined;
|
|
v8.v8__FunctionCallbackInfo__GetReturnValue(info, &rv);
|
|
v8.v8__ReturnValue__Set(rv, value);
|
|
}
|
|
|
|
fn throwError(self: *Runtime, message: []const u8) void {
|
|
_ = v8.v8__Isolate__ThrowException(self.env.isolate.handle, self.env.isolate.createError(message));
|
|
}
|
|
|
|
fn throwTypeError(self: *Runtime, message: []const u8) void {
|
|
_ = v8.v8__Isolate__ThrowException(self.env.isolate.handle, self.env.isolate.createTypeError(message));
|
|
}
|
|
|
|
fn formatCaught(
|
|
self: *Runtime,
|
|
context: *const v8.Context,
|
|
try_catch: *const v8.TryCatch,
|
|
fallback: []const u8,
|
|
) RunError![]const u8 {
|
|
const arena = self.call_arena.allocator();
|
|
if (v8.v8__TryCatch__StackTrace(try_catch, context)) |stack_value| {
|
|
const stack = self.valueToString(arena, context, stack_value) catch "";
|
|
if (stack.len > 0) return stack;
|
|
}
|
|
|
|
const exception = if (v8.v8__TryCatch__Exception(try_catch)) |exception_value|
|
|
self.valueToString(arena, context, exception_value) catch fallback
|
|
else
|
|
fallback;
|
|
|
|
const line: ?u32 = blk: {
|
|
const msg = v8.v8__TryCatch__Message(try_catch) orelse break :blk null;
|
|
const n = v8.v8__Message__GetLineNumber(msg, context);
|
|
break :blk if (n < 0) null else @as(u32, @intCast(n));
|
|
};
|
|
if (line) |n| {
|
|
return std.fmt.allocPrint(arena, "line {d}: {s}", .{ n, exception }) catch return error.OutOfMemory;
|
|
}
|
|
return try self.dupeError(exception);
|
|
}
|
|
|
|
fn valueToString(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
value: *const v8.Value,
|
|
) error{ OutOfMemory, JsException }![]const u8 {
|
|
const string = v8.v8__Value__ToString(value, context) orelse return error.JsException;
|
|
return self.stringToOwned(arena, string);
|
|
}
|
|
|
|
/// Display form for the script's completion value: objects and arrays as JSON
|
|
/// (plain coercion gives a useless `[object Object]`), every other value via that
|
|
/// coercion. Falls back to coercion when JSON.stringify yields no string — a
|
|
/// thrown circular reference, or a value (e.g. a function) that stringifies to
|
|
/// `undefined`; the nested TryCatch keeps such a throw from leaking into the
|
|
/// caller's scope.
|
|
fn displayString(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
context: *const v8.Context,
|
|
value: *const v8.Value,
|
|
) error{ OutOfMemory, JsException }![]const u8 {
|
|
if (v8.v8__Value__IsObject(value)) {
|
|
var try_catch: v8.TryCatch = undefined;
|
|
v8.v8__TryCatch__CONSTRUCT(&try_catch, self.env.isolate.handle);
|
|
defer v8.v8__TryCatch__DESTRUCT(&try_catch);
|
|
if (v8.v8__JSON__Stringify(context, value, null)) |json| {
|
|
return self.stringToOwned(arena, json);
|
|
}
|
|
}
|
|
return self.valueToString(arena, context, value);
|
|
}
|
|
|
|
fn stringToOwned(
|
|
self: *Runtime,
|
|
arena: std.mem.Allocator,
|
|
string: *const v8.String,
|
|
) error{OutOfMemory}![]const u8 {
|
|
const len: usize = @intCast(v8.v8__String__Utf8Length(string, self.env.isolate.handle));
|
|
const buf = try arena.alloc(u8, len);
|
|
const written = v8.v8__String__WriteUtf8(
|
|
string,
|
|
self.env.isolate.handle,
|
|
buf.ptr,
|
|
buf.len,
|
|
v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8,
|
|
);
|
|
return buf[0..written];
|
|
}
|
|
|
|
fn dupeError(self: *Runtime, message: []const u8) RunError![]const u8 {
|
|
return self.call_arena.allocator().dupe(u8, message) catch error.OutOfMemory;
|
|
}
|
|
|
|
const testing = @import("../testing.zig");
|
|
|
|
fn runTestScript(runtime: *Runtime, source: []const u8) !void {
|
|
if (try runtime.runSource(source, "agent-runtime-test.js")) |message| {
|
|
std.debug.print("agent script failed:\n{s}\n", .{message});
|
|
return error.AgentScriptFailed;
|
|
}
|
|
}
|
|
|
|
fn terminateRuntimeSoon(runtime: *Runtime) void {
|
|
std.Thread.sleep(10 * std.time.ns_per_ms);
|
|
runtime.terminate();
|
|
}
|
|
|
|
test "agent script runtime: goto and evaluate dispatch through browser tools" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\const page = new Page();
|
|
\\const same = await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\if (same !== page) throw new Error("page.goto should resolve the same page object");
|
|
\\const text = page.evaluate("document.getElementById('btn').textContent");
|
|
\\if (text !== "Click Me") throw new Error("evaluate ran in the wrong context: " + text);
|
|
);
|
|
|
|
const frame = testing.test_session.currentFrame().?;
|
|
try testing.expect(std.mem.indexOf(u8, frame.url, "/src/browser/tests/mcp_actions.html") != null);
|
|
}
|
|
|
|
test "agent script runtime: Page must be called with new" {
|
|
defer testing.reset();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
const message = (try runtime.runSource("Page();", "agent-runtime-page-no-new.js")).?;
|
|
try testing.expect(std.mem.indexOf(u8, message, "must be called with new") != null);
|
|
}
|
|
|
|
test "agent script runtime: a method on an un-navigated page errors" {
|
|
defer testing.reset();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
const message = (try runtime.runSource(
|
|
\\const page = new Page();
|
|
\\page.extract({ btn: "#btn" });
|
|
, "agent-runtime-not-navigated.js")).?;
|
|
try testing.expect(std.mem.indexOf(u8, message, "not navigated") != null);
|
|
}
|
|
|
|
test "agent script runtime: page.close stales the handle" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
// After close() the wrapper is stale; a later method call must error rather
|
|
// than silently hit whatever page happens to be current.
|
|
const message = (try runtime.runSource(
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\page.close();
|
|
\\page.extract({ btn: "#btn" });
|
|
, "agent-runtime-close.js")).?;
|
|
try testing.expect(std.mem.indexOf(u8, message, "closed") != null);
|
|
}
|
|
|
|
test "agent script runtime: parallel gotos coexist and route per page" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
// Sibling pages stay live; each handle's tools must read its own page.
|
|
try runTestScript(runtime,
|
|
\\const a = new Page();
|
|
\\const b = new Page();
|
|
\\await Promise.all([
|
|
\\ a.goto("http://localhost:9582/src/browser/tests/mcp_actions.html"),
|
|
\\ b.goto("http://localhost:9582/src/browser/tests/runner/runner1.html"),
|
|
\\]);
|
|
\\const da = a.extract({ btn: "#btn" });
|
|
\\const db = b.extract({ s0: "#sel0" });
|
|
\\if (da.btn !== "Click Me") throw new Error("page a read wrong page: " + JSON.stringify(da));
|
|
\\if (db.s0 !== "selector-0-content") throw new Error("page b read wrong page: " + JSON.stringify(db));
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: a tool-triggered navigation keeps the handle routable" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
// click commits a replacement Page mid-call; the frame override and the
|
|
// handle's frame id must both survive the swap.
|
|
try runTestScript(runtime,
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_nav.html");
|
|
\\page.click("#navlink");
|
|
\\const href = page.evaluate("location.href");
|
|
\\if (href !== "about:blank") throw new Error("expected about:blank, got: " + href);
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: re-goto on the same page object replaces its page" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/runner/runner1.html");
|
|
\\const data = page.extract({ s1: "#sel1" });
|
|
\\if (data.s1 !== "selector-1-content") throw new Error("re-goto did not replace the page: " + JSON.stringify(data));
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: a failed navigation rejects the goto promise" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
// An unreachable URL must reject (not hang); the short timeout bounds it.
|
|
try runTestScript(runtime,
|
|
\\let caught = "";
|
|
\\try {
|
|
\\ await new Page().goto("http://localhost:1/", { timeout: 2000 });
|
|
\\} catch (e) {
|
|
\\ caught = String(e);
|
|
\\}
|
|
\\if (!caught.toLowerCase().includes("navigation")) throw new Error("expected a navigation rejection, got: " + caught);
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: extract returns a JavaScript object" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\const data = page.extract({
|
|
\\ button: "#btn",
|
|
\\ options: [{
|
|
\\ selector: "#sel option",
|
|
\\ limit: 2,
|
|
\\ fields: {
|
|
\\ text: "",
|
|
\\ value: { attr: "value" }
|
|
\\ }
|
|
\\ }]
|
|
\\});
|
|
\\if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("extract did not return an object");
|
|
\\if (data.button !== "Click Me") throw new Error("unexpected button text: " + data.button);
|
|
\\if (data.options.length !== 2) throw new Error("unexpected option count: " + data.options.length);
|
|
\\if (data.options[1].value !== "opt2") throw new Error("unexpected option value: " + data.options[1].value);
|
|
\\const options = page.extract({
|
|
\\ options: [{
|
|
\\ selector: "#sel option",
|
|
\\ limit: 2,
|
|
\\ fields: {
|
|
\\ text: "",
|
|
\\ value: { attr: "value" }
|
|
\\ }
|
|
\\ }]
|
|
\\});
|
|
\\if (typeof options !== "object" || options === null || Array.isArray(options)) throw new Error("single object field should stay an object");
|
|
\\if (options.options[0].text !== "Option 1") throw new Error("unexpected option text: " + options.options[0].text);
|
|
\\const direct = page.extract([{ selector: "#sel option", limit: 1 }]);
|
|
\\if (!Array.isArray(direct)) throw new Error("array schema should return an array");
|
|
\\if (direct[0] !== "Option 1") throw new Error("unexpected direct array extract: " + direct[0]);
|
|
\\const saveField = page.extract({ save: "#btn" });
|
|
\\if (saveField.save !== "Click Me") throw new Error("top-level save field should be schema data");
|
|
\\let rejectedSaveOption = false;
|
|
\\try {
|
|
\\ page.extract({ schema: { button: "#btn" }, save: "snap" });
|
|
\\} catch (err) {
|
|
\\ rejectedSaveOption = true;
|
|
\\}
|
|
\\if (!rejectedSaveOption) throw new Error("extract save option should be rejected");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: extract tolerates list selectors that match nothing" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\const empty = page.extract({ comments: [{ selector: ".no-such-element", fields: { text: "" } }] });
|
|
\\if (!Array.isArray(empty.comments) || empty.comments.length !== 0) throw new Error("empty list selector should yield an empty array, not throw");
|
|
\\const bare = page.extract([".no-such-element"]);
|
|
\\if (!Array.isArray(bare) || bare.length !== 0) throw new Error("bare empty array schema should yield an empty array");
|
|
\\const mixed = page.extract({ button: "#btn", comments: [".no-such-element"] });
|
|
\\if (mixed.button !== "Click Me" || mixed.comments.length !== 0) throw new Error("a matched scalar beside an empty list should still resolve");
|
|
\\let threwOnAllNull = false;
|
|
\\try {
|
|
\\ page.extract({ missing: "#does-not-exist" });
|
|
\\} catch (err) {
|
|
\\ threwOnAllNull = true;
|
|
\\}
|
|
\\if (!threwOnAllNull) throw new Error("an all-null scalar schema should still throw");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: strict-mode scripts can call primitives" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\"use strict";
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\const text = page.evaluate("document.getElementById('btn').textContent");
|
|
\\if (text !== "Click Me") throw new Error("strict-mode evaluate failed: " + text);
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: promise microtasks run to completion" {
|
|
defer testing.reset();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\globalThis.microtaskRan = false;
|
|
\\Promise.resolve().then(() => { globalThis.microtaskRan = true; });
|
|
\\if (globalThis.microtaskRan) throw new Error("microtask ran before the checkpoint");
|
|
);
|
|
|
|
try runTestScript(runtime,
|
|
\\if (!globalThis.microtaskRan) throw new Error("microtask did not run after the script");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: primitives re-entered from argument callbacks stay isolated" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\// toJSON re-enters evaluate mid-marshal; the outer extract must still see "#btn".
|
|
\\const data = page.extract({ button: { toJSON() { return page.evaluate("'#btn'"); } } });
|
|
\\if (data.button !== "Click Me") throw new Error("re-entrant extract corrupted: " + JSON.stringify(data));
|
|
\\// toString re-enters a primitive mid-loop; the console buffer must survive.
|
|
\\let probed = 0;
|
|
\\console.log("value", { toString() { probed += 1; return page.evaluate("'ok'"); } }, "tail");
|
|
\\if (probed !== 1) throw new Error("console toString re-entry not exercised");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: terminate interrupts local JavaScript" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
const thread = try std.Thread.spawn(.{}, terminateRuntimeSoon, .{runtime});
|
|
defer runtime.cancelTerminate();
|
|
defer thread.join();
|
|
|
|
const message = try runtime.runSource("while (true) {}", "agent-runtime-terminate-test.js");
|
|
try testing.expect(message != null);
|
|
}
|
|
|
|
test "agent script runtime: agent variables persist and page globals are isolated" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\globalThis.counter = 1;
|
|
\\if (typeof window !== "undefined") throw new Error("window leaked into agent runtime");
|
|
\\if (typeof document !== "undefined") throw new Error("document leaked into agent runtime");
|
|
\\await new Page().goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\globalThis.counter += 1;
|
|
\\if (globalThis.counter !== 2) throw new Error("agent global state did not persist");
|
|
);
|
|
|
|
try runTestScript(runtime,
|
|
\\globalThis.counter += 1;
|
|
\\if (globalThis.counter !== 3) throw new Error("agent global state was reset between scripts");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: page evaluate cannot see agent primitives or bindings" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\const agentOnly = "secret";
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\if (page.evaluate("typeof Page") !== "undefined") throw new Error("agent primitive leaked to page evaluate");
|
|
\\if (page.evaluate("typeof agentOnly") !== "undefined") throw new Error("agent binding leaked to page evaluate");
|
|
\\if (page.evaluate("typeof document") !== "object") throw new Error("page evaluate did not run in the page context");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: console is available in agent context" {
|
|
defer testing.reset();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\if (typeof console !== "object") throw new Error("missing console");
|
|
\\if (typeof console.log !== "function") throw new Error("missing console.log");
|
|
\\console.log("agent console ready");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: tool errors throw and stop execution" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
const message = (try runtime.runSource(
|
|
\\globalThis.marker = "before";
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\page.click({ selector: "#does-not-exist" });
|
|
\\globalThis.marker = "after";
|
|
, "agent-runtime-failure.js")).?;
|
|
|
|
try testing.expect(std.mem.indexOf(u8, message, "click") != null or
|
|
std.mem.indexOf(u8, message, "NodeNotFound") != null or
|
|
std.mem.indexOf(u8, message, "#does-not-exist") != null);
|
|
|
|
try runTestScript(runtime,
|
|
\\if (globalThis.marker !== "before") throw new Error("script continued after tool failure");
|
|
);
|
|
}
|
|
|
|
test "agent script runtime: builtin argument marshalling (positional + options)" {
|
|
defer testing.reset();
|
|
defer testing.test_session.closeAllPages();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
try runTestScript(runtime,
|
|
\\// The reported bug: Playwright-style goto(url, options) must merge, not throw.
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html", { timeout: 5000 });
|
|
\\// waitForState: single required param, positional like waitForSelector.
|
|
\\if (!page.waitForState("load").includes("reached")) throw new Error("waitForState positional failed");
|
|
\\// Object form still works; re-navigation rebinds the same page object.
|
|
\\await page.goto({ url: "http://localhost:9582/src/browser/tests/mcp_actions.html", timeout: 5000 });
|
|
\\// Single selector positional.
|
|
\\page.click("#btn");
|
|
\\if (page.evaluate("String(window.clicked)") !== "true") throw new Error("click positional failed");
|
|
\\// Two positionals: selector, value.
|
|
\\page.fill("#inp", "hello");
|
|
\\if (page.evaluate("window.inputVal") !== "hello") throw new Error("fill two-positional failed");
|
|
\\page.selectOption("#sel", "opt2");
|
|
\\if (page.evaluate("window.selChanged") !== "opt2") throw new Error("selectOption two-positional failed");
|
|
\\// Bool positional, and the default-true shorthand when omitted. Assert via the
|
|
\\// tool's own report (the synthetic click toggles the DOM state, so .checked is
|
|
\\// not a reliable observation of the `checked` argument).
|
|
\\if (!page.setChecked("#chk").includes("to checked")) throw new Error("setChecked default-true failed");
|
|
\\if (!page.setChecked("#chk", false).includes("to unchecked")) throw new Error("setChecked bool positional failed");
|
|
\\// Selector-first press, and a null selector for a page/focused key press.
|
|
\\page.press("#keyTarget", "Enter");
|
|
\\if (page.evaluate("window.keyPressed") !== "Enter") throw new Error("selector-first press failed");
|
|
\\page.press(null, "a");
|
|
);
|
|
|
|
// A field set by both a positional and the options object is a conflict.
|
|
{
|
|
const message = (try runtime.runSource(
|
|
\\await new Page().goto("http://localhost:9582/src/browser/tests/mcp_actions.html", { url: "http://other" });
|
|
, "agent-runtime-conflict.js")).?;
|
|
try testing.expect(std.mem.indexOf(u8, message, "invalid arguments") != null);
|
|
}
|
|
|
|
// More positionals than the tool has fields throws.
|
|
{
|
|
const message = (try runtime.runSource(
|
|
\\const page = new Page();
|
|
\\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
|
|
\\page.click("#btn", "#extra");
|
|
, "agent-runtime-arity.js")).?;
|
|
try testing.expect(std.mem.indexOf(u8, message, "invalid arguments") != null);
|
|
}
|
|
}
|
|
|
|
test "agent script runtime: top-level await runs in an async wrapper" {
|
|
defer testing.reset();
|
|
|
|
var registry = CDPNode.Registry.init(testing.allocator);
|
|
defer registry.deinit();
|
|
|
|
const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, ®istry);
|
|
defer runtime.deinit();
|
|
|
|
// `await` on a non-goto promise resolves without touching the browser, and
|
|
// a top-level `return` surfaces as the (otherwise un-echoed) result.
|
|
try runTestScript(runtime,
|
|
\\const x = await Promise.resolve(40);
|
|
\\if (x + 2 !== 42) throw new Error("top-level await did not resolve: " + x);
|
|
\\return x + 2;
|
|
);
|
|
}
|