Compare commits

..

17 Commits

Author SHA1 Message Date
Adrià Arrufat
1f75ce1778 agent: add unit tests for Command, CommandExecutor, and Recorder 2026-04-04 08:28:56 +02:00
Adrià Arrufat
7aabda9392 agent: add recorder, self-healing, env substitution, and security fixes
- Add Recorder for recording REPL sessions to .panda files, with
  --no-record flag and positional file arg support. Skips read-only
  commands (WAIT, TREE, MARKDOWN) per spec.
- Record resolved LLM tool calls as Pandascript commands so the
  generated artifact is deterministic.
- Add self-healing in --run mode: on command failure, prompt the LLM
  with the # INTENT context to resolve an alternative.
- Add LOGIN and ACCEPT_COOKIES high-level commands (LLM-resolved).
- Add multi-line EVAL """...""" support via ScriptIterator.
- Add $VAR_NAME environment variable substitution in command arguments.
- Escape JS strings in execType/execExtract to prevent injection.
- Sanitize output file paths in EXTRACT to prevent path traversal.
2026-04-04 08:14:48 +02:00
Adrià Arrufat
e29f33642c agent: add --run command for deterministic script replay 2026-04-04 07:56:10 +02:00
Adrià Arrufat
d94effb237 agent: improve tool call detection and logging 2026-04-04 07:56:10 +02:00
Adrià Arrufat
3b1ef66b51 agent: add markdown command 2026-04-04 07:56:10 +02:00
Adrià Arrufat
15c0a7be83 agent: add manual command support to REPL
Adds a parser and executor for manual commands like GOTO and CLICK.
Unrecognized input continues to be processed by the AI.
2026-04-04 07:56:10 +02:00
Adrià Arrufat
a5d3d686b8 agent: use arena allocators for messages and tools 2026-04-04 07:56:10 +02:00
Adrià Arrufat
20c31a3f71 agent: remove bold formatting from prompt 2026-04-04 07:56:10 +02:00
Adrià Arrufat
a81a24229b Add interactive agent mode with LLM-powered web browsing
Introduces `lightpanda agent` command that provides a REPL where users
can chat with an AI that uses the browser's tools (goto, markdown, click,
fill, etc.) to browse the web. Uses zenai for multi-provider LLM support
(Anthropic, OpenAI, Gemini) and linenoise v2 for terminal line editing.
2026-04-04 07:56:10 +02:00
Karl Seguin
5826caf6dc Merge pull request #2070 from lightpanda-io/mcp-new-action-tools
Some checks are pending
e2e-test / zig build release (push) Waiting to run
e2e-test / demo-scripts (push) Blocked by required conditions
e2e-test / wba-demo-scripts (push) Blocked by required conditions
e2e-test / wba-test (push) Blocked by required conditions
e2e-test / cdp-and-hyperfine-bench (push) Blocked by required conditions
e2e-test / perf-fmt (push) Blocked by required conditions
e2e-test / browser fetch (push) Blocked by required conditions
zig-test / zig fmt (push) Waiting to run
zig-test / zig test using v8 in debug mode (push) Waiting to run
zig-test / zig test (push) Waiting to run
zig-test / perf-fmt (push) Blocked by required conditions
mcp: Add hover, press, selectOption, setChecked
2026-04-04 10:20:54 +08:00
Karl Seguin
b0c6c2d591 Merge pull request #2083 from tmchow/fix/2080-keyboard-event-propagation
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
fix: propagate keyUp and char keyboard events to JS listeners
2026-04-04 08:19:53 +08:00
Trevin Chow
b33bb54442 fix: propagate keyUp and char keyboard events to JS listeners
dispatchKeyEvent only handled keyDown, returning early for keyUp,
rawKeyDown, and char types. This meant JS keyup and keypress
listeners never fired via CDP.

Now keyUp dispatches as "keyup" and char dispatches as "keypress".
rawKeyDown remains a no-op (Chrome-internal, not used for JS dispatch).

Fixes #2080
Ref #2043
2026-04-03 17:08:09 -07:00
Adrià Arrufat
72229f715a Merge branch 'main' into mcp-new-action-tools 2026-04-03 07:06:10 +02:00
Adrià Arrufat
6c9a5ddab8 Extract shared helpers to reduce duplication
- Extract dispatchInputAndChangeEvents() in actions.zig, used by fill,
  selectOption, and setChecked
- Extract resolveNodeAndPage() in tools.zig, used by click, fill, hover,
  selectOption, setChecked, and nodeDetails handlers
2026-04-02 11:20:28 +02:00
Adrià Arrufat
46a63e0b4b Add focus before fill and findElement MCP tool
- fill action now calls focus() on the element before setting its value,
  ensuring focus/focusin events fire for JS listeners
- Add findElement MCP tool for locating interactive elements by ARIA role
  and/or accessible name (case-insensitive substring match)
- Add tests for findElement (by role, by name, no matches, missing params)
2026-04-02 11:03:49 +02:00
Adrià Arrufat
58143ee3d1 Fix event order and add tests
- Fix setChecked event order: click fires before input/change to match
  browser behavior
- Add tests for hover, press, selectOption, setChecked MCP tools
- Merge all action tests into a single test case sharing one page load
- Add test elements to mcp_actions.html (hover target, key input,
  second select, checkbox, radio)
2026-04-02 10:46:28 +02:00
Adrià Arrufat
5e79af42f4 mcp: Add hover, press, selectOption, setChecked
New browser actions and MCP tools for AI agent interaction:
- hover: dispatches mouseover/mouseenter events on an element
- press: dispatches keydown/keyup keyboard events (Enter, Tab, etc.)
- selectOption: selects a dropdown option by value with input/change events
- setChecked: checks/unchecks checkbox or radio with input/change/click events
2026-04-02 09:47:22 +02:00
34 changed files with 2730 additions and 2230 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.3.8'
default: 'v0.3.7'
v8:
description: 'v8 version to install'
required: false

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.3.8
ARG ZIG_V8=v0.3.7
ARG TARGETPLATFORM
RUN apt-get update -yq && \

View File

@@ -85,6 +85,8 @@ pub fn build(b: *Build) !void {
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
try linkCurl(b, mod, enable_tsan);
try linkHtml5Ever(b, mod);
linkZenai(b, mod);
linkLinenoise(b, mod);
break :blk mod;
};
@@ -493,7 +495,6 @@ fn buildCurl(
.CURL_DISABLE_SMTP = true,
.CURL_DISABLE_TELNET = true,
.CURL_DISABLE_TFTP = true,
.CURL_DISABLE_WEBSOCKETS = false, // Enable WebSocket support
.ssize_t = null,
._FILE_OFFSET_BITS = 64,
@@ -751,6 +752,19 @@ fn buildCurl(
return lib;
}
fn linkZenai(b: *Build, mod: *Build.Module) void {
const dep = b.dependency("zenai", .{});
mod.addImport("zenai", dep.module("zenai"));
}
fn linkLinenoise(b: *Build, mod: *Build.Module) void {
const dep = b.dependency("linenoise", .{});
mod.addIncludePath(dep.path(""));
mod.addCSourceFile(.{
.file = dep.path("linenoise.c"),
});
}
/// Resolves the semantic version of the build.
///
/// The base version is read from `build.zig.zon`. This can be overridden

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.8.tar.gz",
.hash = "v8-0.0.0-xddH6weEBAAdY3uxkNWqYpG7bX_h1Oj3UYBIkbxEyNCl",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
@@ -30,6 +30,13 @@
.url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
.hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
},
.zenai = .{
.path = "../zenai",
},
.linenoise = .{
.url = "https://github.com/antirez/linenoise/archive/refs/tags/2.0.tar.gz",
.hash = "N-V-__8AAJ4HAgCX79UDBfNwhqAqUVoGC44ib6UYa18q6oa_",
},
},
.paths = .{""},
}

View File

@@ -32,6 +32,7 @@ pub const RunMode = enum {
serve,
version,
mcp,
agent,
};
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
@@ -63,56 +64,56 @@ pub fn deinit(self: *const Config, allocator: Allocator) void {
pub fn tlsVerifyHost(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.tls_verify_host,
else => unreachable,
};
}
pub fn obeyRobots(self: *const Config) bool {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.obey_robots,
else => unreachable,
};
}
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.http_proxy,
else => unreachable,
};
}
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.proxy_bearer_token,
.help, .version => null,
};
}
pub fn httpMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.http_max_concurrent orelse 10,
else => unreachable,
};
}
pub fn httpMaxHostOpen(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.http_max_host_open orelse 4,
else => unreachable,
};
}
pub fn httpConnectTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.http_connect_timeout orelse 0,
else => unreachable,
};
}
pub fn httpTimeout(self: *const Config) u31 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.http_timeout orelse 5000,
else => unreachable,
};
}
@@ -123,42 +124,35 @@ pub fn httpMaxRedirects(_: *const Config) u8 {
pub fn httpMaxResponseSize(self: *const Config) ?usize {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
else => unreachable,
};
}
pub fn wsMaxConcurrent(self: *const Config) u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.ws_max_concurrent orelse 8,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.http_max_response_size,
else => unreachable,
};
}
pub fn logLevel(self: *const Config) ?log.Level {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.log_level,
else => unreachable,
};
}
pub fn logFormat(self: *const Config) ?log.Format {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.log_format,
else => unreachable,
};
}
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.log_filter_scopes,
else => unreachable,
};
}
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
inline .serve, .fetch, .mcp, .agent => |opts| opts.common.user_agent_suffix,
.help, .version => null,
};
}
@@ -196,7 +190,7 @@ pub fn advertiseHost(self: *const Config) []const u8 {
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
return switch (self.mode) {
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
inline .serve, .fetch, .mcp, .agent => |opts| WebBotAuthConfig{
.key_file = opts.common.web_bot_auth_key_file orelse return null,
.keyid = opts.common.web_bot_auth_keyid orelse return null,
.domain = opts.common.web_bot_auth_domain orelse return null,
@@ -227,6 +221,7 @@ pub const Mode = union(RunMode) {
serve: Serve,
version: void,
mcp: Mcp,
agent: Agent,
};
pub const Serve = struct {
@@ -245,6 +240,24 @@ pub const Mcp = struct {
cdp_port: ?u16 = null,
};
pub const AiProvider = enum {
anthropic,
openai,
gemini,
};
pub const Agent = struct {
common: Common = .{},
provider: AiProvider = .anthropic,
model: ?[:0]const u8 = null,
api_key: ?[:0]const u8 = null,
system_prompt: ?[:0]const u8 = null,
repl: bool = true,
script_file: ?[]const u8 = null,
record_file: ?[]const u8 = null,
no_record: bool = false,
};
pub const DumpFormat = enum {
html,
markdown,
@@ -282,7 +295,6 @@ pub const Common = struct {
http_timeout: ?u31 = null,
http_connect_timeout: ?u31 = null,
http_max_response_size: ?usize = null,
ws_max_concurrent: ?u8 = null,
tls_verify_host: bool = true,
log_level: ?log.Level = null,
log_format: ?log.Format = null,
@@ -383,10 +395,6 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ (e.g. XHR, fetch, script loading, ...).
\\ Defaults to no limit.
\\
\\--ws-max-concurrent
\\ The maximum number of concurrent WebSocket connections.
\\ Defaults to 8.
\\
\\--log-level The log level: debug, info, warn, error or fatal.
\\ Defaults to
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
@@ -423,7 +431,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
const usage =
\\usage: {s} command [options] [URL]
\\
\\Command can be either 'fetch', 'serve', 'mcp' or 'help'
\\Command can be either 'fetch', 'serve', 'mcp', 'agent' or 'help'
\\
\\fetch command
\\Fetches the specified URL
@@ -505,6 +513,24 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\ Valid: 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25.
\\ Defaults to "2024-11-05".
\\
++ common_options ++
\\
\\agent command
\\Starts an interactive AI agent that can browse the web
\\Example: {s} agent --provider anthropic --model claude-sonnet-4-20250514
\\
\\Options:
\\--provider The AI provider: anthropic, openai, or gemini.
\\ Defaults to "anthropic".
\\
\\--model The model name to use.
\\ Defaults to a sensible default per provider.
\\
\\--api-key The API key. Can also be set via environment variable:
\\ ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY.
\\
\\--system-prompt Override the default system prompt.
\\
++ common_options ++
\\
\\version command
@@ -514,7 +540,7 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void {
\\Displays this message
\\
;
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
if (success) {
return std.process.cleanExit();
}
@@ -551,6 +577,8 @@ pub fn parseArgs(allocator: Allocator) !Config {
return init(allocator, exec_name, .{ .help = false }) },
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.agent => .{ .agent = parseAgentArgs(allocator, &args) catch
return init(allocator, exec_name, .{ .help = false }) },
.version => .{ .version = {} },
};
return init(allocator, exec_name, mode);
@@ -896,6 +924,93 @@ fn parseFetchArgs(
};
}
fn parseAgentArgs(
allocator: Allocator,
args: *std.process.ArgIterator,
) !Agent {
var result: Agent = .{};
while (args.next()) |opt| {
if (std.mem.eql(u8, "--provider", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.provider = std.meta.stringToEnum(AiProvider, str) orelse {
log.fatal(.app, "invalid provider", .{ .arg = opt, .val = str });
return error.InvalidArgument;
};
continue;
}
if (std.mem.eql(u8, "--model", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.model = try allocator.dupeZ(u8, str);
continue;
}
if (std.mem.eql(u8, "--api-key", opt) or std.mem.eql(u8, "--api_key", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.api_key = try allocator.dupeZ(u8, str);
continue;
}
if (std.mem.eql(u8, "--repl", opt)) {
result.repl = true;
continue;
}
if (std.mem.eql(u8, "--run", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.script_file = str;
continue;
}
if (std.mem.eql(u8, "--system-prompt", opt) or std.mem.eql(u8, "--system_prompt", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
result.system_prompt = try allocator.dupeZ(u8, str);
continue;
}
if (std.mem.eql(u8, "--no-record", opt) or std.mem.eql(u8, "--no_record", opt)) {
result.no_record = true;
continue;
}
if (try parseCommonArg(allocator, opt, args, &result.common)) {
continue;
}
// Positional argument: recording file for REPL mode (e.g. `agent --repl my_workflow.panda`)
if (!std.mem.startsWith(u8, opt, "-")) {
result.record_file = opt;
continue;
}
log.fatal(.app, "unknown argument", .{ .mode = "agent", .arg = opt });
return error.UnkownOption;
}
// If --no-record is set, clear the record file
if (result.no_record) {
result.record_file = null;
}
return result;
}
fn parseCommonArg(
allocator: Allocator,
opt: []const u8,
@@ -995,19 +1110,6 @@ fn parseCommonArg(
return true;
}
if (std.mem.eql(u8, "--ws-max-concurrent", opt) or std.mem.eql(u8, "--ws_max_concurrent", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });
return error.InvalidArgument;
};
common.ws_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
log.fatal(.app, "invalid argument value", .{ .arg = opt, .err = err });
return error.InvalidArgument;
};
return true;
}
if (std.mem.eql(u8, "--log-level", opt) or std.mem.eql(u8, "--log_level", opt)) {
const str = args.next() orelse {
log.fatal(.app, "missing argument value", .{ .arg = opt });

View File

@@ -1,366 +0,0 @@
// 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 posix = std.posix;
const TestWSServer = @This();
shutdown: std.atomic.Value(bool),
listener: ?posix.socket_t,
pub fn init() TestWSServer {
return .{
.shutdown = .init(true),
.listener = null,
};
}
pub fn stop(self: *TestWSServer) void {
self.shutdown.store(true, .release);
if (self.listener) |socket| {
switch (@import("builtin").target.os.tag) {
.linux => std.posix.shutdown(socket, .recv) catch {},
else => std.posix.close(socket),
}
}
}
pub fn run(self: *TestWSServer, wg: *std.Thread.WaitGroup) void {
self.runImpl(wg) catch |err| {
std.debug.print("WebSocket echo server error: {}\n", .{err});
};
}
fn runImpl(self: *TestWSServer, wg: *std.Thread.WaitGroup) !void {
const socket = try posix.socket(posix.AF.INET, posix.SOCK.STREAM, 0);
errdefer posix.close(socket);
const addr = std.net.Address.initIp4(.{ 127, 0, 0, 1 }, 9584);
try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
try posix.bind(socket, &addr.any, addr.getOsSockLen());
try posix.listen(socket, 8);
self.listener = socket;
self.shutdown.store(false, .release);
wg.finish();
while (!self.shutdown.load(.acquire)) {
var client_addr: posix.sockaddr = undefined;
var addr_len: posix.socklen_t = @sizeOf(posix.sockaddr);
const client = posix.accept(socket, &client_addr, &addr_len, 0) catch |err| {
if (self.shutdown.load(.acquire)) return;
std.debug.print("[WS Server] Accept error: {}\n", .{err});
continue;
};
const thread = std.Thread.spawn(.{}, handleClient, .{client}) catch |err| {
std.debug.print("[WS Server] Thread spawn error: {}\n", .{err});
posix.close(client);
continue;
};
thread.detach();
}
}
fn handleClient(client: posix.socket_t) void {
defer posix.close(client);
var buf: [4096]u8 = undefined;
const n = posix.read(client, &buf) catch return;
const request = buf[0..n];
// Find Sec-WebSocket-Key
const key_header = "Sec-WebSocket-Key: ";
const key_start = std.mem.indexOf(u8, request, key_header) orelse return;
const key_line_start = key_start + key_header.len;
const key_end = std.mem.indexOfScalarPos(u8, request, key_line_start, '\r') orelse return;
const key = request[key_line_start..key_end];
// Compute accept key
var hasher = std.crypto.hash.Sha1.init(.{});
hasher.update(key);
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
var hash: [20]u8 = undefined;
hasher.final(&hash);
var accept_key: [28]u8 = undefined;
_ = std.base64.standard.Encoder.encode(&accept_key, &hash);
// Send upgrade response
var resp_buf: [256]u8 = undefined;
const resp = std.fmt.bufPrint(&resp_buf, "HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: Upgrade\r\n" ++
"Sec-WebSocket-Accept: {s}\r\n\r\n", .{accept_key}) catch return;
_ = posix.write(client, resp) catch return;
// Message loop with larger buffer for big messages
var msg_buf: [128 * 1024]u8 = undefined;
var recv_buf = RecvBuffer{ .buf = &msg_buf };
while (true) {
const frame = recv_buf.readFrame(client) orelse break;
// Close frame - echo it back before closing
if (frame.opcode == 8) {
sendFrame(client, 8, "", frame.payload) catch {};
break;
}
// Handle commands or echo
if (frame.opcode == 1) { // Text
handleTextMessage(client, frame.payload) catch break;
} else if (frame.opcode == 2) { // Binary
handleBinaryMessage(client, frame.payload) catch break;
}
}
}
const Frame = struct {
opcode: u8,
payload: []u8,
};
const RecvBuffer = struct {
buf: []u8,
start: usize = 0,
end: usize = 0,
fn available(self: *RecvBuffer) []u8 {
return self.buf[self.start..self.end];
}
fn consume(self: *RecvBuffer, n: usize) void {
self.start += n;
if (self.start >= self.end) {
self.start = 0;
self.end = 0;
}
}
fn ensureBytes(self: *RecvBuffer, client: posix.socket_t, needed: usize) bool {
while (self.end - self.start < needed) {
// Compact buffer if needed
if (self.end >= self.buf.len - 1024) {
const avail = self.end - self.start;
std.mem.copyForwards(u8, self.buf[0..avail], self.buf[self.start..self.end]);
self.start = 0;
self.end = avail;
}
const n = posix.read(client, self.buf[self.end..]) catch return false;
if (n == 0) return false;
self.end += n;
}
return true;
}
fn readFrame(self: *RecvBuffer, client: posix.socket_t) ?Frame {
// Need at least 2 bytes for basic header
if (!self.ensureBytes(client, 2)) return null;
const data = self.available();
const opcode = data[0] & 0x0F;
const masked = (data[1] & 0x80) != 0;
var payload_len: usize = data[1] & 0x7F;
var header_size: usize = 2;
// Extended payload length
if (payload_len == 126) {
if (!self.ensureBytes(client, 4)) return null;
const d = self.available();
payload_len = @as(usize, d[2]) << 8 | d[3];
header_size = 4;
} else if (payload_len == 127) {
if (!self.ensureBytes(client, 10)) return null;
const d = self.available();
payload_len = @as(usize, d[2]) << 56 |
@as(usize, d[3]) << 48 |
@as(usize, d[4]) << 40 |
@as(usize, d[5]) << 32 |
@as(usize, d[6]) << 24 |
@as(usize, d[7]) << 16 |
@as(usize, d[8]) << 8 |
d[9];
header_size = 10;
}
const mask_size: usize = if (masked) 4 else 0;
const total_frame_size = header_size + mask_size + payload_len;
if (!self.ensureBytes(client, total_frame_size)) return null;
const frame_data = self.available();
// Get mask key if present
var mask_key: [4]u8 = undefined;
if (masked) {
@memcpy(&mask_key, frame_data[header_size..][0..4]);
}
// Get payload and unmask
const payload_start = header_size + mask_size;
const payload = frame_data[payload_start..][0..payload_len];
if (masked) {
for (payload, 0..) |*b, i| {
b.* ^= mask_key[i % 4];
}
}
self.consume(total_frame_size);
return .{ .opcode = opcode, .payload = payload };
}
};
fn handleTextMessage(client: posix.socket_t, payload: []const u8) !void {
// Command: force-close - close socket immediately without close frame
if (std.mem.eql(u8, payload, "force-close")) {
return error.ForceClose;
}
// Command: send-large:N - send a message of N bytes
if (std.mem.startsWith(u8, payload, "send-large:")) {
const size_str = payload["send-large:".len..];
const size = std.fmt.parseInt(usize, size_str, 10) catch return error.InvalidCommand;
try sendLargeMessage(client, size);
return;
}
// Command: close:CODE:REASON - send close frame with specific code/reason
if (std.mem.startsWith(u8, payload, "close:")) {
const rest = payload["close:".len..];
if (std.mem.indexOf(u8, rest, ":")) |sep| {
const code = std.fmt.parseInt(u16, rest[0..sep], 10) catch 1000;
const reason = rest[sep + 1 ..];
try sendCloseFrame(client, code, reason);
}
return;
}
// Default: echo with "echo-" prefix
const prefix = "echo-";
try sendFrame(client, 1, prefix, payload);
}
fn handleBinaryMessage(client: posix.socket_t, payload: []const u8) !void {
// Echo binary data back with byte 0xEE prepended as marker
const marker = [_]u8{0xEE};
try sendFrame(client, 2, &marker, payload);
}
fn sendFrame(client: posix.socket_t, opcode: u8, prefix: []const u8, payload: []const u8) !void {
const total_len = prefix.len + payload.len;
// Build header
var header: [10]u8 = undefined;
var header_len: usize = 2;
header[0] = 0x80 | opcode; // FIN + opcode
if (total_len <= 125) {
header[1] = @intCast(total_len);
} else if (total_len <= 65535) {
header[1] = 126;
header[2] = @intCast((total_len >> 8) & 0xFF);
header[3] = @intCast(total_len & 0xFF);
header_len = 4;
} else {
header[1] = 127;
header[2] = @intCast((total_len >> 56) & 0xFF);
header[3] = @intCast((total_len >> 48) & 0xFF);
header[4] = @intCast((total_len >> 40) & 0xFF);
header[5] = @intCast((total_len >> 32) & 0xFF);
header[6] = @intCast((total_len >> 24) & 0xFF);
header[7] = @intCast((total_len >> 16) & 0xFF);
header[8] = @intCast((total_len >> 8) & 0xFF);
header[9] = @intCast(total_len & 0xFF);
header_len = 10;
}
_ = try posix.write(client, header[0..header_len]);
if (prefix.len > 0) {
_ = try posix.write(client, prefix);
}
if (payload.len > 0) {
_ = try posix.write(client, payload);
}
}
fn sendLargeMessage(client: posix.socket_t, size: usize) !void {
// Build header
var header: [10]u8 = undefined;
var header_len: usize = 2;
header[0] = 0x81; // FIN + text
if (size <= 125) {
header[1] = @intCast(size);
} else if (size <= 65535) {
header[1] = 126;
header[2] = @intCast((size >> 8) & 0xFF);
header[3] = @intCast(size & 0xFF);
header_len = 4;
} else {
header[1] = 127;
header[2] = @intCast((size >> 56) & 0xFF);
header[3] = @intCast((size >> 48) & 0xFF);
header[4] = @intCast((size >> 40) & 0xFF);
header[5] = @intCast((size >> 32) & 0xFF);
header[6] = @intCast((size >> 24) & 0xFF);
header[7] = @intCast((size >> 16) & 0xFF);
header[8] = @intCast((size >> 8) & 0xFF);
header[9] = @intCast(size & 0xFF);
header_len = 10;
}
_ = try posix.write(client, header[0..header_len]);
// Send payload in chunks - pattern of 'A'-'Z' repeating
var sent: usize = 0;
var chunk: [4096]u8 = undefined;
while (sent < size) {
const to_send = @min(chunk.len, size - sent);
for (chunk[0..to_send], 0..) |*b, i| {
b.* = @intCast('A' + ((sent + i) % 26));
}
_ = try posix.write(client, chunk[0..to_send]);
sent += to_send;
}
}
fn sendCloseFrame(client: posix.socket_t, code: u16, reason: []const u8) !void {
const reason_len = @min(reason.len, 123); // Max 123 bytes for reason
const payload_len = 2 + reason_len;
var frame: [129]u8 = undefined; // 2 header + 2 code + 123 reason + 2 padding
frame[0] = 0x88; // FIN + close
frame[1] = @intCast(payload_len);
frame[2] = @intCast((code >> 8) & 0xFF);
frame[3] = @intCast(code & 0xFF);
if (reason_len > 0) {
@memcpy(frame[4..][0..reason_len], reason[0..reason_len]);
}
_ = try posix.write(client, frame[0 .. 4 + reason_len]);
}

12
src/agent.zig Normal file
View File

@@ -0,0 +1,12 @@
pub const Agent = @import("agent/Agent.zig");
pub const ToolExecutor = @import("agent/ToolExecutor.zig");
pub const Terminal = @import("agent/Terminal.zig");
pub const Command = @import("agent/Command.zig");
pub const CommandExecutor = @import("agent/CommandExecutor.zig");
pub const Recorder = @import("agent/Recorder.zig");
test {
_ = Command;
_ = CommandExecutor;
_ = Recorder;
}

523
src/agent/Agent.zig Normal file
View File

@@ -0,0 +1,523 @@
const std = @import("std");
const zenai = @import("zenai");
const lp = @import("lightpanda");
const log = lp.log;
const Config = lp.Config;
const App = @import("../App.zig");
const ToolExecutor = @import("ToolExecutor.zig");
const Terminal = @import("Terminal.zig");
const Command = @import("Command.zig");
const CommandExecutor = @import("CommandExecutor.zig");
const Recorder = @import("Recorder.zig");
const Self = @This();
const default_system_prompt =
\\You are a web browsing assistant powered by the Lightpanda browser.
\\You can navigate to websites, read their content, interact with forms,
\\click links, and extract information.
\\
\\When helping the user, navigate to relevant pages and extract information.
\\Use the semantic_tree or interactiveElements tools to understand page structure
\\before clicking or filling forms. Be concise in your responses.
;
const self_heal_prompt_prefix =
\\A Pandascript command failed during replay. The original intent was:
\\
;
const self_heal_prompt_suffix =
\\
\\The command that failed was:
\\
;
const self_heal_prompt_page_state =
\\
\\Please analyze the current page state and execute the equivalent action.
\\Use the available tools to accomplish the original intent.
;
const login_prompt =
\\Find the login form on the current page. Fill in the credentials using
\\environment variables (look for $LP_EMAIL or $LP_USERNAME for the username
\\field, and $LP_PASSWORD for the password field). Handle any cookie banners
\\or popups first, then submit the login form.
;
const accept_cookies_prompt =
\\Find and dismiss the cookie consent banner on the current page.
\\Look for "Accept", "Accept All", "I agree", or similar buttons and click them.
;
allocator: std.mem.Allocator,
ai_client: ?AiClient,
tool_executor: *ToolExecutor,
terminal: Terminal,
cmd_executor: CommandExecutor,
recorder: Recorder,
messages: std.ArrayListUnmanaged(zenai.provider.Message),
message_arena: std.heap.ArenaAllocator,
tools: []const zenai.provider.Tool,
model: []const u8,
system_prompt: []const u8,
script_file: ?[]const u8,
record_file: ?[]const u8,
const AiClient = union(Config.AiProvider) {
anthropic: *zenai.anthropic.Client,
openai: *zenai.openai.Client,
gemini: *zenai.gemini.Client,
fn toProvider(self: AiClient) zenai.provider.Client {
return switch (self) {
.anthropic => |c| .{ .anthropic = c },
.openai => |c| .{ .openai = c },
.gemini => |c| .{ .gemini = c },
};
}
};
pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self {
const is_script_mode = opts.script_file != null;
// API key is only required for REPL mode and self-healing
const api_key: ?[:0]const u8 = opts.api_key orelse getEnvApiKey(opts.provider) orelse if (!is_script_mode) {
log.fatal(.app, "missing API key", .{
.hint = "Set the API key via --api-key or environment variable",
});
return error.MissingApiKey;
} else null;
const tool_executor = try ToolExecutor.init(allocator, app);
errdefer tool_executor.deinit();
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
const ai_client: ?AiClient = if (api_key) |key| switch (opts.provider) {
.anthropic => blk: {
const client = try allocator.create(zenai.anthropic.Client);
client.* = zenai.anthropic.Client.init(allocator, key, .{});
break :blk .{ .anthropic = client };
},
.openai => blk: {
const client = try allocator.create(zenai.openai.Client);
client.* = zenai.openai.Client.init(allocator, key, .{});
break :blk .{ .openai = client };
},
.gemini => blk: {
const client = try allocator.create(zenai.gemini.Client);
client.* = zenai.gemini.Client.init(allocator, key, .{});
break :blk .{ .gemini = client };
},
} else null;
const tools = tool_executor.getTools() catch {
log.fatal(.app, "failed to initialize tools", .{});
return error.ToolInitFailed;
};
self.* = .{
.allocator = allocator,
.ai_client = ai_client,
.tool_executor = tool_executor,
.terminal = Terminal.init(null),
.cmd_executor = undefined,
.recorder = Recorder.init(opts.record_file),
.messages = .empty,
.message_arena = std.heap.ArenaAllocator.init(allocator),
.tools = tools,
.model = opts.model orelse defaultModel(opts.provider),
.system_prompt = opts.system_prompt orelse default_system_prompt,
.script_file = opts.script_file,
.record_file = opts.record_file,
};
self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal);
return self;
}
pub fn deinit(self: *Self) void {
self.recorder.deinit();
self.message_arena.deinit();
self.messages.deinit(self.allocator);
self.tool_executor.deinit();
if (self.ai_client) |ai_client| {
switch (ai_client) {
inline else => |c| {
c.deinit();
self.allocator.destroy(c);
},
}
}
self.allocator.destroy(self);
}
pub fn run(self: *Self) void {
if (self.script_file) |script_file| {
self.runScript(script_file);
} else {
self.runRepl();
}
}
fn runRepl(self: *Self) void {
self.terminal.printInfo("Lightpanda Agent (type 'quit' to exit)");
log.debug(.app, "tools loaded", .{ .count = self.tools.len });
const info = if (self.ai_client) |ai_client|
std.fmt.allocPrint(self.allocator, "Provider: {s}, Model: {s}", .{
@tagName(std.meta.activeTag(ai_client)),
self.model,
}) catch null
else
null;
self.terminal.printInfo(info orelse "Ready.");
if (info) |i| self.allocator.free(i);
while (true) {
const line = self.terminal.readLine("> ") orelse break;
defer self.terminal.freeLine(line);
if (line.len == 0) continue;
const cmd = Command.parse(line);
switch (cmd) {
.exit => break,
.comment => continue,
.login => {
self.recorder.recordComment("# INTENT: LOGIN");
self.processUserMessage(login_prompt) catch |err| {
const msg = std.fmt.allocPrint(self.allocator, "LOGIN failed: {s}", .{@errorName(err)}) catch "LOGIN failed";
self.terminal.printError(msg);
};
},
.accept_cookies => {
self.recorder.recordComment("# INTENT: ACCEPT_COOKIES");
self.processUserMessage(accept_cookies_prompt) catch |err| {
const msg = std.fmt.allocPrint(self.allocator, "ACCEPT_COOKIES failed: {s}", .{@errorName(err)}) catch "ACCEPT_COOKIES failed";
self.terminal.printError(msg);
};
},
.natural_language => {
// "quit" as a convenience alias
if (std.mem.eql(u8, line, "quit")) break;
self.processUserMessage(line) catch |err| {
const msg = std.fmt.allocPrint(self.allocator, "Request failed: {s}", .{@errorName(err)}) catch "Request failed";
self.terminal.printError(msg);
};
},
else => {
self.cmd_executor.execute(cmd);
self.recorder.record(line);
},
}
}
self.terminal.printInfo("Goodbye!");
}
fn runScript(self: *Self, path: []const u8) void {
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
const msg = std.fmt.allocPrint(self.allocator, "Failed to open script '{s}': {s}", .{ path, @errorName(err) }) catch "Failed to open script";
self.terminal.printError(msg);
return;
};
defer file.close();
const content = file.readToEndAlloc(self.allocator, 10 * 1024 * 1024) catch |err| {
const msg = std.fmt.allocPrint(self.allocator, "Failed to read script: {s}", .{@errorName(err)}) catch "Failed to read script";
self.terminal.printError(msg);
return;
};
defer self.allocator.free(content);
const script_info = std.fmt.allocPrint(self.allocator, "Running script: {s}", .{path}) catch null;
self.terminal.printInfo(script_info orelse "Running script...");
if (script_info) |i| self.allocator.free(i);
var script_arena = std.heap.ArenaAllocator.init(self.allocator);
defer script_arena.deinit();
var iter = Command.ScriptIterator.init(content, script_arena.allocator());
var last_intent: ?[]const u8 = null;
while (iter.next()) |entry| {
switch (entry.command) {
.exit => {
self.terminal.printInfo("EXIT — stopping script.");
return;
},
.comment => {
// Track # INTENT: comments for self-healing
if (std.mem.startsWith(u8, entry.raw_line, "# INTENT:")) {
last_intent = std.mem.trim(u8, entry.raw_line["# INTENT:".len..], &std.ascii.whitespace);
}
continue;
},
.natural_language => {
const msg = std.fmt.allocPrint(self.allocator, "line {d}: unrecognized command: {s}", .{ entry.line_num, entry.raw_line }) catch "unrecognized command";
self.terminal.printError(msg);
return;
},
.login, .accept_cookies => {
// High-level commands require LLM
if (self.ai_client == null) {
const msg = std.fmt.allocPrint(self.allocator, "line {d}: {s} requires an API key for LLM resolution", .{
entry.line_num,
entry.raw_line,
}) catch "LLM required";
self.terminal.printError(msg);
return;
}
const prompt = if (entry.command == .login) login_prompt else accept_cookies_prompt;
self.processUserMessage(prompt) catch |err| {
const msg = std.fmt.allocPrint(self.allocator, "line {d}: {s} failed: {s}", .{
entry.line_num,
entry.raw_line,
@errorName(err),
}) catch "command failed";
self.terminal.printError(msg);
return;
};
},
else => {
const line_info = std.fmt.allocPrint(self.allocator, "[{d}] {s}", .{ entry.line_num, entry.raw_line }) catch null;
self.terminal.printInfo(line_info orelse entry.raw_line);
if (line_info) |li| self.allocator.free(li);
// Execute with result checking for self-healing
var cmd_arena = std.heap.ArenaAllocator.init(self.allocator);
defer cmd_arena.deinit();
const result = self.cmd_executor.executeWithResult(cmd_arena.allocator(), entry.command);
self.terminal.printAssistant(result.output);
std.debug.print("\n", .{});
if (result.failed) {
// Attempt self-healing via LLM
if (self.ai_client != null) {
self.terminal.printInfo("Command failed, attempting self-healing...");
if (self.attemptSelfHeal(last_intent, entry.raw_line)) {
continue;
}
}
const msg = std.fmt.allocPrint(self.allocator, "line {d}: command failed: {s}", .{
entry.line_num,
entry.raw_line,
}) catch "command failed";
self.terminal.printError(msg);
return;
}
},
}
}
self.terminal.printInfo("Script completed.");
}
/// Attempt to self-heal a failed command by asking the LLM to resolve it.
fn attemptSelfHeal(self: *Self, intent: ?[]const u8, failed_command: []const u8) bool {
var heal_arena = std.heap.ArenaAllocator.init(self.allocator);
defer heal_arena.deinit();
const ha = heal_arena.allocator();
// Build the self-healing prompt
const prompt = std.fmt.allocPrint(ha, "{s}{s}{s}{s}{s}", .{
self_heal_prompt_prefix,
intent orelse "(no recorded intent)",
self_heal_prompt_suffix,
failed_command,
self_heal_prompt_page_state,
}) catch return false;
self.processUserMessage(prompt) catch return false;
return true;
}
fn processUserMessage(self: *Self, user_input: []const u8) !void {
const ma = self.message_arena.allocator();
// Add system prompt as first message if this is the first user message
if (self.messages.items.len == 0) {
try self.messages.append(self.allocator, .{
.role = .system,
.content = self.system_prompt,
});
}
// Add user message
try self.messages.append(self.allocator, .{
.role = .user,
.content = try ma.dupe(u8, user_input),
});
// Loop: send to LLM, execute tool calls, repeat until we get text
var max_iterations: u32 = 20;
while (max_iterations > 0) : (max_iterations -= 1) {
const provider_client = (self.ai_client orelse return error.NoAiClient).toProvider();
var result = provider_client.generateContent(self.model, self.messages.items, .{
.tools = self.tools,
.max_tokens = 4096,
}) catch |err| {
log.err(.app, "AI API error", .{ .err = err });
return error.ApiError;
};
defer result.deinit();
log.debug(.app, "LLM response", .{
.finish_reason = @tagName(result.finish_reason),
.has_text = result.text != null,
.has_tool_calls = result.tool_calls != null,
});
// Handle tool calls (check for tool_calls presence, not just finish_reason,
// because some providers like Gemini return finish_reason=STOP for tool calls)
if (result.tool_calls) |tool_calls| {
// Add the assistant message with tool calls
try self.messages.append(self.allocator, .{
.role = .assistant,
.content = if (result.text) |t| try ma.dupe(u8, t) else null,
.tool_calls = try self.dupeToolCalls(tool_calls),
});
// Execute each tool call and collect results
var tool_results: std.ArrayListUnmanaged(zenai.provider.ToolResult) = .empty;
for (tool_calls) |tc| {
self.terminal.printToolCall(tc.name, tc.arguments);
var tool_arena = std.heap.ArenaAllocator.init(self.allocator);
defer tool_arena.deinit();
const tool_result = self.tool_executor.call(tool_arena.allocator(), tc.name, tc.arguments) catch "Error: tool execution failed";
self.terminal.printToolResult(tc.name, tool_result);
// Record resolved tool call as Pandascript command
if (!std.mem.startsWith(u8, tool_result, "Error:")) {
self.recordToolCall(tool_arena.allocator(), tc.name, tc.arguments);
}
try tool_results.append(ma, .{
.id = try ma.dupe(u8, tc.id),
.name = try ma.dupe(u8, tc.name),
.content = try ma.dupe(u8, tool_result),
});
}
// Add tool results as a message
try self.messages.append(self.allocator, .{
.role = .tool,
.tool_results = try tool_results.toOwnedSlice(ma),
});
continue;
}
// Text response
if (result.text) |text| {
std.debug.print("\n", .{});
self.terminal.printAssistant(text);
std.debug.print("\n\n", .{});
try self.messages.append(self.allocator, .{
.role = .assistant,
.content = try ma.dupe(u8, text),
});
} else {
self.terminal.printInfo("(no response from model)");
}
break;
}
}
/// Convert a tool call (name + JSON arguments) into a Pandascript command and record it.
fn recordToolCall(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments: []const u8) void {
const parsed = std.json.parseFromSlice(std.json.Value, arena, arguments, .{}) catch return;
const obj = switch (parsed.value) {
.object => |o| o,
else => return,
};
const panda_cmd: ?[]const u8 = if (std.mem.eql(u8, tool_name, "goto") or std.mem.eql(u8, tool_name, "navigate")) blk: {
const url = switch (obj.get("url") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
break :blk std.fmt.allocPrint(arena, "GOTO {s}", .{url}) catch null;
} else if (std.mem.eql(u8, tool_name, "click")) blk: {
if (obj.get("selector")) |sel_val| {
const sel = switch (sel_val) {
.string => |s| s,
else => break :blk null,
};
break :blk std.fmt.allocPrint(arena, "CLICK \"{s}\"", .{sel}) catch null;
}
if (obj.get("backendNodeId")) |_| {
// Can't meaningfully record a node ID as Pandascript
break :blk null;
}
break :blk null;
} else if (std.mem.eql(u8, tool_name, "fill")) blk: {
const sel = switch (obj.get("selector") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
const val = switch (obj.get("value") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
break :blk std.fmt.allocPrint(arena, "TYPE \"{s}\" \"{s}\"", .{ sel, val }) catch null;
} else if (std.mem.eql(u8, tool_name, "waitForSelector")) blk: {
// WAIT is read-only, not recorded — Recorder will skip it anyway
break :blk null;
} else if (std.mem.eql(u8, tool_name, "evaluate") or std.mem.eql(u8, tool_name, "eval")) blk: {
const script = switch (obj.get("script") orelse break :blk null) {
.string => |s| s,
else => break :blk null,
};
// Use multi-line format if the script contains newlines
if (std.mem.indexOfScalar(u8, script, '\n') != null) {
break :blk std.fmt.allocPrint(arena, "EVAL \"\"\"\n{s}\n\"\"\"", .{script}) catch null;
}
break :blk std.fmt.allocPrint(arena, "EVAL \"{s}\"", .{script}) catch null;
} else null;
if (panda_cmd) |cmd| {
self.recorder.record(cmd);
}
}
fn dupeToolCalls(self: *Self, calls: []const zenai.provider.ToolCall) ![]const zenai.provider.ToolCall {
const ma = self.message_arena.allocator();
const duped = try ma.alloc(zenai.provider.ToolCall, calls.len);
for (calls, 0..) |tc, i| {
duped[i] = .{
.id = try ma.dupe(u8, tc.id),
.name = try ma.dupe(u8, tc.name),
.arguments = try ma.dupe(u8, tc.arguments),
};
}
return duped;
}
fn getEnvApiKey(provider_type: Config.AiProvider) ?[:0]const u8 {
return switch (provider_type) {
.anthropic => std.posix.getenv("ANTHROPIC_API_KEY"),
.openai => std.posix.getenv("OPENAI_API_KEY"),
.gemini => std.posix.getenv("GOOGLE_API_KEY") orelse std.posix.getenv("GEMINI_API_KEY"),
};
}
fn defaultModel(provider_type: Config.AiProvider) []const u8 {
return switch (provider_type) {
.anthropic => "claude-sonnet-4-20250514",
.openai => "gpt-4o",
.gemini => "gemini-2.5-flash",
};
}

409
src/agent/Command.zig Normal file
View File

@@ -0,0 +1,409 @@
const std = @import("std");
pub const TypeArgs = struct {
selector: []const u8,
value: []const u8,
};
pub const ExtractArgs = struct {
selector: []const u8,
file: ?[]const u8,
};
pub const Command = union(enum) {
goto: []const u8,
click: []const u8,
type_cmd: TypeArgs,
wait: []const u8,
tree: void,
markdown: void,
extract: ExtractArgs,
eval_js: []const u8,
login: void,
accept_cookies: void,
exit: void,
comment: void,
natural_language: []const u8,
};
/// Parse a line of REPL input into a Pandascript command.
/// Unrecognized input is returned as `.natural_language`.
/// For multi-line EVAL blocks in scripts, use `ScriptParser`.
pub fn parse(line: []const u8) Command {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) return .{ .natural_language = trimmed };
// Skip comment lines
if (trimmed[0] == '#') return .{ .comment = {} };
// Find the command word (first whitespace-delimited token)
const cmd_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
const cmd_word = trimmed[0..cmd_end];
const rest = std.mem.trim(u8, trimmed[cmd_end..], &std.ascii.whitespace);
if (eqlIgnoreCase(cmd_word, "GOTO")) {
if (rest.len == 0) return .{ .natural_language = trimmed };
return .{ .goto = rest };
}
if (eqlIgnoreCase(cmd_word, "CLICK")) {
const arg = extractQuoted(rest) orelse rest;
if (arg.len == 0) return .{ .natural_language = trimmed };
return .{ .click = arg };
}
if (eqlIgnoreCase(cmd_word, "TYPE")) {
const first = extractQuotedWithRemainder(rest) orelse return .{ .natural_language = trimmed };
const second_arg = std.mem.trim(u8, first.remainder, &std.ascii.whitespace);
const second = extractQuoted(second_arg) orelse return .{ .natural_language = trimmed };
return .{ .type_cmd = .{ .selector = first.value, .value = second } };
}
if (eqlIgnoreCase(cmd_word, "WAIT")) {
const arg = extractQuoted(rest) orelse rest;
if (arg.len == 0) return .{ .natural_language = trimmed };
return .{ .wait = arg };
}
if (eqlIgnoreCase(cmd_word, "TREE")) {
return .{ .tree = {} };
}
if (eqlIgnoreCase(cmd_word, "MARKDOWN") or eqlIgnoreCase(cmd_word, "MD")) {
return .{ .markdown = {} };
}
if (eqlIgnoreCase(cmd_word, "EXTRACT")) {
const selector = extractQuoted(rest) orelse {
if (rest.len == 0) return .{ .natural_language = trimmed };
return .{ .extract = .{ .selector = rest, .file = null } };
};
// Look for > filename after the quoted selector
const after_quote = extractQuotedWithRemainder(rest) orelse return .{ .extract = .{ .selector = selector, .file = null } };
const after = std.mem.trim(u8, after_quote.remainder, &std.ascii.whitespace);
if (after.len > 0 and after[0] == '>') {
const file = std.mem.trim(u8, after[1..], &std.ascii.whitespace);
return .{ .extract = .{ .selector = selector, .file = if (file.len > 0) file else null } };
}
return .{ .extract = .{ .selector = selector, .file = null } };
}
if (eqlIgnoreCase(cmd_word, "EVAL")) {
if (rest.len == 0) return .{ .natural_language = trimmed };
const arg = extractQuoted(rest) orelse rest;
return .{ .eval_js = arg };
}
if (eqlIgnoreCase(cmd_word, "LOGIN")) {
return .{ .login = {} };
}
if (eqlIgnoreCase(cmd_word, "ACCEPT_COOKIES") or eqlIgnoreCase(cmd_word, "ACCEPT-COOKIES")) {
return .{ .accept_cookies = {} };
}
if (eqlIgnoreCase(cmd_word, "EXIT")) {
return .{ .exit = {} };
}
return .{ .natural_language = trimmed };
}
/// Iterator for parsing a script file, handling multi-line EVAL """ ... """ blocks.
pub const ScriptIterator = struct {
lines: std.mem.SplitIterator(u8, .scalar),
line_num: u32,
allocator: std.mem.Allocator,
pub fn init(content: []const u8, allocator: std.mem.Allocator) ScriptIterator {
return .{
.lines = std.mem.splitScalar(u8, content, '\n'),
.line_num = 0,
.allocator = allocator,
};
}
pub const Entry = struct {
line_num: u32,
raw_line: []const u8,
command: Command,
};
/// Returns the next command from the script, or null at EOF.
/// Multi-line EVAL blocks are assembled into a single eval_js command.
pub fn next(self: *ScriptIterator) ?Entry {
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
// Check for EVAL """ multi-line block
if (isEvalTripleQuote(trimmed)) {
const start_line = self.line_num;
if (self.collectEvalBlock()) |js| {
return .{
.line_num = start_line,
.raw_line = trimmed,
.command = .{ .eval_js = js },
};
} else {
return .{
.line_num = start_line,
.raw_line = trimmed,
.command = .{ .natural_language = "unterminated EVAL block" },
};
}
}
return .{
.line_num = self.line_num,
.raw_line = trimmed,
.command = parse(trimmed),
};
}
return null;
}
fn isEvalTripleQuote(line: []const u8) bool {
const cmd_end = std.mem.indexOfAny(u8, line, &std.ascii.whitespace) orelse line.len;
const cmd_word = line[0..cmd_end];
if (!eqlIgnoreCase(cmd_word, "EVAL")) return false;
const rest = std.mem.trim(u8, line[cmd_end..], &std.ascii.whitespace);
return std.mem.startsWith(u8, rest, "\"\"\"");
}
/// Collect lines until closing """, return the JS content.
fn collectEvalBlock(self: *ScriptIterator) ?[]const u8 {
var parts: std.ArrayListUnmanaged(u8) = .empty;
while (self.lines.next()) |line| {
self.line_num += 1;
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (std.mem.eql(u8, trimmed, "\"\"\"")) {
return parts.toOwnedSlice(self.allocator) catch null;
}
if (parts.items.len > 0) {
parts.append(self.allocator, '\n') catch return null;
}
parts.appendSlice(self.allocator, line) catch return null;
}
// Unterminated
parts.deinit(self.allocator);
return null;
}
};
const QuotedResult = struct {
value: []const u8,
remainder: []const u8,
};
fn extractQuotedWithRemainder(s: []const u8) ?QuotedResult {
if (s.len < 2 or s[0] != '"') return null;
const end = std.mem.indexOfScalarPos(u8, s, 1, '"') orelse return null;
return .{
.value = s[1..end],
.remainder = s[end + 1 ..],
};
}
fn extractQuoted(s: []const u8) ?[]const u8 {
const result = extractQuotedWithRemainder(s) orelse return null;
return result.value;
}
fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool {
if (a.len != upper.len) return false;
for (a, upper) |ac, uc| {
if (std.ascii.toUpper(ac) != uc) return false;
}
return true;
}
// --- Tests ---
test "parse GOTO" {
const cmd = parse("GOTO https://example.com");
try std.testing.expectEqualStrings("https://example.com", cmd.goto);
}
test "parse GOTO case insensitive" {
const cmd = parse("goto https://example.com");
try std.testing.expectEqualStrings("https://example.com", cmd.goto);
}
test "parse GOTO missing url" {
const cmd = parse("GOTO");
try std.testing.expect(cmd == .natural_language);
}
test "parse CLICK quoted" {
const cmd = parse("CLICK \"Login\"");
try std.testing.expectEqualStrings("Login", cmd.click);
}
test "parse CLICK unquoted" {
const cmd = parse("CLICK .submit-btn");
try std.testing.expectEqualStrings(".submit-btn", cmd.click);
}
test "parse TYPE two quoted args" {
const cmd = parse("TYPE \"#email\" \"user@test.com\"");
try std.testing.expectEqualStrings("#email", cmd.type_cmd.selector);
try std.testing.expectEqualStrings("user@test.com", cmd.type_cmd.value);
}
test "parse TYPE missing second arg" {
const cmd = parse("TYPE \"#email\"");
try std.testing.expect(cmd == .natural_language);
}
test "parse WAIT" {
const cmd = parse("WAIT \".dashboard\"");
try std.testing.expectEqualStrings(".dashboard", cmd.wait);
}
test "parse TREE" {
const cmd = parse("TREE");
try std.testing.expect(cmd == .tree);
}
test "parse MARKDOWN alias MD" {
try std.testing.expect(parse("MARKDOWN") == .markdown);
try std.testing.expect(parse("md") == .markdown);
}
test "parse EXTRACT with file" {
const cmd = parse("EXTRACT \".title\" > titles.json");
try std.testing.expectEqualStrings(".title", cmd.extract.selector);
try std.testing.expectEqualStrings("titles.json", cmd.extract.file.?);
}
test "parse EXTRACT without file" {
const cmd = parse("EXTRACT \".title\"");
try std.testing.expectEqualStrings(".title", cmd.extract.selector);
try std.testing.expect(cmd.extract.file == null);
}
test "parse EVAL single line" {
const cmd = parse("EVAL \"document.title\"");
try std.testing.expectEqualStrings("document.title", cmd.eval_js);
}
test "parse LOGIN" {
try std.testing.expect(parse("LOGIN") == .login);
try std.testing.expect(parse("login") == .login);
}
test "parse ACCEPT_COOKIES" {
try std.testing.expect(parse("ACCEPT_COOKIES") == .accept_cookies);
try std.testing.expect(parse("ACCEPT-COOKIES") == .accept_cookies);
}
test "parse EXIT" {
try std.testing.expect(parse("EXIT") == .exit);
}
test "parse comment" {
try std.testing.expect(parse("# this is a comment") == .comment);
try std.testing.expect(parse("# INTENT: LOGIN") == .comment);
}
test "parse natural language fallback" {
const cmd = parse("what is on this page?");
try std.testing.expectEqualStrings("what is on this page?", cmd.natural_language);
}
test "parse whitespace trimming" {
const cmd = parse(" GOTO https://example.com ");
try std.testing.expectEqualStrings("https://example.com", cmd.goto);
}
test "parse empty input" {
const cmd = parse("");
try std.testing.expect(cmd == .natural_language);
}
test "ScriptIterator basic commands" {
const script =
\\GOTO https://example.com
\\TREE
\\CLICK "Login"
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expectEqualStrings("https://example.com", e1.command.goto);
try std.testing.expectEqual(@as(u32, 1), e1.line_num);
const e2 = iter.next().?;
try std.testing.expect(e2.command == .tree);
const e3 = iter.next().?;
try std.testing.expectEqualStrings("Login", e3.command.click);
try std.testing.expect(iter.next() == null);
}
test "ScriptIterator skips blank lines and comments" {
const script =
\\# Navigate
\\GOTO https://example.com
\\
\\# Extract
\\TREE
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .comment);
const e2 = iter.next().?;
try std.testing.expect(e2.command == .goto);
const e3 = iter.next().?;
try std.testing.expect(e3.command == .comment);
const e4 = iter.next().?;
try std.testing.expect(e4.command == .tree);
try std.testing.expect(iter.next() == null);
}
test "ScriptIterator multi-line EVAL" {
const script =
\\GOTO https://example.com
\\EVAL """
\\ const x = 1;
\\ const y = 2;
\\ return x + y;
\\"""
\\TREE
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .goto);
const e2 = iter.next().?;
try std.testing.expect(e2.command == .eval_js);
try std.testing.expect(std.mem.indexOf(u8, e2.command.eval_js, "const x = 1;") != null);
try std.testing.expect(std.mem.indexOf(u8, e2.command.eval_js, "return x + y;") != null);
defer std.testing.allocator.free(e2.command.eval_js);
const e3 = iter.next().?;
try std.testing.expect(e3.command == .tree);
try std.testing.expect(iter.next() == null);
}
test "ScriptIterator unterminated EVAL" {
const script =
\\EVAL """
\\ const x = 1;
;
var iter = ScriptIterator.init(script, std.testing.allocator);
const e1 = iter.next().?;
try std.testing.expect(e1.command == .natural_language);
try std.testing.expectEqualStrings("unterminated EVAL block", e1.command.natural_language);
}

View File

@@ -0,0 +1,298 @@
const std = @import("std");
const Command = @import("Command.zig");
const ToolExecutor = @import("ToolExecutor.zig");
const Terminal = @import("Terminal.zig");
const Self = @This();
tool_executor: *ToolExecutor,
terminal: *Terminal,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, tool_executor: *ToolExecutor, terminal: *Terminal) Self {
return .{
.allocator = allocator,
.tool_executor = tool_executor,
.terminal = terminal,
};
}
pub const ExecResult = struct {
output: []const u8,
failed: bool,
};
/// Execute a command and return the result with success/failure status.
pub fn executeWithResult(self: *Self, a: std.mem.Allocator, cmd: Command.Command) ExecResult {
const result = switch (cmd) {
.goto => |url| self.execGoto(a, url),
.click => |target| self.execClick(a, target),
.type_cmd => |args| self.execType(a, args),
.wait => |selector| self.tool_executor.call(a, "waitForSelector", buildJson(a, .{ .selector = selector })) catch "Error: wait failed",
.tree => self.tool_executor.call(a, "semantic_tree", "") catch "Error: tree failed",
.markdown => self.tool_executor.call(a, "markdown", "") catch "Error: markdown failed",
.extract => |args| self.execExtract(a, args),
.eval_js => |script| self.tool_executor.call(a, "evaluate", buildJson(a, .{ .script = script })) catch "Error: eval failed",
.exit, .natural_language, .comment, .login, .accept_cookies => unreachable,
};
return .{
.output = result,
.failed = std.mem.startsWith(u8, result, "Error:"),
};
}
pub fn execute(self: *Self, cmd: Command.Command) void {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
const result = self.executeWithResult(arena.allocator(), cmd);
self.terminal.printAssistant(result.output);
std.debug.print("\n", .{});
}
fn execGoto(self: *Self, arena: std.mem.Allocator, raw_url: []const u8) []const u8 {
const url = substituteEnvVars(arena, raw_url);
return self.tool_executor.call(arena, "goto", buildJson(arena, .{ .url = url })) catch "Error: goto failed";
}
fn execClick(self: *Self, arena: std.mem.Allocator, raw_target: []const u8) []const u8 {
const target = substituteEnvVars(arena, raw_target);
// Try as CSS selector via interactiveElements + click
// First get interactive elements to find the target
const elements_result = self.tool_executor.call(arena, "interactiveElements", "") catch
return "Error: failed to get interactive elements";
// Try to find a backendNodeId by searching the elements result for the target text
if (findNodeIdByText(arena, elements_result, target)) |node_id| {
const args = std.fmt.allocPrint(arena, "{{\"backendNodeId\":{d}}}", .{node_id}) catch
return "Error: failed to build click args";
return self.tool_executor.call(arena, "click", args) catch "Error: click failed";
}
return "Error: could not find element matching the target";
}
fn execType(self: *Self, arena: std.mem.Allocator, args: Command.TypeArgs) []const u8 {
const selector = escapeJs(arena, substituteEnvVars(arena, args.selector));
const value = escapeJs(arena, substituteEnvVars(arena, args.value));
// Use JavaScript to set the value on the element matching the selector
const script = std.fmt.allocPrint(arena,
\\(function() {{
\\ var el = document.querySelector("{s}");
\\ if (!el) return "Error: element not found";
\\ el.value = "{s}";
\\ el.dispatchEvent(new Event("input", {{bubbles: true}}));
\\ return "Typed into " + el.tagName;
\\}})()
, .{ selector, value }) catch return "Error: failed to build type script";
return self.tool_executor.call(arena, "evaluate", buildJson(arena, .{ .script = script })) catch "Error: type failed";
}
fn execExtract(self: *Self, arena: std.mem.Allocator, args: Command.ExtractArgs) []const u8 {
const selector = escapeJs(arena, substituteEnvVars(arena, args.selector));
const script = std.fmt.allocPrint(arena,
\\JSON.stringify(Array.from(document.querySelectorAll("{s}")).map(el => el.textContent.trim()))
, .{selector}) catch return "Error: failed to build extract script";
const result = self.tool_executor.call(arena, "evaluate", buildJson(arena, .{ .script = script })) catch
return "Error: extract failed";
if (args.file) |raw_file| {
const file = sanitizePath(raw_file) orelse {
self.terminal.printError("Invalid output path: must be relative and not traverse above working directory");
return result;
};
std.fs.cwd().writeFile(.{
.sub_path = file,
.data = result,
}) catch {
self.terminal.printError("Failed to write to file");
return result;
};
const msg = std.fmt.allocPrint(arena, "Extracted to {s}", .{file}) catch return "Extracted.";
return msg;
}
return result;
}
/// Substitute $VAR_NAME references with values from the environment.
fn substituteEnvVars(arena: std.mem.Allocator, input: []const u8) []const u8 {
// Quick scan: if no $ present, return as-is
if (std.mem.indexOfScalar(u8, input, '$') == null) return input;
var result: std.ArrayListUnmanaged(u8) = .empty;
var i: usize = 0;
while (i < input.len) {
if (input[i] == '$') {
// Find the end of the variable name (alphanumeric + underscore)
const var_start = i + 1;
var var_end = var_start;
while (var_end < input.len and (std.ascii.isAlphanumeric(input[var_end]) or input[var_end] == '_')) {
var_end += 1;
}
if (var_end > var_start) {
const var_name = input[var_start..var_end];
// We need a null-terminated string for getenv
const var_name_z = arena.dupeZ(u8, var_name) catch return input;
if (std.posix.getenv(var_name_z)) |env_val| {
result.appendSlice(arena, env_val) catch return input;
} else {
// Keep the original $VAR if not found
result.appendSlice(arena, input[i..var_end]) catch return input;
}
i = var_end;
} else {
result.append(arena, '$') catch return input;
i += 1;
}
} else {
result.append(arena, input[i]) catch return input;
i += 1;
}
}
return result.toOwnedSlice(arena) catch input;
}
/// Escape a string for safe interpolation inside a JS double-quoted string literal.
fn escapeJs(arena: std.mem.Allocator, input: []const u8) []const u8 {
// Quick scan: if nothing to escape, return as-is
const needs_escape = for (input) |ch| {
if (ch == '"' or ch == '\\' or ch == '\n' or ch == '\r' or ch == '\t') break true;
} else false;
if (!needs_escape) return input;
var out: std.ArrayListUnmanaged(u8) = .empty;
for (input) |ch| {
switch (ch) {
'\\' => out.appendSlice(arena, "\\\\") catch return input,
'"' => out.appendSlice(arena, "\\\"") catch return input,
'\n' => out.appendSlice(arena, "\\n") catch return input,
'\r' => out.appendSlice(arena, "\\r") catch return input,
'\t' => out.appendSlice(arena, "\\t") catch return input,
else => out.append(arena, ch) catch return input,
}
}
return out.toOwnedSlice(arena) catch input;
}
/// Validate that a file path is safe: relative, no traversal above cwd.
fn sanitizePath(path: []const u8) ?[]const u8 {
// Reject absolute paths
if (path.len > 0 and path[0] == '/') return null;
// Reject paths containing ".." components
var iter = std.mem.splitScalar(u8, path, '/');
while (iter.next()) |component| {
if (std.mem.eql(u8, component, "..")) return null;
}
return path;
}
fn findNodeIdByText(arena: std.mem.Allocator, elements_json: []const u8, target: []const u8) ?u32 {
_ = arena;
// Simple text search in the JSON result for the target text
// Look for patterns like "backendNodeId":N near the target text
// This is a heuristic — search for the target text, then scan backwards for backendNodeId
var pos: usize = 0;
while (std.mem.indexOfPos(u8, elements_json, pos, target)) |idx| {
// Search backwards from idx for "backendNodeId":
const search_start = if (idx > 200) idx - 200 else 0;
const window = elements_json[search_start..idx];
if (std.mem.lastIndexOf(u8, window, "\"backendNodeId\":")) |bid_offset| {
const num_start = search_start + bid_offset + "\"backendNodeId\":".len;
const num_end = std.mem.indexOfAnyPos(u8, elements_json, num_start, ",}] \n") orelse continue;
const num_str = elements_json[num_start..num_end];
return std.fmt.parseInt(u32, num_str, 10) catch {
pos = idx + 1;
continue;
};
}
pos = idx + 1;
}
return null;
}
fn buildJson(arena: std.mem.Allocator, value: anytype) []const u8 {
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(value, .{}, &aw.writer) catch return "{}";
return aw.written();
}
// --- Tests ---
test "escapeJs no escaping needed" {
const result = escapeJs(std.testing.allocator, "hello world");
try std.testing.expectEqualStrings("hello world", result);
}
test "escapeJs quotes and backslashes" {
const result = escapeJs(std.testing.allocator, "say \"hello\\world\"");
defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("say \\\"hello\\\\world\\\"", result);
}
test "escapeJs newlines and tabs" {
const result = escapeJs(std.testing.allocator, "line1\nline2\ttab");
defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("line1\\nline2\\ttab", result);
}
test "escapeJs injection attempt" {
const result = escapeJs(std.testing.allocator, "\"; alert(1); //");
defer std.testing.allocator.free(result);
try std.testing.expectEqualStrings("\\\"; alert(1); //", result);
}
test "sanitizePath allows relative" {
try std.testing.expectEqualStrings("output.json", sanitizePath("output.json").?);
try std.testing.expectEqualStrings("dir/file.json", sanitizePath("dir/file.json").?);
}
test "sanitizePath rejects absolute" {
try std.testing.expect(sanitizePath("/etc/passwd") == null);
}
test "sanitizePath rejects traversal" {
try std.testing.expect(sanitizePath("../../../etc/passwd") == null);
try std.testing.expect(sanitizePath("foo/../../bar") == null);
}
test "substituteEnvVars no vars" {
const result = substituteEnvVars(std.testing.allocator, "hello world");
try std.testing.expectEqualStrings("hello world", result);
}
test "substituteEnvVars with HOME" {
// Use arena since substituteEnvVars makes intermediate allocations (dupeZ)
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const a = arena.allocator();
const result = substituteEnvVars(a, "dir=$HOME/test");
// Result should not contain $HOME literally (it got substituted)
try std.testing.expect(std.mem.indexOf(u8, result, "$HOME") == null);
try std.testing.expect(std.mem.indexOf(u8, result, "/test") != null);
}
test "substituteEnvVars missing var kept literal" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const result = substituteEnvVars(arena.allocator(), "$UNLIKELY_VAR_12345");
try std.testing.expectEqualStrings("$UNLIKELY_VAR_12345", result);
}
test "substituteEnvVars bare dollar" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const result = substituteEnvVars(arena.allocator(), "price is $ 5");
try std.testing.expectEqualStrings("price is $ 5", result);
}

146
src/agent/Recorder.zig Normal file
View File

@@ -0,0 +1,146 @@
const std = @import("std");
const Command = @import("Command.zig");
const Self = @This();
file: ?std.fs.File,
/// Commands that are read-only / ephemeral and should NOT be recorded.
pub fn init(path: ?[]const u8) Self {
const file: ?std.fs.File = if (path) |p|
std.fs.cwd().createFile(p, .{ .truncate = false }) catch |err| blk: {
std.debug.print("Warning: could not open recording file: {s}\n", .{@errorName(err)});
break :blk null;
}
else
null;
// Seek to end for appending
if (file) |f| {
f.seekFromEnd(0) catch {};
}
return .{ .file = file };
}
pub fn deinit(self: *Self) void {
if (self.file) |f| f.close();
}
/// Record a successfully executed command line to the .panda file.
/// Skips read-only commands (WAIT, TREE, MARKDOWN).
pub fn record(self: *Self, line: []const u8) void {
const f = self.file orelse return;
// Check if this command should be skipped
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0) return;
if (trimmed[0] == '#') return;
const cmd_end = std.mem.indexOfAny(u8, trimmed, &std.ascii.whitespace) orelse trimmed.len;
const cmd_word = trimmed[0..cmd_end];
if (isNonRecordedCommand(cmd_word)) return;
f.writeAll(trimmed) catch return;
f.writeAll("\n") catch return;
}
/// Record a comment line (e.g. # INTENT: ...).
pub fn recordComment(self: *Self, comment: []const u8) void {
const f = self.file orelse return;
f.writeAll(comment) catch return;
f.writeAll("\n") catch return;
}
fn isNonRecordedCommand(cmd_word: []const u8) bool {
const non_recorded = [_][]const u8{ "WAIT", "TREE", "MARKDOWN", "MD" };
inline for (non_recorded) |skip| {
if (eqlIgnoreCase(cmd_word, skip)) return true;
}
return false;
}
fn eqlIgnoreCase(a: []const u8, comptime upper: []const u8) bool {
if (a.len != upper.len) return false;
for (a, upper) |ac, uc| {
if (std.ascii.toUpper(ac) != uc) return false;
}
return true;
}
// --- Tests ---
test "isNonRecordedCommand" {
try std.testing.expect(isNonRecordedCommand("WAIT"));
try std.testing.expect(isNonRecordedCommand("wait"));
try std.testing.expect(isNonRecordedCommand("TREE"));
try std.testing.expect(isNonRecordedCommand("MARKDOWN"));
try std.testing.expect(isNonRecordedCommand("MD"));
try std.testing.expect(isNonRecordedCommand("md"));
try std.testing.expect(!isNonRecordedCommand("GOTO"));
try std.testing.expect(!isNonRecordedCommand("CLICK"));
try std.testing.expect(!isNonRecordedCommand("EXTRACT"));
}
test "record writes state-mutating commands" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const file = tmp.dir.createFile("test.panda", .{ .read = true }) catch unreachable;
var recorder = Self{ .file = file };
defer recorder.deinit();
recorder.record("GOTO https://example.com");
recorder.record("CLICK \"Login\"");
recorder.record("TREE"); // should be skipped
recorder.record("WAIT \".dashboard\""); // should be skipped
recorder.record("MARKDOWN"); // should be skipped
recorder.record("EXTRACT \".title\"");
recorder.recordComment("# INTENT: LOGIN");
// Read back and verify
file.seekTo(0) catch unreachable;
var buf: [512]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expect(std.mem.indexOf(u8, content, "GOTO https://example.com\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "CLICK \"Login\"\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "EXTRACT \".title\"\n") != null);
try std.testing.expect(std.mem.indexOf(u8, content, "# INTENT: LOGIN\n") != null);
// Verify skipped commands are NOT present
try std.testing.expect(std.mem.indexOf(u8, content, "TREE") == null);
try std.testing.expect(std.mem.indexOf(u8, content, "WAIT") == null);
try std.testing.expect(std.mem.indexOf(u8, content, "MARKDOWN") == null);
}
test "record skips empty and comment lines" {
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const file = tmp.dir.createFile("test2.panda", .{ .read = true }) catch unreachable;
var recorder = Self{ .file = file };
defer recorder.deinit();
recorder.record("");
recorder.record(" ");
recorder.record("# this is a comment");
recorder.record("GOTO https://example.com");
file.seekTo(0) catch unreachable;
var buf: [256]u8 = undefined;
const n = file.readAll(&buf) catch unreachable;
const content = buf[0..n];
try std.testing.expectEqualStrings("GOTO https://example.com\n", content);
}
test "recorder with null file is no-op" {
var recorder = Self{ .file = null };
recorder.record("GOTO https://example.com");
recorder.recordComment("# test");
recorder.deinit();
}

64
src/agent/Terminal.zig Normal file
View File

@@ -0,0 +1,64 @@
const std = @import("std");
const c = @cImport({
@cInclude("linenoise.h");
});
const Self = @This();
const ansi_reset = "\x1b[0m";
const ansi_bold = "\x1b[1m";
const ansi_dim = "\x1b[2m";
const ansi_cyan = "\x1b[36m";
const ansi_green = "\x1b[32m";
const ansi_yellow = "\x1b[33m";
const ansi_red = "\x1b[31m";
history_path: ?[:0]const u8,
pub fn init(history_path: ?[:0]const u8) Self {
c.linenoiseSetMultiLine(1);
const self = Self{ .history_path = history_path };
if (history_path) |path| {
_ = c.linenoiseHistoryLoad(path.ptr);
}
return self;
}
pub fn readLine(self: *Self, prompt: [*:0]const u8) ?[]const u8 {
const line = c.linenoise(prompt) orelse return null;
const slice = std.mem.sliceTo(line, 0);
if (slice.len > 0) {
_ = c.linenoiseHistoryAdd(line);
if (self.history_path) |path| {
_ = c.linenoiseHistorySave(path.ptr);
}
}
return slice;
}
pub fn freeLine(_: *Self, line: []const u8) void {
c.linenoiseFree(@ptrCast(@constCast(line.ptr)));
}
pub fn printAssistant(_: *Self, text: []const u8) void {
const fd = std.posix.STDOUT_FILENO;
_ = std.posix.write(fd, text) catch {};
}
pub fn printToolCall(_: *Self, name: []const u8, args: []const u8) void {
std.debug.print("\n{s}{s}[tool: {s}]{s} {s}\n", .{ ansi_dim, ansi_cyan, name, ansi_reset, args });
}
pub fn printToolResult(_: *Self, name: []const u8, result: []const u8) void {
const truncated = if (result.len > 500) result[0..500] else result;
const ellipsis: []const u8 = if (result.len > 500) "..." else "";
std.debug.print("{s}{s}[result: {s}]{s} {s}{s}\n", .{ ansi_dim, ansi_green, name, ansi_reset, truncated, ellipsis });
}
pub fn printError(_: *Self, msg: []const u8) void {
std.debug.print("{s}{s}Error: {s}{s}\n", .{ ansi_bold, ansi_red, msg, ansi_reset });
}
pub fn printInfo(_: *Self, msg: []const u8) void {
std.debug.print("{s}{s}{s}\n", .{ ansi_dim, msg, ansi_reset });
}

446
src/agent/ToolExecutor.zig Normal file
View File

@@ -0,0 +1,446 @@
const std = @import("std");
const lp = @import("lightpanda");
const zenai = @import("zenai");
const App = @import("../App.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const CDPNode = @import("../cdp/Node.zig");
const mcp_tools = @import("../mcp/tools.zig");
const protocol = @import("../mcp/protocol.zig");
const Self = @This();
allocator: std.mem.Allocator,
app: *App,
http_client: *HttpClient,
notification: *lp.Notification,
browser: lp.Browser,
session: *lp.Session,
node_registry: CDPNode.Registry,
tool_schema_arena: std.heap.ArenaAllocator,
pub fn init(allocator: std.mem.Allocator, app: *App) !*Self {
const http_client = try HttpClient.init(allocator, &app.network);
errdefer http_client.deinit();
const notification = try lp.Notification.init(allocator);
errdefer notification.deinit();
const self = try allocator.create(Self);
errdefer allocator.destroy(self);
var browser = try lp.Browser.init(app, .{ .http_client = http_client });
errdefer browser.deinit();
self.* = .{
.allocator = allocator,
.app = app,
.http_client = http_client,
.notification = notification,
.browser = browser,
.session = undefined,
.node_registry = CDPNode.Registry.init(allocator),
.tool_schema_arena = std.heap.ArenaAllocator.init(allocator),
};
self.session = try self.browser.newSession(self.notification);
return self;
}
pub fn deinit(self: *Self) void {
self.tool_schema_arena.deinit();
self.node_registry.deinit();
self.browser.deinit();
self.notification.deinit();
self.http_client.deinit();
self.allocator.destroy(self);
}
/// Returns the list of tools in zenai provider.Tool format.
pub fn getTools(self: *Self) ![]const zenai.provider.Tool {
const arena = self.tool_schema_arena.allocator();
const tools = try arena.alloc(zenai.provider.Tool, mcp_tools.tool_list.len);
for (mcp_tools.tool_list, 0..) |t, i| {
const parsed = try std.json.parseFromSliceLeaky(
std.json.Value,
arena,
t.inputSchema,
.{},
);
tools[i] = .{
.name = t.name,
.description = t.description orelse "",
.parameters = parsed,
};
}
return tools;
}
/// Execute a tool by name with JSON arguments, returning the result as a string.
pub fn call(self: *Self, arena: std.mem.Allocator, tool_name: []const u8, arguments_json: []const u8) ![]const u8 {
const arguments = if (arguments_json.len > 0)
(std.json.parseFromSlice(std.json.Value, arena, arguments_json, .{}) catch
return "Error: invalid JSON arguments").value
else
null;
const Action = enum {
goto,
navigate,
markdown,
links,
nodeDetails,
interactiveElements,
structuredData,
detectForms,
evaluate,
eval,
semantic_tree,
click,
fill,
scroll,
waitForSelector,
};
const action_map = std.StaticStringMap(Action).initComptime(.{
.{ "goto", .goto },
.{ "navigate", .navigate },
.{ "markdown", .markdown },
.{ "links", .links },
.{ "nodeDetails", .nodeDetails },
.{ "interactiveElements", .interactiveElements },
.{ "structuredData", .structuredData },
.{ "detectForms", .detectForms },
.{ "evaluate", .evaluate },
.{ "eval", .eval },
.{ "semantic_tree", .semantic_tree },
.{ "click", .click },
.{ "fill", .fill },
.{ "scroll", .scroll },
.{ "waitForSelector", .waitForSelector },
});
const action = action_map.get(tool_name) orelse return "Error: unknown tool";
return switch (action) {
.goto, .navigate => self.execGoto(arena, arguments),
.markdown => self.execMarkdown(arena, arguments),
.links => self.execLinks(arena, arguments),
.nodeDetails => self.execNodeDetails(arena, arguments),
.interactiveElements => self.execInteractiveElements(arena, arguments),
.structuredData => self.execStructuredData(arena, arguments),
.detectForms => self.execDetectForms(arena, arguments),
.evaluate, .eval => self.execEvaluate(arena, arguments),
.semantic_tree => self.execSemanticTree(arena, arguments),
.click => self.execClick(arena, arguments),
.fill => self.execFill(arena, arguments),
.scroll => self.execScroll(arena, arguments),
.waitForSelector => self.execWaitForSelector(arena, arguments),
};
}
fn execGoto(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const GotoParams = struct {
url: [:0]const u8,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrErr(GotoParams, arena, arguments) orelse return "Error: missing or invalid 'url' argument";
self.performGoto(args.url, args.timeout, args.waitUntil) catch return "Error: navigation failed";
return "Navigated successfully.";
}
fn execMarkdown(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
var aw: std.Io.Writer.Allocating = .init(arena);
lp.markdown.dump(page.window._document.asNode(), .{}, &aw.writer, page) catch return "Error: failed to generate markdown";
return aw.written();
}
fn execLinks(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
const links_list = lp.links.collectLinks(arena, page.window._document.asNode(), page) catch
return "Error: failed to collect links";
var aw: std.Io.Writer.Allocating = .init(arena);
for (links_list, 0..) |href, i| {
if (i > 0) aw.writer.writeByte('\n') catch {};
aw.writer.writeAll(href) catch {};
}
return aw.written();
}
fn execNodeDetails(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId";
_ = self.session.currentPage() orelse return "Error: page not loaded";
const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse
return "Error: node not found";
const page = self.session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &self.node_registry, page) catch
return "Error: failed to get node details";
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(&details, .{}, &aw.writer) catch return "Error: serialization failed";
return aw.written();
}
fn execInteractiveElements(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
const elements = lp.interactive.collectInteractiveElements(page.window._document.asNode(), arena, page) catch
return "Error: failed to collect interactive elements";
lp.interactive.registerNodes(elements, &self.node_registry) catch
return "Error: failed to register nodes";
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(elements, .{}, &aw.writer) catch return "Error: serialization failed";
return aw.written();
}
fn execStructuredData(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
const data = lp.structured_data.collectStructuredData(page.window._document.asNode(), arena, page) catch
return "Error: failed to collect structured data";
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(data, .{}, &aw.writer) catch return "Error: serialization failed";
return aw.written();
}
fn execDetectForms(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const UrlParams = struct {
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(UrlParams, arena, arguments);
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
const forms_data = lp.forms.collectForms(arena, page.window._document.asNode(), page) catch
return "Error: failed to collect forms";
lp.forms.registerNodes(forms_data, &self.node_registry) catch
return "Error: failed to register form nodes";
var aw: std.Io.Writer.Allocating = .init(arena);
std.json.Stringify.value(forms_data, .{}, &aw.writer) catch return "Error: serialization failed";
return aw.written();
}
fn execEvaluate(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct {
script: [:0]const u8,
url: ?[:0]const u8 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'script' argument";
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
var ls: lp.js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const js_result = ls.local.compileAndRun(args.script, null) catch |err| {
const caught = try_catch.caughtOrError(arena, err);
var aw: std.Io.Writer.Allocating = .init(arena);
caught.format(&aw.writer) catch {};
return aw.written();
};
return js_result.toStringSliceWithAlloc(arena) catch "undefined";
}
fn execSemanticTree(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const TreeParams = struct {
url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
timeout: ?u32 = null,
waitUntil: ?lp.Config.WaitUntil = null,
};
const args = parseArgsOrDefault(TreeParams, arena, arguments);
const page = self.ensurePage(args.url, args.timeout, args.waitUntil) catch return "Error: page not loaded";
var root_node = page.window._document.asNode();
if (args.backendNodeId) |node_id| {
if (self.node_registry.lookup_by_id.get(node_id)) |n| {
root_node = n.dom;
}
}
const st = lp.SemanticTree{
.dom_node = root_node,
.registry = &self.node_registry,
.page = page,
.arena = arena,
.prune = true,
.max_depth = args.maxDepth orelse std.math.maxInt(u32) - 1,
};
var aw: std.Io.Writer.Allocating = .init(arena);
st.textStringify(&aw.writer) catch return "Error: failed to generate semantic tree";
return aw.written();
}
fn execClick(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct { backendNodeId: CDPNode.Id };
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId";
const page = self.session.currentPage() orelse return "Error: page not loaded";
const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found";
lp.actions.click(node.dom, page) catch |err| {
if (err == error.InvalidNodeType) return "Error: node is not an HTML element";
return "Error: failed to click element";
};
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
page.url,
page_title orelse "(none)",
}) catch "Clicked element.";
}
fn execFill(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct {
backendNodeId: CDPNode.Id,
text: []const u8,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing backendNodeId or text";
const page = self.session.currentPage() orelse return "Error: page not loaded";
const node = self.node_registry.lookup_by_id.get(args.backendNodeId) orelse return "Error: node not found";
lp.actions.fill(node.dom, args.text, page) catch |err| {
if (err == error.InvalidNodeType) return "Error: node is not an input, textarea or select";
return "Error: failed to fill element";
};
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId,
args.text,
page.url,
page_title orelse "(none)",
}) catch "Filled element.";
}
fn execScroll(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct {
backendNodeId: ?CDPNode.Id = null,
x: ?i32 = null,
y: ?i32 = null,
};
const args = parseArgsOrDefault(Params, arena, arguments);
const page = self.session.currentPage() orelse return "Error: page not loaded";
var target_node: ?*@import("../browser/webapi/Node.zig") = null;
if (args.backendNodeId) |node_id| {
const node = self.node_registry.lookup_by_id.get(node_id) orelse return "Error: node not found";
target_node = node.dom;
}
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
if (err == error.InvalidNodeType) return "Error: node is not an element";
return "Error: failed to scroll";
};
const page_title = page.getTitle() catch null;
return std.fmt.allocPrint(arena, "Scrolled to x: {d}, y: {d}. Page url: {s}, title: {s}", .{
args.x orelse 0,
args.y orelse 0,
page.url,
page_title orelse "(none)",
}) catch "Scrolled.";
}
fn execWaitForSelector(self: *Self, arena: std.mem.Allocator, arguments: ?std.json.Value) []const u8 {
const Params = struct {
selector: [:0]const u8,
timeout: ?u32 = null,
};
const args = parseArgsOrErr(Params, arena, arguments) orelse return "Error: missing 'selector' argument";
_ = self.session.currentPage() orelse return "Error: page not loaded";
const timeout_ms = args.timeout orelse 5000;
const node = lp.actions.waitForSelector(args.selector, timeout_ms, self.session) catch |err| {
if (err == error.InvalidSelector) return "Error: invalid selector";
if (err == error.Timeout) return "Error: timeout waiting for selector";
return "Error: failed waiting for selector";
};
const registered = self.node_registry.register(node) catch return "Element found.";
return std.fmt.allocPrint(arena, "Element found. backendNodeId: {d}", .{registered.id}) catch "Element found.";
}
fn ensurePage(self: *Self, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| {
try self.performGoto(u, timeout, waitUntil);
}
return self.session.currentPage() orelse error.PageNotLoaded;
}
fn performGoto(self: *Self, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !void {
const session = self.session;
if (session.page != null) {
session.removePage();
}
const page = try session.createPage();
_ = try page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },
});
var runner = try session.runner(.{});
try runner.wait(.{
.ms = timeout orelse 10000,
.until = waitUntil orelse .done,
});
}
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch .{};
}
fn parseArgsOrErr(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value) ?T {
const args_raw = arguments orelse return null;
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch null;
}

View File

@@ -28,7 +28,6 @@ const URL = @import("URL.zig");
const Config = @import("../Config.zig");
const Notification = @import("../Notification.zig");
const CookieJar = @import("webapi/storage/Cookie.zig").Jar;
const WebSocket = @import("webapi/net/WebSocket.zig");
const http = @import("../network/http.zig");
const Network = @import("../network/Network.zig");
@@ -117,8 +116,6 @@ obey_robots: bool,
cdp_client: ?CDPClient = null,
max_response_size: usize,
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
// both HTTP data as well as messages from an CDP connection.
// Furthermore, we have some tension between blocking scripts and request
@@ -159,7 +156,6 @@ pub fn init(allocator: Allocator, network: *Network) !*Client {
.http_proxy = http_proxy,
.tls_verify = network.config.tlsVerifyHost(),
.obey_robots = network.config.obeyRobots(),
.max_response_size = network.config.httpMaxResponseSize() orelse std.math.maxInt(u32),
};
return client;
@@ -228,18 +224,16 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
while (n) |node| {
n = node.next;
const conn: *http.Connection = @fieldParentPtr("node", node);
switch (conn.transport) {
.http => |transfer| {
if ((comptime abort_all) or transfer.req.frame_id == frame_id) {
transfer.kill();
}
},
.websocket => |ws| {
if ((comptime abort_all) or ws._page._frame_id == frame_id) {
ws.kill();
}
},
.none => unreachable,
var transfer = Transfer.fromConnection(conn) catch |err| {
// Let's cleanup what we can
self.removeConn(conn);
log.err(.http, "get private info", .{ .err = err, .source = "abort" });
continue;
};
if (comptime abort_all) {
transfer.kill();
} else if (transfer.req.frame_id == frame_id) {
transfer.kill();
}
}
}
@@ -270,11 +264,7 @@ fn _abort(self: *Client, comptime abort_all: bool, frame_id: u32) void {
var leftover: usize = 0;
while (it) |node| : (it = node.next) {
const conn: *http.Connection = @fieldParentPtr("node", node);
switch (conn.transport) {
.http => |transfer| std.debug.assert(transfer.aborted),
.websocket => {},
.none => {},
}
std.debug.assert((Transfer.fromConnection(conn) catch unreachable).aborted);
leftover += 1;
}
std.debug.assert(self.active == leftover);
@@ -716,6 +706,7 @@ fn makeTransfer(self: *Client, req: Request) !*Transfer {
.url = req.url,
.req = req,
.client = self,
.max_response_size = self.network.config.httpMaxResponseSize(),
};
return transfer;
}
@@ -773,11 +764,15 @@ fn makeRequest(self: *Client, conn: *http.Connection, transfer: *Transfer) anyer
// fails BEFORE `curl_multi_add_handle` succeeds, the we still need to do
// cleanup. But if things fail after `curl_multi_add_handle`, we expect
// perfom to pickup the failure and cleanup.
self.trackConn(conn) catch |err| {
self.in_use.append(&conn.node);
self.handles.add(conn) catch |err| {
transfer._conn = null;
transfer.deinit();
self.in_use.remove(&conn.node);
self.releaseConn(conn);
return err;
};
self.active += 1;
if (transfer.req.start_callback) |cb| {
cb(Response.fromTransfer(transfer)) catch |err| {
@@ -841,7 +836,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
// Also check on RecvError: proxy may send 407 with headers before
// closing the connection (CONNECT tunnel not yet established).
if (msg.err == null or msg.err.? == error.RecvError) {
transfer.detectAuthChallenge(msg.conn);
transfer.detectAuthChallenge(&msg.conn);
}
// In case of auth challenge
@@ -943,7 +938,7 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
if (!transfer._header_done_called) {
// In case of request w/o data, we need to call the header done
// callback now.
const proceed = try transfer.headerDoneCallback(msg.conn);
const proceed = try transfer.headerDoneCallback(&msg.conn);
if (!proceed) {
transfer.requestFailed(error.Abort, true);
return true;
@@ -989,63 +984,30 @@ fn processOneMessage(self: *Client, msg: http.Handles.MultiMessage, transfer: *T
fn processMessages(self: *Client) !bool {
var processed = false;
while (try self.handles.readMessage()) |msg| {
switch (msg.conn.transport) {
.http => |transfer| {
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
transfer.requestFailed(err, true);
if (transfer._detached_conn) |c| {
// Conn was removed from handles during redirect reconfiguration
// but not re-added. Release it directly to avoid double-remove.
self.in_use.remove(&c.node);
self.active -= 1;
self.releaseConn(c);
transfer._detached_conn = null;
}
break :blk true;
};
if (done) {
transfer.deinit();
processed = true;
}
},
.websocket => |ws| {
if (msg.err) |err| switch (err) {
error.GotNothing => ws.disconnected(null),
else => ws.disconnected(err),
} else {
// Clean close - no error
ws.disconnected(null);
}
processed = true;
},
.none => unreachable,
while (self.handles.readMessage()) |msg| {
const transfer = try Transfer.fromConnection(&msg.conn);
const done = self.processOneMessage(msg, transfer) catch |err| blk: {
log.err(.http, "process_messages", .{ .err = err, .req = transfer });
transfer.requestFailed(err, true);
if (transfer._detached_conn) |c| {
// Conn was removed from handles during redirect reconfiguration
// but not re-added. Release it directly to avoid double-remove.
self.in_use.remove(&c.node);
self.active -= 1;
self.releaseConn(c);
transfer._detached_conn = null;
}
break :blk true;
};
if (done) {
transfer.deinit();
processed = true;
}
}
return processed;
}
pub fn trackConn(self: *Client, conn: *http.Connection) !void {
self.in_use.append(&conn.node);
// Set private pointer so readMessage can find the Connection.
// Must be done each time since curl_easy_reset clears it when
// connections are returned to pool.
conn.setPrivate(conn) catch |err| {
self.in_use.remove(&conn.node);
self.releaseConn(conn);
return err;
};
self.handles.add(conn) catch |err| {
self.in_use.remove(&conn.node);
self.releaseConn(conn);
return err;
};
self.active += 1;
}
pub fn removeConn(self: *Client, conn: *http.Connection) void {
fn removeConn(self: *Client, conn: *http.Connection) void {
self.in_use.remove(&conn.node);
self.active -= 1;
if (self.handles.remove(conn)) {
@@ -1078,6 +1040,7 @@ pub const Request = struct {
resource_type: ResourceType,
credentials: ?[:0]const u8 = null,
notification: *Notification,
max_response_size: ?usize = null,
// This is only relevant for intercepted requests. If a request is flagged
// as blocking AND is intercepted, then it'll be up to us to wait until
@@ -1205,6 +1168,8 @@ pub const Transfer = struct {
aborted: bool = false,
max_response_size: ?usize = null,
// We'll store the response header here
response_header: ?ResponseHead = null,
@@ -1335,7 +1300,7 @@ pub const Transfer = struct {
const req = &self.req;
// Set callbacks and per-client settings on the pooled connection.
try conn.setWriteCallback(Transfer.dataCallback);
try conn.setCallbacks(Transfer.dataCallback);
try conn.setFollowLocation(false);
try conn.setProxy(client.http_proxy);
try conn.setTlsVerify(client.tls_verify, client.use_proxy);
@@ -1363,7 +1328,7 @@ pub const Transfer = struct {
try conn.setCookies(@ptrCast(cookies.ptr));
}
conn.transport = .{ .http = self };
try conn.setPrivate(self);
// add credentials
if (req.credentials) |creds| {
@@ -1563,9 +1528,11 @@ pub const Transfer = struct {
}
}
if (transfer.getContentLength()) |cl| {
if (cl > transfer.client.max_response_size) {
return error.ResponseTooLarge;
if (transfer.max_response_size) |max_size| {
if (transfer.getContentLength()) |cl| {
if (cl > max_size) {
return error.ResponseTooLarge;
}
}
}
@@ -1638,7 +1605,10 @@ pub const Transfer = struct {
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
var transfer = conn.transport.http;
var transfer = fromConnection(conn) catch |err| {
log.err(.http, "get private info", .{ .err = err, .source = "body callback" });
return http.writefunc_error;
};
if (!transfer._first_data_received) {
transfer._first_data_received = true;
@@ -1655,9 +1625,11 @@ pub const Transfer = struct {
// Pre-size buffer from Content-Length.
if (transfer.getContentLength()) |cl| {
if (cl > transfer.client.max_response_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
if (transfer.max_response_size) |max_size| {
if (cl > max_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
}
transfer._stream_buffer.ensureTotalCapacity(transfer.arena.allocator(), cl) catch {};
}
@@ -1666,9 +1638,11 @@ pub const Transfer = struct {
if (transfer._skip_body) return @intCast(chunk_len);
transfer.bytes_received += chunk_len;
if (transfer.bytes_received > transfer.client.max_response_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
if (transfer.max_response_size) |max_size| {
if (transfer.bytes_received > max_size) {
transfer._callback_error = error.ResponseTooLarge;
return http.writefunc_error;
}
}
const chunk = buffer[0..chunk_len];
@@ -1697,6 +1671,11 @@ pub const Transfer = struct {
return .{ .list = .{ .list = self.response_header.?._injected_headers } };
}
fn fromConnection(conn: *const http.Connection) !*Transfer {
const private = try conn.getPrivate();
return @ptrCast(@alignCast(private));
}
pub fn fulfill(transfer: *Transfer, status: u16, headers: []const http.Header, body: ?[]const u8) !void {
if (transfer._conn != null) {
// should never happen, should have been intercepted/paused, and then

View File

@@ -22,10 +22,23 @@ const DOMNode = @import("webapi/Node.zig");
const Element = @import("webapi/Element.zig");
const Event = @import("webapi/Event.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const Page = @import("Page.zig");
const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig");
fn dispatchInputAndChangeEvents(el: *Element, page: *Page) !void {
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
}
pub fn click(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
@@ -43,9 +56,107 @@ pub fn click(node: *DOMNode, page: *Page) !void {
};
}
pub fn hover(node: *DOMNode, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const mouseover_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseover"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouseover_event.asEvent()) catch |err| {
lp.log.err(.app, "hover mouseover failed", .{ .err = err });
return error.ActionFailed;
};
const mouseenter_event: *MouseEvent = try .initTrusted(comptime .wrap("mouseenter"), .{
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), mouseenter_event.asEvent()) catch |err| {
lp.log.err(.app, "hover mouseenter failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn press(node: ?*DOMNode, key: []const u8, page: *Page) !void {
const target = if (node) |n|
(n.is(Element) orelse return error.InvalidNodeType).asEventTarget()
else
page.document.asNode().asEventTarget();
const keydown_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keydown"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, page);
page._event_manager.dispatch(target, keydown_event.asEvent()) catch |err| {
lp.log.err(.app, "press keydown failed", .{ .err = err });
return error.ActionFailed;
};
const keyup_event: *KeyboardEvent = try .initTrusted(comptime .wrap("keyup"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.key = key,
}, page);
page._event_manager.dispatch(target, keyup_event.asEvent()) catch |err| {
lp.log.err(.app, "press keyup failed", .{ .err = err });
return error.ActionFailed;
};
}
pub fn selectOption(node: *DOMNode, value: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const select = el.is(Element.Html.Select) orelse return error.InvalidNodeType;
select.setValue(value, page) catch |err| {
lp.log.err(.app, "select setValue failed", .{ .err = err });
return error.ActionFailed;
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn setChecked(node: *DOMNode, checked: bool, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
const input = el.is(Element.Html.Input) orelse return error.InvalidNodeType;
if (input._input_type != .checkbox and input._input_type != .radio) {
return error.InvalidNodeType;
}
input.setChecked(checked, page) catch |err| {
lp.log.err(.app, "setChecked failed", .{ .err = err });
return error.ActionFailed;
};
// Match browser event order: click fires first, then input and change.
const click_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
}, page);
page._event_manager.dispatch(el.asEventTarget(), click_event.asEvent()) catch |err| {
lp.log.err(.app, "dispatch click event failed", .{ .err = err });
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
const el = node.is(Element) orelse return error.InvalidNodeType;
el.focus(page) catch |err| {
lp.log.err(.app, "fill focus failed", .{ .err = err });
};
if (el.is(Element.Html.Input)) |input| {
input.setValue(text, page) catch |err| {
lp.log.err(.app, "fill input failed", .{ .err = err });
@@ -65,15 +176,7 @@ pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
return error.InvalidNodeType;
}
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
try dispatchInputAndChangeEvents(el, page);
}
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {

View File

@@ -829,8 +829,6 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/net/URLSearchParams.zig"),
@import("../webapi/net/XMLHttpRequest.zig"),
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/net/WebSocket.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/streams/ReadableStream.zig"),
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
@import("../webapi/streams/ReadableStreamDefaultController.zig"),

View File

@@ -10,5 +10,20 @@
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
<div style="height: 500px;">Long content</div>
</div>
<div id="hoverTarget" onmouseover="window.hovered = true;">Hover Me</div>
<input id="keyTarget" onkeydown="window.keyPressed = event.key;" onkeyup="window.keyReleased = event.key;">
<select id="sel2" onchange="window.sel2Changed = this.value">
<option value="a">Alpha</option>
<option value="b">Beta</option>
<option value="c">Gamma</option>
</select>
<input id="chk" type="checkbox">
<input id="rad" type="radio" name="group1">
<script>
document.getElementById('chk').addEventListener('click', function() { window.chkClicked = true; });
document.getElementById('chk').addEventListener('change', function() { window.chkChanged = true; });
document.getElementById('rad').addEventListener('click', function() { window.radClicked = true; });
document.getElementById('rad').addEventListener('change', function() { window.radChanged = true; });
</script>
</body>
</html>

View File

@@ -1,563 +0,0 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=basic_echo type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('msg1');
});
ws.addEventListener('close', (e) => {
received.push(['close', e.code, e.reason]);
state.resolve();
});
ws.addEventListener('message', (e) => {
received.push(e.data);
ws.close(1000, 'bye');
});
await state.done(() => {
testing.expectEqual([
'echo-msg1',
['close', 1000, 'bye'],
], received);
});
}
</script>
<script id=multiple_messages type=module>
{
const state = await testing.async();
let received = [];
let sendCount = 0;
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('first');
ws.send('second');
ws.send('third');
sendCount = 3;
});
ws.addEventListener('message', (e) => {
received.push(e.data);
if (received.length === sendCount) {
ws.close();
}
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([
'echo-first',
'echo-second',
'echo-third',
], received);
});
}
</script>
<script id=empty_message type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('');
});
ws.addEventListener('message', (e) => {
received.push(e.data);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
// Empty message echoed with "echo-" prefix
testing.expectEqual(['echo-'], received);
});
}
</script>
<script id=boundary_125 type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// 120 bytes + "echo-" prefix = 125 bytes response (single-byte length)
ws.send('A'.repeat(120));
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([125], received);
});
}
</script>
<script id=boundary_16bit type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// 200 bytes message
ws.send('B'.repeat(200));
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([205], received); // 200 + "echo-".length
});
}
</script>
<script id=receive_large type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// Ask server to send us a 10000 byte message
ws.send('send-large:10000');
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
// Verify pattern (A-Z repeating)
const expected_start = 'ABCDEFGHIJ';
received.push(e.data.substring(0, 10) === expected_start);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([10000, true], received);
});
}
</script>
<script id=send_large type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
// Send 5000 byte message
ws.send('X'.repeat(5000));
});
ws.addEventListener('message', (e) => {
received.push(e.data.length);
// Check it starts with echo- and then our Xs
received.push(e.data.startsWith('echo-XXXXX'));
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([5005, true], received); // 5000 + "echo-".length
});
}
</script>
<script id=binary_uint8array type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
ws.send(data);
});
ws.addEventListener('message', (e) => {
const arr = new Uint8Array(e.data);
// Server prepends 0xEE marker to binary responses
received.push(arr[0]); // 0xEE marker
received.push(arr.length);
received.push(arr[1]); // Our first byte
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([0xEE, 6, 1], received);
});
}
</script>
<script id=binary_arraybuffer type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const buffer = new ArrayBuffer(4);
const view = new Uint8Array(buffer);
view[0] = 10;
view[1] = 20;
view[2] = 30;
view[3] = 40;
ws.send(buffer);
});
ws.addEventListener('message', (e) => {
const arr = new Uint8Array(e.data);
received.push(arr.length);
received.push(arr[1]); // First byte of our data (after 0xEE marker)
received.push(arr[4]); // Last byte of our data
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([5, 10, 40], received);
});
}
</script>
<script id=binary_int32array type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const arr = new Int32Array([0x01020304, 0x05060708]);
ws.send(arr);
});
ws.addEventListener('message', (e) => {
received.push(e.data.byteLength);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
// 1 marker byte + 8 bytes (2 x 4-byte int32)
testing.expectEqual([9], received);
});
}
</script>
<script id=binary_blob type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
const blob = new Blob(['hello'], { type: 'text/plain' });
ws.send(blob);
});
ws.addEventListener('message', (e) => {
const arr = new Uint8Array(e.data);
received.push(arr.length);
received.push(arr[0]); // 0xEE marker
// 'h' = 104, 'e' = 101, 'l' = 108
received.push(arr[1]); // 'h'
received.push(arr[2]); // 'e'
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([6, 0xEE, 104, 101], received);
});
}
</script>
<script id=server_close type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('close:1001:going away');
});
ws.addEventListener('close', (e) => {
received.push(e.code);
received.push(e.reason);
received.push(e.wasClean);
state.resolve();
});
await state.done(() => {
testing.expectEqual([1001, 'going away', true], received);
});
}
</script>
<script id=force_close type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
ws.send('force-close');
});
ws.addEventListener('close', (e) => {
received.push('closed');
received.push(e.wasClean);
state.resolve();
});
ws.addEventListener('error', () => {
received.push('error');
});
await state.done(() => {
// Connection was not cleanly closed - error fires before close
testing.expectEqual(['error', 'closed', false], received);
});
}
</script>
<script id=ready_state type=module>
{
const state = await testing.async();
let states = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
states.push(ws.readyState); // CONNECTING = 0
ws.addEventListener('open', () => {
states.push(ws.readyState); // OPEN = 1
ws.close();
states.push(ws.readyState); // CLOSING = 2
});
ws.addEventListener('close', () => {
states.push(ws.readyState); // CLOSED = 3
state.resolve();
});
await state.done(() => {
testing.expectEqual([0, 1, 2, 3], states);
});
}
</script>
<script id=buffered_amount type=module>
{
const state = await testing.async();
let results = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
results.push(ws.bufferedAmount); // Should be 0 initially
ws.send('test');
// bufferedAmount might be non-zero right after send
// but will go to 0 after message is sent
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([0], results);
});
}
</script>
<script id=handler_properties type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.onopen = () => {
ws.send('handler-test');
};
ws.onmessage = (e) => {
received.push(e.data);
ws.close();
};
ws.onclose = () => {
received.push('closed');
state.resolve();
};
await state.done(() => {
testing.expectEqual(['echo-handler-test', 'closed'], received);
});
}
</script>
<script id=binary_type type=module>
{
const state = await testing.async();
let results = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => {
results.push(ws.binaryType); // Default is 'blob'
ws.binaryType = 'arraybuffer';
results.push(ws.binaryType);
ws.binaryType = 'blob';
results.push(ws.binaryType);
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual(['blob', 'arraybuffer', 'blob'], results);
});
}
</script>
<script id=url_property type=module>
{
const state = await testing.async();
let result = null;
let ws = new WebSocket('ws://127.0.0.1:9584/path');
ws.addEventListener('open', () => {
result = ws.url;
ws.close();
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual('ws://127.0.0.1:9584/path', result);
});
}
</script>
<script id=receive_binary_as_blob type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
// binaryType defaults to 'blob'
ws.addEventListener('open', () => {
// Send binary data - server will echo with 0xEE marker
const data = new Uint8Array([1, 2, 3, 4, 5]);
ws.send(data);
});
ws.addEventListener('message', async (e) => {
// e.data should be a Blob
received.push(e.data instanceof Blob);
received.push(e.data.size);
// Read the Blob using FileReader
const reader = new FileReader();
reader.onload = () => {
const arr = new Uint8Array(reader.result);
received.push(arr[0]); // 0xEE marker
received.push(arr[1]); // Our first byte
ws.close();
};
reader.readAsArrayBuffer(e.data);
});
ws.addEventListener('close', () => {
state.resolve();
});
await state.done(() => {
testing.expectEqual([true, 6, 0xEE, 1], received);
});
}
</script>
<script id=url_with_fragment_rejected>
{
testing.expectError('SyntaxError', () => {
new WebSocket('ws://127.0.0.1:9584/#fragment');
});
}
</script>

View File

@@ -80,7 +80,6 @@ pub const Type = union(enum) {
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
submit_event: *@import("event/SubmitEvent.zig"),
form_data_event: *@import("event/FormDataEvent.zig"),
close_event: *@import("event/CloseEvent.zig"),
};
pub const Options = struct {
@@ -172,7 +171,6 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
.close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null,
.ui_event => |e| {
if (T == @import("event/UIEvent.zig")) {
return e;

View File

@@ -45,7 +45,6 @@ pub const Type = union(enum) {
visual_viewport: *@import("VisualViewport.zig"),
file_reader: *@import("FileReader.zig"),
font_face_set: *@import("css/FontFaceSet.zig"),
websocket: *@import("net/WebSocket.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -142,7 +141,6 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.visual_viewport => writer.writeAll("<VisualViewport>"),
.file_reader => writer.writeAll("<FileReader>"),
.font_face_set => writer.writeAll("<FontFaceSet>"),
.websocket => writer.writeAll("<WebSocket>"),
};
}
@@ -162,7 +160,6 @@ pub fn toString(self: *EventTarget) []const u8 {
.visual_viewport => return "[object VisualViewport]",
.file_reader => return "[object FileReader]",
.font_face_set => return "[object FontFaceSet]",
.websocket => return "[object WebSocket]",
};
}

View File

@@ -125,7 +125,7 @@ const PostMessageCallback = struct {
const target = self.port.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) {
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = .{ .value = self.message },
.data = self.message,
.origin = "",
.source = null,
}, page) catch |err| {

View File

@@ -791,7 +791,7 @@ const PostMessageCallback = struct {
const event_target = window.asEventTarget();
if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = .{ .value = self.message },
.data = self.message,
.origin = self.origin,
.source = self.source,
.bubbles = false,

View File

@@ -1,102 +0,0 @@
// 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 String = @import("../../../string.zig").String;
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const Event = @import("../Event.zig");
const Allocator = std.mem.Allocator;
const CloseEvent = @This();
_proto: *Event,
_code: u16 = 1000,
_reason: []const u8 = "",
_was_clean: bool = true,
const CloseEventOptions = struct {
code: u16 = 1000,
reason: []const u8 = "",
wasClean: bool = true,
};
const Options = Event.inheritOptions(CloseEvent, CloseEventOptions);
pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*CloseEvent {
const arena = try page.getArena(.{ .debug = "CloseEvent" });
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, page);
}
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*CloseEvent {
const arena = try page.getArena(.{ .debug = "CloseEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*CloseEvent {
const opts = _opts orelse Options{};
const event = try page._factory.event(
arena,
typ,
CloseEvent{
._proto = undefined,
._code = opts.code,
._reason = if (opts.reason.len > 0) try arena.dupe(u8, opts.reason) else "",
._was_clean = opts.wasClean,
},
);
Event.populatePrototypes(event, opts, trusted);
return event;
}
pub fn asEvent(self: *CloseEvent) *Event {
return self._proto;
}
pub fn getCode(self: *const CloseEvent) u16 {
return self._code;
}
pub fn getReason(self: *const CloseEvent) []const u8 {
return self._reason;
}
pub fn getWasClean(self: *const CloseEvent) bool {
return self._was_clean;
}
pub const JsApi = struct {
const js = @import("../../js/js.zig");
pub const bridge = js.Bridge(CloseEvent);
pub const Meta = struct {
pub const name = "CloseEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(CloseEvent.init, .{});
pub const code = bridge.accessor(CloseEvent.getCode, null, .{});
pub const reason = bridge.accessor(CloseEvent.getReason, null, .{});
pub const wasClean = bridge.accessor(CloseEvent.getWasClean, null, .{});
};

View File

@@ -30,23 +30,16 @@ const Allocator = std.mem.Allocator;
const MessageEvent = @This();
_proto: *Event,
_data: ?Data = null,
_data: ?js.Value.Temp = null,
_origin: []const u8 = "",
_source: ?*Window = null,
const MessageEventOptions = struct {
data: ?Data = null,
data: ?js.Value.Temp = null,
origin: ?[]const u8 = null,
source: ?*Window = null,
};
pub const Data = union(enum) {
value: js.Value.Temp,
string: []const u8,
arraybuffer: js.ArrayBuffer,
blob: *@import("../Blob.zig"),
};
const Options = Event.inheritOptions(MessageEvent, MessageEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent {
@@ -82,11 +75,7 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
pub fn deinit(self: *MessageEvent, session: *Session) void {
if (self._data) |d| {
switch (d) {
.value => |js_val| js_val.release(),
.blob => |blob| blob.releaseRef(session),
.string, .arraybuffer => {},
}
d.release();
}
self._proto.deinit(session);
}
@@ -103,7 +92,7 @@ pub fn asEvent(self: *MessageEvent) *Event {
return self._proto;
}
pub fn getData(self: *const MessageEvent) ?Data {
pub fn getData(self: *const MessageEvent) ?js.Value.Temp {
return self._data;
}

View File

@@ -1,727 +0,0 @@
// 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 log = @import("../../../log.zig");
const http = @import("../../../network/http.zig");
const js = @import("../../js/js.zig");
const Blob = @import("../Blob.zig");
const URL = @import("../../URL.zig");
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const HttpClient = @import("../../HttpClient.zig");
const Event = @import("../Event.zig");
const EventTarget = @import("../EventTarget.zig");
const MessageEvent = @import("../event/MessageEvent.zig");
const CloseEvent = @import("../event/CloseEvent.zig");
const Allocator = std.mem.Allocator;
const IS_DEBUG = @import("builtin").mode == .Debug;
const WebSocket = @This();
_rc: lp.RC(u8) = .{},
_page: *Page,
_proto: *EventTarget,
_arena: Allocator,
// Connection state
_ready_state: ReadyState = .connecting,
_url: [:0]const u8 = "",
_binary_type: BinaryType = .blob,
// Handshake tracking
_got_101: bool = false,
_got_upgrade: bool = false,
_conn: ?*http.Connection,
_http_client: *HttpClient,
// buffered outgoing messages
_send_queue: std.ArrayList(Message) = .empty,
_send_offset: usize = 0,
// buffered incoming frame
_recv_buffer: std.ArrayList(u8) = .empty,
// close info for event dispatch
_close_code: u16 = 1000,
_close_reason: []const u8 = "",
// Event handlers
_on_open: ?js.Function.Temp = null,
_on_message: ?js.Function.Temp = null,
_on_error: ?js.Function.Temp = null,
_on_close: ?js.Function.Temp = null,
pub const ReadyState = enum(u8) {
connecting = 0,
open = 1,
closing = 2,
closed = 3,
};
pub const BinaryType = enum {
blob,
arraybuffer,
};
pub fn init(url: []const u8, protocols_: ?[]const u8, page: *Page) !*WebSocket {
if (protocols_) |protocols| {
if (protocols.len > 0) {
log.warn(.not_implemented, "WS protocols", .{ .protocols = protocols });
}
}
{
if (url.len < 6) {
return error.SyntaxError;
}
const normalized_start = std.ascii.lowerString(&page.buf, url[0..6]);
if (!std.mem.startsWith(u8, normalized_start, "ws://") and !std.mem.startsWith(u8, normalized_start, "wss://")) {
return error.SyntaxError;
}
// Fragments are not allowed in WebSocket URLs
if (std.mem.indexOfScalar(u8, url, '#') != null) {
return error.SyntaxError;
}
}
const arena = try page.getArena(.{ .debug = "WebSocket" });
errdefer page.releaseArena(arena);
const resolved_url = try URL.resolve(arena, page.base(), url, .{ .always_dupe = true, .encode = true });
const http_client = page._session.browser.http_client;
const conn = http_client.network.newConnection() orelse {
return error.NoFreeConnection;
};
errdefer http_client.network.releaseConnection(conn);
try conn.setURL(resolved_url);
try conn.setConnectOnly(false);
try conn.setReadCallback(sendDataCallback, true);
try conn.setWriteCallback(receivedDataCallback);
try conn.setHeaderCallback(receivedHeaderCallback);
const self = try page._factory.eventTargetWithAllocator(arena, WebSocket{
._page = page,
._conn = conn,
._arena = arena,
._proto = undefined,
._url = resolved_url,
._http_client = http_client,
});
conn.transport = .{ .websocket = self };
try http_client.trackConn(conn);
if (comptime IS_DEBUG) {
log.info(.websocket, "connecting", .{ .url = url });
}
// Unlike an XHR object where we only selectively reference the instance
// while the request is actually inflight, WS connection is "inflight" from
// the moment it's created.
self.acquireRef();
return self;
}
pub fn deinit(self: *WebSocket, session: *Session) void {
self.cleanup();
if (self._on_open) |func| {
func.release();
}
if (self._on_message) |func| {
func.release();
}
if (self._on_error) |func| {
func.release();
}
if (self._on_close) |func| {
func.release();
}
for (self._send_queue.items) |msg| {
msg.deinit(session);
}
session.releaseArena(self._arena);
}
// we're being aborted internally (e.g. page shutting down)
pub fn kill(self: *WebSocket) void {
self.cleanup();
}
pub fn disconnected(self: *WebSocket, err_: ?anyerror) void {
const was_clean = self._ready_state == .closing and err_ == null;
self._ready_state = .closed;
if (err_) |err| {
log.warn(.websocket, "disconnected", .{ .err = err, .url = self._url });
} else {
log.info(.websocket, "disconnected", .{ .url = self._url, .reason = "closed" });
}
self.cleanup();
// Use 1006 (abnormal closure) if connection wasn't cleanly closed
const code = if (was_clean) self._close_code else 1006;
const reason = if (was_clean) self._close_reason else "";
// Spec requires error event before close on abnormal closure
if (!was_clean) {
self.dispatchErrorEvent() catch |err| {
log.err(.websocket, "error event dispatch failed", .{ .err = err });
};
}
self.dispatchCloseEvent(code, reason, was_clean) catch |err| {
log.err(.websocket, "close event dispatch failed", .{ .err = err });
};
}
fn cleanup(self: *WebSocket) void {
if (self._conn) |conn| {
self._http_client.removeConn(conn);
self._conn = null;
self.releaseRef(self._page._session);
self._send_queue.clearRetainingCapacity();
}
}
pub fn releaseRef(self: *WebSocket, session: *Session) void {
self._rc.release(self, session);
}
pub fn acquireRef(self: *WebSocket) void {
self._rc.acquire();
}
fn asEventTarget(self: *WebSocket) *EventTarget {
return self._proto;
}
fn queueMessage(self: *WebSocket, msg: Message) !void {
const was_empty = self._send_queue.items.len == 0;
try self._send_queue.append(self._arena, msg);
if (was_empty) {
// Unpause the send callback so libcurl will request data
if (self._conn) |conn| {
try conn.pause(.{ .cont = true });
}
}
}
/// WebSocket send() accepts string, Blob, ArrayBuffer, or TypedArray
const SendData = union(enum) {
blob: *Blob,
js_val: js.Value,
};
/// Union for extracting bytes from ArrayBuffer/TypedArray
const BinaryData = union(enum) {
int8: []i8,
uint8: []u8,
int16: []i16,
uint16: []u16,
int32: []i32,
uint32: []u32,
int64: []i64,
uint64: []u64,
fn asBuffer(self: BinaryData) []u8 {
return switch (self) {
.int8 => |b| @as([*]u8, @ptrCast(b.ptr))[0..b.len],
.uint8 => |b| b,
.int16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
.uint16 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 2],
.int32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
.uint32 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 4],
.int64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
.uint64 => |b| @as([*]u8, @ptrCast(b.ptr))[0 .. b.len * 8],
};
}
};
pub fn send(self: *WebSocket, data: SendData) !void {
if (self._ready_state != .open) {
return error.InvalidStateError;
}
// Get a dedicated arena for this message
const arena = try self._page._session.getArena(.{ .debug = "WebSocket message" });
errdefer self._page._session.releaseArena(arena);
switch (data) {
.blob => |blob| {
try self.queueMessage(.{ .binary = .{
.arena = arena,
.data = try arena.dupe(u8, blob._slice),
} });
},
.js_val => |js_val| {
if (js_val.isString()) |str| {
try self.queueMessage(.{ .text = .{
.arena = arena,
.data = try str.toSliceWithAlloc(arena),
} });
} else {
const binary = try js_val.toZig(BinaryData);
try self.queueMessage(.{ .binary = .{
.arena = arena,
.data = try arena.dupe(u8, binary.asBuffer()),
} });
}
},
}
}
pub fn close(self: *WebSocket, code_: ?u16, reason_: ?[]const u8) !void {
if (self._ready_state == .closing or self._ready_state == .closed) {
return;
}
// Validate close code per spec: must be 1000 or in range 3000-4999
if (code_) |code| {
if (code != 1000 and (code < 3000 or code > 4999)) {
return error.InvalidAccessError;
}
}
const code = code_ orelse 1000;
const reason = reason_ orelse "";
if (self._ready_state == .connecting) {
// Connection not yet established - fail it
self._ready_state = .closed;
self.cleanup();
try self.dispatchCloseEvent(code, reason, false);
return;
}
self._ready_state = .closing;
self._close_code = code;
self._close_reason = try self._arena.dupe(u8, reason);
try self.queueMessage(.close);
}
pub fn getUrl(self: *const WebSocket) []const u8 {
return self._url;
}
pub fn getReadyState(self: *const WebSocket) u16 {
return @intFromEnum(self._ready_state);
}
pub fn getBufferedAmount(self: *const WebSocket) u32 {
var buffered: u32 = 0;
for (self._send_queue.items) |msg| {
switch (msg) {
.text, .binary => |byte_msg| buffered += @intCast(byte_msg.data.len),
.close => buffered += @intCast(2 + self._close_reason.len),
}
}
return buffered;
}
pub fn getBinaryType(self: *const WebSocket) []const u8 {
return @tagName(self._binary_type);
}
pub fn setBinaryType(self: *WebSocket, value: []const u8) void {
if (std.meta.stringToEnum(BinaryType, value)) |bt| {
self._binary_type = bt;
}
}
pub fn getOnOpen(self: *const WebSocket) ?js.Function.Temp {
return self._on_open;
}
pub fn setOnOpen(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_open) |old| old.release();
if (cb_) |cb| {
self._on_open = try cb.tempWithThis(self);
} else {
self._on_open = null;
}
}
pub fn getOnMessage(self: *const WebSocket) ?js.Function.Temp {
return self._on_message;
}
pub fn setOnMessage(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_message) |old| old.release();
if (cb_) |cb| {
self._on_message = try cb.tempWithThis(self);
} else {
self._on_message = null;
}
}
pub fn getOnError(self: *const WebSocket) ?js.Function.Temp {
return self._on_error;
}
pub fn setOnError(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_error) |old| old.release();
if (cb_) |cb| {
self._on_error = try cb.tempWithThis(self);
} else {
self._on_error = null;
}
}
pub fn getOnClose(self: *const WebSocket) ?js.Function.Temp {
return self._on_close;
}
pub fn setOnClose(self: *WebSocket, cb_: ?js.Function) !void {
if (self._on_close) |old| old.release();
if (cb_) |cb| {
self._on_close = try cb.tempWithThis(self);
} else {
self._on_close = null;
}
}
fn dispatchOpenEvent(self: *WebSocket) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "open", self._on_open)) {
const event = try Event.initTrusted(comptime .wrap("open"), .{}, page);
try page._event_manager.dispatchDirect(target, event, self._on_open, .{ .context = "WebSocket open" });
}
}
fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsFrameType) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "message", self._on_message)) {
const msg_data: MessageEvent.Data = if (frame_type == .binary)
switch (self._binary_type) {
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
.blob => blk: {
const blob = try Blob.init(&.{data}, .{}, page);
blob.acquireRef();
break :blk .{ .blob = blob };
},
}
else
.{ .string = data };
const event = try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = msg_data,
.origin = "",
}, page);
try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" });
}
}
fn dispatchErrorEvent(self: *WebSocket) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "error", self._on_error)) {
const event = try Event.initTrusted(comptime .wrap("error"), .{}, page);
try page._event_manager.dispatchDirect(target, event, self._on_error, .{ .context = "WebSocket error" });
}
}
fn dispatchCloseEvent(self: *WebSocket, code: u16, reason: []const u8, was_clean: bool) !void {
const page = self._page;
const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "close", self._on_close)) {
const event = try CloseEvent.initTrusted(comptime .wrap("close"), .{
.code = code,
.reason = reason,
.wasClean = was_clean,
}, page);
try page._event_manager.dispatchDirect(target, event.asEvent(), self._on_close, .{ .context = "WebSocket close" });
}
}
fn sendDataCallback(buffer: [*]u8, buf_count: usize, buf_len: usize, data: *anyopaque) usize {
if (comptime IS_DEBUG) {
std.debug.assert(buf_count == 1);
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
return _sendDataCallback(conn, buffer[0..buf_len]) catch |err| {
log.warn(.websocket, "send callback", .{ .err = err });
return http.readfunc_pause;
};
}
fn _sendDataCallback(conn: *http.Connection, buf: []u8) !usize {
lp.assert(buf.len >= 2, "WS short buffer", .{ .len = buf.len });
const self = conn.transport.websocket;
if (self._send_queue.items.len == 0) {
// No data to send - pause until queueMessage is called
return http.readfunc_pause;
}
const msg = &self._send_queue.items[0];
switch (msg.*) {
.close => {
const code = self._close_code;
const reason = self._close_reason;
// Close frame: 2 bytes for code (big-endian) + optional reason
// Truncate reason to fit in buf (max 123 bytes per spec)
const reason_len: usize = @min(reason.len, 123, buf.len -| 2);
const frame_len = 2 + reason_len;
const to_copy = @min(buf.len, frame_len);
var close_payload: [125]u8 = undefined;
close_payload[0] = @intCast((code >> 8) & 0xFF);
close_payload[1] = @intCast(code & 0xFF);
if (reason_len > 0) {
@memcpy(close_payload[2..][0..reason_len], reason[0..reason_len]);
}
try conn.wsStartFrame(.close, to_copy);
@memcpy(buf[0..to_copy], close_payload[0..to_copy]);
_ = self._send_queue.orderedRemove(0);
return to_copy;
},
.text => |content| return self.writeContent(conn, buf, content, .text),
.binary => |content| return self.writeContent(conn, buf, content, .binary),
}
}
fn writeContent(self: *WebSocket, conn: *http.Connection, buf: []u8, byte_msg: Message.Content, frame_type: http.WsFrameType) !usize {
if (self._send_offset == 0) {
// start of the message
if (comptime IS_DEBUG) {
log.debug(.websocket, "send start", .{ .url = self._url, .len = byte_msg.data.len });
}
try conn.wsStartFrame(frame_type, byte_msg.data.len);
}
const remaining = byte_msg.data[self._send_offset..];
const to_copy = @min(remaining.len, buf.len);
@memcpy(buf[0..to_copy], remaining[0..to_copy]);
self._send_offset += to_copy;
if (self._send_offset >= byte_msg.data.len) {
const removed = self._send_queue.orderedRemove(0);
removed.deinit(self._page._session);
if (comptime IS_DEBUG) {
log.debug(.websocket, "send complete", .{ .url = self._url, .len = byte_msg.data.len, .queue = self._send_queue.items.len });
}
self._send_offset = 0;
}
return to_copy;
}
fn receivedDataCallback(buffer: [*]const u8, buf_count: usize, buf_len: usize, data: *anyopaque) usize {
if (comptime IS_DEBUG) {
std.debug.assert(buf_count == 1);
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
_receivedDataCallback(conn, buffer[0..buf_len]) catch |err| {
log.warn(.websocket, "receive callback", .{ .err = err });
// TODO: are there errors, like an invalid frame, that we shouldn't treat
// as an error?
return http.writefunc_error;
};
return buf_len;
}
fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void {
const self = conn.transport.websocket;
const meta = conn.wsMeta() orelse {
log.err(.websocket, "missing meta", .{ .url = self._url });
return error.NoFrameMeta;
};
if (meta.offset == 0) {
if (comptime IS_DEBUG) {
log.debug(.websocket, "incoming message", .{ .url = self._url, .len = meta.len, .bytes_left = meta.bytes_left, .type = meta.frame_type });
}
// Start of new frame. Pre-allocate buffer
self._recv_buffer.clearRetainingCapacity();
if (meta.len > self._http_client.max_response_size) {
return error.MessageTooLarge;
}
try self._recv_buffer.ensureTotalCapacity(self._arena, meta.len);
}
try self._recv_buffer.appendSlice(self._arena, data);
if (meta.bytes_left > 0) {
// still more data waiting for this frame
return;
}
const message = self._recv_buffer.items;
switch (meta.frame_type) {
.text, .binary => try self.dispatchMessageEvent(message, meta.frame_type),
.close => {
// Parse close frame: 2-byte code (big-endian) + optional reason
const received_code = if (message.len >= 2)
@as(u16, message[0]) << 8 | message[1]
else
1005; // No status code received
if (self._ready_state == .closing) {
// Client-initiated close: this is the server's response.
// Close handshake complete - disconnect.
self.disconnected(null);
} else {
// Server-initiated close: send reciprocal close frame per RFC 6455 §5.5.1
self._close_code = received_code;
if (message.len > 2) {
self._close_reason = try self._arena.dupe(u8, message[2..]);
}
self._ready_state = .closing;
try self.queueMessage(.close);
}
},
.ping, .pong, .cont => {},
}
}
// libcurl has no mechanism to signal that the connection is established. The
// best option I could come up with was looking for an upgrade header response.
fn receivedHeaderCallback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) usize {
if (comptime IS_DEBUG) {
std.debug.assert(header_count == 1);
}
const conn: *http.Connection = @ptrCast(@alignCast(data));
const self = conn.transport.websocket;
const header = buffer[0..buf_len];
if (self._got_101 == false and std.mem.startsWith(u8, header, "HTTP/")) {
if (std.mem.indexOf(u8, header, " 101 ")) |_| {
self._got_101 = true;
}
return buf_len;
}
// Empty line = end of headers
if (buf_len <= 2) {
if (!self._got_101 or !self._got_upgrade) {
return 0;
}
self._ready_state = .open;
log.info(.websocket, "connected", .{ .url = self._url });
self.dispatchOpenEvent() catch |err| {
log.err(.websocket, "open event fail", .{ .err = err });
};
return buf_len;
}
if (self._got_upgrade) {
// dont' care about headers once we've gotten the upgrade header
return buf_len;
}
const colon = std.mem.indexOfScalarPos(u8, header, 0, ':') orelse {
// weird, continue...
return buf_len;
};
if (std.ascii.eqlIgnoreCase(header[0..colon], "upgrade") == false) {
return buf_len;
}
const value = std.mem.trim(u8, header[colon + 1 ..], " \t\r\n");
if (std.ascii.eqlIgnoreCase(value, "websocket")) {
self._got_upgrade = true;
}
return buf_len;
}
const Message = union(enum) {
close,
text: Content,
binary: Content,
const Content = struct {
arena: Allocator,
data: []const u8,
};
fn deinit(self: Message, session: *Session) void {
switch (self) {
.text, .binary => |msg| session.releaseArena(msg.arena),
.close => {},
}
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(WebSocket);
pub const Meta = struct {
pub const name = "WebSocket";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(WebSocket.init, .{ .dom_exception = true });
pub const CONNECTING = bridge.property(@intFromEnum(ReadyState.connecting), .{ .template = true });
pub const OPEN = bridge.property(@intFromEnum(ReadyState.open), .{ .template = true });
pub const CLOSING = bridge.property(@intFromEnum(ReadyState.closing), .{ .template = true });
pub const CLOSED = bridge.property(@intFromEnum(ReadyState.closed), .{ .template = true });
pub const url = bridge.accessor(WebSocket.getUrl, null, .{});
pub const readyState = bridge.accessor(WebSocket.getReadyState, null, .{});
pub const bufferedAmount = bridge.accessor(WebSocket.getBufferedAmount, null, .{});
pub const binaryType = bridge.accessor(WebSocket.getBinaryType, WebSocket.setBinaryType, .{});
pub const protocol = bridge.property("", .{ .template = false });
pub const extensions = bridge.property("", .{ .template = false });
pub const onopen = bridge.accessor(WebSocket.getOnOpen, WebSocket.setOnOpen, .{});
pub const onmessage = bridge.accessor(WebSocket.getOnMessage, WebSocket.setOnMessage, .{});
pub const onerror = bridge.accessor(WebSocket.getOnError, WebSocket.setOnError, .{});
pub const onclose = bridge.accessor(WebSocket.getOnClose, WebSocket.setOnClose, .{});
pub const send = bridge.function(WebSocket.send, .{ .dom_exception = true });
pub const close = bridge.function(WebSocket.close, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: WebSocket" {
try testing.htmlRunner("net/websocket.html", .{});
}

View File

@@ -52,17 +52,19 @@ fn dispatchKeyEvent(cmd: *CDP.Command) !void {
try cmd.sendResult(null, .{});
// quickly ignore types we know we don't handle
switch (params.type) {
.keyUp, .rawKeyDown, .char => return,
.keyDown => {},
}
// rawKeyDown is a Chrome-internal event type not used for JS dispatch
if (params.type == .rawKeyDown) return;
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
const KeyboardEvent = @import("../../browser/webapi/event/KeyboardEvent.zig");
const keyboard_event = try KeyboardEvent.initTrusted(comptime .wrap("keydown"), .{
const keyboard_event = try KeyboardEvent.initTrusted(switch (params.type) {
.keyDown => comptime .wrap("keydown"),
.keyUp => comptime .wrap("keyup"),
.char => comptime .wrap("keypress"),
.rawKeyDown => unreachable,
}, .{
.key = params.key,
.code = params.code,
.altKey = params.modifiers & 1 == 1,

View File

@@ -40,6 +40,7 @@ pub const forms = @import("browser/forms.zig");
pub const actions = @import("browser/actions.zig");
pub const structured_data = @import("browser/structured_data.zig");
pub const mcp = @import("mcp.zig");
pub const agent = @import("agent.zig");
pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");

View File

@@ -40,7 +40,6 @@ pub const Scope = enum {
unknown_prop,
mcp,
cache,
websocket,
};
const Opts = struct {

View File

@@ -165,10 +165,30 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
app.network.run();
},
.agent => |opts| {
log.info(.app, "starting agent", .{});
var worker_thread = try std.Thread.spawn(.{}, agentThread, .{ allocator, app, opts });
defer worker_thread.join();
app.network.run();
},
else => unreachable,
}
}
fn agentThread(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) void {
defer app.network.stop();
var agent_instance = lp.agent.Agent.init(allocator, app, opts) catch |err| {
log.fatal(.app, "agent init error", .{ .err = err });
return;
};
defer agent_instance.deinit();
agent_instance.run();
}
fn fetchThread(app: *App, url: [:0]const u8, fetch_opts: lp.FetchOpts) void {
defer app.network.stop();
lp.fetch(app, url, fetch_opts) catch |err| {

View File

@@ -175,6 +175,74 @@ pub const tool_list = [_]protocol.Tool{
\\}
),
},
.{
.name = "hover",
.description = "Hover over an element, triggering mouseover and mouseenter events. Useful for menus, tooltips, and hover states.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to hover over." }
\\ },
\\ "required": ["backendNodeId"]
\\}
),
},
.{
.name = "press",
.description = "Press a keyboard key, dispatching keydown and keyup events. Use key names like 'Enter', 'Tab', 'Escape', 'ArrowDown', 'Backspace', or single characters like 'a', '1'.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "key": { "type": "string", "description": "The key to press (e.g. 'Enter', 'Tab', 'a')." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID of the element to target. Defaults to the document." }
\\ },
\\ "required": ["key"]
\\}
),
},
.{
.name = "selectOption",
.description = "Select an option in a <select> dropdown element by its value. Dispatches input and change events.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the <select> element." },
\\ "value": { "type": "string", "description": "The value of the option to select." }
\\ },
\\ "required": ["backendNodeId", "value"]
\\}
),
},
.{
.name = "setChecked",
.description = "Check or uncheck a checkbox or radio button. Dispatches input, change, and click events.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the checkbox or radio input element." },
\\ "checked": { "type": "boolean", "description": "Whether to check (true) or uncheck (false) the element." }
\\ },
\\ "required": ["backendNodeId", "checked"]
\\}
),
},
.{
.name = "findElement",
.description = "Find interactive elements by role and/or accessible name. Returns matching elements with their backend node IDs. Useful for locating specific elements without parsing the full semantic tree.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "role": { "type": "string", "description": "Optional ARIA role to match (e.g. 'button', 'link', 'textbox', 'checkbox')." },
\\ "name": { "type": "string", "description": "Optional accessible name substring to match (case-insensitive)." }
\\ }
\\}
),
},
};
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -282,6 +350,11 @@ const ToolAction = enum {
fill,
scroll,
waitForSelector,
hover,
press,
selectOption,
setChecked,
findElement,
};
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
@@ -300,6 +373,11 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "fill", .fill },
.{ "scroll", .scroll },
.{ "waitForSelector", .waitForSelector },
.{ "hover", .hover },
.{ "press", .press },
.{ "selectOption", .selectOption },
.{ "setChecked", .setChecked },
.{ "findElement", .findElement },
});
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -334,6 +412,11 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.fill => try handleFill(server, arena, req.id.?, call_params.arguments),
.scroll => try handleScroll(server, arena, req.id.?, call_params.arguments),
.waitForSelector => try handleWaitForSelector(server, arena, req.id.?, call_params.arguments),
.hover => try handleHover(server, arena, req.id.?, call_params.arguments),
.press => try handlePress(server, arena, req.id.?, call_params.arguments),
.selectOption => try handleSelectOption(server, arena, req.id.?, call_params.arguments),
.setChecked => try handleSetChecked(server, arena, req.id.?, call_params.arguments),
.findElement => try handleFindElement(server, arena, req.id.?, call_params.arguments),
}
}
@@ -400,17 +483,9 @@ fn handleNodeDetails(server: *Server, arena: std.mem.Allocator, id: std.json.Val
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "nodeDetails");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
_ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
const page = server.session.currentPage().?;
const details = lp.SemanticTree.getNodeDetails(arena, node.dom, &server.node_registry, page) catch {
const details = lp.SemanticTree.getNodeDetails(arena, resolved.node, &server.node_registry, resolved.page) catch {
return server.sendError(id, .InternalError, "Failed to get node details");
};
@@ -510,26 +585,19 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.click(node.dom, page) catch |err| {
lp.actions.click(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to click element");
};
const page_title = page.getTitle() catch null;
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Clicked element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
page.url,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
@@ -542,27 +610,20 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
text: []const u8,
};
const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.fill(node.dom, args.text, page) catch |err| {
lp.actions.fill(resolved.node, args.text, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
}
return server.sendError(id, .InternalError, "Failed to fill element");
};
const page_title = page.getTitle() catch null;
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Filled element (backendNodeId: {d}) with \"{s}\". Page url: {s}, title: {s}", .{
args.backendNodeId,
args.text,
page.url,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
@@ -636,6 +697,189 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleHover(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArgs(Params, arena, arguments, server, id, "hover");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.hover(resolved.node, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to hover element");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Hovered element (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handlePress(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
key: []const u8,
backendNodeId: ?CDPNode.Id = null,
};
const args = try parseArgs(Params, arena, arguments, server, id, "press");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
target_node = node.dom;
}
lp.actions.press(target_node, args.key, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to press key");
};
const page_title = page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Pressed key '{s}'. Page url: {s}, title: {s}", .{
args.key,
page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleSelectOption(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
value: []const u8,
};
const args = try parseArgs(Params, arena, arguments, server, id, "selectOption");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.selectOption(resolved.node, args.value, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a <select> element");
}
return server.sendError(id, .InternalError, "Failed to select option");
};
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Selected option '{s}' (backendNodeId: {d}). Page url: {s}, title: {s}", .{
args.value,
args.backendNodeId,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleSetChecked(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
backendNodeId: CDPNode.Id,
checked: bool,
};
const args = try parseArgs(Params, arena, arguments, server, id, "setChecked");
const resolved = try resolveNodeAndPage(server, id, args.backendNodeId);
lp.actions.setChecked(resolved.node, args.checked, resolved.page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not a checkbox or radio input");
}
return server.sendError(id, .InternalError, "Failed to set checked state");
};
const state_str = if (args.checked) "checked" else "unchecked";
const page_title = resolved.page.getTitle() catch null;
const result_text = try std.fmt.allocPrint(arena, "Set element (backendNodeId: {d}) to {s}. Page url: {s}, title: {s}", .{
args.backendNodeId,
state_str,
resolved.page.url,
page_title orelse "(none)",
});
const content = [_]protocol.TextContent([]const u8){.{ .text = result_text }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleFindElement(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct {
role: ?[]const u8 = null,
name: ?[]const u8 = null,
};
const args = try parseArgsOrDefault(Params, arena, arguments, server, id);
if (args.role == null and args.name == null) {
return server.sendError(id, .InvalidParams, "At least one of 'role' or 'name' must be provided");
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to collect interactive elements");
};
var matches: std.ArrayList(lp.interactive.InteractiveElement) = .empty;
for (elements) |el| {
if (args.role) |role| {
const el_role = el.role orelse continue;
if (!std.ascii.eqlIgnoreCase(el_role, role)) continue;
}
if (args.name) |name| {
const el_name = el.name orelse continue;
if (!containsIgnoreCase(el_name, name)) continue;
}
try matches.append(arena, el);
}
const matched = try matches.toOwnedSlice(arena);
lp.interactive.registerNodes(matched, &server.node_registry) catch |err| {
log.err(.mcp, "node registration failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to register element nodes");
};
var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(matched, .{}, &aw.writer);
const content = [_]protocol.TextContent([]const u8){.{ .text = aw.written() }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
if (needle.len > haystack.len) return false;
if (needle.len == 0) return true;
const end = haystack.len - needle.len + 1;
for (0..end) |i| {
if (std.ascii.eqlIgnoreCase(haystack[i..][0..needle.len], needle)) return true;
}
return false;
}
const NodeAndPage = struct { node: *DOMNode, page: *lp.Page };
fn resolveNodeAndPage(server: *Server, id: std.json.Value, node_id: CDPNode.Id) !NodeAndPage {
const page = server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded");
return error.PageNotLoaded;
};
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
try server.sendError(id, .InvalidParams, "Node not found");
return error.InvalidParams;
};
return .{ .node = node.dom, .page = page };
}
fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) !*lp.Page {
if (url) |u| {
try performGoto(server, u, id, timeout, waitUntil);
@@ -736,7 +980,7 @@ test "MCP - evaluate error reporting" {
} }, out.written());
}
test "MCP - Actions: click, fill, scroll" {
test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked" {
defer testing.reset();
const aa = testing.arena_allocator;
@@ -797,7 +1041,67 @@ test "MCP - Actions: click, fill, scroll" {
out.clearRetainingCapacity();
}
// Evaluate assertions
{
// Test Hover
const el = page.document.getElementById("hoverTarget", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Hovered element") != null);
out.clearRetainingCapacity();
}
{
// Test Press
const el = page.document.getElementById("keyTarget", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Pressed key") != null);
out.clearRetainingCapacity();
}
{
// Test SelectOption
const el = page.document.getElementById("sel2", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Selected option") != null);
out.clearRetainingCapacity();
}
{
// Test SetChecked (checkbox)
const el = page.document.getElementById("chk", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null);
out.clearRetainingCapacity();
}
{
// Test SetChecked (radio)
const el = page.document.getElementById("rad", page).?.asNode();
const el_id = (try server.node_registry.register(el)).id;
var id_buf: [12]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "checked") != null);
out.clearRetainingCapacity();
}
// Evaluate JS assertions for all actions
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
@@ -809,12 +1113,66 @@ test "MCP - Actions: click, fill, scroll" {
const result = try ls.local.exec(
\\ window.clicked === true && window.inputVal === 'hello' &&
\\ window.changed === true && window.selChanged === 'opt2' &&
\\ window.scrolled === true
\\ window.scrolled === true &&
\\ window.hovered === true &&
\\ window.keyPressed === 'Enter' && window.keyReleased === 'Enter' &&
\\ window.sel2Changed === 'b' &&
\\ window.chkClicked === true && window.chkChanged === true &&
\\ window.radClicked === true && window.radChanged === true
, null);
try testing.expect(result.isTrue());
}
test "MCP - findElement" {
defer testing.reset();
const aa = testing.arena_allocator;
var out: std.io.Writer.Allocating = .init(aa);
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
{
// Find by role
const msg =
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"button"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null);
out.clearRetainingCapacity();
}
{
// Find by name (case-insensitive substring)
const msg =
\\{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"findElement","arguments":{"name":"click"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "Click Me") != null);
out.clearRetainingCapacity();
}
{
// Find with no matches
const msg =
\\{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"findElement","arguments":{"role":"slider"}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "[]") != null);
out.clearRetainingCapacity();
}
{
// Error: no params provided
const msg =
\\{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"findElement","arguments":{}}}
;
try router.handleMessage(server, aa, msg);
try testing.expect(std.mem.indexOf(u8, out.written(), "error") != null);
out.clearRetainingCapacity();
}
}
test "MCP - waitForSelector: existing element" {
defer testing.reset();
var out: std.io.Writer.Allocating = .init(testing.arena_allocator);

View File

@@ -61,11 +61,6 @@ connections: []http.Connection,
available: std.DoublyLinkedList = .{},
conn_mutex: std.Thread.Mutex = .{},
ws_pool: std.heap.MemoryPool(http.Connection),
ws_count: usize = 0,
ws_max: u8,
ws_mutex: std.Thread.Mutex = .{},
pollfds: []posix.pollfd,
listener: ?Listener = null,
@@ -273,13 +268,9 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network {
.connections = connections,
.app = app,
.robot_store = RobotStore.init(allocator),
.web_bot_auth = web_bot_auth,
.cache = cache,
.ws_pool = .init(allocator),
.ws_max = config.wsMaxConcurrent(),
};
}
@@ -307,8 +298,6 @@ pub fn deinit(self: *Network) void {
}
self.allocator.free(self.connections);
self.ws_pool.deinit();
self.robot_store.deinit();
if (self.web_bot_auth) |wba| {
wba.deinit(self.allocator);
@@ -603,50 +592,18 @@ pub fn getConnection(self: *Network) ?*http.Connection {
}
pub fn releaseConnection(self: *Network, conn: *http.Connection) void {
switch (conn.transport) {
.websocket => {
conn.deinit();
self.ws_mutex.lock();
defer self.ws_mutex.unlock();
self.ws_pool.destroy(conn);
self.ws_count -= 1;
},
else => {
conn.reset(self.config, self.ca_blob) catch |err| {
lp.assert(false, "couldn't reset curl easy", .{ .err = err });
};
self.conn_mutex.lock();
defer self.conn_mutex.unlock();
self.available.append(&conn.node);
},
}
conn.reset(self.config, self.ca_blob) catch |err| {
lp.assert(false, "couldn't reset curl easy", .{ .err = err });
};
self.conn_mutex.lock();
defer self.conn_mutex.unlock();
self.available.append(&conn.node);
}
pub fn newConnection(self: *Network) ?*http.Connection {
const conn = blk: {
self.ws_mutex.lock();
defer self.ws_mutex.unlock();
if (self.ws_count >= self.ws_max) {
return null;
}
const c = self.ws_pool.create() catch return null;
self.ws_count += 1;
break :blk c;
};
// don't do this under lock
conn.* = http.Connection.init(self.ca_blob, self.config) catch {
self.ws_mutex.lock();
defer self.ws_mutex.unlock();
self.ws_pool.destroy(conn);
self.ws_count -= 1;
return null;
};
return conn;
pub fn newConnection(self: *Network) !http.Connection {
return http.Connection.init(self.ca_blob, self.config);
}
// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is

View File

@@ -28,9 +28,7 @@ pub const ENABLE_DEBUG = false;
pub const Blob = libcurl.CurlBlob;
pub const WaitFd = libcurl.CurlWaitFd;
pub const readfunc_pause = libcurl.curl_readfunc_pause;
pub const writefunc_error = libcurl.curl_writefunc_error;
pub const WsFrameType = libcurl.WsFrameType;
const Error = libcurl.Error;
@@ -224,19 +222,15 @@ pub const ResponseHead = struct {
pub const Connection = struct {
_easy: *libcurl.Curl,
transport: Transport,
node: std.DoublyLinkedList.Node = .{},
pub const Transport = union(enum) {
none, // used for cases that manage their own connection, e.g. telemetry
http: *@import("../browser/HttpClient.zig").Transfer,
websocket: *@import("../browser/webapi/net/WebSocket.zig"),
};
pub fn init(ca_blob: ?libcurl.CurlBlob, config: *const Config) !Connection {
pub fn init(
ca_blob: ?libcurl.CurlBlob,
config: *const Config,
) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
var self = Connection{ ._easy = easy, .transport = .none };
const self = Connection{ ._easy = easy };
errdefer self.deinit();
try self.reset(config, ca_blob);
@@ -316,12 +310,7 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr);
}
pub fn setConnectOnly(self: *const Connection, connect_only: bool) !void {
const value: c_long = if (connect_only) 2 else 0;
try libcurl.curl_easy_setopt(self._easy, .connect_only, value);
}
pub fn setWriteCallback(
pub fn setCallbacks(
self: *Connection,
comptime data_cb: libcurl.CurlWriteFunction,
) !void {
@@ -329,40 +318,12 @@ pub const Connection = struct {
try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb);
}
pub fn setReadCallback(
self: *Connection,
comptime data_cb: libcurl.CurlReadFunction,
upload: bool,
) !void {
try libcurl.curl_easy_setopt(self._easy, .read_data, self);
try libcurl.curl_easy_setopt(self._easy, .read_function, data_cb);
if (upload) {
try libcurl.curl_easy_setopt(self._easy, .upload, true);
}
}
pub fn setHeaderCallback(
self: *Connection,
comptime data_cb: libcurl.CurlHeaderFunction,
) !void {
try libcurl.curl_easy_setopt(self._easy, .header_data, self);
try libcurl.curl_easy_setopt(self._easy, .header_function, data_cb);
}
pub fn pause(
self: *Connection,
flags: libcurl.CurlPauseFlags,
) !void {
try libcurl.curl_easy_pause(self._easy, flags);
}
pub fn reset(
self: *Connection,
self: *const Connection,
config: *const Config,
ca_blob: ?libcurl.CurlBlob,
) !void {
libcurl.curl_easy_reset(self._easy);
self.transport = .none;
// timeouts
try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout());
@@ -499,6 +460,12 @@ pub const Connection = struct {
};
}
pub fn getPrivate(self: *const Connection) !*anyopaque {
var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(self._easy, .private, &private);
return private;
}
// These are headers that may not be send to the users for inteception.
pub fn secretHeaders(_: *const Connection, headers: *Headers, http_headers: *const Config.HttpHeaders) !void {
if (http_headers.proxy_bearer_header) |hdr| {
@@ -515,14 +482,6 @@ pub const Connection = struct {
try libcurl.curl_easy_perform(self._easy);
return self.getResponseCode();
}
pub fn wsStartFrame(self: *const Connection, frame_type: libcurl.WsFrameType, size: usize) !void {
try libcurl.curl_ws_start_frame(self._easy, frame_type, @intCast(size));
}
pub fn wsMeta(self: *const Connection) ?libcurl.WsFrameMeta {
return libcurl.curl_ws_meta(self._easy);
}
};
pub const Handles = struct {
@@ -560,21 +519,17 @@ pub const Handles = struct {
}
pub const MultiMessage = struct {
conn: *Connection,
conn: Connection,
err: ?Error,
};
pub fn readMessage(self: *Handles) !?MultiMessage {
pub fn readMessage(self: *Handles) ?MultiMessage {
var messages_count: c_int = 0;
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
return switch (msg.data) {
.done => |err| {
var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(msg.easy_handle, .private, &private);
return .{
.conn = @ptrCast(@alignCast(private)),
.err = err,
};
.done => |err| .{
.conn = .{ ._easy = msg.easy_handle },
.err = err,
},
else => unreachable,
};

View File

@@ -40,8 +40,6 @@ pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque
pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize;
pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize;
pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR;
pub const curl_readfunc_pause: usize = c.CURL_READFUNC_PAUSE;
pub const CurlReadFunction = fn ([*]u8, usize, usize, *anyopaque) usize;
pub const FreeCallback = fn (ptr: ?*anyopaque) void;
pub const StrdupCallback = fn (str: [*:0]const u8) ?[*:0]u8;
@@ -100,23 +98,6 @@ pub const CurlWaitFd = extern struct {
revents: CurlWaitEvents,
};
pub const CurlPauseFlags = packed struct(c_short) {
recv: bool = false,
send: bool = false,
all: bool = false,
cont: bool = false,
_reserved: u12 = 0,
pub fn to_c(self: @This()) c_int {
var flags: c_int = 0;
if (self.recv) flags |= c.CURLPAUSE_RECV;
if (self.send) flags |= c.CURLPAUSE_SEND;
if (self.all) flags |= c.CURLPAUSE_ALL;
if (self.cont) flags |= c.CURLPAUSE_CONT;
return flags;
}
};
comptime {
const debug_cb_check: c.curl_debug_callback = struct {
fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int {
@@ -186,10 +167,6 @@ pub const CurlOption = enum(c.CURLoption) {
header_function = c.CURLOPT_HEADERFUNCTION,
write_data = c.CURLOPT_WRITEDATA,
write_function = c.CURLOPT_WRITEFUNCTION,
read_data = c.CURLOPT_READDATA,
read_function = c.CURLOPT_READFUNCTION,
connect_only = c.CURLOPT_CONNECT_ONLY,
upload = c.CURLOPT_UPLOAD,
};
pub const CurlMOption = enum(c.CURLMoption) {
@@ -553,7 +530,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
const code = switch (option) {
.verbose,
.post,
.upload,
.http_get,
.ssl_verify_host,
.ssl_verify_peer,
@@ -575,7 +551,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.max_redirs,
.follow_location,
.post_field_size,
.connect_only,
=> blk: {
const n: c_long = switch (@typeInfo(@TypeOf(value))) {
.comptime_int, .int => @intCast(value),
@@ -618,7 +593,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.private,
.header_data,
.read_data,
.write_data,
=> blk: {
const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) {
@@ -657,22 +631,6 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
break :blk c.curl_easy_setopt(easy, opt, cb);
},
.read_function => blk: {
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
.null => null,
.@"fn" => |info| struct {
fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize {
const user_arg = if (@typeInfo(info.params[3].type.?) == .optional)
user
else
user orelse unreachable;
return value(@ptrCast(buffer), count, len, user_arg);
}
}.cb,
else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))),
};
break :blk c.curl_easy_setopt(easy, opt, cb);
},
.write_function => blk: {
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
.null => null,
@@ -719,10 +677,6 @@ pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Err
try errorCheck(code);
}
pub fn curl_easy_pause(easy: *Curl, flags: CurlPauseFlags) Error!void {
try errorCheck(c.curl_easy_pause(easy, flags.to_c()));
}
pub fn curl_easy_header(
easy: *Curl,
name: [*:0]const u8,
@@ -850,79 +804,3 @@ pub fn curl_slist_free_all(list: ?*CurlSList) void {
c.curl_slist_free_all(ptr);
}
}
// WebSocket support (requires libcurl 7.86.0+)
pub const WsFrameType = enum {
text,
binary,
cont,
close,
ping,
pong,
fn toInt(self: WsFrameType) c_uint {
return switch (self) {
.text => c.CURLWS_TEXT,
.binary => c.CURLWS_BINARY,
.cont => c.CURLWS_CONT,
.close => c.CURLWS_CLOSE,
.ping => c.CURLWS_PING,
.pong => c.CURLWS_PONG,
};
}
fn fromFlags(flags: c_int) WsFrameType {
const f: c_uint = @bitCast(flags);
if (f & c.CURLWS_TEXT != 0) return .text;
if (f & c.CURLWS_BINARY != 0) return .binary;
if (f & c.CURLWS_CLOSE != 0) return .close;
if (f & c.CURLWS_PING != 0) return .ping;
if (f & c.CURLWS_PONG != 0) return .pong;
if (f & c.CURLWS_CONT != 0) return .cont;
return .binary; // default fallback
}
};
pub const WsFrameMeta = struct {
frame_type: WsFrameType,
offset: usize,
bytes_left: usize,
len: usize,
fn from(frame: *const c.curl_ws_frame) WsFrameMeta {
return .{
.frame_type = WsFrameType.fromFlags(frame.flags),
.offset = @intCast(frame.offset),
.bytes_left = @intCast(frame.bytesleft),
.len = if (frame.len < 0)
std.math.maxInt(usize)
else
@intCast(frame.len),
};
}
};
pub fn curl_ws_send(easy: *Curl, buffer: []const u8, sent: *usize, fragsize: CurlOffT, frame_type: WsFrameType) Error!void {
try errorCheck(c.curl_ws_send(easy, buffer.ptr, buffer.len, sent, fragsize, frame_type.toInt()));
}
pub fn curl_ws_recv(easy: *Curl, buffer: []u8, recv: *usize, meta: *?WsFrameMeta) Error!void {
var c_meta: [*c]const c.curl_ws_frame = null;
const code = c.curl_ws_recv(easy, buffer.ptr, buffer.len, recv, &c_meta);
if (c_meta) |m| {
meta.* = WsFrameMeta.from(m);
} else {
meta.* = null;
}
try errorCheck(code);
}
pub fn curl_ws_meta(easy: *Curl) ?WsFrameMeta {
const ptr = c.curl_ws_meta(easy);
if (ptr == null) return null;
return WsFrameMeta.from(ptr);
}
pub fn curl_ws_start_frame(easy: *Curl, frame_type: WsFrameType, size: CurlOffT) Error!void {
try errorCheck(c.curl_ws_start_frame(easy, frame_type.toInt(), size));
}

View File

@@ -436,17 +436,19 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
if (js_val.isTrue()) {
return;
}
const sleep_ms: usize = switch (try runner.tick(.{ .ms = 20 })) {
.done => 20,
.ok => |next_ms| @min(next_ms, 20),
};
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
return error.TestTimedOut;
switch (try runner.tick(.{ .ms = 20 })) {
.done => return error.TestNeverSignaledCompletion,
.ok => |next_ms| {
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
return error.TestTimedOut;
}
wait_ms -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
},
}
wait_ms -= @intCast(ms_elapsed);
std.Thread.sleep(std.time.ns_per_ms * sleep_ms);
}
}
@@ -474,15 +476,12 @@ pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Page {
const log = @import("log.zig");
const TestHTTPServer = @import("TestHTTPServer.zig");
const TestWSServer = @import("TestWSServer.zig");
const Server = @import("Server.zig");
var test_cdp_server: ?*Server = null;
var test_cdp_server_thread: ?std.Thread = null;
var test_http_server: ?TestHTTPServer = null;
var test_http_server_thread: ?std.Thread = null;
var test_ws_server: ?TestWSServer = null;
var test_ws_server_thread: ?std.Thread = null;
var test_config: Config = undefined;
@@ -496,7 +495,6 @@ test "tests:beforeAll" {
.common = .{
.tls_verify_host = false,
.user_agent_suffix = "internal-tester",
.ws_max_concurrent = 50,
},
} });
@@ -516,16 +514,13 @@ test "tests:beforeAll" {
test_session = try test_browser.newSession(test_notification);
var wg: std.Thread.WaitGroup = .{};
wg.startMany(3);
wg.startMany(2);
test_cdp_server_thread = try std.Thread.spawn(.{}, serveCDP, .{&wg});
test_http_server = TestHTTPServer.init(testHTTPHandler);
test_http_server_thread = try std.Thread.spawn(.{}, TestHTTPServer.run, .{ &test_http_server.?, &wg });
test_ws_server = TestWSServer.init();
test_ws_server_thread = try std.Thread.spawn(.{}, TestWSServer.run, .{ &test_ws_server.?, &wg });
// need to wait for the servers to be listening, else tests will fail because
// they aren't able to connect.
wg.wait();
@@ -550,13 +545,6 @@ test "tests:afterAll" {
server.deinit();
}
if (test_ws_server) |*server| {
server.stop();
}
if (test_ws_server_thread) |thread| {
thread.join();
}
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
test_notification.deinit();