diff --git a/.github/workflows/agent-regression.yml b/.github/workflows/agent-regression.yml
index 431f88313..a3f689548 100644
--- a/.github/workflows/agent-regression.yml
+++ b/.github/workflows/agent-regression.yml
@@ -65,6 +65,7 @@ jobs:
shell: bash
env:
LPD_PATH: ${{ github.workspace }}/bin/lightpanda
+ LPD_ARGS: --load-resources iframe --load-resources worker
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
LP_MODEL: ${{ github.event.inputs.model }}
# Optional: news.ycombinator.com often blocks datacenter IPs. Reuse
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index 10cb9264c..02c763b0f 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -106,7 +106,7 @@ jobs:
- id: args
name: build LP args
run: |
- args="--http-cache-dir /tmp/lp-cache"
+ args="--http-cache-dir /tmp/lp-cache --load-resources worker --load-resources iframe"
[ "${{ matrix.proxy }}" = "true" ] && args="$args --http-proxy http://127.0.0.1:3000"
[ "${{ matrix.robotstxt }}" = "true" ] && args="$args --obey-robots"
[ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-key-file private_key.pem"
@@ -162,7 +162,7 @@ jobs:
- id: args
name: build LP args
run: |
- args="--http-cache-dir /tmp/lp-cache"
+ args="--http-cache-dir /tmp/lp-cache --load-resources worker --load-resources iframe"
[ "${{ matrix.robotstxt }}" = "true" ] && args="$args --obey-robots"
[ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-key-file private_key.pem"
[ "${{ matrix.wba }}" = "true" ] && args="$args --web-bot-auth-domain ${{ vars.WBA_DOMAIN }}"
@@ -429,6 +429,7 @@ jobs:
- name: deterministic agent replay
env:
LPD_PATH: ${{ github.workspace }}/bin/lightpanda
+ LPD_ARGS: --load-resources iframe --load-resources worker
run: ./agent/run.sh deterministic
mcp-smoke:
diff --git a/src/Config.zig b/src/Config.zig
index 7dbebfe6f..f23c5127b 100644
--- a/src/Config.zig
+++ b/src/Config.zig
@@ -40,9 +40,11 @@ pub const CDP_TCP_USER_TIMEOUT_MS: c_int = 10_000;
const Config = @This();
-fn logFilterScopesValidator(allocator: Allocator, args: *std.process.Args.Iterator, list: *std.ArrayList(log.FilterRule)) !void {
+fn logFilterValidator(allocator: Allocator, args: *std.process.Args.Iterator, list: *std.ArrayList(log.FilterRule)) !void {
const str = args.next() orelse return error.InvalidOption;
+ defer log.opts.scope_enabled = log.resolveFilters(list.items);
+
var it = std.mem.splitScalar(u8, str, ',');
while (it.next()) |part| {
if (part.len == 0) continue;
@@ -66,7 +68,7 @@ fn logFilterScopesValidator(allocator: Allocator, args: *std.process.Args.Iterat
}
const v = std.meta.stringToEnum(log.Scope, name) orelse {
- log.fatal(.app, "invalid option choice", .{ .arg = "--log-filter-scopes", .value = part });
+ log.fatal(.app, "invalid option choice", .{ .arg = "--log-filter", .value = part });
return error.InvalidOption;
};
@@ -78,6 +80,7 @@ fn logLevelValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?l
const str = args.next() orelse return error.MissingArgument;
if (std.mem.eql(u8, str, "error")) {
target.* = .err;
+ log.opts.level = .err;
return;
}
@@ -85,6 +88,24 @@ fn logLevelValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?l
log.fatal(.app, "invalid option choice", .{ .arg = "--log-level", .value = str });
return error.InvalidArgument;
};
+ log.opts.level = target.*.?;
+}
+
+// The MCP host captures stderr into a log file, where pretty's ANSI
+// escapes and multi-line entries are noise. Runs before any option is
+// read so parse-time lines match; --log-format still overrides.
+fn mcpLogDefaults() void {
+ log.opts.format = .logfmt;
+}
+
+fn logFormatValidator(_: Allocator, args: *std.process.Args.Iterator, target: *?log.Format) !void {
+ const str = args.next() orelse return error.MissingArgument;
+ const format = std.meta.stringToEnum(log.Format, str) orelse {
+ log.fatal(.app, "invalid option choice", .{ .arg = "--log-format", .value = str });
+ return error.InvalidArgument;
+ };
+ target.* = format;
+ log.opts.format = format;
}
fn httpHeaderValidator(allocator: Allocator, args: *std.process.Args.Iterator, list: *std.ArrayList(HttpHeader)) !void {
@@ -211,8 +232,11 @@ fn caPathValidator(
}
}
-pub const LoadResources = packed struct(u1) {
+pub const LoadResources = packed struct(u4) {
image: bool = false,
+ iframe: bool = false,
+ worker: bool = false,
+ stylesheet: bool = false,
};
/// Common CLI args.
@@ -231,8 +255,9 @@ const CommonOptions = .{
.{ .name = "ws_max_concurrent", .type = ?u8 },
.{ .name = "insecure_disable_tls_host_verification", .type = bool },
.{ .name = "log_level", .type = ?log.Level, .validator = logLevelValidator },
- .{ .name = "log_format", .type = ?log.Format },
- .{ .name = "log_filter_scopes", .type = log.FilterRule, .multiple = true, .validator = logFilterScopesValidator },
+ .{ .name = "log_format", .type = ?log.Format, .validator = logFormatValidator },
+ .{ .name = "log_filter", .type = log.FilterRule, .multiple = true, .validator = logFilterValidator },
+ .{ .name = "log_filter_scopes", .type = log.FilterRule, .multiple = true, .validator = logFilterValidator, .deprecated = "use --log-filter" },
.{ .name = "user_agent_suffix", .type = ?[]const u8 },
.{ .name = "http_cache_dir", .type = ?[]const u8 },
.{ .name = "http_cache_entry_limit", .type = ?u32, .default = 1000 },
@@ -246,9 +271,9 @@ const CommonOptions = .{
.{ .name = "adblock_lists", .type = ?[]const u8 },
.{ .name = "cookie", .type = ?[]const u8 },
.{ .name = "cookie_jar", .type = ?[]const u8 },
- .{ .name = "disable_subframes", .type = bool },
- .{ .name = "disable_workers", .type = bool },
- .{ .name = "enable_external_stylesheets", .type = bool },
+ .{ .name = "disable_subframes", .type = bool, .deprecated = "subframes are now disabled by default, use \"--load-resources iframe\" to enable" },
+ .{ .name = "disable_workers", .type = bool, .deprecated = "workers are now disabled by default, use \"--load-resources worker\" to enable" },
+ .{ .name = "enable_external_stylesheets", .type = bool, .deprecated = "use \"--load-resources stylesheet\" to enable" },
.{ .name = "load_resources", .type = LoadResources, .default = LoadResources{} },
.{ .name = "v8_flags_unsafe", .type = ?[]const u8 },
.{ .name = "v8_max_heap_mb", .type = ?u32 },
@@ -355,7 +380,6 @@ const Commands = cli.Builder(.{
.{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
.{ .name = "port", .type = u16, .default = 9222 },
.{ .name = "advertise_host", .type = ?[]const u8 },
- .{ .name = "timeout", .type = ?u31 },
.{ .name = "cdp_max_connections", .type = u16, .default = 16 },
.{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 },
.{ .name = "cdp_max_message_size", .type = u32, .default = 1024 * 1024 },
@@ -403,6 +427,7 @@ const Commands = cli.Builder(.{
},
.{
.name = "mcp",
+ .before_parse = mcpLogDefaults,
.options = .{
.{ .name = "port", .type = ?u16 },
.{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
@@ -469,6 +494,18 @@ pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config {
if (modeNeedsHttp(mode)) {
config.http_headers = try HttpHeaders.init(allocator, &config);
}
+
+ switch (config.mode) {
+ inline else => |*m| {
+ if (@hasField(@TypeOf(m.*), "enable_external_stylesheets")) {
+ if (m.enable_external_stylesheets) {
+ // map deprecated property onto updated one
+ m.load_resources.stylesheet = true;
+ }
+ }
+ },
+ }
+
return config;
}
@@ -503,20 +540,6 @@ pub fn obeyRobots(self: *const Config) bool {
};
}
-pub fn disableSubframes(self: *const Config) bool {
- return switch (self.mode) {
- inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_subframes,
- else => unreachable,
- };
-}
-
-pub fn disableWorkers(self: *const Config) bool {
- return switch (self.mode) {
- inline .serve, .fetch, .mcp, .agent => |opts| opts.disable_workers,
- else => unreachable,
- };
-}
-
pub fn watchdogMs(self: *const Config) ?u32 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| {
@@ -527,13 +550,6 @@ pub fn watchdogMs(self: *const Config) ?u32 {
};
}
-pub fn enableExternalStylesheets(self: *const Config) bool {
- return switch (self.mode) {
- inline .serve, .fetch, .mcp, .agent => |opts| opts.enable_external_stylesheets,
- else => unreachable,
- };
-}
-
pub fn loadResources(self: *const Config) LoadResources {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.load_resources,
@@ -641,18 +657,6 @@ pub fn wsMaxConcurrent(self: *const Config) u8 {
};
}
-pub fn logLevel(self: *const Config) ?log.Level {
- return switch (self.mode) {
- // Agent mode quiets page-driven `console.error` noise unless verbosity=high.
- .agent => |opts| opts.log_level orelse switch (agentVerbosity(opts)) {
- .low, .medium => .err,
- .high => null,
- },
- inline .serve, .fetch, .mcp => |opts| opts.log_level,
- else => unreachable,
- };
-}
-
/// Resolve --verbosity. Explicit value wins. Else: --task with stderr
/// captured (pipe/file) defaults to .high so benchmark harnesses and
/// other programmatic consumers get the [tool/result] trace; REPL and
@@ -676,23 +680,6 @@ fn stderrIsTty() bool {
return stderr_tty_cached;
}
-pub fn logFormat(self: *const Config) ?log.Format {
- return switch (self.mode) {
- // The MCP host captures stderr into a log file, where pretty's ANSI
- // escapes and multi-line entries are noise.
- .mcp => |opts| opts.log_format orelse .logfmt,
- inline .serve, .fetch, .agent => |opts| opts.log_format,
- else => unreachable,
- };
-}
-
-pub fn logFilterScopes(self: *const Config) std.ArrayList(log.FilterRule) {
- return switch (self.mode) {
- inline .serve, .fetch, .mcp, .agent => |opts| opts.log_filter_scopes,
- else => unreachable,
- };
-}
-
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
return switch (self.mode) {
inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix,
@@ -1075,9 +1062,6 @@ fn printPaged(allocator: Allocator, text: []const u8) void {
pub fn parseArgs(allocator: Allocator, proc_args: std.process.Args) !Config {
const exec_name, var command = try Commands.parse(allocator, proc_args);
- if (command == .serve and command.serve.timeout != null) {
- log.warn(.app, "--timeout is deprecated", .{});
- }
const invoked = std.meta.activeTag(command);
// Rewrite `run` to `.agent` so nothing downstream needs a `.run` case.
if (command == .run) {
@@ -1093,6 +1077,17 @@ pub fn parseArgs(allocator: Allocator, proc_args: std.process.Args) !Config {
}
command = .{ .agent = agent_opts };
}
+
+ // Agent mode quiets page-driven `console.error` noise unless
+ // verbosity=high. Depends on --verbosity/--task, so it can only be
+ // resolved after the options are parsed; an explicit --log-level wins.
+ if (command == .agent) {
+ const opts = command.agent;
+ if (opts.log_level == null and agentVerbosity(opts) != .high) {
+ log.opts.level = .err;
+ }
+ }
+
var config = try Config.init(allocator, exec_name, command);
config.command = invoked;
return config;
diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig
index f01195b84..cef2034f4 100644
--- a/src/browser/Frame.zig
+++ b/src/browser/Frame.zig
@@ -1094,7 +1094,7 @@ pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void {
errdefer transfer.deinit();
try self.headersForRequest(transfer);
}
- return transfer.submit();
+ transfer.submit() catch {};
}
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
@@ -1855,8 +1855,8 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
if (iframe._executed) {
return;
}
- if (!self._session.subframe_loading_enabled) {
- // configured not to load frames
+ if (self._session.load_resources.iframe == false) {
+ log.warnDisabledIFrame();
iframe._executed = true;
return;
}
@@ -2269,9 +2269,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
const session = self._session;
- // this feature is disabled by default, and can be turned on via a command
- // line flag or via an CDP command
- if (session.load_external_stylesheets == false) {
+ if (session.load_resources.stylesheet == false) {
return self.queueLoad(Factory.protoOf(link));
}
@@ -3796,9 +3794,9 @@ test "Frame: iframeAddedCallback does not create a frame when termination is pen
defer testing.test_session.closeAllPages();
const session = frame._session;
- const subframe_loading_enabled = session.subframe_loading_enabled;
- session.subframe_loading_enabled = true;
- defer session.subframe_loading_enabled = subframe_loading_enabled;
+ const subframe_loading_enabled = session.load_resources.iframe;
+ session.load_resources.iframe = true;
+ defer session.load_resources.iframe = subframe_loading_enabled;
const element = try frame.document.createElement("iframe", null, frame);
const iframe = element.as(HtmlElement.IFrame);
diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig
index dc673a34a..f491c471b 100644
--- a/src/browser/ScriptManager.zig
+++ b/src/browser/ScriptManager.zig
@@ -671,3 +671,23 @@ test "ScriptManager: async script whose submit fails synchronously releases its
\\document.head.appendChild(s);
, null);
}
+
+test "ScriptManager: preload whose submit fails synchronously releases its arena once" {
+ const page = try testing.pageTest("mcp_nav.html", .{});
+ defer page.close();
+
+ const frame = page.frame().?;
+ const sm = &frame._script_manager;
+ const client = sm.base.client;
+ client.test_fail_submit = error.TestSubmitFailure;
+ defer client.test_fail_submit = null;
+
+ // PreloadedScript.errorCallback logs the fetch error.
+ testing.expectLog(&.{.http});
+
+ const url = "http://127.0.0.1:9582/fails-at-submit.js";
+ // A fetch was started (and failed), so the hint's error event fires.
+ try testing.expectEqual(true, try sm.preloadScript(null, url));
+ // errorCallback consumed the entry; nothing dangles in the map.
+ try testing.expectEqual(false, sm.preloaded_scripts.contains(url));
+}
diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig
index 524313e5a..37de84f2c 100644
--- a/src/browser/ScriptManagerBase.zig
+++ b/src/browser/ScriptManagerBase.zig
@@ -1120,6 +1120,58 @@ test "ScriptManagerBase: shutdownCallback fails a .loading module" {
try testing.expectError(error.Failed, sm.waitForImport(url));
}
+test "ScriptManagerBase: import whose submit fails synchronously releases its arena once" {
+ const page = try testing.pageTest("mcp_nav.html", .{});
+ defer page.close();
+ const frame = page.frame().?;
+
+ const sm = &frame._script_manager.base;
+ const client = sm.client;
+ client.test_fail_submit = error.TestSubmitFailure;
+ defer client.test_fail_submit = null;
+
+ // Script.errorCallback logs the fetch error.
+ testing.expectLog(&.{.http});
+
+ const url: [:0]const u8 = "http://127.0.0.1:9582/fails-at-submit.js";
+ try sm.preloadImport(url, frame.url, .{});
+
+ // The failure is delivered through the entry, same as an async one.
+ try testing.expect(sm.async_scripts.first == null);
+ try testing.expect(sm.imported_modules.getPtr(url).?.state == .err);
+ try testing.expectError(error.Failed, sm.waitForImport(url));
+}
+
+test "ScriptManagerBase: dynamic import whose submit fails synchronously rejects once" {
+ const page = try testing.pageTest("mcp_nav.html", .{});
+ defer page.close();
+ const frame = page.frame().?;
+
+ const sm = &frame._script_manager.base;
+ const client = sm.client;
+ client.test_fail_submit = error.TestSubmitFailure;
+ defer client.test_fail_submit = null;
+
+ // Script.errorCallback logs the fetch error.
+ testing.expectLog(&.{.http});
+
+ var ls: js.Local.Scope = undefined;
+ frame.js.localScope(&ls);
+ defer ls.deinit();
+
+ try ls.local.eval(
+ \\globalThis.__dyn = 'pending';
+ \\import('http://127.0.0.1:9582/fails-at-submit.js').then(
+ \\ () => { globalThis.__dyn = 'resolved'; },
+ \\ (e) => { globalThis.__dyn = String(e); },
+ \\);
+ , frame.url); // the resource name is the import's base url
+ ls.local.runMicrotasks();
+
+ try testing.expect(sm.async_scripts.first == null);
+ try testing.expectEqual(true, (try ls.local.exec("globalThis.__dyn === 'TestSubmitFailure'", null)).toBool());
+}
+
test "ScriptManagerBase: waitForImport stops when teardown is pending" {
const page = try testing.pageTest("mcp_nav.html", .{});
defer page.close();
diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index f28ce8e29..c9412e64a 100644
--- a/src/browser/Session.zig
+++ b/src/browser/Session.zig
@@ -92,33 +92,14 @@ _tool_frame_override: ?u32 = null,
// connection (see `Browser.frame_id_gen` and issue #2472).
loader_id_gen: u32 = 0,
-// configuration (or CDP command) to disable iframe loading
-subframe_loading_enabled: bool = true,
-
-// configuration (or CDP command) to disable Web Worker loading. When false,
-// `new Worker(url)` returns a Worker object whose script is never fetched
-// and never evaluated. Set from the `--disable-workers` CLI flag at
-// session init; the LP.configureLoading CDP method can flip it per-session.
-worker_loading_enabled: bool = true,
-
// Console.* capture for the `consoleLogs` tool, capped at `max_console_bytes`.
// Opt-in via `enableConsoleCapture`: plain CDP `serve` never drains it, so
// leaving the listener off keeps the buffer at zero bytes.
_console_messages: std.Io.Writer.Allocating,
_console_capture: bool = false,
-// Opt-in fetch of external resources. Defaults to
-// false to preserve the current rendering-free fast path: drivers that
-// don't need accurate visibility checks pay nothing. Set from the
-// `--enable-external-stylesheets` CLI flag at session init; the
-// LP.configureLoading CDP method can flip it per-session. When true,
-// `Link.linkAddedCallback` routes to `Frame.loadExternalStylesheet`
-// (synchronous fetch + parse + register on `document.styleSheets`).
-load_external_stylesheets: bool = false,
-
-// Sub-resources to actually request. Off by default: a driver that only
-// reads the DOM shouldn't pay for bytes it never looks at.
-load_resources: Config.LoadResources = .{},
+// configured external resources (images, stylesheet, worker, iframe) to load
+load_resources: Config.LoadResources,
/// Caller-supplied cancellation probe. `Runner._wait` polls it between
/// ticks; once `check` returns true the wait returns `error.Cancelled`.
@@ -180,11 +161,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
.browser = browser,
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator, notification),
- // CLI defaults; LP.configureLoading can flip these per-session.
- .subframe_loading_enabled = !browser.app.config.disableSubframes(),
- .worker_loading_enabled = !browser.app.config.disableWorkers(),
._console_messages = .init(allocator),
- .load_external_stylesheets = browser.app.config.enableExternalStylesheets(),
.load_resources = browser.app.config.loadResources(),
};
errdefer self._console_messages.deinit();
diff --git a/src/browser/color.zig b/src/browser/color.zig
index 1b5c82938..91514db77 100644
--- a/src/browser/color.zig
+++ b/src/browser/color.zig
@@ -201,7 +201,7 @@ pub const RGBA = packed struct(u32) {
pub fn init(r: u8, g: u8, b: u8, a: f32) RGBA {
const clamped = std.math.clamp(a, 0, 1);
- return .{ .r = r, .g = g, .b = b, .a = @intFromFloat(clamped * 255) };
+ return .{ .r = r, .g = g, .b = b, .a = @trunc(clamped * 255) };
}
/// Finds a color by its name.
diff --git a/src/browser/css/Tokenizer.zig b/src/browser/css/Tokenizer.zig
index d87eb38c1..544231b95 100644
--- a/src/browser/css/Tokenizer.zig
+++ b/src/browser/css/Tokenizer.zig
@@ -612,7 +612,7 @@ fn consumeNumeric(self: *Tokenizer) Token {
break :blk std.math.minInt(i32);
}
- break :blk @as(i32, @intFromFloat(value));
+ break :blk @as(i32, @trunc(value));
} else null;
if (!self.isEof() and self.nextByteUnchecked() == '%') {
diff --git a/src/browser/dump.zig b/src/browser/dump.zig
index b8cc01fe6..32a8261fd 100644
--- a/src/browser/dump.zig
+++ b/src/browser/dump.zig
@@ -89,7 +89,7 @@ fn rootUncapped(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, frame:
if (opts.with_base) {
const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode();
const base = try doc.createElement("base", null, frame);
- try base.setAttributeSafe(comptime .wrap("base"), .wrap(frame.base()), frame);
+ try base.setAttributeSafe(comptime .wrap("href"), .wrap(frame.base()), frame);
_ = try parent.insertBefore(base.asNode(), parent.firstChild(), frame);
}
}
@@ -348,13 +348,17 @@ fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, frame: *Fram
}
}
-fn isVoidElement(el: *const Node.Element) bool {
- return switch (el._type) {
- .html => switch (el.subtype(Node.Element.Html)._type) {
- .br, .hr, .img, .input, .link, .meta => true,
- else => false,
+fn isVoidElement(el: *Node.Element) bool {
+ return switch (el.getTag()) {
+ .area, .base, .br, .col, .embed, .hr, .img, .input, .link, .meta, .param, .source, .track => true,
+ .unknown => {
+ const unknown = el.as(Node.Element.Html.Unknown);
+ if (unknown._tag_name.eql(comptime .wrap("wbr"))) {
+ return true;
+ }
+ return false;
},
- .svg => false,
+ else => false,
};
}
@@ -494,10 +498,29 @@ test "dump: default dumps the whole document" {
test "dump: with_base injects a
secret
visible & well
+ \\secret
visible & well
); } +test "dump: void elements have no end tag" { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + const doc = frame.window._document; + const div = try doc.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, div.asNode(), + \\