From 081979be3bfb2297292d2294a2884e39f893683f Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 16 Feb 2026 17:43:38 +0800 Subject: [PATCH] Initial support for frames Missing: - [ ] Navigation support within frames (in fact, as-is, any navigation done inside a frame, will almost certainly break things - [ ] Correct CDP support. I don't know how frames are supposed to be exposed to CDP. Normal navigate events? Distinct CDP frame_ids? - [ ] Cross-origin restrictions. The interaction between frames is supposed to change depending on whether or not they're on the same origin - [ ] Potentially handling src-less frames incorrectly. Might not really matter Adds basic frame support. Initially explored adding a BrowsingContext and embedding it in Page, with the goal of also having it embedded in a to-be created Frame. But it turns out that 98% of Page _was_ BrowsingContext and introducing a BrowsingContext as the primary interaction unit broke pretty much _every_ single WebAPI. So Page was expanded: - Added `_parent: ?*Page`, which is `null` for "root" page. - Added `frame: ?*IFrame`, which is `null` for the "root" page. This is the HTMLIFrameElement for frame-pages. - Added a _type: enum{root, frame}, which is currently only used to improve the logs - Added a frames: std.ArrayList(*Page). This is a list of frames for the page. Note that a "frame-page" can itself haven nested frames. Besides the above, there were 3 "big" changes. 1 - Adding frames (dynamically, parsed) has to create a new page, start navigation, track it (in the frames list). Part of this was just piggybacking off of code that handles + + + + + + + + + + + diff --git a/src/browser/tests/frames/support/sub1.html b/src/browser/tests/frames/support/sub1.html new file mode 100644 index 00000000..f6b8ec4b --- /dev/null +++ b/src/browser/tests/frames/support/sub1.html @@ -0,0 +1,6 @@ + +
sub1 div1
+ diff --git a/src/browser/tests/frames/support/sub2.html b/src/browser/tests/frames/support/sub2.html new file mode 100644 index 00000000..ca1aaa21 --- /dev/null +++ b/src/browser/tests/frames/support/sub2.html @@ -0,0 +1,7 @@ + +
sub2 div1
+ + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 62c8473f..314fc001 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -220,4 +220,8 @@ return val; }); } + + if (window._lightpanda_skip_auto_assert !== true) { + window.addEventListener('load', testing.assertOk); + } })(); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 124da937..5bdfa5fb 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -679,7 +679,7 @@ pub fn normalize(self: *Node, page: *Page) !void { return self._normalize(page.call_arena, &buffer, page); } -pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError }!*Node { +pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError, CloneError, IFrameLoadError }!*Node { const deep = deep_ orelse false; switch (self._type) { .cdata => |cd| { diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index d339d60b..80694dfa 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -52,6 +52,7 @@ const Allocator = std.mem.Allocator; const Window = @This(); _proto: *EventTarget, +_page: *Page, _document: *Document, _css: CSS = .init, _crypto: Crypto = .init, @@ -96,6 +97,21 @@ pub fn getWindow(self: *Window) *Window { return self; } +pub fn getTop(self: *Window) *Window { + var p = self._page; + while (p.parent) |parent| { + p = parent; + } + return p.window; +} + +pub fn getParent(self: *Window) *Window { + if (self._page.parent) |p| { + return p.window; + } + return self; +} + pub fn getDocument(self: *Window) *Document { return self._document; } @@ -388,23 +404,31 @@ pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 { return decoded; } -pub fn getFrame(_: *Window, _: usize) !?*Window { - // TODO return the iframe's window. - return null; +pub fn getFrame(self: *Window, idx: usize) !?*Window { + const page = self._page; + const frames = page.frames.items; + if (idx >= frames.len) { + return null; + } + + if (page.frames_sorted == false) { + std.mem.sort(*Page, frames, {}, struct { + fn lessThan(_: void, a: *Page, b: *Page) bool { + const iframe_a = a.iframe orelse return false; + const iframe_b = b.iframe orelse return true; + + const pos = iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()); + // Return true if a precedes b (a should come before b in sorted order) + return (pos & 0x04) != 0; // FOLLOWING bit: b follows a + } + }.lessThan); + page.frames_sorted = true; + } + return frames[idx].window; } pub fn getFramesLength(self: *const Window) u32 { - const TreeWalker = @import("TreeWalker.zig"); - var walker = TreeWalker.Full.init(self._document.asNode(), .{}); - - var ln: u32 = 0; - while (walker.next()) |node| { - if (node.is(Element.Html.IFrame) != null) { - ln += 1; - } - } - - return ln; + return @intCast(self._page.frames.items.len); } pub fn getScrollX(self: *const Window) u32 { @@ -716,10 +740,10 @@ pub const JsApi = struct { pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } }); - pub const top = bridge.accessor(Window.getWindow, null, .{}); + pub const top = bridge.accessor(Window.getTop, null, .{}); pub const self = bridge.accessor(Window.getWindow, null, .{}); pub const window = bridge.accessor(Window.getWindow, null, .{}); - pub const parent = bridge.accessor(Window.getWindow, null, .{}); + pub const parent = bridge.accessor(Window.getParent, null, .{}); pub const navigator = bridge.accessor(Window.getNavigator, null, .{}); pub const screen = bridge.accessor(Window.getScreen, null, .{}); pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{}); diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 5f2cb867..8f7ebe95 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -342,6 +342,7 @@ pub fn click(self: *HtmlElement, page: *Page) !void { try page._event_manager.dispatch(self.asEventTarget(), event); } +<<<<<<< HEAD // TODO: Per spec, hidden is a tristate: true | false | "until-found". // We only support boolean for now; "until-found" would need bridge union support. pub fn getHidden(self: *HtmlElement) bool { @@ -373,13 +374,14 @@ pub fn setTabIndex(self: *HtmlElement, value: i32, page: *Page) !void { try self.asElement().setAttributeSafe(comptime .wrap("tabindex"), .wrap(str), page); } -fn getAttributeFunction( + +pub fn getAttributeFunction( self: *HtmlElement, listener_type: GlobalEventHandler, page: *Page, ) !?js.Function.Global { const element = self.asElement(); - if (page.getAttrListener(element, listener_type)) |cached| { + if (page._element_attr_listeners.get(.{ .target = element.asEventTarget(), .handler = listener_type })) |cached| { return cached; } diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 4aa65df0..7d4d183f 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -16,15 +16,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const log = @import("../../../../log.zig"); const js = @import("../../../js/js.zig"); const Page = @import("../../../Page.zig"); const Window = @import("../../Window.zig"); +const Document = @import("../../Document.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const HtmlElement = @import("../Html.zig"); +const URL = @import("../../URL.zig"); const IFrame = @This(); _proto: *HtmlElement, +_src: []const u8 = "", +_executed: bool = false, +_content_window: ?*Window = null, pub fn asElement(self: *IFrame) *Element { return self._proto._proto; @@ -33,8 +39,27 @@ pub fn asNode(self: *IFrame) *Node { return self.asElement().asNode(); } -pub fn getContentWindow(_: *const IFrame, page: *Page) *Window { - return page.window; +pub fn getContentWindow(self: *const IFrame) ?*Window { + return self._content_window; +} + +pub fn getContentDocument(self: *const IFrame) ?*Document { + const window = self._content_window orelse return null; + return window._document; +} + +pub fn getSrc(self: *const IFrame, page: *Page) ![:0]const u8 { + if (self._src.len == 0) return ""; + return try URL.resolve(page.call_arena, page.base(), self._src, .{}); +} + +pub fn setSrc(self: *IFrame, src: []const u8, page: *Page) !void { + const element = self.asElement(); + try element.setAttributeSafe(comptime .wrap("src"), .wrap(src), page); + self._src = element.getAttributeSafe(comptime .wrap("src")) orelse unreachable; + if (element.asNode().isConnected()) { + try page.iframeAddedCallback(self); + } } pub const JsApi = struct { @@ -46,5 +71,15 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; + pub const src = bridge.accessor(IFrame.getSrc, IFrame.setSrc, .{}); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); + pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); +}; + +pub const Build = struct { + pub fn complete(node: *Node, _: *Page) !void { + const self = node.as(IFrame); + const element = self.asElement(); + self._src = element.getAttributeSafe(comptime .wrap("src")) orelse ""; + } }; diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index f7a3dd88..ae83ad17 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -763,7 +763,7 @@ const IsolatedWorld = struct { // Currently we have only 1 page/frame and thus also only 1 state in the isolate world. pub fn createContext(self: *IsolatedWorld, page: *Page) !*js.Context { if (self.context == null) { - self.context = try self.browser.env.createContext(page, false); + self.context = try self.browser.env.createContext(page); } else { log.warn(.cdp, "not implemented", .{ .feature = "createContext: Not implemented second isolated context creation", diff --git a/src/testing.zig b/src/testing.zig index b79eacd4..62ec8870 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -414,6 +414,15 @@ fn runWebApiTest(test_file: [:0]const u8) !void { try_catch.init(&ls.local); defer try_catch.deinit(); + // by default, on load, testing.js will call testing.assertOk(). This makes our + // tests work well in a browser. But, for our test runner, we disable that + // and call it explicitly. This gives us better error messages. + ls.local.eval("window._lightpanda_skip_auto_assert = true;", "auto_assert") catch |err| { + const caught = try_catch.caughtOrError(arena_allocator, err); + std.debug.print("disable auto assert failure\nError: {f}\n", .{caught}); + return err; + }; + try page.navigate(url, .{}); _ = test_session.wait(2000);