Merge branch 'screenshot-kitty' of https://github.com/lightpanda-io/browser into screenshot-kitty

This commit is contained in:
Adrià Arrufat committed 2026-08-28 08:37:34 +02:00
commit 55d092d6b6
36 files changed
+722 -520

No files matched your search

+1
View File
@@ -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
+3 -2
View File
@@ -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:
+57 -62
View File
@@ -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;
+7 -9
View File
@@ -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);
+20
View File
@@ -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));
}
+52
View File
@@ -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();
+2 -25
View File
@@ -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 <link rel=stylesheet> 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();
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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() == '%') {
+31 -8
View File
@@ -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 <base> element" {
try expectDump(.{ .with_base = true },
\\<!DOCTYPE html>
\\<html><head><base base="http://127.0.0.1:9582/src/browser/tests/dump.html"></base><style>.hidden{display:none}</style><link rel="stylesheet" href="data:text/css,"><script>var a=1;</script></head><body><h1>Title</h1><p class="hidden">secret</p><img><svg></svg><noscript>nojs</noscript><p>visible &amp; well</p></body></html>
\\<html><head><base href="http://127.0.0.1:9582/src/browser/tests/dump.html"><style>.hidden{display:none}</style><link rel="stylesheet" href="data:text/css,"><script>var a=1;</script></head><body><h1>Title</h1><p class="hidden">secret</p><img><svg></svg><noscript>nojs</noscript><p>visible &amp; well</p></body></html>
);
}
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(),
\\<video><source src="a.mp4"><track kind="captions"></video><map><area shape="rect"></map><embed src="e.swf"><p>a<wbr>b</p><table><colgroup><col span="2"></colgroup></table>
);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try deep(div.asNode(), .{}, &aw.writer, frame);
try testing.expectString(
\\<div><video><source src="a.mp4"><track kind="captions"></video><map><area shape="rect"></map><embed src="e.swf"><p>a<wbr>b</p><table><colgroup><col span="2"></colgroup></table></div>
, aw.written());
}
test "dump: strip.js removes script and noscript" {
try expectDump(.{ .strip = .{ .js = true } },
\\<!DOCTYPE html>
+72 -13
View File
@@ -32,6 +32,8 @@ const Node = @import("../webapi/Node.zig");
const Event = @import("../webapi/Event.zig");
const Element = @import("../webapi/Element.zig");
const TreeWalker = @import("../webapi/TreeWalker.zig");
const TextEvent = @import("../webapi/event/TextEvent.zig");
const InputEvent = @import("../webapi/event/InputEvent.zig");
const MouseEvent = @import("../webapi/event/MouseEvent.zig");
const WheelEvent = @import("../webapi/event/WheelEvent.zig");
const PointerEvent = @import("../webapi/event/PointerEvent.zig");
@@ -312,7 +314,7 @@ pub fn triggerMouseWheel(frame: *Frame, x: f64, y: f64, delta_x: f64, delta_y: f
fn deltaToScroll(d: f64) i32 {
if (std.math.isNan(d)) return 0;
return @intFromFloat(std.math.clamp(d, std.math.minInt(i32), std.math.maxInt(i32)));
return @trunc(std.math.clamp(d, std.math.minInt(i32), std.math.maxInt(i32)));
}
// callback when the "click" event reaches the frame.
@@ -628,22 +630,79 @@ pub fn handleKeydown(frame: *Frame, target: *Node, event: *Event) !void {
return;
}
// Handle printable characters
if (key.isPrintable()) {
try input.innerInsert(key.asString(), frame);
return editKey(frame, event, input, key);
}
if (target.is(Element.Html.TextArea)) |textarea| {
if (key == .Enter) {
if (try allowEdit(frame, event, textarea.asElement(), null, "\n", "insertLineBreak")) {
try textarea.innerInsert("\n", frame);
}
return;
}
return editKey(frame, event, textarea, key);
}
}
// edit keys are handled by Input and TextArea the same
fn editKey(frame: *Frame, event: *Event, ctl: anytype, key: KeyboardEvent.Key) !void {
if (key == .Backspace or key == .Delete) {
const forward = key == .Delete;
if (try allowEdit(frame, event, ctl.asElement(), null, null, deleteInputType(forward))) {
try ctl.innerDelete(forward, frame);
}
return;
}
if (target.is(Element.Html.TextArea)) |textarea| {
// zig fmt: off
const append =
if (key == .Enter) "\n"
else if (key.isPrintable()) key.asString()
else return
;
// zig fmt: on
return textarea.innerInsert(append, frame);
if (key.isPrintable()) {
if (try allowEdit(frame, event, ctl.asElement(), key.asString(), key.asString(), "insertText")) {
try ctl.innerInsert(key.asString(), frame);
}
}
}
fn deleteInputType(forward: bool) []const u8 {
return if (forward) "deleteContentForward" else "deleteContentBackward";
}
// pre-edit events for a key's default action, can cancel the edit (i.e. by
// returning false)
fn allowEdit(frame: *Frame, keydown: *Event, target: *Element, before_data: ?[]const u8, text_data: ?[]const u8, input_type: []const u8) !bool {
if (keydown.getIsTrusted() == false) {
// only trusted events fire these events, so for a untrusted event, the
// edit isn't cancelled.
return true;
}
{
const before = (try InputEvent.initTrusted(comptime .wrap("beforeinput"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.data = before_data,
.inputType = input_type,
}, frame)).asEvent();
before.acquireRef(); // need to check its _prevent_default
defer _ = before.releaseRef(frame._page);
try frame._event_manager.dispatch(target.asEventTarget(), before);
if (before._prevent_default) {
return false;
}
}
{
const data = text_data orelse return true;
const text_event = (try TextEvent.initTrusted("textInput", .{
.bubbles = true,
.cancelable = true,
.view = frame.window,
.data = data,
}, frame)).asEvent();
text_event.acquireRef(); // need to check its _prevent_default
defer _ = text_event.releaseRef(frame._page);
try frame._event_manager.dispatch(target.asEventTarget(), text_event);
return text_event._prevent_default == false;
}
}
+2 -1
View File
@@ -25,7 +25,8 @@
let evt = document.createEvent('TextEvent');
evt.initTextEvent('foo');
testing.expectEqual('foo', evt.type);
testing.expectEqual('', evt.data);
// legacy IDL default: optional DOMString dataArg = "undefined"
testing.expectEqual('undefined', evt.data);
testing.expectEqual(false, evt.bubbles);
testing.expectEqual(false, evt.cancelable);
}
+38
View File
@@ -1361,3 +1361,41 @@
testing.expectEqual('sc:///', url.href);
}
</script>
<script id=searchParamsQuerySync>
{
// Accessing searchParams must not rewrite the query...
const url = new URL('http://www.example.com/?a=b,c');
url.searchParams;
testing.expectEqual('http://www.example.com/?a=b,c', url.toString());
testing.expectEqual('?a=b,c', url.search);
testing.expectEqual('a=b%2Cc', url.searchParams.toString());
// ...but mutations push the serialized list into it.
url.searchParams.append('x', 'y');
testing.expectEqual('http://www.example.com/?a=b%2Cc&x=y', url.toString());
// Deleting the last param drops the '?'.
const url2 = new URL('http://example.com/?p1&p2');
url2.searchParams.delete('p1');
url2.searchParams.delete('p2');
testing.expectEqual('http://example.com/', url2.href);
testing.expectEqual('', url2.search);
// A double-'?' query reads back raw via url.search.
const url3 = new URL('http://example.org/file??a=b&c=d');
testing.expectEqual('??a=b&c=d', url3.search);
testing.expectEqual('%3Fa=b&c=d', url3.searchParams.toString());
// set() keeps the first entry's position when pushing back.
const url4 = new URL('http://example.org/file?e=f&g=h');
const sp = url4.searchParams;
sp.append('i', ' j ');
sp.set('e', 'updated');
testing.expectEqual('?e=updated&g=h&i=+j+', url4.search);
// search setter still propagates into an existing searchParams.
url4.search = 'n=1';
testing.expectEqual('n=1', sp.toString());
}
</script>
+1 -1
View File
@@ -405,7 +405,7 @@ fn clampLongLong(value: f64) i64 {
if (@abs(value - @trunc(value)) == 0.5 and @mod(rounded, 2) != 0) {
rounded -= std.math.sign(value);
}
return @intFromFloat(rounded);
return @trunc(rounded);
}
/// Returns the size of the Blob in bytes.
+1 -1
View File
@@ -1839,7 +1839,7 @@ pub fn scrollIntoView(self: *Element, opts: ?ScrollIntoViewOpts, frame: *Frame)
// Positions come from the faux-layout document position (top = preorder
// depth-scaled y), the same source getBoundingClientRect uses.
const y = calculateDocumentPosition(self.asNode());
frame.window.scrollTo(.{ .x = 0 }, @intFromFloat(@max(0, y)), frame) catch {};
frame.window.scrollTo(.{ .x = 0 }, @trunc(@max(0, y)), frame) catch {};
}
const ScrollToOpts = union(enum) {
@@ -92,8 +92,8 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor
const proto = self._proto;
errdefer proto.deinit();
if (!session.worker_loading_enabled) {
log.debug(.browser, "shared worker disabled", .{ .url = owned_url });
if (session.load_resources.worker == false) {
log.warnDisabledWorker();
return self;
}
+23 -28
View File
@@ -60,6 +60,9 @@ pub fn parse(url: []const u8, maybe_base: ?[]const u8, exec: *const Execution) ?
pub fn deinit(self: *URL, page: *Page) void {
if (self._search_params) |search_params| {
// The params can outlive the URL. Unlink it.
search_params._url = null;
// And, remove the RC that we (URL) were holding on it.
search_params.releaseRef(page);
}
// Not tracked by arena.
@@ -171,18 +174,8 @@ pub fn setPort(self: *URL, maybe_value: ?[]const u8) void {
_ = U.url_set_port(self._url, port);
}
pub fn getSearch(self: *const URL, exec: *const Execution) ![]const u8 {
if (self._search_params) |search_params| {
if (search_params.getSize() == 0) {
return "";
}
var buf = std.Io.Writer.Allocating.init(exec.local_arena);
try buf.writer.writeByte('?');
try search_params.toString(&buf.writer);
return buf.written();
}
// searchParam pushes its mutations to URL, so self._url is always in sync
pub fn getSearch(self: *const URL, _: *const Execution) ![]const u8 {
var out: [*]const u8 = undefined;
var len: usize = 0;
const res = U.url_get_query(self._url, &out, &len);
@@ -257,12 +250,28 @@ pub fn getSearchParams(self: *URL, exec: *const Execution) !*URLSearchParams {
const search_value = if (U.url_get_query(self._url, &out, &len) == 0) (out - 1)[0 .. len + 1] else "";
const params = try URLSearchParams.init(.{ .query_string = search_value }, exec);
// Released in deinit; the cached params must outlive their JS wrapper.
params.acquireRef();
params._url = self;
self._search_params = params;
return params;
}
// Every update to the url's _search_params needs to keep the url in sync
pub fn syncQueryFromParams(self: *URL, exec: *const Execution) !void {
const params = self._search_params orelse return;
if (params.getSize() == 0) {
U.url_set_query_to_null(self._url);
return;
}
var buf = std.Io.Writer.Allocating.init(exec.local_arena);
try params.toString(&buf.writer);
const query = buf.written();
if (U.url_set_query(self._url, query.ptr, query.len) != 0) {
return error.TypeError;
}
}
pub fn getOrigin(self: *const URL, exec: *const Execution) ![]const u8 {
const origin = U.url_get_origin(self._url);
defer origin.deinit();
@@ -288,21 +297,7 @@ pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void {
try search_params.updateFromString(search_value, exec);
}
pub fn toString(self: *const URL, exec: *const Execution) ![]const u8 {
if (self._search_params) |search_params| {
if (search_params.getSize() == 0) {
U.url_set_query_to_null(self._url);
} else {
var buf = std.Io.Writer.Allocating.init(exec.local_arena);
defer buf.deinit();
try search_params.toString(&buf.writer);
const query = buf.written();
if (U.url_set_query(self._url, query.ptr, query.len) != 0) {
return error.ToString;
}
}
}
pub fn toString(self: *const URL, _: *const Execution) ![]const u8 {
var out: [*]const u8 = undefined;
var len: usize = 0;
U.url_to_string(self._url, &out, &len);
+2 -8
View File
@@ -91,14 +91,8 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker {
self._worker_scope = dedicated_worker;
try frame.trackWorker(self);
// `--disable-workers` (or `LP.configureLoading { worker: false }`):
// skip the script fetch and eval. The Worker object is still
// constructed so JS `new Worker(url)` does not throw, but the
// worker's eval never runs (postMessage from the page is queued
// indefinitely with no handler to drain it). Mirrors the
// `subframe_loading_enabled` pattern for iframes.
if (!session.worker_loading_enabled) {
log.debug(.browser, "worker disabled", .{ .url = resolved_url });
if (session.load_resources.worker == false) {
log.warnDisabledWorker();
return self;
}
+1 -1
View File
@@ -270,7 +270,7 @@ pub fn makeRequest(self: *WorkerGlobalScope, 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.
+12 -125
View File
@@ -29,12 +29,12 @@ const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
const ValidityState = @import("ValidityState.zig");
const popover = @import("../popover.zig");
const File = @import("../../File.zig");
const FileList = @import("../../FileList.zig");
const reflection = @import("../reflection.zig");
const text_entry = @import("../text_entry.zig");
const String = lp.String;
@@ -113,16 +113,6 @@ pub fn setOnSelectionChange(self: *Input, listener: ?js.Function) !void {
}
}
fn dispatchSelectionChangeEvent(self: *Input, frame: *Frame) !void {
const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, frame: *Frame) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, frame);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
pub fn asElement(self: *Input) *Element {
return Factory.protoOf(self).asElement();
}
@@ -623,137 +613,34 @@ pub fn setSrc(self: *Input, src: []const u8, frame: *Frame) !void {
try self.asElement().setAttributeSafe(comptime .wrap("src"), .wrap(trimmed), frame);
}
pub fn select(self: *Input, frame: *Frame) !void {
const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0;
try self.setSelectionRange(0, len, null, frame);
const event = try Event.init("select", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
const entry = text_entry.TextEntry(Input);
fn selectionAvailable(self: *const Input) bool {
pub const select = entry.select;
pub const innerInsert = entry.innerInsert;
pub const innerDelete = entry.innerDelete;
pub const getSelectionDirection = entry.getSelectionDirection;
pub const setSelectionStart = entry.setSelectionStart;
pub const setSelectionEnd = entry.setSelectionEnd;
pub const setSelectionRange = entry.setSelectionRange;
pub fn selectionAvailable(self: *const Input) bool {
switch (self._input_type) {
.text, .search, .url, .tel, .password => return true,
else => return false,
}
}
const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none };
fn howSelected(self: *const Input) HowSelected {
if (!self.selectionAvailable()) return .none;
const value = self._value orelse return .none;
if (self._selection_start == self._selection_end) return .none;
if (self._selection_start == 0 and self._selection_end == value.len) return .full;
return .{ .partial = .{ self._selection_start, self._selection_end } };
}
pub fn innerInsert(self: *Input, str: []const u8, frame: *Frame) !void {
const arena = frame.arena;
switch (self.howSelected()) {
.full => {
// if the input is fully selected, replace the content.
const new_value = try arena.dupe(u8, str);
try self.setValue(new_value, frame);
self._selection_start = @intCast(new_value.len);
self._selection_end = @intCast(new_value.len);
self._selection_direction = .none;
try self.dispatchSelectionChangeEvent(frame);
},
.partial => |range| {
// if the input is partially selected, replace the selected content.
const current_value = self.getValue();
const before = current_value[0..range[0]];
const remaining = current_value[range[1]..];
const new_value = try std.mem.concat(
arena,
u8,
&.{ before, str, remaining },
);
try self.setValue(new_value, frame);
const new_pos = range[0] + str.len;
self._selection_start = @intCast(new_pos);
self._selection_end = @intCast(new_pos);
self._selection_direction = .none;
try self.dispatchSelectionChangeEvent(frame);
},
.none => {
// if the input is not selected, just insert at cursor.
const current_value = self.getValue();
const new_value = try std.mem.concat(arena, u8, &.{ current_value, str });
try self.setValue(new_value, frame);
},
}
try self.dispatchInputEvent(str, "insertText", frame);
}
pub fn getSelectionDirection(self: *const Input) []const u8 {
return @tagName(self._selection_direction);
}
// Nullable here, unlike <textarea>'s, which is why these two aren't shared.
pub fn getSelectionStart(self: *const Input) !?u32 {
if (!self.selectionAvailable()) return null;
return self._selection_start;
}
pub fn setSelectionStart(self: *Input, value: u32, frame: *Frame) !void {
if (!self.selectionAvailable()) return error.InvalidStateError;
self._selection_start = value;
try self.dispatchSelectionChangeEvent(frame);
}
pub fn getSelectionEnd(self: *const Input) !?u32 {
if (!self.selectionAvailable()) return null;
return self._selection_end;
}
pub fn setSelectionEnd(self: *Input, value: u32, frame: *Frame) !void {
if (!self.selectionAvailable()) return error.InvalidStateError;
self._selection_end = value;
try self.dispatchSelectionChangeEvent(frame);
}
pub fn setSelectionRange(
self: *Input,
selection_start: u32,
selection_end: u32,
selection_dir: ?[]const u8,
frame: *Frame,
) !void {
if (!self.selectionAvailable()) return error.InvalidStateError;
const direction = blk: {
if (selection_dir) |sd| {
break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none;
} else break :blk .none;
};
const value = self._value orelse {
self._selection_start = 0;
self._selection_end = 0;
self._selection_direction = .none;
return;
};
const len_u32: u32 = @intCast(value.len);
var start: u32 = if (selection_start > len_u32) len_u32 else selection_start;
const end: u32 = if (selection_end > len_u32) len_u32 else selection_end;
// If end is less than start, both are equal to end.
if (end < start) {
start = end;
}
self._selection_direction = direction;
self._selection_start = start;
self._selection_end = end;
try self.dispatchSelectionChangeEvent(frame);
}
pub fn getLabels(self: *Input, frame: *Frame) !js.Array {
if (self._input_type == .hidden) {
return frame.js.local.?.newArray(0);
+2 -2
View File
@@ -193,7 +193,7 @@ test "WebApi: HTML.Link" {
test "WebApi: HTML.Link external stylesheet" {
testing.silenceLog(&.{.http});
try testing.htmlRunner("css/external_stylesheet.html", .{ .load_external_stylesheets = true });
try testing.htmlRunner("css/external_stylesheet.html", .{ .load_resources = .{ .stylesheet = true } });
}
// Regression: a synchronous external-stylesheet fetch must not strand the
@@ -202,5 +202,5 @@ test "WebApi: HTML.Link external stylesheet" {
// never drains and the document is stuck at readyState "loading".
test "WebApi: HTML.Link deferred script then external stylesheet" {
testing.silenceLog(&.{.http});
try testing.htmlRunner("css/deferred_script_then_stylesheet.html", .{ .load_external_stylesheets = true });
try testing.htmlRunner("css/deferred_script_then_stylesheet.html", .{ .load_resources = .{ .stylesheet = true } });
}
+15 -118
View File
@@ -28,9 +28,9 @@ const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
const ValidityState = @import("ValidityState.zig");
const reflection = @import("../reflection.zig");
const text_entry = @import("../text_entry.zig");
const TextArea = @This();
@@ -59,16 +59,6 @@ pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void {
}
}
fn dispatchSelectionChangeEvent(self: *TextArea, frame: *Frame) !void {
const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8, frame: *Frame) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, frame);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
pub fn asElement(self: *TextArea) *Element {
return Factory.protoOf(self).asElement();
}
@@ -123,123 +113,30 @@ pub fn getMinLength(self: *const TextArea) i32 {
return reflection.getLimitedLong(self.asConstElement(), comptime .wrap("minlength"));
}
pub fn select(self: *TextArea, frame: *Frame) !void {
const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0;
try self.setSelectionRange(0, len, null, frame);
const event = try Event.init("select", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none };
fn howSelected(self: *const TextArea) HowSelected {
const value = self._value orelse return .none;
if (self._selection_start == self._selection_end) return .none;
if (self._selection_start == 0 and self._selection_end == value.len) return .full;
return .{ .partial = .{ self._selection_start, self._selection_end } };
}
pub fn innerInsert(self: *TextArea, str: []const u8, frame: *Frame) !void {
const arena = frame.arena;
switch (self.howSelected()) {
.full => {
// if the text area is fully selected, replace the content.
const new_value = try arena.dupe(u8, str);
try self.setValue(new_value, frame);
self._selection_start = @intCast(new_value.len);
self._selection_end = @intCast(new_value.len);
self._selection_direction = .none;
try self.dispatchSelectionChangeEvent(frame);
},
.partial => |range| {
// if the text area is partially selected, replace the selected content.
const current_value = self.getValue();
const before = current_value[0..range[0]];
const remaining = current_value[range[1]..];
const new_value = try std.mem.concat(
arena,
u8,
&.{ before, str, remaining },
);
try self.setValue(new_value, frame);
const new_pos = range[0] + str.len;
self._selection_start = @intCast(new_pos);
self._selection_end = @intCast(new_pos);
self._selection_direction = .none;
try self.dispatchSelectionChangeEvent(frame);
},
.none => {
// if the text area is not selected, just insert at cursor.
const current_value = self.getValue();
const new_value = try std.mem.concat(arena, u8, &.{ current_value, str });
try self.setValue(new_value, frame);
},
}
try self.dispatchInputEvent(str, "insertText", frame);
}
pub fn getSelectionDirection(self: *const TextArea) []const u8 {
return @tagName(self._selection_direction);
const entry = text_entry.TextEntry(TextArea);
pub const select = entry.select;
pub const innerInsert = entry.innerInsert;
pub const innerDelete = entry.innerDelete;
pub const getSelectionDirection = entry.getSelectionDirection;
pub const setSelectionStart = entry.setSelectionStart;
pub const setSelectionEnd = entry.setSelectionEnd;
pub const setSelectionRange = entry.setSelectionRange;
// <textarea> always supports selection; <input> only does for some types.
pub fn selectionAvailable(_: *const TextArea) bool {
return true;
}
// Non-null unlike input
pub fn getSelectionStart(self: *const TextArea) u32 {
return self._selection_start;
}
pub fn setSelectionStart(self: *TextArea, value: u32, frame: *Frame) !void {
self._selection_start = value;
try self.dispatchSelectionChangeEvent(frame);
}
pub fn getSelectionEnd(self: *const TextArea) u32 {
return self._selection_end;
}
pub fn setSelectionEnd(self: *TextArea, value: u32, frame: *Frame) !void {
self._selection_end = value;
try self.dispatchSelectionChangeEvent(frame);
}
pub fn setSelectionRange(
self: *TextArea,
selection_start: u32,
selection_end: u32,
selection_dir: ?[]const u8,
frame: *Frame,
) !void {
const direction = blk: {
if (selection_dir) |sd| {
break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none;
} else break :blk .none;
};
const value = self._value orelse {
self._selection_start = 0;
self._selection_end = 0;
self._selection_direction = .none;
return;
};
const len_u32: u32 = @intCast(value.len);
var start: u32 = if (selection_start > len_u32) len_u32 else selection_start;
const end: u32 = if (selection_end > len_u32) len_u32 else selection_end;
// If end is less than start, both are equal to end.
if (end < start) {
start = end;
}
self._selection_direction = direction;
self._selection_start = start;
self._selection_end = end;
try self.dispatchSelectionChangeEvent(frame);
}
pub fn getForm(self: *TextArea, frame: *Frame) ?*Form {
const element = self.asElement();
+218
View File
@@ -0,0 +1,218 @@
// 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/>.
// Input and TextArea share much in common. All shared logic between Input and
// TextArea sits here.
const std = @import("std");
const Frame = @import("../../Frame.zig");
const Event = @import("../Event.zig");
const Selection = @import("../Selection.zig");
const InputEvent = @import("../event/InputEvent.zig");
pub fn TextEntry(comptime T: type) type {
return struct {
pub fn select(self: *T, frame: *Frame) !void {
const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0;
try setSelectionRange(self, 0, len, null, frame);
const event = try Event.init("select", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
pub fn innerInsert(self: *T, str: []const u8, frame: *Frame) !void {
const arena = frame.arena;
switch (howSelected(self)) {
.full => {
// fully selected, replace the content.
const new_value = try arena.dupe(u8, str);
try self.setValue(new_value, frame);
self._selection_start = @intCast(new_value.len);
self._selection_end = @intCast(new_value.len);
self._selection_direction = .none;
try dispatchSelectionChangeEvent(self, frame);
},
.partial => |range| {
// partially selected, replace the selected content.
const current_value = self.getValue();
const before = current_value[0..range[0]];
const remaining = current_value[range[1]..];
const new_value = try std.mem.concat(
arena,
u8,
&.{ before, str, remaining },
);
try self.setValue(new_value, frame);
const new_pos = range[0] + str.len;
self._selection_start = @intCast(new_pos);
self._selection_end = @intCast(new_pos);
self._selection_direction = .none;
try dispatchSelectionChangeEvent(self, frame);
},
.none => {
// nothing selected, just insert at cursor.
const current_value = self.getValue();
const new_value = try std.mem.concat(arena, u8, &.{ current_value, str });
try self.setValue(new_value, frame);
},
}
try dispatchInputEvent(self, str, "insertText", frame);
}
// forward == delete
// !forward == backspace
pub fn innerDelete(self: *T, forward: bool, frame: *Frame) !void {
const current_value = self.getValue();
const value_len: u32 = @intCast(current_value.len);
var start: u32 = undefined;
var end: u32 = undefined;
switch (howSelected(self)) {
.full => {
start = 0;
end = value_len;
},
.partial => |range| {
start, end = range;
},
.none => {
// Controls without selection support keep no caret; edit at the end.
const caret = if (self.selectionAvailable()) @min(self._selection_start, value_len) else value_len;
if (forward) {
if (caret >= value_len) {
return;
}
start = caret;
end = caret + (std.unicode.utf8ByteSequenceLength(current_value[caret]) catch 1);
} else {
if (caret == 0) {
return;
}
end = caret;
start = caret - 1;
while (start > 0 and (current_value[start] & 0xC0) == 0x80) {
start -= 1;
}
}
},
}
const new_value = try std.mem.concat(frame.arena, u8, &.{
current_value[0..start],
current_value[@min(end, value_len)..],
});
try self.setValue(new_value, frame);
self._selection_start = start;
self._selection_end = start;
self._selection_direction = .none;
try dispatchSelectionChangeEvent(self, frame);
try dispatchInputEvent(self, null, if (forward) "deleteContentForward" else "deleteContentBackward", frame);
}
pub fn getSelectionDirection(self: *const T) []const u8 {
return @tagName(self._selection_direction);
}
pub fn setSelectionStart(self: *T, value: u32, frame: *Frame) !void {
if (self.selectionAvailable() == false) {
return error.InvalidStateError;
}
self._selection_start = value;
try dispatchSelectionChangeEvent(self, frame);
}
pub fn setSelectionEnd(self: *T, value: u32, frame: *Frame) !void {
if (self.selectionAvailable() == false) {
return error.InvalidStateError;
}
self._selection_end = value;
try dispatchSelectionChangeEvent(self, frame);
}
pub fn setSelectionRange(
self: *T,
selection_start: u32,
selection_end: u32,
selection_dir: ?[]const u8,
frame: *Frame,
) !void {
if (self.selectionAvailable() == false) {
return error.InvalidStateError;
}
const direction = blk: {
if (selection_dir) |sd| {
break :blk std.meta.stringToEnum(Selection.SelectionDirection, sd) orelse .none;
} else break :blk .none;
};
const value = self._value orelse {
self._selection_start = 0;
self._selection_end = 0;
self._selection_direction = .none;
return;
};
const len_u32: u32 = @intCast(value.len);
var start: u32 = if (selection_start > len_u32) len_u32 else selection_start;
const end: u32 = if (selection_end > len_u32) len_u32 else selection_end;
// If end is less than start, both are equal to end.
if (end < start) {
start = end;
}
self._selection_direction = direction;
self._selection_start = start;
self._selection_end = end;
try dispatchSelectionChangeEvent(self, frame);
}
const HowSelected = union(enum) { partial: struct { u32, u32 }, full, none };
fn howSelected(self: *const T) HowSelected {
if (self.selectionAvailable() == false) {
return .none;
}
const value = self._value orelse return .none;
if (self._selection_start == self._selection_end) {
return .none;
}
if (self._selection_start == 0 and self._selection_end == value.len) {
return .full;
}
return .{ .partial = .{ self._selection_start, self._selection_end } };
}
fn dispatchSelectionChangeEvent(self: *T, frame: *Frame) !void {
const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
fn dispatchInputEvent(self: *T, data: ?[]const u8, input_type: []const u8, frame: *Frame) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, frame);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
};
}
+11 -2
View File
@@ -43,6 +43,14 @@ pub const Options = Event.inheritOptions(
);
pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TextEvent {
return initWithTrusted(typ, _opts, false, frame);
}
pub fn initTrusted(typ: []const u8, _opts: ?Options, frame: *Frame) !*TextEvent {
return initWithTrusted(typ, _opts, true, frame);
}
fn initWithTrusted(typ: []const u8, _opts: ?Options, trusted: bool, frame: *Frame) !*TextEvent {
const arena = try frame.getArena(.tiny, "TextEvent");
errdefer arena.release();
const type_string = try String.init(arena.allocator(), typ, .{});
@@ -58,7 +66,7 @@ pub fn init(typ: []const u8, _opts: ?Options, frame: *Frame) !*TextEvent {
},
);
Event.populatePrototypes(event, opts, false);
Event.populatePrototypes(event, opts, trusted);
return event;
}
@@ -90,7 +98,8 @@ pub fn initTextEvent(
event._bubbles = bubbles orelse false;
event._cancelable = cancelable orelse false;
ui._view = view;
self._data = if (data) |d| try arena.dupe(u8, d) else "";
// yup, the literal string "undefined" (not undefined) is the default
self._data = if (data) |d| try arena.dupe(u8, d) else "undefined";
}
pub const JsApi = struct {
+45 -24
View File
@@ -22,6 +22,7 @@ const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const URL = @import("../URL.zig");
const FormData = @import("FormData.zig");
const KeyValueList = @import("../KeyValueList.zig");
@@ -44,6 +45,7 @@ const URLSearchParams = @This();
_rc: lp.RC = .{},
_arena: *lp.Arena,
_params: KeyValueList,
_url: ?*URL = null, // Set when created via the url.searchParams getter
const InitOpts = union(enum) {
form_data: *FormData,
@@ -62,9 +64,12 @@ pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams {
.form_data => |fd| break :blk try fd.toKeyValueList(arena.allocator()),
.value => |js_val| {
if (js_val.isObject()) {
// Per Web IDL, an object with @@iterator converts as a
// sequence of [name, value] pairs. This covers arrays,
// Maps, generators and other URLSearchParams - including
// ones with a patched @@iterator, so no instanceof
// fast-path here.
if (try js_val.iterator()) |it| {
// value satisfies the @@iterator protocol, so we're
// expecting [name, value] tuples
break :blk try paramsFromIterator(arena.allocator(), it);
}
// normalizer is null, so frame won't be used
@@ -118,16 +123,19 @@ pub fn has(self: *const URLSearchParams, name: []const u8, value: ?[]const u8) b
return self._params.has(name, value);
}
pub fn set(self: *URLSearchParams, name: []const u8, value: []const u8) !void {
return self._params.set(self._arena.allocator(), name, value);
pub fn set(self: *URLSearchParams, name: []const u8, value: []const u8, exec: *const Execution) !void {
try self._params.set(self._arena.allocator(), name, value);
try self.postUpdate(exec);
}
pub fn append(self: *URLSearchParams, name: []const u8, value: []const u8) !void {
return self._params.append(self._arena.allocator(), name, value);
pub fn append(self: *URLSearchParams, name: []const u8, value: []const u8, exec: *const Execution) !void {
try self._params.append(self._arena.allocator(), name, value);
try self.postUpdate(exec);
}
pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8) void {
pub fn delete(self: *URLSearchParams, name: []const u8, value: ?[]const u8, exec: *const Execution) !void {
self._params.delete(name, value);
try self.postUpdate(exec);
}
pub fn keys(self: *URLSearchParams, exec: *const Execution) !*KeyIterator {
@@ -154,7 +162,10 @@ pub fn format(self: *const URLSearchParams, writer: *std.Io.Writer) !void {
pub fn forEach(self: *URLSearchParams, cb_: js.Function, js_this_: ?js.Object) !void {
const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
// the callback can mutate the list
// Index-based on purpose: the callback can mutate the list (delete,
// append, ...), which both changes its length and can reallocate the
// backing slice, so neither a captured slice nor a pointer into it
// survives the call.
var i: usize = 0;
while (i < self._params._entries.items.len) : (i += 1) {
const entry = self._params._entries.items[i];
@@ -165,15 +176,27 @@ pub fn forEach(self: *URLSearchParams, cb_: js.Function, js_this_: ?js.Object) !
}
}
pub fn sort(self: *URLSearchParams) void {
// std.mem.sort is stable (as required by the spec)
pub fn sort(self: *URLSearchParams, exec: *const Execution) !void {
// std.mem.sort (block sort) is stable, which the spec requires: entries
// with equal names keep their relative order.
std.mem.sort(KeyValueList.Entry, self._params._entries.items, {}, struct {
fn cmp(_: void, a: KeyValueList.Entry, b: KeyValueList.Entry) bool {
return utf16Order(a.name.str(), b.name.str()) == .lt;
}
}.cmp);
try self.postUpdate(exec);
}
// we need to keep the linked url in sync
fn postUpdate(self: *URLSearchParams, exec: *const Execution) !void {
const url = self._url orelse return;
try url.syncQueryFromParams(exec);
}
// The URL spec sorts by UTF-16 code units, not bytes or code points. The
// difference is observable for supplementary-plane characters: 🌈 (U+1F308)
// is a higher code point than ffi (U+FB03), but its UTF-16 lead surrogate
// (0xD83C) is a lower code unit.
fn utf16Order(a: []const u8, b: []const u8) std.math.Order {
var ia: usize = 0;
var ib: usize = 0;
@@ -231,21 +254,17 @@ fn paramsFromIterator(allocator: Allocator, it: js.Value.Iterator) !KeyValueList
if (item.isArray()) {
const as_array = item.toArray();
if (as_array.len() != 2) {
// we should be getting [name, value]
return error.TypeError;
}
// Each pair must have exactly 2 items.
if (as_array.len() != 2) return error.TypeError;
name_val = try as_array.get(0);
value_val = try as_array.get(1);
} else {
// Not an array, but it could itself be an iterator
// A non-array pair (e.g. what a Map entries iterator or a custom
// generator yields) converts through its own @@iterator.
var pair_it = (try item.iterator()) orelse return error.TypeError;
name_val = (try pair_it.next()) orelse return error.TypeError;
value_val = (try pair_it.next()) orelse return error.TypeError;
if (try pair_it.next() != null) {
// should only have a name and a value, anything else is wrong
return error.TypeError;
}
if (try pair_it.next() != null) return error.TypeError;
}
try params._entries.append(allocator, .{
@@ -301,6 +320,13 @@ fn paramsFromString(allocator: Allocator, input_: []const u8, buf: []u8) !KeyVal
return params;
}
// True when value[i] starts a valid %XX escape. A '%' not followed by two
// hex digits is not an error: percent-decode passes it through literally
// ("b=%2sf" parses to "%2sf").
fn isEscapeTriplet(value: []const u8, i: usize) bool {
return i + 2 < value.len and std.ascii.isHex(value[i + 1]) and std.ascii.isHex(value[i + 2]);
}
fn unescape(arena: Allocator, value: []const u8, buf: []u8) !String {
if (value.len == 0) {
return comptime .wrap("");
@@ -390,11 +416,6 @@ pub const Iterator = struct {
}
};
// True when value[i] starts a valid %XX escape
fn isEscapeTriplet(value: []const u8, i: usize) bool {
return i + 2 < value.len and std.ascii.isHex(value[i + 1]) and std.ascii.isHex(value[i + 2]);
}
const GenericIterator = @import("../collections/iterator.zig").Entry;
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
+2 -2
View File
@@ -154,8 +154,8 @@ test "BodyInit: URLSearchParams emit urlencoded body + content-type" {
const usp = try arena.create(URLSearchParams);
usp.* = .{ ._arena = arena, ._params = .empty };
try usp.append("a", "1");
try usp.append("b", "2");
try usp._params.append(arena.allocator(), "a", "1");
try usp._params.append(arena.allocator(), "b", "2");
const r = try (BodyInit{ .url_search_params = usp }).extract(arena.allocator());
try testing.expectString("a=1&b=2", r.bytes);
+1 -1
View File
@@ -261,7 +261,7 @@ pub fn maybeBumpGenerator(self: *Engine, store_id: i64, key: f64) !void {
// Cap at 2^53, the largest integer the generator tracks per spec.
const capped = @min(@floor(key), 9007199254740992);
const want: i64 = @intFromFloat(capped + 1);
const want: i64 = @trunc(capped + 1);
try self.conn.exec(
"update idb_object_stores set key_generator = ?2 where id = ?1 and key_generator < ?2",
.{ store_id, want },
+4 -4
View File
@@ -231,19 +231,19 @@ fn substringFn(arena: Allocator, args: []const result.Result) Error![]const u8 {
if (std.math.isNan(len_raw)) return "";
const len = roundHalfToPosInf(len_raw);
const sum = start - 1 + len;
// -inf + inf is NaN; @intFromFloat(NaN) is illegal behavior.
// -inf + inf is NaN, and NaN to integer is illegal behavior.
if (std.math.isNan(sum)) return "";
const si_f = @max(start - 1, 0);
const ei_f = @min(sum, s_len);
if (si_f >= ei_f) return "";
const si: usize = @intFromFloat(si_f);
const ei: usize = @intFromFloat(ei_f);
const si: usize = @trunc(si_f);
const ei: usize = @trunc(ei_f);
return s[si..ei];
}
const si_f = @max(start - 1, 0);
if (si_f >= s_len) return "";
const si: usize = @intFromFloat(si_f);
const si: usize = @trunc(si_f);
return s[si..];
}
+1 -1
View File
@@ -115,7 +115,7 @@ pub fn numberToString(arena: Allocator, n: f64) error{OutOfMemory}![]const u8 {
if (std.math.isNegativeInf(n)) return "-Infinity";
if (n == 0) return "0"; // covers +0 and -0
if (@trunc(n) == n and n >= -9.007199254740992e15 and n <= 9.007199254740992e15) {
return std.fmt.allocPrint(arena, "{d}", .{@as(i64, @intFromFloat(n))});
return std.fmt.allocPrint(arena, "{d}", .{@as(i64, @trunc(n))});
}
return std.fmt.allocPrint(arena, "{d}", .{n});
}
+2 -2
View File
@@ -537,8 +537,8 @@ fn getBoxModel(cmd: *CDP.Command) !void {
.padding = zero,
.border = zero,
.margin = zero,
.width = @intFromFloat(rect.width),
.height = @intFromFloat(rect.height),
.width = @trunc(rect.width),
.height = @trunc(rect.height),
} }, .{});
}
+18 -18
View File
@@ -102,10 +102,10 @@ fn configureLoading(cmd: *CDP.Command) !void {
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
if (params.subFrame) |v| bc.session.subframe_loading_enabled = v;
if (params.worker) |v| bc.session.worker_loading_enabled = v;
if (params.externalStylesheets) |v| bc.session.load_external_stylesheets = v;
if (params.images) |v| bc.session.load_resources.image = v;
if (params.worker) |v| bc.session.load_resources.worker = v;
if (params.subFrame) |v| bc.session.load_resources.iframe = v;
if (params.externalStylesheets) |v| bc.session.load_resources.stylesheet = v;
return cmd.sendResult(null, .{});
}
@@ -771,8 +771,8 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" {
_ = try bc.session.createPage();
// Defaults: both loading types enabled.
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
try testing.expectEqual(true, bc.session.worker_loading_enabled);
try testing.expectEqual(true, bc.session.load_resources.iframe);
try testing.expectEqual(true, bc.session.load_resources.worker);
// subFrame-only: leaves worker untouched.
try ctx.processMessage(.{
@@ -781,8 +781,8 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" {
.params = .{ .subFrame = false },
});
try ctx.expectSentResult(null, .{ .id = 1 });
try testing.expectEqual(false, bc.session.subframe_loading_enabled);
try testing.expectEqual(true, bc.session.worker_loading_enabled);
try testing.expectEqual(false, bc.session.load_resources.iframe);
try testing.expectEqual(true, bc.session.load_resources.worker);
// worker-only: leaves subFrame untouched.
try ctx.processMessage(.{
@@ -791,8 +791,8 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" {
.params = .{ .worker = false },
});
try ctx.expectSentResult(null, .{ .id = 2 });
try testing.expectEqual(false, bc.session.subframe_loading_enabled);
try testing.expectEqual(false, bc.session.worker_loading_enabled);
try testing.expectEqual(false, bc.session.load_resources.iframe);
try testing.expectEqual(false, bc.session.load_resources.worker);
// Both at once.
try ctx.processMessage(.{
@@ -801,8 +801,8 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" {
.params = .{ .subFrame = true, .worker = true },
});
try ctx.expectSentResult(null, .{ .id = 3 });
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
try testing.expectEqual(true, bc.session.worker_loading_enabled);
try testing.expectEqual(true, bc.session.load_resources.iframe);
try testing.expectEqual(true, bc.session.load_resources.worker);
}
test "cdp.lp: configureLoading toggles externalStylesheets independently" {
@@ -813,7 +813,7 @@ test "cdp.lp: configureLoading toggles externalStylesheets independently" {
_ = try bc.session.createPage();
// Default is opt-in: off unless the CLI flag or CDP toggle enables it.
try testing.expectEqual(false, bc.session.load_external_stylesheets);
try testing.expectEqual(false, bc.session.load_resources.stylesheet);
// Enable via CDP; the other two loading toggles stay at their defaults.
try ctx.processMessage(.{
@@ -822,9 +822,9 @@ test "cdp.lp: configureLoading toggles externalStylesheets independently" {
.params = .{ .externalStylesheets = true },
});
try ctx.expectSentResult(null, .{ .id = 1 });
try testing.expectEqual(true, bc.session.load_external_stylesheets);
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
try testing.expectEqual(true, bc.session.worker_loading_enabled);
try testing.expectEqual(true, bc.session.load_resources.stylesheet);
try testing.expectEqual(true, bc.session.load_resources.iframe);
try testing.expectEqual(true, bc.session.load_resources.worker);
// Flip back off; partial params must not reset the other fields.
try ctx.processMessage(.{
@@ -833,7 +833,7 @@ test "cdp.lp: configureLoading toggles externalStylesheets independently" {
.params = .{ .externalStylesheets = false },
});
try ctx.expectSentResult(null, .{ .id = 2 });
try testing.expectEqual(false, bc.session.load_external_stylesheets);
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
try testing.expectEqual(true, bc.session.worker_loading_enabled);
try testing.expectEqual(false, bc.session.load_resources.stylesheet);
try testing.expectEqual(true, bc.session.load_resources.iframe);
try testing.expectEqual(true, bc.session.load_resources.worker);
}
+13 -1
View File
@@ -52,6 +52,11 @@ const log = lp.log;
/// that appears in both with the same field name and type is collapsed
/// into one field (the command's own option wins); reusing a name with
/// a different type is a compile error.
/// - `before_parse: fn () void` (optional) — called once the command is
/// known (by name or sniffed from a legacy flag), before any option is
/// read. For mode-level process defaults that must already hold while
/// the options themselves are parsed, e.g. log settings; an explicit
/// option parsed later still wins.
/// - `positional: struct` (optional) — a positional argument with `.name`
/// and `.type` that may appear anywhere in argv. By default it holds a
/// single value: `.type` must be an optional pointer-to-u8 slice (e.g.
@@ -79,6 +84,8 @@ const log = lp.log;
/// built-in type switch. See the validator section below.
/// - `variants: tuple` (optional) — alternate flag names that write into
/// the same field. See the variants section below.
/// - `deprecated: []const u8` (optional) — the option still parses, but
/// each use logs a warning carrying this note.
///
/// ## Supported types and their defaults
///
@@ -470,7 +477,6 @@ pub fn Builder(comptime commands: anytype) type {
inline for (.{
"--host",
"--port",
"--timeout",
}) |heuristic| {
if (std.mem.eql(u8, cmd_str, heuristic)) {
return .serve;
@@ -526,6 +532,9 @@ pub fn Builder(comptime commands: anytype) type {
const OptionType = @TypeOf(option);
const is_multiple = @hasField(OptionType, "multiple") and option.multiple;
const has_validator = @hasField(OptionType, "validator");
if (@hasField(OptionType, "deprecated")) {
log.warn(.app, "deprecated CLI parameter", .{ .name = option.name, .note = option.deprecated });
}
// Prefer validator for parsing if provided. The validator writes
// through the field pointer (the list itself for multiples).
@@ -682,6 +691,9 @@ pub fn Builder(comptime commands: anytype) type {
args: *std.process.Args.Iterator,
) !Union {
const Command = @FieldType(Union, command.name);
if (@hasField(@TypeOf(command), "before_parse")) {
command.before_parse();
}
var c = Command{};
const options = blk: {
+19 -21
View File
@@ -351,28 +351,26 @@
\\ --cookie-jar <PATH>
\\ Path to a JSON file to save cookies to on exit (write-only).
\\ Defaults to no cookie saving.
\\ --disable-subframes
\\ Skip loading <iframe> elements. The parser still registers them in the
\\ DOM, but no child frame or Page.frameAttached events are produced.
\\ Defaults to false.
\\ --disable-workers
\\ Skip loading dedicated Web Workers. The Worker constructor still
\\ returns a Worker object, but no script fetch is initiated and its scope
\\ never runs.
\\ Defaults to false.
\\ --enable-external-stylesheets
\\ Fetch external <link rel=stylesheet> resources so their rules
\\ contribute to computed styles (and therefore to visibility checks like
\\ display, visibility, opacity, pointer-events).
\\ Defaults to false, except in agent mode with an LLM, where it is on.
\\ --load-resources <RESOURCE>
\\ Sub-resource to actually request. Can be passed multiple times.
\\ Defaults to requesting none of them.
\\ Allowed values:
\\ image <img> sources, so that load/error reflects the real
\\ HTTP status. Only the response headers are read;
\\ images are never decoded, so naturalWidth and
\\ naturalHeight stay 0. Delays the window load event.
\\ iframe When enabled, <iframe> elements are fully loaded.
\\
\\ image <img> sources, so that load/error reflects the real
\\ HTTP status. Only the response headers are read;
\\ images are never decoded, so naturalWidth and
\\ naturalHeight stay 0. Delays the window load event.
\\
\\ stylesheet Fetch external <link rel=stylesheet> resources so
\\ their rules contribute to computed styles (and
\\ therefore to visibility checks like display,
\\ visibility, opacity, pointer-events).
\\
\\ worker Enable loading dedicated and shared workers. When
\\ disabled, the Worker constructor still returns a
\\ Worker, but no script fetch is initiated and the
\\ Worker never runs.
\\ --http-cache-dir <PATH>
\\ Directory used as a filesystem cache for network resources. Omitting
\\ this disables caching.
@@ -423,13 +421,13 @@
\\ --insecure-disable-tls-host-verification
\\ Disables host verification on all HTTP requests.
\\ Only set this if you understand and accept the risk.
\\ --log-filter-scopes <SCOPE>
\\ --log-filter <SCOPE>
\\ Filter logs per scope, applied first-to-last. Can be passed multiple times.
\\ "-X" (or bare "X") filters out a scope, "+X" filters it in, and
\\ "all" targets every scope.
\\ e.g. --log-filter-scopes http --log-filter-scopes unknown_prop
\\ e.g. --log-filter http --log-filter unknown_prop
\\ hides those two.
\\ --log-filter-scopes -all --log-filter-scopes +cdp
\\ --log-filter -all --log-filter +cdp
\\ hides everything except cdp.
\\ --log-format <FORMAT>
\\ The log format.
+32 -20
View File
@@ -21,23 +21,24 @@ const lp = @import("lightpanda");
pub const Scope = enum {
app,
dom,
bug,
browser,
bug,
cache,
cdp,
console,
http,
frame,
js,
disabled,
dom,
event,
scheduler,
frame,
http,
js,
mcp,
not_implemented,
scheduler,
storage,
telemetry,
unknown_prop,
mcp,
cache,
websocket,
storage,
};
pub const num_scopes = @typeInfo(Scope).@"enum".fields.len;
@@ -54,7 +55,7 @@ pub const FilterRule = struct {
/// array. Directives apply left-to-right, so `-all,+cdp` disables every
/// scope then re-enables `cdp`. Scopes untouched by any directive stay
/// enabled.
pub fn resolveFilterScopes(rules: []const FilterRule) [num_scopes]bool {
pub fn resolveFilters(rules: []const FilterRule) [num_scopes]bool {
var scope_enabled = [_]bool{true} ** num_scopes;
for (rules) |rule| {
if (rule.scope) |scope| {
@@ -70,7 +71,6 @@ const Opts = struct {
format: Format = if (lp.IS_DEBUG) .pretty else .logfmt,
level: Level = if (lp.IS_DEBUG) .info else .warn,
// Per-scope enabled flags; a `false` entry suppresses that scope's logs.
// Only consulted in Debug builds. Default: everything enabled.
scope_enabled: [num_scopes]bool = [_]bool{true} ** num_scopes,
};
@@ -86,10 +86,8 @@ pub fn enabled(scope: Scope, level: Level) bool {
return false;
}
if (comptime lp.IS_DEBUG) {
if (opts.scope_enabled[@intFromEnum(scope)] == false) {
return false;
}
if (opts.scope_enabled[@intFromEnum(scope)] == false) {
return false;
}
return true;
@@ -164,6 +162,20 @@ pub fn note(scope: Scope, msg: []const u8, data: anytype) void {
}
}
var warned_disabled_worker = std.atomic.Value(bool).init(false);
pub fn warnDisabledWorker() void {
if (warned_disabled_worker.swap(true, .monotonic) == false) {
warn(.disabled, "workers disabled", .{ .hint = "enable via --load-resources worker" });
}
}
var warned_disabled_iframe = std.atomic.Value(bool).init(false);
pub fn warnDisabledIFrame() void {
if (warned_disabled_iframe.swap(true, .monotonic) == false) {
warn(.disabled, "iframes disabled", .{ .hint = "enable via --load-resources iframe" });
}
}
pub fn log(scope: Scope, level: Level, msg: []const u8, data: anytype) void {
if (enabled(scope, level) == false) {
return;
@@ -602,17 +614,17 @@ test "log: string escape" {
}
}
test "log: resolveFilterScopes" {
test "log: resolveFilters" {
// No directives: everything enabled.
{
const se = resolveFilterScopes(&.{});
const se = resolveFilters(&.{});
try testing.expectEqual(true, se[@intFromEnum(Scope.cdp)]);
try testing.expectEqual(true, se[@intFromEnum(Scope.http)]);
}
// Backward compatible: bare/`-` scope filters that scope out, rest stay in.
{
const se = resolveFilterScopes(&.{
const se = resolveFilters(&.{
.{ .scope = .cdp, .enable = false },
.{ .scope = .http, .enable = false },
});
@@ -623,7 +635,7 @@ test "log: resolveFilterScopes" {
// `-all,+cdp`: disable everything, then re-enable cdp.
{
const se = resolveFilterScopes(&.{
const se = resolveFilters(&.{
.{ .scope = null, .enable = false },
.{ .scope = .cdp, .enable = true },
});
@@ -634,7 +646,7 @@ test "log: resolveFilterScopes" {
// `+all,-cdp`: enable everything, then disable cdp. Order matters.
{
const se = resolveFilterScopes(&.{
const se = resolveFilters(&.{
.{ .scope = null, .enable = true },
.{ .scope = .cdp, .enable = false },
});
-10
View File
@@ -83,16 +83,6 @@ fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args)
else => {},
}
if (args.logLevel()) |ll| {
log.opts.level = ll;
}
if (args.logFormat()) |lf| {
log.opts.format = lf;
}
// Set log filter scopes.
log.opts.scope_enabled = log.resolveFilterScopes(args.logFilterScopes().items);
// must be installed before any other threads
const sighandler = try main_arena.create(SigHandler);
sighandler.* = .{ .arena = main_arena };
+10 -6
View File
@@ -341,8 +341,10 @@ const WEB_API_TEST_ROOT = "src/browser/tests/";
const HtmlRunnerOpts = struct {
timeout_ms: u32 = 2000,
inject_script: ?[]const u8 = null,
load_external_stylesheets: bool = false,
load_resources: Config.LoadResources = .{},
load_resources: Config.LoadResources = .{
.worker = true,
.iframe = true,
},
};
// Create a fresh page on `test_session` and return its root frame — for tests
@@ -368,11 +370,12 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void {
}
defer test_session.inject_scripts = &.{};
test_session.load_external_stylesheets = opts.load_external_stylesheets;
defer test_session.load_external_stylesheets = false;
test_session.load_resources = opts.load_resources;
defer test_session.load_resources = .{};
defer test_session.load_resources = .{
// original defaults, tests expect these to be on
.worker = true,
.iframe = true,
};
const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path });
const stat = std.Io.Dir.cwd().statFile(io, root, .{}) catch |err| {
@@ -533,6 +536,7 @@ test "tests:beforeAll" {
.insecure_disable_tls_host_verification = true,
.user_agent_suffix = "internal-tester",
.ws_max_concurrent = 50,
.load_resources = .{ .worker = true, .iframe = true },
} });
test_app = try App.init(test_allocator, &test_config);