diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml index 306f1457..71d485d0 100644 --- a/.github/workflows/wpt.yml +++ b/.github/workflows/wpt.yml @@ -108,7 +108,7 @@ jobs: run: | ./wpt serve 2> /dev/null & echo $! > WPT.pid sleep 10s - ./wptrunner -lpd-path ./lightpanda -json -concurrency 6 > wpt.json + ./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json kill `cat WPT.pid` - name: write commit diff --git a/src/browser/Page.zig b/src/browser/Page.zig index dedb2a6e..0cc7d8d9 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -652,6 +652,10 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url: .navigation_type = std.meta.activeTag(nt), }; + if (target._queued_navigation) |existing| { + target.arena_pool.release(existing.arena); + } + target._queued_navigation = qn; return session.scheduleNavigation(target); } @@ -2908,9 +2912,12 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { } if (node.is(Element.Html.Script)) |script| { if ((comptime from_parser == false) and script._src.len == 0) { - // script was added via JavaScript, but without a src, don't try - // to execute it (we'll execute it if/when the src is set) - return; + // Script was added via JavaScript without a src attribute. + // Only skip if it has no inline content either — scripts with + // textContent/text should still execute per spec. + if (node.firstChild() == null) { + return; + } } self.scriptAddedCallback(from_parser, script) catch |err| { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 7400744e..9180223c 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -535,6 +535,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: * nested_gop.key_ptr.* = owned_specifier; nested_gop.value_ptr.* = .{}; try script_manager.preloadImport(owned_specifier, url); + } else if (nested_gop.value_ptr.module == null) { + // Entry exists but module failed to compile previously. + // The imported_modules entry may have been consumed, so + // re-preload to ensure waitForImport can find it. + // Key was stored via dupeZ so it has a sentinel in memory. + const key = nested_gop.key_ptr.*; + const key_z: [:0]const u8 = key.ptr[0..key.len :0]; + try script_manager.preloadImport(key_z, url); } } } @@ -683,7 +691,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co return local.toLocal(m).handle; } - var source = try self.script_manager.?.waitForImport(normalized_specifier); + var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) { + error.UnknownModule => blk: { + // Module is in cache but was consumed from imported_modules + // (e.g., by a previous failed resolution). Re-preload and retry. + try self.script_manager.?.preloadImport(normalized_specifier, referrer_path); + break :blk try self.script_manager.?.waitForImport(normalized_specifier); + }, + else => return err, + }; defer source.deinit(); var try_catch: js.TryCatch = undefined; diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 2801f86b..fcbbea21 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -730,6 +730,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/css/CSSStyleRule.zig"), @import("../webapi/css/CSSStyleSheet.zig"), @import("../webapi/css/CSSStyleProperties.zig"), + @import("../webapi/css/FontFace.zig"), @import("../webapi/css/FontFaceSet.zig"), @import("../webapi/css/MediaQueryList.zig"), @import("../webapi/css/StyleSheetList.zig"), diff --git a/src/browser/tests/css/font_face.html b/src/browser/tests/css/font_face.html new file mode 100644 index 00000000..b515984b --- /dev/null +++ b/src/browser/tests/css/font_face.html @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/html/script/dynamic_inline.html b/src/browser/tests/element/html/script/dynamic_inline.html new file mode 100644 index 00000000..d5cb60f2 --- /dev/null +++ b/src/browser/tests/element/html/script/dynamic_inline.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/pseudo_classes.html b/src/browser/tests/element/pseudo_classes.html index fe75ab84..355d2ee7 100644 --- a/src/browser/tests/element/pseudo_classes.html +++ b/src/browser/tests/element/pseudo_classes.html @@ -81,6 +81,17 @@ } + +
diff --git a/src/browser/webapi/css/FontFace.zig b/src/browser/webapi/css/FontFace.zig new file mode 100644 index 00000000..0da560fc --- /dev/null +++ b/src/browser/webapi/css/FontFace.zig @@ -0,0 +1,90 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); +const js = @import("../../js/js.zig"); +const Page = @import("../../Page.zig"); + +const Allocator = std.mem.Allocator; + +const FontFace = @This(); + +_arena: Allocator, +_family: []const u8, + +pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace { + _ = source; + + const arena = try page.getArena(.{.debug = "FontFace"}); + errdefer page.releaseArena(arena); + + const self = try arena.create(FontFace); + self.* = .{ + ._arena = arena, + ._family = try arena.dupe(u8, family), + }; + return self; +} + +pub fn deinit(self: *FontFace, _: bool, page: *Page) void { + page.releaseArena(self._arena); +} + +pub fn getFamily(self: *const FontFace) []const u8 { + return self._family; +} + +// load() - resolves immediately; headless browser has no real font loading. +pub fn load(_: *FontFace, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise({}); +} + +// loaded - returns an already-resolved Promise. +pub fn getLoaded(_: *FontFace, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise({}); +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(FontFace); + + pub const Meta = struct { + pub const name = "FontFace"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(FontFace.deinit); + }; + + pub const constructor = bridge.constructor(FontFace.init, .{}); + pub const family = bridge.accessor(FontFace.getFamily, null, .{}); + pub const status = bridge.property("loaded", .{ .template = false, .readonly = true }); + pub const style = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const weight = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const stretch = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const unicodeRange = bridge.property("U+0-10FFFF", .{ .template = false, .readonly = true }); + pub const variant = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const featureSettings = bridge.property("normal", .{ .template = false, .readonly = true }); + pub const display = bridge.property("auto", .{ .template = false, .readonly = true }); + pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{}); + pub const load = bridge.function(FontFace.load, .{}); +}; + +const testing = @import("../../../testing.zig"); +test "WebApi: FontFace" { + try testing.htmlRunner("css/font_face.html", .{}); +} diff --git a/src/browser/webapi/css/FontFaceSet.zig b/src/browser/webapi/css/FontFaceSet.zig index 28703080..12f090bd 100644 --- a/src/browser/webapi/css/FontFaceSet.zig +++ b/src/browser/webapi/css/FontFaceSet.zig @@ -1,14 +1,45 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + const std = @import("std"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); +const FontFace = @import("FontFace.zig"); + +const Allocator = std.mem.Allocator; const FontFaceSet = @This(); -// Padding to avoid zero-size struct, which causes identity_map pointer collisions. -_pad: bool = false, +_arena: Allocator, pub fn init(page: *Page) !*FontFaceSet { - return page._factory.create(FontFaceSet{}); + const arena = try page.getArena(.{.debug = "FontFaceSet"}); + errdefer page.releaseArena(arena); + + const self = try arena.create(FontFaceSet); + self.* = .{ + ._arena = arena, + }; + return self; +} + +pub fn deinit(self: *FontFaceSet, _: bool, page: *Page) void { + page.releaseArena(self._arena); } // FontFaceSet.ready - returns an already-resolved Promise. @@ -29,6 +60,11 @@ pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise { return page.js.local.?.resolvePromise({}); } +// add(fontFace) - no-op; headless browser does not track loaded fonts. +pub fn add(self: *FontFaceSet, _: *FontFace) *FontFaceSet { + return self; +} + pub const JsApi = struct { pub const bridge = js.Bridge(FontFaceSet); @@ -36,6 +72,8 @@ pub const JsApi = struct { pub const name = "FontFaceSet"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; + pub const weak = true; + pub const finalizer = bridge.finalizer(FontFaceSet.deinit); }; pub const size = bridge.property(0, .{ .template = false, .readonly = true }); @@ -43,6 +81,7 @@ pub const JsApi = struct { pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{}); pub const check = bridge.function(FontFaceSet.check, .{}); pub const load = bridge.function(FontFaceSet.load, .{}); + pub const add = bridge.function(FontFaceSet.add, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index 376bbcdc..f9b1bb23 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -487,7 +487,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; - if (selectors.items.len == 0) return error.InvalidPseudoClass; + // Empty :is() is valid per spec - matches nothing return .{ .is = selectors.items }; } @@ -514,7 +514,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() != ')') return error.InvalidPseudoClass; self.input = self.input[1..]; - if (selectors.items.len == 0) return error.InvalidPseudoClass; + // Empty :where() is valid per spec - matches nothing return .{ .where = selectors.items }; }