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 element" { try expectDump(.{ .with_base = true }, \\ - \\

Title

visible & well

+ \\

Title

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(), + \\

ab

+ ); + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try deep(div.asNode(), .{}, &aw.writer, frame); + + try testing.expectString( + \\

ab

+ , aw.written()); +} + test "dump: strip.js removes script and noscript" { try expectDump(.{ .strip = .{ .js = true } }, \\ diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 32a6f8f11..c074caf44 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -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; } } diff --git a/src/browser/tests/event/text.html b/src/browser/tests/event/text.html index 618945022..193156663 100644 --- a/src/browser/tests/event/text.html +++ b/src/browser/tests/event/text.html @@ -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); } diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index 5b1b0b12b..bcab61e4a 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -1361,3 +1361,41 @@ testing.expectEqual('sc:///', url.href); } + + diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 751c0d127..5bb53d66e 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -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. diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index c8a546cdf..70b046bca 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -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) { diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 65ef9f1d2..7bf26cdc6 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -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; } diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 19c1037d2..37e10204f 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -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); diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 049bb8064..d6013a4b4 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -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; } diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 5ec3f8fbb..6326cda6c 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -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. diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index 0664b134c..88276c052 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -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