Files
browser/src/script/Runtime.zig
Adrià Arrufat ae2359c1af script: fix segfault when a tool call triggers navigation
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.
2026-07-03 23:52:29 +02:00

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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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, &registry);
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;
);
}