diff --git a/src/Notification.zig b/src/Notification.zig index 91fcb673..d01492c8 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -73,6 +73,7 @@ const EventListeners = struct { page_navigated: List = .{}, page_network_idle: List = .{}, page_network_almost_idle: List = .{}, + page_frame_created: List = .{}, http_request_fail: List = .{}, http_request_start: List = .{}, http_request_intercept: List = .{}, @@ -89,6 +90,7 @@ const Events = union(enum) { page_navigated: *const PageNavigated, page_network_idle: *const PageNetworkIdle, page_network_almost_idle: *const PageNetworkAlmostIdle, + page_frame_created: *const PageFrameCreated, http_request_fail: *const RequestFail, http_request_start: *const RequestStart, http_request_intercept: *const RequestIntercept, @@ -129,6 +131,12 @@ pub const PageNetworkAlmostIdle = struct { timestamp: u64, }; +pub const PageFrameCreated = struct { + page_id: u32, + parent_id: u32, + timestamp: u64, +}; + pub const RequestStart = struct { transfer: *Transfer, }; diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index f89ea7ca..cfe5966e 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -495,12 +495,15 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null; // Look up the inline handler for this target - const element = switch (target._type) { - .node => |n| n.is(Element) orelse return null, + const html_element = switch (target._type) { + .node => |n| n.is(Element.Html) orelse return null, else => return null, }; - return self.page.getAttrListener(element, handler_type); + return html_element.getAttributeFunction(handler_type, self.page) catch |err| { + log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err }); + return null; + }; } fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 4b4c99e5..bf87fad7 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -186,7 +186,7 @@ _notified_network_almost_idle: IdleNotification = .init, // A navigation event that happens from a script gets scheduled to run on the // next tick. -_queued_navigation: ?QueuedNavigation = null, +_queued_navigation: ?*QueuedNavigation = null, // The URL of the current page url: [:0]const u8 = "about:blank", @@ -221,16 +221,29 @@ _arena_pool_leak_track: (if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct { count: usize, }) else void) = if (IS_DEBUG) .empty else {}, +parent: ?*Page, window: *Window, document: *Document, +iframe: ?*Element.Html.IFrame = null, +frames: std.ArrayList(*Page) = .{}, +frames_sorted: bool = true, // DOM version used to invalidate cached state of "live" collections version: usize = 0, +// This is maybe not great. It's a counter on the number of events that we're +// waiting on before triggering the "load" event. Essentially, we need all +// synchronous scripts and all iframes to be loaded. Scripts are handled by the +// ScriptManager, so all scripts just count as 1 pending load. +_pending_loads: u32, + +_parent_notified: if (IS_DEBUG) bool else void = if (IS_DEBUG) false else {}, + +_type: enum { root, frame }, // only used for logs right now _req_id: u32 = 0, _navigated_options: ?NavigatedOpts = null, -pub fn init(self: *Page, id: u32, session: *Session) !void { +pub fn init(self: *Page, id: u32, session: *Session, parent: ?*Page) !void { if (comptime IS_DEBUG) { log.debug(.page, "page.init", .{}); } @@ -251,6 +264,7 @@ pub fn init(self: *Page, id: u32, session: *Session) !void { self.* = .{ .id = id, .js = undefined, + .parent = parent, .arena = page_arena, .document = document, .window = undefined, @@ -258,28 +272,41 @@ pub fn init(self: *Page, id: u32, session: *Session) !void { .call_arena = call_arena, ._session = session, ._factory = factory, + ._pending_loads = 1, // always 1 for the ScriptManager + ._type = if (parent == null) .root else .frame, ._script_manager = undefined, ._event_manager = EventManager.init(page_arena, self), }; + var screen: *Screen = undefined; + var visual_viewport: *VisualViewport = undefined; + if (parent) |p| { + screen = p.window._screen; + visual_viewport = p.window._visual_viewport; + } else { + screen = try factory.eventTarget(Screen{ + ._proto = undefined, + ._orientation = null, + }); + visual_viewport = try factory.eventTarget(VisualViewport{ + ._proto = undefined, + }); + } + self.window = try factory.eventTarget(Window{ + ._page = self, ._proto = undefined, ._document = self.document, ._location = &default_location, ._performance = Performance.init(), - ._screen = try factory.eventTarget(Screen{ - ._proto = undefined, - ._orientation = null, - }), - ._visual_viewport = try factory.eventTarget(VisualViewport{ - ._proto = undefined, - }), + ._screen = screen, + ._visual_viewport = visual_viewport, }); self._script_manager = ScriptManager.init(browser.allocator, browser.http_client, self); errdefer self._script_manager.deinit(); - self.js = try browser.env.createContext(self, true); + self.js = try browser.env.createContext(self); errdefer self.js.deinit(); if (comptime builtin.is_test == false) { @@ -295,8 +322,12 @@ pub fn init(self: *Page, id: u32, session: *Session) !void { } pub fn deinit(self: *Page) void { + for (self.frames.items) |frame| { + frame.deinit(); + } + if (comptime IS_DEBUG) { - log.debug(.page, "page.deinit", .{ .url = self.url }); + log.debug(.page, "page.deinit", .{ .url = self.url, .type = self._type }); // Uncomment if you want slab statistics to print. // const stats = self._factory._slab.getStats(self.arena) catch unreachable; @@ -305,6 +336,10 @@ pub fn deinit(self: *Page) void { // stats.print(&stream) catch unreachable; } + if (self._queued_navigation) |qn| { + self.arena_pool.release(qn.arena); + } + const session = self._session; session.browser.env.destroyContext(self.js); @@ -316,7 +351,7 @@ pub fn deinit(self: *Page) void { var it = self._arena_pool_leak_track.valueIterator(); while (it.next()) |value_ptr| { if (value_ptr.count > 0) { - log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner }); + log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner, .type = self._type }); } } } @@ -385,7 +420,7 @@ pub fn releaseArena(self: *Page, allocator: Allocator) void { if (comptime IS_DEBUG) { const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?; if (found.count != 1) { - log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count }); + log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count, .type = self._type }); return; } found.count = 0; @@ -410,6 +445,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .reason = opts.reason, .body = opts.body != null, .req_id = req_id, + .type = self._type, }); // if the url is about:blank, we load an empty HTML document in the @@ -506,7 +542,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi .done_callback = pageDoneCallback, .error_callback = pageErrorCallback, }) catch |err| { - log.err(.page, "navigate request", .{ .url = self.url, .err = err }); + log.err(.page, "navigate request", .{ .url = self.url, .err = err, .type = self._type }); return err; }; } @@ -514,23 +550,27 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi // We cannot navigate immediately as navigating will delete the DOM tree, // which holds this event's node. // As such we schedule the function to be called as soon as possible. -// The page.arena is safe to use here, but the transfer_arena exists -// specifically for this type of lifetime. pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void { if (self.canScheduleNavigation(priority) == false) { return; } + const arena = try self.arena_pool.acquire(); + errdefer self.arena_pool.release(arena); + return self.scheduleNavigationWithArena(arena, request_url, opts, priority); +} - const session = self._session; - +fn scheduleNavigationWithArena(self: *Page, arena: Allocator, request_url: []const u8, opts: NavigateOpts, priority: NavigationPriority) !void { const resolved_url = try URL.resolve( - session.transfer_arena, + arena, self.base(), request_url, .{ .always_dupe = true }, ); + const session = self._session; if (!opts.force and URL.eqlDocument(self.url, resolved_url)) { + self.arena_pool.release(arena); + self.url = try self.arena.dupeZ(u8, resolved_url); self.window._location = try Location.init(self.url, self); self.document._location = self.window._location; @@ -541,15 +581,23 @@ pub fn scheduleNavigation(self: *Page, request_url: []const u8, opts: NavigateOp .url = resolved_url, .reason = opts.reason, .target = resolved_url, + .type = self._type, }); - self._session.browser.http_client.abort(); + session.browser.http_client.abort(); - self._queued_navigation = .{ + const qn = try arena.create(QueuedNavigation); + qn.* = .{ .opts = opts, + .arena = arena, .url = resolved_url, .priority = priority, }; + + if (self._queued_navigation) |existing| { + self.arena_pool.release(existing.arena); + } + self._queued_navigation = qn; } // A script can have multiple competing navigation events, say it starts off @@ -589,7 +637,7 @@ pub fn documentIsLoaded(self: *Page) void { self._load_state = .load; self.document._ready_state = .interactive; self._documentIsLoaded() catch |err| { - log.err(.page, "document is loaded", .{ .err = err }); + log.err(.page, "document is loaded", .{ .err = err, .type = self._type }); }; } @@ -602,6 +650,38 @@ pub fn _documentIsLoaded(self: *Page) !void { ); } +pub fn scriptsCompletedLoading(self: *Page) void { + self.pendingLoadCompleted(); +} + +pub fn iframeCompletedLoading(self: *Page, iframe: *Element.Html.IFrame) void { + blk: { + var ls: JS.Local.Scope = undefined; + self.js.localScope(&ls); + defer ls.deinit(); + + const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| { + log.err(.page, "iframe event init", .{ .err = err }); + break :blk; + }; + defer if (!event._v8_handoff) event.deinit(false); + self._event_manager.dispatch(iframe.asNode().asEventTarget(), event) catch |err| { + log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src }); + }; + } + self.pendingLoadCompleted(); +} + +fn pendingLoadCompleted(self: *Page) void { + const pending_loads = self._pending_loads; + if (pending_loads == 1) { + self._pending_loads = 0; + self.documentIsComplete(); + } else { + self._pending_loads = pending_loads - 1; + } +} + pub fn documentIsComplete(self: *Page) void { if (self._load_state == .complete) { // Ideally, documentIsComplete would only be called once, but with @@ -621,7 +701,7 @@ pub fn documentIsComplete(self: *Page) void { self._load_state = .complete; self._documentIsComplete() catch |err| { - log.err(.page, "document is complete", .{ .err = err }); + log.err(.page, "document is complete", .{ .err = err, .type = self._type }); }; if (IS_DEBUG) { @@ -675,6 +755,19 @@ fn _documentIsComplete(self: *Page) !void { ls.toLocal(self.window._on_pageshow), .{ .context = "page show" }, ); + + self.notifyParentLoadComplete(); +} + +fn notifyParentLoadComplete(self: *Page) void { + if (comptime IS_DEBUG) { + std.debug.assert(self._parent_notified == false); + self._parent_notified = true; + } + + if (self.parent) |p| { + p.iframeCompletedLoading(self.iframe.?); + } } fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { @@ -692,6 +785,7 @@ fn pageHeaderDoneCallback(transfer: *Http.Transfer) !bool { .url = self.url, .status = header.status, .content_type = header.contentType(), + .type = self._type, }); } @@ -712,7 +806,7 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { } orelse .unknown; if (comptime IS_DEBUG) { - log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len }); + log.debug(.page, "navigate first chunk", .{ .content_type = mime.content_type, .len = data.len, .type = self._type }); } switch (mime.content_type) { @@ -756,18 +850,17 @@ fn pageDataCallback(transfer: *Http.Transfer, data: []const u8) !void { } fn pageDoneCallback(ctx: *anyopaque) !void { - if (comptime IS_DEBUG) { - log.debug(.page, "navigate done", .{}); - } - var self: *Page = @ptrCast(@alignCast(ctx)); - self.clearTransferArena(); + + if (comptime IS_DEBUG) { + log.debug(.page, "navigate done", .{ .type = self._type }); + } //We need to handle different navigation types differently. try self._session.navigation.commitNavigation(self); defer if (comptime IS_DEBUG) { - log.debug(.page, "page.load.complete", .{ .url = self.url }); + log.debug(.page, "page.load.complete", .{ .url = self.url, .type = self._type }); }; const parse_arena = try self.getArena(.{ .debug = "Page.parse" }); @@ -836,36 +929,19 @@ fn pageDoneCallback(ctx: *anyopaque) !void { } fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void { - log.err(.page, "navigate failed", .{ .err = err }); - var self: *Page = @ptrCast(@alignCast(ctx)); + + log.err(.page, "navigate failed", .{ .err = err, .type = self._type }); self._parse_state = .{ .err = err }; // In case of error, we want to complete the page with a custom HTML // containing the error. pageDoneCallback(ctx) catch |e| { - log.err(.browser, "pageErrorCallback", .{ .err = e }); + log.err(.browser, "pageErrorCallback", .{ .err = e, .type = self._type }); return; }; } -// The transfer arena is useful and interesting, but has a weird lifetime. -// When we're transferring from one page to another (via delayed navigation) -// we need things in memory: like the URL that we're navigating to and -// optionally the body to POST. That cannot exist in the page.arena, because -// the page that we have is going to be destroyed and a new page is going -// to be created. If we used the page.arena, we'd wouldn't be able to reset -// it between navigation. -// So the transfer arena is meant to exist between a navigation event. It's -// freed when the main html navigation is complete, either in pageDoneCallback -// or pageErrorCallback. It needs to exist for this long because, if we set -// a body, CURLOPT_POSTFIELDS does not copy the body (it optionally can, but -// why would we want to) and requires the body to live until the transfer -// is complete. -fn clearTransferArena(self: *Page) void { - self.arena_pool.reset(self._session.transfer_arena, 4 * 1024); -} - pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { return self._wait(wait_ms) catch |err| { switch (err) { @@ -876,7 +952,7 @@ pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { // to run this through more real-world sites and see if we need // to expand the switch (err) to have more customized logs for // specific messages. - log.err(.browser, "page wait", .{ .err = err }); + log.err(.browser, "page wait", .{ .err = err, .type = self._type }); }, } return .done; @@ -884,6 +960,10 @@ pub fn wait(self: *Page, wait_ms: u32) Session.WaitResult { } fn _wait(self: *Page, wait_ms: u32) !Session.WaitResult { + if (comptime IS_DEBUG) { + std.debug.assert(self._type == .root); + } + var timer = try std.time.Timer.start(); var ms_remaining = wait_ms; @@ -1107,10 +1187,82 @@ pub fn scriptAddedCallback(self: *Page, comptime from_parser: bool, script: *Ele log.err(.page, "page.scriptAddedCallback", .{ .err = err, .src = script.asElement().getAttributeSafe(comptime .wrap("src")), + .type = self._type, }); }; } +pub fn iframeAddedCallback(self: *Page, iframe: *Element.Html.IFrame) !void { + if (self.isGoingAway()) { + // if we're planning on navigating to another page, don't load this iframe + return; + } + if (iframe._executed) { + return; + } + + const src = try iframe.getSrc(self); + if (src.len == 0) { + return; + } + + iframe._executed = true; + + const session = self._session; + const page_id = session.nextPageId(); + const page_frame = try self.arena.create(Page); + try Page.init(page_frame, page_id, session, self); + + self._pending_loads += 1; + page_frame.iframe = iframe; + iframe._content_window = page_frame.window; + + self._session.notification.dispatch(.page_frame_created, &.{ + .page_id = page_id, + .parent_id = self.id, + .timestamp = timestamp(.monotonic), + }); + + page_frame.navigate(src, .{ .reason = .initialFrameNavigation }) catch |err| { + log.warn(.page, "iframe navigate failure", .{ .url = src, .err = err }); + self._pending_loads -= 1; + iframe._content_window = null; + page_frame.deinit(); + return error.IFrameLoadError; + }; + + // window[N] is based on document order. For now we'll just append the frame + // at the end of our list and set frames_sorted == false. window.getFrame + // will check this flag to decide if it needs to sort the frames or not. + // But, we can optimize this a bit. Since we expect frames to often be + // added in document order, we can do a quick check to see whether the list + // is sorted or not. + try self.frames.append(self.arena, page_frame); + + const frames_len = self.frames.items.len; + if (frames_len == 1) { + // this is the only frame, it must be sorted. + return; + } + + if (self.frames_sorted == false) { + // the list already wasn't sorted, it still isn't + return; + } + + // So we added a frame into a sorted list. If this frame is sorted relative + // to the last frame, it's still sorted + const iframe_a = self.frames.items[frames_len - 2].iframe.?; + const iframe_b = self.frames.items[frames_len - 1].iframe.?; + + if (iframe_a.asNode().compareDocumentPosition(iframe_b.asNode()) & 0x04 == 0) { + // if b followed a, then & 0x04 = 0x04 + // but since we got 0, it means b does not follow a, and thus our list + // is no longer sorted. + self.frames_sorted = false; + } +} + pub fn domChanged(self: *Page) void { self.version += 1; @@ -1120,7 +1272,7 @@ pub fn domChanged(self: *Page) void { self._intersection_check_scheduled = true; self.js.queueIntersectionChecks() catch |err| { - log.err(.page, "page.schedIntersectChecks", .{ .err = err }); + log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); }; } @@ -1211,6 +1363,7 @@ pub fn setAttrListener( log.debug(.event, "Page.setAttrListener", .{ .element = element, .listener_type = listener_type, + .type = self._type, }); } @@ -1221,18 +1374,6 @@ pub fn setAttrListener( gop.value_ptr.* = listener_callback; } -/// Returns the inline event listener by an element and listener type. -pub fn getAttrListener( - self: *const Page, - element: *Element, - listener_type: GlobalEventHandler, -) ?JS.Function.Global { - return self._element_attr_listeners.get(.{ - .target = element.asEventTarget(), - .handler = listener_type, - }); -} - pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void { return self._performance_observers.append(self.arena, observer); } @@ -1252,7 +1393,7 @@ pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void for (self._performance_observers.items) |observer| { if (observer.interested(entry)) { observer._entries.append(self.arena, entry) catch |err| { - log.err(.page, "notifyPerformanceObservers", .{ .err = err }); + log.err(.page, "notifyPerformanceObservers", .{ .err = err, .type = self._type }); }; } } @@ -1347,7 +1488,7 @@ pub fn performScheduledIntersectionChecks(self: *Page) void { } self._intersection_check_scheduled = false; self.checkIntersections() catch |err| { - log.err(.page, "page.schedIntersectChecks", .{ .err = err }); + log.err(.page, "page.schedIntersectChecks", .{ .err = err, .type = self._type }); }; } @@ -1363,7 +1504,7 @@ pub fn deliverIntersections(self: *Page) void { i -= 1; const observer = self._intersection_observers.items[i]; observer.deliverEntries(self) catch |err| { - log.err(.page, "page.deliverIntersections", .{ .err = err }); + log.err(.page, "page.deliverIntersections", .{ .err = err, .type = self._type }); }; } } @@ -1381,7 +1522,7 @@ pub fn deliverMutations(self: *Page) void { }; if (self._mutation_delivery_depth > 100) { - log.err(.page, "page.MutationLimit", .{}); + log.err(.page, "page.MutationLimit", .{ .type = self._type }); self._mutation_delivery_depth = 0; return; } @@ -1390,7 +1531,7 @@ pub fn deliverMutations(self: *Page) void { while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.deliverRecords(self) catch |err| { - log.err(.page, "page.deliverMutations", .{ .err = err }); + log.err(.page, "page.deliverMutations", .{ .err = err, .type = self._type }); }; } } @@ -1408,7 +1549,7 @@ pub fn deliverSlotchangeEvents(self: *Page) void { var i: usize = 0; var slots = self.call_arena.alloc(*Element.Html.Slot, pending) catch |err| { - log.err(.page, "deliverSlotchange.append", .{ .err = err }); + log.err(.page, "deliverSlotchange.append", .{ .err = err, .type = self._type }); return; }; @@ -1421,19 +1562,19 @@ pub fn deliverSlotchangeEvents(self: *Page) void { for (slots) |slot| { const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| { - log.err(.page, "deliverSlotchange.init", .{ .err = err }); + log.err(.page, "deliverSlotchange.init", .{ .err = err, .type = self._type }); continue; }; defer if (!event._v8_handoff) event.deinit(false); const target = slot.asNode().asEventTarget(); _ = target.dispatchEvent(event, self) catch |err| { - log.err(.page, "deliverSlotchange.dispatch", .{ .err = err }); + log.err(.page, "deliverSlotchange.dispatch", .{ .err = err, .type = self._type }); }; } } -fn notifyNetworkIdle(self: *Page) void { +pub fn notifyNetworkIdle(self: *Page) void { lp.assert(self._notified_network_idle == .done, "Page.notifyNetworkIdle", .{}); self._session.notification.dispatch(.page_network_idle, &.{ .page_id = self.id, @@ -1442,7 +1583,7 @@ fn notifyNetworkIdle(self: *Page) void { }); } -fn notifyNetworkAlmostIdle(self: *Page) void { +pub fn notifyNetworkAlmostIdle(self: *Page) void { lp.assert(self._notified_network_almost_idle == .done, "Page.notifyNetworkAlmostIdle", .{}); self._session.notification.dispatch(.page_network_almost_idle, &.{ .page_id = self.id, @@ -1483,7 +1624,7 @@ pub fn appendNew(self: *Page, parent: *Node, child: Node.NodeOrText) !void { // called from the parser when the node and all its children have been added pub fn nodeComplete(self: *Page, node: *Node) !void { Node.Build.call(node, "complete", .{ node, self }) catch |err| { - log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err }); + log.err(.bug, "build.complete", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); return err; }; return self.nodeIsReady(true, node); @@ -2152,7 +2293,6 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const }, else => {}, } - const tag_name = try String.init(self.arena, name, .{}); // Check if this is a custom element (must have hyphen for HTML namespace) @@ -2183,7 +2323,7 @@ pub fn createElementNS(self: *Page, namespace: Element.Namespace, name: []const var caught: JS.TryCatch.Caught = undefined; _ = ls.toLocal(def.constructor).newInstance(&caught) catch |err| { - log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught }); + log.warn(.js, "custom element constructor", .{ .name = name, .err = err, .caught = caught, .type = self._type }); return node; }; @@ -2241,7 +2381,7 @@ fn createHtmlElementT(self: *Page, comptime E: type, namespace: Element.Namespac const node = element.asNode(); if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) { @call(.auto, @field(E.Build, "created"), .{ node, self }) catch |err| { - log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err }); + log.err(.page, "build.created", .{ .tag = node.getNodeName(&self.buf), .err = err, .type = self._type }); return err; }; } @@ -2651,7 +2791,7 @@ pub fn _insertNodeRelative(self: *Page, comptime from_parser: bool, parent: *Nod pub fn attributeChange(self: *Page, element: *Element, name: String, value: String, old_value: ?String) void { _ = Element.Build.call(element, "attributeChange", .{ element, name, value, self }) catch |err| { - log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err }); + log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type }); }; Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self); @@ -2660,7 +2800,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyAttributeChange(element, name, old_value, self) catch |err| { - log.err(.page, "attributeChange.notifyObserver", .{ .err = err }); + log.err(.page, "attributeChange.notifyObserver", .{ .err = err, .type = self._type }); }; } @@ -2677,7 +2817,7 @@ pub fn attributeChange(self: *Page, element: *Element, name: String, value: Stri pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: String) void { _ = Element.Build.call(element, "attributeRemove", .{ element, name, self }) catch |err| { - log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err }); + log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type }); }; Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self); @@ -2686,7 +2826,7 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyAttributeChange(element, name, old_value, self) catch |err| { - log.err(.page, "attributeRemove.notifyObserver", .{ .err = err }); + log.err(.page, "attributeRemove.notifyObserver", .{ .err = err, .type = self._type }); }; } @@ -2703,11 +2843,11 @@ pub fn attributeRemove(self: *Page, element: *Element, name: String, old_value: fn signalSlotChange(self: *Page, slot: *Element.Html.Slot) void { self._slots_pending_slotchange.put(self.arena, slot, {}) catch |err| { - log.err(.page, "signalSlotChange.put", .{ .err = err }); + log.err(.page, "signalSlotChange.put", .{ .err = err, .type = self._type }); return; }; self.scheduleSlotchangeDelivery() catch |err| { - log.err(.page, "signalSlotChange.schedule", .{ .err = err }); + log.err(.page, "signalSlotChange.schedule", .{ .err = err, .type = self._type }); }; } @@ -2747,7 +2887,7 @@ fn updateElementAssignedSlot(self: *Page, element: *Element) void { // Recursively search through the shadow root for a matching slot if (findMatchingSlot(shadow_root.asNode(), slot_name)) |slot| { self._element_assigned_slots.put(self.arena, element, slot) catch |err| { - log.err(.page, "updateElementAssignedSlot.put", .{ .err = err }); + log.err(.page, "updateElementAssignedSlot.put", .{ .err = err, .type = self._type }); }; } } @@ -2794,7 +2934,7 @@ pub fn characterDataChange( while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyCharacterDataChange(target, old_value, self) catch |err| { - log.err(.page, "cdataChange.notifyObserver", .{ .err = err }); + log.err(.page, "cdataChange.notifyObserver", .{ .err = err, .type = self._type }); }; } } @@ -2821,7 +2961,7 @@ pub fn childListChange( while (it) |node| : (it = node.next) { const observer: *MutationObserver = @fieldParentPtr("node", node); observer.notifyChildListChange(target, added_nodes, removed_nodes, previous_sibling, next_sibling, self) catch |err| { - log.err(.page, "childListChange.notifyObserver", .{ .err = err }); + log.err(.page, "childListChange.notifyObserver", .{ .err = err, .type = self._type }); }; } } @@ -2872,7 +3012,17 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void { } self.scriptAddedCallback(from_parser, script) catch |err| { - log.err(.page, "page.nodeIsReady", .{ .err = err }); + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "script", .type = self._type }); + return err; + }; + } else if (node.is(Element.Html.IFrame)) |iframe| { + if ((comptime from_parser == false) and iframe._src.len == 0) { + // iframe was added via JavaScript, but without a src + return; + } + + self.iframeAddedCallback(iframe) catch |err| { + log.err(.page, "page.nodeIsReady", .{ .err = err, .element = "iframe", .type = self._type }); return err; }; } @@ -2982,6 +3132,7 @@ pub const NavigateReason = enum { script, history, navigation, + initialFrameNavigation, }; pub const NavigateOpts = struct { @@ -3006,7 +3157,8 @@ const NavigationPriority = enum { anchor, }; -const QueuedNavigation = struct { +pub const QueuedNavigation = struct { + arena: Allocator, url: [:0]const u8, opts: NavigateOpts, priority: NavigationPriority, @@ -3020,6 +3172,7 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void { .node = target, .x = x, .y = y, + .type = self._type, }); } const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{ @@ -3054,12 +3207,12 @@ pub fn handleClick(self: *Page, target: *Node) !void { // Check target attribute - don't navigate if opening in new window/tab const target_val = anchor.getTarget(); if (target_val.len > 0 and !std.mem.eql(u8, target_val, "_self")) { - log.warn(.not_implemented, "a.target", .{}); + log.warn(.not_implemented, "a.target", .{ .type = self._type }); return; } if (try element.hasAttribute(comptime .wrap("download"), self)) { - log.warn(.browser, "a.download", .{}); + log.warn(.browser, "a.download", .{ .type = self._type }); return; } @@ -3096,6 +3249,7 @@ pub fn triggerKeyboard(self: *Page, keyboard_event: *KeyboardEvent) !void { .url = self.url, .node = element, .key = keyboard_event._key, + .type = self._type, }); } try self._event_manager.dispatch(element.asEventTarget(), event); @@ -3185,11 +3339,12 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // I don't think this is technically correct, but FormData handles it ok const form_data = try FormData.init(form, submitter_, self); - const transfer_arena = self._session.transfer_arena; + const arena = try self.arena_pool.acquire(); + errdefer self.arena_pool.release(arena); const encoding = form_element.getAttributeSafe(comptime .wrap("enctype")); - var buf = std.Io.Writer.Allocating.init(transfer_arena); + var buf = std.Io.Writer.Allocating.init(arena); try form_data.write(encoding, &buf.writer); const method = form_element.getAttributeSafe(comptime .wrap("method")) orelse ""; @@ -3205,9 +3360,9 @@ pub fn submitForm(self: *Page, submitter_: ?*Element, form_: ?*Element.Html.Form // form_data.write currently only supports this encoding, so we know this has to be the content type opts.header = "Content-Type: application/x-www-form-urlencoded"; } else { - action = try URL.concatQueryString(transfer_arena, action, buf.written()); + action = try URL.concatQueryString(arena, action, buf.written()); } - return self.scheduleNavigation(action, opts, .form); + return self.scheduleNavigationWithArena(arena, action, opts, .form); } // insertText is a shortcut to insert text into the active element. @@ -3259,6 +3414,10 @@ test "WebApi: Page" { try testing.htmlRunner("page", .{}); } +test "WebApi: Frames" { + try testing.htmlRunner("frames", .{}); +} + test "WebApi: Integration" { try testing.htmlRunner("integration", .{}); } diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 570ef743..19e20825 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -83,6 +83,10 @@ imported_modules: std.StringHashMapUnmanaged(ImportedModule), // importmap contains resolved urls. importmap: std.StringHashMapUnmanaged([:0]const u8), +// have we notified the page that all scripts are loaded (used to fire the "load" +// event). +page_notified_of_completion: bool, + pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) ScriptManager { return .{ .page = page, @@ -96,6 +100,7 @@ pub fn init(allocator: Allocator, http_client: *Http.Client, page: *Page) Script .client = http_client, .static_scripts_done = false, .buffer_pool = BufferPool.init(allocator, 5), + .page_notified_of_completion = false, .script_pool = std.heap.MemoryPool(Script).init(allocator), }; } @@ -570,10 +575,9 @@ fn evaluate(self: *ScriptManager) void { // Page makes this safe to call multiple times. page.documentIsLoaded(); - if (self.async_scripts.first == null) { - // Looks like all async scripts are done too! - // Page makes this safe to call multiple times. - page.documentIsComplete(); + if (self.async_scripts.first == null and self.page_notified_of_completion == false) { + self.page_notified_of_completion = true; + page.scriptsCompletedLoading(); } } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 2d0f87ce..c9b4db48 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -18,6 +18,7 @@ const std = @import("std"); const lp = @import("lightpanda"); +const builtin = @import("builtin"); const log = @import("../log.zig"); @@ -31,7 +32,7 @@ const Browser = @import("Browser.zig"); const Notification = @import("../Notification.zig"); const Allocator = std.mem.Allocator; -const IS_DEBUG = @import("builtin").mode == .Debug; +const IS_DEBUG = builtin.mode == .Debug; // Session is like a browser's tab. // It owns the js env and the loader for all the pages of the session. @@ -45,16 +46,6 @@ notification: *Notification, // Used to create our Inspector and in the BrowserContext. arena: Allocator, -// The page's arena is unsuitable for data that has to existing while -// navigating from one page to another. For example, if we're clicking -// on an HREF, the URL exists in the original page (where the click -// originated) but also has to exist in the new page. -// While we could use the Session's arena, this could accumulate a lot of -// memory if we do many navigation events. The `transfer_arena` is meant to -// bridge the gap: existing long enough to store any data needed to end one -// page and start another. -transfer_arena: Allocator, - cookie_jar: storage.Cookie.Jar, storage_shed: storage.Shed, @@ -70,9 +61,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi const arena = try browser.arena_pool.acquire(); errdefer browser.arena_pool.release(arena); - const transfer_arena = try browser.arena_pool.acquire(); - errdefer browser.arena_pool.release(transfer_arena); - self.* = .{ .page = null, .arena = arena, @@ -83,7 +71,6 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi .storage_shed = .{}, .browser = browser, .notification = notification, - .transfer_arena = transfer_arena, .cookie_jar = storage.Cookie.Jar.init(allocator), }; } @@ -96,7 +83,6 @@ pub fn deinit(self: *Session) void { self.cookie_jar.deinit(); self.storage_shed.deinit(browser.app.allocator); - browser.arena_pool.release(self.transfer_arena); browser.arena_pool.release(self.arena); } @@ -107,11 +93,7 @@ pub fn createPage(self: *Session) !*Page { self.page = @as(Page, undefined); const page = &self.page.?; - - const id = self.page_id_gen +% 1; - self.page_id_gen = id; - - try Page.init(page, id, self); + try Page.init(page, self.nextPageId(), self, null); // Creates a new NavigationEventTarget for this page. try self.navigation.onNewPage(page); @@ -150,13 +132,14 @@ pub fn replacePage(self: *Session) !*Page { var current = self.page.?; const page_id = current.id; + const parent = current.parent; current.deinit(); self.browser.env.memoryPressureNotification(.moderate); self.page = @as(Page, undefined); const page = &self.page.?; - try Page.init(page, page_id, self); + try Page.init(page, page_id, self, parent); return page; } @@ -176,54 +159,208 @@ pub fn findPage(self: *Session, id: u32) ?*Page { } pub fn wait(self: *Session, wait_ms: u32) WaitResult { + var page = &(self.page orelse return .no_page); while (true) { - if (self.page) |*page| { - switch (page.wait(wait_ms)) { - .done => { - if (page._queued_navigation == null) { - return .done; - } - self.processScheduledNavigation() catch return .done; - }, - else => |result| return result, + const wait_result = self._wait(page, wait_ms) catch |err| { + switch (err) { + error.JsError => {}, // already logged (with hopefully more context) + else => log.err(.browser, "session wait", .{ + .err = err, + }), } - } else { - return .no_page; + return .done; + }; + + switch (wait_result) { + .done => { + if (page._queued_navigation == null) { + return .done; + } + page = self.processScheduledNavigation(page) catch return .done; + }, + else => |result| return result, } - // if we've successfull navigated, we'll give the new page another - // page.wait(wait_ms) } } -fn processScheduledNavigation(self: *Session) !void { - defer self.browser.arena_pool.reset(self.transfer_arena, 4 * 1024); - const url, const opts, const page_id = blk: { - const page = self.page.?; - const qn = page._queued_navigation.?; - // qn might not be safe to use after self.removePage is called, hence - // this block; - const url = qn.url; - const opts = qn.opts; +fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult { + var timer = try std.time.Timer.start(); + var ms_remaining = wait_ms; - // This was already aborted on the page, but it would be pretty - // bad if old requests went to the new page, so let's make double sure - self.browser.http_client.abort(); + const browser = self.browser; + var http_client = browser.http_client; + + // I'd like the page to know NOTHING about cdp_socket / CDP, but the + // fact is that the behavior of wait changes depending on whether or + // not we're using CDP. + // If we aren't using CDP, as soon as we think there's nothing left + // to do, we can exit - we'de done. + // But if we are using CDP, we should wait for the whole `wait_ms` + // because the http_click.tick() also monitors the CDP socket. And while + // we could let CDP poll http (like it does for HTTP requests), the fact + // is that we know more about the timing of stuff (e.g. how long to + // poll/sleep) in the page. + const exit_when_done = http_client.cdp_client == null; + + while (true) { + switch (page._parse_state) { + .pre, .raw, .text, .image => { + // The main page hasn't started/finished navigating. + // There's no JS to run, and no reason to run the scheduler. + if (http_client.active == 0 and exit_when_done) { + // haven't started navigating, I guess. + return .done; + } + // Either we have active http connections, or we're in CDP + // mode with an extra socket. Either way, we're waiting + // for http traffic + if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) { + // exit_when_done is explicitly set when there isn't + // an extra socket, so it should not be possibl to + // get an cdp_socket message when exit_when_done + // is true. + if (IS_DEBUG) { + std.debug.assert(exit_when_done == false); + } + + // data on a socket we aren't handling, return to caller + return .cdp_socket; + } + }, + .html, .complete => { + if (page._queued_navigation != null) { + return .done; + } + + // The HTML page was parsed. We now either have JS scripts to + // download, or scheduled tasks to execute, or both. + + // scheduler.run could trigger new http transfers, so do not + // store http_client.active BEFORE this call and then use + // it AFTER. + const ms_to_next_task = try browser.runMacrotasks(); + + const http_active = http_client.active; + const total_network_activity = http_active + http_client.intercepted; + if (page._notified_network_almost_idle.check(total_network_activity <= 2)) { + page.notifyNetworkAlmostIdle(); + } + if (page._notified_network_idle.check(total_network_activity == 0)) { + page.notifyNetworkIdle(); + } + + if (http_active == 0 and exit_when_done) { + // we don't need to consider http_client.intercepted here + // because exit_when_done is true, and that can only be + // the case when interception isn't possible. + if (comptime IS_DEBUG) { + std.debug.assert(http_client.intercepted == 0); + } + + const ms = ms_to_next_task orelse blk: { + if (wait_ms - ms_remaining < 100) { + if (comptime builtin.is_test) { + return .done; + } + // Look, we want to exit ASAP, but we don't want + // to exit so fast that we've run none of the + // background jobs. + break :blk 50; + } + // No http transfers, no cdp extra socket, no + // scheduled tasks, we're done. + return .done; + }; + + if (ms > ms_remaining) { + // Same as above, except we have a scheduled task, + // it just happens to be too far into the future + // compared to how long we were told to wait. + return .done; + } + + // We have a task to run in the not-so-distant future. + // You might think we can just sleep until that task is + // ready, but we should continue to run lowPriority tasks + // in the meantime, and that could unblock things. So + // we'll just sleep for a bit, and then restart our wait + // loop to see if anything new can be processed. + std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20)))); + } else { + // We're here because we either have active HTTP + // connections, or exit_when_done == false (aka, there's + // an cdp_socket registered with the http client). + // We should continue to run lowPriority tasks, so we + // minimize how long we'll poll for network I/O. + const ms_to_wait = @min(200, @min(ms_remaining, ms_to_next_task orelse 200)); + if (try http_client.tick(ms_to_wait) == .cdp_socket) { + // data on a socket we aren't handling, return to caller + return .cdp_socket; + } + } + }, + .err => |err| { + page._parse_state = .{ .raw_done = @errorName(err) }; + return err; + }, + .raw_done => { + if (exit_when_done) { + return .done; + } + // we _could_ http_client.tick(ms_to_wait), but this has + // the same result, and I feel is more correct. + return .no_page; + }, + } + + const ms_elapsed = timer.lap() / 1_000_000; + if (ms_elapsed >= ms_remaining) { + return .done; + } + ms_remaining -= @intCast(ms_elapsed); + } +} + +fn processScheduledNavigation(self: *Session, current_page: *Page) !*Page { + const browser = self.browser; + + const qn = current_page._queued_navigation.?; + // take ownership of the page's queued navigation + current_page._queued_navigation = null; + defer browser.arena_pool.release(qn.arena); + + const page_id, const parent = blk: { + const page = &self.page.?; + const page_id = page.id; + const parent = page.parent; + + browser.http_client.abort(); self.removePage(); - break :blk .{ url, opts, page.id }; + break :blk .{ page_id, parent }; }; - const page = self.createPage() catch |err| { - log.err(.browser, "queued navigation page error", .{ - .err = err, - .url = url, - }); - return err; - }; - page.id = page_id; + self.page = @as(Page, undefined); + const page = &self.page.?; + try Page.init(page, page_id, self, parent); - page.navigate(url, opts) catch |err| { - log.err(.browser, "queued navigation error", .{ .err = err, .url = url }); + // Creates a new NavigationEventTarget for this page. + try self.navigation.onNewPage(page); + + // start JS env + // Inform CDP the main page has been created such that additional context for other Worlds can be created as well + self.notification.dispatch(.page_created, page); + + page.navigate(qn.url, qn.opts) catch |err| { + log.err(.browser, "queued navigation error", .{ .err = err, .url = qn.url }); return err; }; + + return page; +} + +pub fn nextPageId(self: *Session) u32 { + const id = self.page_id_gen +% 1; + self.page_id_gen = id; + return id; } diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index de9c4849..115b6156 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -47,9 +47,6 @@ isolate: js.Isolate, // from this, and we can free it when the context is done. handle: v8.Global, -// True if the context is auto-entered, -entered: bool, - cpu_profiler: ?*v8.CpuProfiler = null, heap_profiler: ?*v8.HeapProfiler = null, @@ -247,11 +244,6 @@ pub fn deinit(self: *Context) void { v8.v8__Global__Reset(global); } } - - if (self.entered) { - v8.v8__Context__Exit(@ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle))); - } - v8.v8__Global__Reset(&self.handle); } @@ -333,11 +325,14 @@ pub fn localScope(self: *Context, ls: *js.Local.Scope) void { const isolate = self.isolate; js.HandleScope.init(&ls.handle_scope, isolate); + const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)); + v8.v8__Context__Enter(local_v8_context); + // TODO: add and init ls.hs for the handlescope ls.local = .{ .ctx = self, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, isolate.handle)), .isolate = isolate, + .handle = local_v8_context, .call_arena = self.call_arena, }; } @@ -364,7 +359,6 @@ pub fn stringToPersistedFunction(self: *Context, str: []const u8) !js.Function.G extra = "(e)"; } const full = try std.fmt.allocPrintSentinel(self.call_arena, "(function(e) {{ {s}{s} }})", .{ normalized, extra }, 0); - const js_val = try ls.local.compileAndRun(full, null); if (!js_val.isFunction()) { return error.StringFunctionError; diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index ef0cc1f6..458ff099 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -175,8 +175,23 @@ pub fn init(app: *App, opts: InitOpts) !Env { .data = null, .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, }); + // I don't 100% understand this. We actually set this up in the snapshot, + // but for the global instance, it doesn't work. SetIndexedHandler and + // SetNamedHandler are set on the Instance template, and that's the key + // difference. The context has its own global instance, so we need to set + // these back up directly on it. There might be a better way to do this. + v8.v8__ObjectTemplate__SetIndexedHandler(global_template_local, &.{ + .getter = Window.JsApi.index.getter, + .setter = null, + .query = null, + .deleter = null, + .enumerator = null, + .definer = null, + .descriptor = null, + .data = null, + .flags = 0, + }); v8.v8__Eternal__New(isolate_handle, @ptrCast(global_template_local), &global_eternal); - private_symbols = PrivateSymbols.init(isolate_handle); } @@ -225,7 +240,7 @@ pub fn deinit(self: *Env) void { allocator.destroy(self.isolate_params); } -pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { +pub fn createContext(self: *Env, page: *Page) !*Context { const context_arena = try self.app.arena_pool.acquire(); errdefer self.app.arena_pool.release(context_arena); @@ -264,13 +279,6 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { var global_global: v8.Global = undefined; v8.v8__Global__New(isolate.handle, global_obj, &global_global); - if (enter) { - v8.v8__Context__Enter(v8_context); - } - errdefer if (enter) { - v8.v8__Context__Exit(v8_context); - }; - const context_id = self.context_id; self.context_id = context_id + 1; @@ -279,7 +287,6 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context { .env = self, .page = page, .id = context_id, - .entered = enter, .isolate = isolate, .arena = context_arena, .handle = context_global, diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index 4c5d1fba..b51453b5 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -1333,6 +1333,7 @@ pub const Scope = struct { handle_scope: js.HandleScope, pub fn deinit(self: *Scope) void { + v8.v8__Context__Exit(self.local.handle); self.handle_scope.deinit(); } diff --git a/src/browser/tests/frames/frames.html b/src/browser/tests/frames/frames.html new file mode 100644 index 00000000..00403f3a --- /dev/null +++ b/src/browser/tests/frames/frames.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + 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 @@ + +