Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-23 12:31:24 +02:00
99 changed files with 2254 additions and 1712 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -726,7 +726,7 @@ test "SemanticTree backendDOMNodeId" {
var frame = try testing.pageTest("cdp/registry1.html", .{});
defer testing.reset();
defer frame._session.removeFrame();
defer frame._session.removePage();
const st: Self = .{
.dom_node = frame.window._document.asNode(),
@@ -750,7 +750,7 @@ test "SemanticTree max_depth" {
var frame = try testing.pageTest("cdp/registry1.html", .{});
defer testing.reset();
defer frame._session.removeFrame();
defer frame._session.removePage();
const st: Self = .{
.dom_node = frame.window._document.asNode(),

View File

@@ -172,7 +172,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Self
.self_heal = opts.self_heal,
.interactive = opts.interactive,
.one_shot_task = opts.task,
.one_shot_attachments = opts.task_attachments,
.one_shot_attachments = if (opts.task_attachments.items.len == 0) null else opts.task_attachments.items,
};
self.cmd_executor = CommandExecutor.init(allocator, tool_executor, &self.terminal);

View File

@@ -104,7 +104,7 @@ pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) Dispat
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
event.acquireRef();
defer _ = event.releaseRef(self.frame._session);
defer _ = event.releaseRef(self.frame._page);
// Increment event count for Event Timing API
self.frame.window._performance._event_counts.increment(event._type_string.str());
@@ -124,7 +124,7 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
// property is just a shortcut for calling addEventListener, but they are distinct.
// An event set via property cannot be removed by removeEventListener. If you
// set both the property and add a listener, they both execute.
const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions;
pub const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions;
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
// property handlers. No propagation - just calls the handler and registered listeners.
@@ -138,7 +138,7 @@ pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event,
window._current_event = event;
defer window._current_event = prev_event;
try self.base.dispatchDirect(frame.call_arena, frame.js, target, event, handler, frame._session, opts);
try self.base.dispatchDirect(frame.call_arena, frame.js, target, event, handler, frame._page, opts);
}
/// Check if there are any listeners for a direct dispatch (non-DOM target).
@@ -617,7 +617,7 @@ const ActivationState = struct {
const event = try Event.initTrusted(comptime .wrap(typ), .{
.bubbles = true,
.cancelable = false,
}, frame);
}, frame._page);
const target = input.asElement().asEventTarget();
try frame._event_manager.dispatch(target, event);

View File

@@ -21,7 +21,7 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const js = @import("js/js.zig");
const Session = @import("Session.zig");
const Page = @import("Page.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
@@ -216,7 +216,7 @@ pub fn dispatchDirect(
target: *EventTarget,
event: *Event,
handler: anytype,
session: *Session,
page: *Page,
comptime opts: DispatchDirectOptions,
) DispatchError!void {
if (comptime IS_DEBUG) {
@@ -224,7 +224,7 @@ pub fn dispatchDirect(
}
event.acquireRef();
defer _ = event.releaseRef(session);
defer _ = event.releaseRef(page);
if (comptime opts.inject_target) {
event._target = target;

View File

@@ -22,6 +22,7 @@ const builtin = @import("builtin");
const JS = @import("js/js.zig");
const Mime = @import("Mime.zig");
const Page = @import("Page.zig");
const Factory = @import("Factory.zig");
const Session = @import("Session.zig");
const EventManager = @import("EventManager.zig");
@@ -35,6 +36,7 @@ const URL = @import("URL.zig");
const Blob = @import("webapi/Blob.zig");
const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
@@ -86,6 +88,8 @@ _frame_id: u32,
// navigate.
_loader_id: u32,
_page: *Page,
_session: *Session,
_event_manager: EventManager,
@@ -255,35 +259,39 @@ _req_id: u32 = 0,
_console_messages: std.ArrayListUnmanaged(ConsoleMessage) = .{},
_navigated_options: ?NavigatedOpts = null,
pub fn init(self: *Frame, frame_id: u32, session: *Session, parent: ?*Frame) !void {
pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
if (comptime IS_DEBUG) {
log.debug(.frame, "frame.init", .{});
}
const session = page.session;
const call_arena = try session.getArena(.medium, "call_arena");
errdefer session.releaseArena(call_arena);
const factory = &session.factory;
const factory = &page.factory;
const document = (try factory.document(Node.Document.HTMLDocument{
._proto = undefined,
})).asDocument();
const arena = page.frame_arena;
self.* = .{
.js = undefined,
.arena = arena,
.parent = parent,
.arena = session.frame_arena,
.document = document,
.window = undefined,
.call_arena = call_arena,
._frame_id = frame_id,
._loader_id = session.nextLoaderId(),
._page = page,
._session = session,
._loader_id = session.nextLoaderId(),
._factory = factory,
._pending_loads = 1, // always 1 for the ScriptManager
._type = if (parent == null) .root else .frame,
._style_manager = undefined,
._script_manager = undefined,
._event_manager = EventManager.init(session.frame_arena, self),
._event_manager = EventManager.init(arena, self),
};
self._to_load = &self._to_load_1;
@@ -322,8 +330,8 @@ pub fn init(self: *Frame, frame_id: u32, session: *Session, parent: ?*Frame) !vo
errdefer self._script_manager.deinit();
self.js = try browser.env.createContext(self, .{
.identity = &session.identity,
.identity_arena = session.frame_arena,
.identity = &page.identity,
.identity_arena = arena,
.call_arena = self.call_arena,
});
errdefer browser.env.destroyContext(self.js);
@@ -363,10 +371,10 @@ pub fn deinit(self: *Frame, abort_http: bool) void {
// stats.print(&stream) catch unreachable;
}
const session = self._session;
const page = self._page;
if (self._queued_navigation) |qn| {
session.releaseArena(qn.arena);
page.releaseArena(qn.arena);
}
{
@@ -374,7 +382,7 @@ pub fn deinit(self: *Frame, abort_http: bool) void {
{
var it = self._blob_urls.valueIterator();
while (it.next()) |blob| {
blob.*.releaseRef(session);
blob.*.releaseRef(page);
}
}
@@ -383,39 +391,40 @@ pub fn deinit(self: *Frame, abort_http: bool) void {
while (node) |n| {
node = n.next; // capture before we potentially delete observer
const observer: *MutationObserver = @fieldParentPtr("node", n);
observer.releaseRef(session);
observer.releaseRef(page);
}
}
for (self._intersection_observers.items) |observer| {
observer.releaseRef(session);
observer.releaseRef(page);
}
var document = self.window._document;
document._selection.releaseRef(session);
document._selection.releaseRef(page);
if (document._fonts) |f| {
f.releaseRef(session);
f.releaseRef(page);
}
}
session.browser.env.destroyContext(self.js);
const browser = page.session.browser;
browser.env.destroyContext(self.js);
self._script_manager.shutdown = true;
if (self.parent == null) {
session.browser.http_client.abort();
browser.http_client.abort();
} else if (abort_http) {
// a small optimization, it's faster to abort _everything_ on the root
// frame, so we prefer that. But if it's just the frame that's going
// away (a frame navigation) then we'll abort the frame-related requests
session.browser.http_client.abortFrame(self._frame_id);
browser.http_client.abortFrame(self._frame_id);
}
self._script_manager.deinit();
self._style_manager.deinit();
session.releaseArena(self.call_arena);
page.releaseArena(self.call_arena);
}
pub fn trackWorker(self: *Frame, worker: *Worker) !void {
@@ -801,13 +810,14 @@ pub fn documentIsLoaded(self: *Frame) void {
self._load_state = .load;
self.document._ready_state = .interactive;
self._documentIsLoaded() catch |err| {
log.err(.frame, "document is loaded", .{ .err = err, .type = self._type, .url = self.url });
self._documentIsLoaded() catch |err| switch (err) {
error.JsException => {}, // already logged
else => log.err(.frame, "document is loaded2", .{ .err = err, .type = self._type, .url = self.url }),
};
}
pub fn _documentIsLoaded(self: *Frame) !void {
const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self);
const event = try Event.initTrusted(.wrap("DOMContentLoaded"), .{ .bubbles = true }, self._page);
try self._event_manager.dispatch(
self.document.asEventTarget(),
event,
@@ -834,7 +844,7 @@ pub fn iframeCompletedLoading(self: *Frame, iframe: *IFrame) void {
defer entered.exit();
blk: {
const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
const event = Event.initTrusted(comptime .wrap("load"), .{}, self._page) catch |err| {
log.err(.frame, "iframe event init", .{ .err = err, .url = iframe._src });
break :blk;
};
@@ -874,8 +884,9 @@ pub fn documentIsComplete(self: *Frame) void {
}
self._load_state = .complete;
self._documentIsComplete() catch |err| {
log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
self._documentIsComplete() catch |err| switch (err) {
error.JsException => {}, // already logged
else => log.err(.frame, "document is complete", .{ .err = err, .type = self._type, .url = self.url }),
};
}
@@ -888,7 +899,7 @@ fn _documentIsComplete(self: *Frame) !void {
// Dispatch window.load event.
const window_target = self.window.asEventTarget();
if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page);
// This event is weird, it's dispatched directly on the window, but
// with the document as the target.
event._target = self.document.asEventTarget();
@@ -1213,7 +1224,7 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
const new_frame = try self.arena.create(Frame);
const frame_id = session.nextFrameId();
try Frame.init(new_frame, frame_id, session, self);
try Frame.init(new_frame, frame_id, self._page, self);
errdefer new_frame.deinit(true);
self._pending_loads += 1;
@@ -1431,7 +1442,7 @@ pub fn registerMutationObserver(self: *Frame, observer: *MutationObserver) !void
}
pub fn unregisterMutationObserver(self: *Frame, observer: *MutationObserver) void {
observer.releaseRef(self._session);
observer.releaseRef(self._page);
self._mutation_observers.remove(&observer.node);
}
@@ -1443,7 +1454,7 @@ pub fn registerIntersectionObserver(self: *Frame, observer: *IntersectionObserve
pub fn unregisterIntersectionObserver(self: *Frame, observer: *IntersectionObserver) void {
for (self._intersection_observers.items, 0..) |obs, i| {
if (obs == observer) {
observer.releaseRef(self._session);
observer.releaseRef(self._page);
_ = self._intersection_observers.swapRemove(i);
return;
}
@@ -1468,7 +1479,7 @@ pub fn dispatchLoad(self: *Frame) !void {
for (to_process.items) |html_element| {
if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page);
try self._event_manager.dispatch(html_element.asEventTarget(), event);
}
}
@@ -1579,7 +1590,7 @@ pub fn deliverSlotchangeEvents(self: *Frame) void {
self._slots_pending_slotchange.clearRetainingCapacity();
for (slots) |slot| {
const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self) catch |err| {
const event = Event.initTrusted(comptime .wrap("slotchange"), .{ .bubbles = true }, self._page) catch |err| {
log.err(.frame, "deliverSlotchange.init", .{ .err = err, .type = self._type, .url = self.url });
continue;
};
@@ -2625,6 +2636,19 @@ pub fn dupeString(self: *Frame, value: []const u8) ![]const u8 {
return self.arena.dupe(u8, value);
}
// Direct (non-propagating) dispatch of an event. Mirrors WorkerGlobalScope.dispatch
// so worker-compatible APIs can uniformly call `global.dispatch(...)` across both
// Frame and Worker contexts.
pub fn dispatch(
self: *Frame,
target: *EventTarget,
event: *Event,
handler: anytype,
comptime opts: EventManager.DispatchDirectOptions,
) !void {
return self._event_manager.dispatchDirect(target, event, handler, opts);
}
pub fn dupeSSO(self: *Frame, value: []const u8) !String {
return String.init(self.arena, value, .{ .dupe = true });
}
@@ -3534,7 +3558,7 @@ pub fn handleClick(self: *Frame, target: *Node) !void {
pub fn triggerKeyboard(self: *Frame, keyboard_event: *KeyboardEvent) !void {
const event = keyboard_event.asEvent();
const element = self.window._document._active_element orelse {
event.deinit(self._session);
event.deinit(self._page);
return;
};
@@ -3630,7 +3654,7 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
// so submit_event is still valid when we check _prevent_default
submit_event.acquireRef();
defer _ = submit_event.releaseRef(self._session);
defer _ = submit_event.releaseRef(self._page);
try self._event_manager.dispatch(form_element.asEventTarget(), submit_event);
// If the submit event was prevented, don't submit the form

232
src/browser/Page.zig Normal file
View File

@@ -0,0 +1,232 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const App = @import("../App.zig");
const js = @import("js/js.zig");
const v8 = js.v8;
const Frame = @import("Frame.zig");
const Session = @import("Session.zig");
const Factory = @import("Factory.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
// A Page is the container for a root Frame and all of its descendants
// (nested iframes). It owns the resources that share the lifetime of the root
// document: the DOM factory, the per-page arena, the JS identity map, shared
// origins, v8 global handles, and queued navigation buffers.
//
// In the future, a Session may hold multiple Pages at once (e.g. during a
// navigation, while the old Page is retiring and the new one is provisional).
// For now, Session still holds a single Page.
const Page = @This();
session: *Session,
// DOM object factory scoped to this Page's documents.
factory: Factory,
// The arena for this Page's lifetime. Document / Frame / Factory / DOM
// objects allocate out of this.
frame_arena: Allocator,
// Origin map for same-origin context sharing. Entries live for the Page's
// lifetime.
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
// Identity tracking for the main world. All main-world contexts in this Page
// share this, ensuring object identity works across same-origin frames.
identity: js.Identity = .{},
// Finalizer callbacks for Zig instances exposed to v8 in this Page. Keyed by
// Zig instance ptr. The backing FinalizerCallback.Identity structs come from
// Session.fc_identity_pool so they outlive the Page for v8 weak-callback
// safety.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *Session.FinalizerCallback) = .empty,
// Tracked global v8 objects that need to be released when the Page tears down.
globals: std.ArrayList(v8.Global) = .empty,
// Temporary v8 globals that can be released early. Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Double buffered so that, as we process one list of queued navigations, new
// entries are added to the separate buffer. Prevents endless navigation loops
// and invalidation of the list during iteration.
queued_navigation_1: std.ArrayList(*Frame) = .empty,
queued_navigation_2: std.ArrayList(*Frame) = .empty,
// pointer to either queued_navigation_1 or queued_navigation_2
queued_navigation: *std.ArrayList(*Frame) = undefined,
// Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Frame) = .empty,
// The root Frame of this Page. Non-optional — a Page always has a root frame.
frame: Frame,
// Initialize a Page and its root Frame.
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");
errdefer session.arena_pool.release(frame_arena);
self.* = .{
.session = session,
.frame = undefined,
.frame_arena = frame_arena,
.factory = Factory.init(frame_arena),
};
self.queued_navigation = &self.queued_navigation_1;
try Frame.init(&self.frame, frame_id, self, null);
}
// Tear down the Page and its root Frame. Equivalent to the old
// Session.removePage + Session.resetFrameResources.
pub fn deinit(self: *Page, abort_http: bool) void {
self.frame.deinit(abort_http);
const session = self.session;
defer session.browser.env.memoryPressureNotification(.moderate);
self.identity.deinit();
self.identity = .{};
// Force cleanup all remaining finalized objects.
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |fc| {
fc.*.deinit(self);
}
self.finalizer_callbacks = .empty;
}
{
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
self.globals = .empty;
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
self.temps = .empty;
}
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);
}
// Defensive cleanup in case origins leaked.
{
const app = session.browser.app;
var it = self.origins.valueIterator();
while (it.next()) |value| {
value.*.deinit(app);
}
self.origins = .empty;
}
session.arena_pool.release(self.frame_arena);
}
pub fn getArena(self: *Page, size_or_bucket: anytype, debug: []const u8) !Allocator {
return self.session.getArena(size_or_bucket, debug);
}
pub fn releaseArena(self: *Page, allocator: Allocator) void {
return self.session.releaseArena(allocator);
}
pub fn getOrCreateOrigin(self: *Page, key_: ?[]const u8) !*js.Origin {
const session = self.session;
const key = key_ orelse {
var opaque_origin: [36]u8 = undefined;
@import("../id.zig").uuidv4(&opaque_origin);
// Origin.init will dupe opaque_origin. It's fine that this doesn't
// get added to self.origins. In fact, it further isolates it. When the
// context is freed, it'll call Page.releaseOrigin which will free it.
return js.Origin.init(session.browser.app, session.browser.env.isolate, &opaque_origin);
};
const gop = try self.origins.getOrPut(session.arena, key);
if (gop.found_existing) {
const origin = gop.value_ptr.*;
origin.rc += 1;
return origin;
}
errdefer _ = self.origins.remove(key);
const origin = try js.Origin.init(session.browser.app, session.browser.env.isolate, key);
gop.key_ptr.* = origin.key;
gop.value_ptr.* = origin;
return origin;
}
pub fn releaseOrigin(self: *Page, origin: *js.Origin) void {
const rc = origin.rc;
if (rc == 1) {
_ = self.origins.remove(origin.key);
origin.deinit(self.session.browser.app);
} else {
origin.rc = rc - 1;
}
}
pub fn scheduleNavigation(self: *Page, frame: *Frame) !void {
const list = self.queued_navigation;
// Check if frame is already queued
for (list.items) |existing| {
if (existing == frame) {
// Already queued
return;
}
}
return list.append(self.session.arena, frame);
}
pub fn findFrameByFrameId(self: *Page, frame_id: u32) ?*Frame {
return findFrameBy(&self.frame, "_frame_id", frame_id);
}
pub fn findFrameByLoaderId(self: *Page, loader_id: u32) ?*Frame {
return findFrameBy(&self.frame, "_loader_id", loader_id);
}
fn findFrameBy(frame: *Frame, comptime field: []const u8, id: u32) ?*Frame {
if (@field(frame, field) == id) return frame;
for (frame.child_frames.items) |f| {
if (findFrameBy(f, field, id)) |found| {
return found;
}
}
return null;
}

View File

@@ -40,7 +40,7 @@ http_client: *HttpClient,
pub const Opts = struct {};
pub fn init(session: *Session, _: Opts) !Runner {
const frame = &(session.frame orelse return error.NoPage);
const frame = session.currentFrame() orelse return error.NoPage;
return .{
.frame = frame,
@@ -150,10 +150,12 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
},
.html, .complete => {
const session = self.session;
if (session.queued_navigation.items.len != 0) {
try session.processQueuedNavigation();
self.frame = &session.frame.?; // might have changed
return .{ .ok = 0 };
if (session.currentPage()) |page| {
if (page.queued_navigation.items.len != 0) {
try session.processQueuedNavigation();
self.frame = session.currentFrame().?; // might have changed
return .{ .ok = 0 };
}
}
const browser = session.browser;
@@ -323,7 +325,7 @@ test "Runner: no page" {
test "Runner: waitForSelector timeout" {
const frame = try testing.pageTest("runner/runner1.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var runner = try frame._session.runner(.{});
try testing.expectError(error.Timeout, runner.waitForSelector("#nope", 10));
@@ -332,7 +334,7 @@ test "Runner: waitForSelector timeout" {
test "Runner: waitForSelector" {
defer testing.reset();
const frame = try testing.pageTest("runner/runner1.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var runner = try frame._session.runner(.{});
const el = try runner.waitForSelector("#sel1", 10);
@@ -341,7 +343,7 @@ test "Runner: waitForSelector" {
test "Runner: waitForScript timeout" {
const frame = try testing.pageTest("runner/runner1.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var runner = try frame._session.runner(.{});
try testing.expectError(error.Timeout, runner.waitForScript("document.querySelector('#nope')", 10));
@@ -349,7 +351,7 @@ test "Runner: waitForScript timeout" {
test "Runner: waitForScript" {
const frame = try testing.pageTest("runner/runner1.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var runner = try frame._session.runner(.{});
try runner.waitForScript("document.querySelector('#sel1')", 10);

View File

@@ -949,7 +949,7 @@ pub const Script = struct {
fn executeCallback(self: *const Script, typ: String, frame: *Frame) void {
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, frame) catch |err| {
const event = Event.initTrusted(typ, .{}, frame._page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,

View File

@@ -29,9 +29,9 @@ const Navigation = @import("webapi/navigation/Navigation.zig");
const History = @import("webapi/History.zig");
const Frame = @import("Frame.zig");
const Page = @import("Page.zig");
pub const Runner = @import("Runner.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const Notification = @import("../Notification.zig");
const QueuedNavigation = Frame.QueuedNavigation;
@@ -40,17 +40,16 @@ const ArenaPool = App.ArenaPool;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
// You can create successively multiple frames for a session, but you must
// deinit a frame before running another one. It manages two distinct lifetimes.
// A Session represents a browsing context group (cookie jar, session storage,
// navigation history) within a Browser. It hosts one Page at a time — the
// root Frame and all of its descendants — and is responsible for Page
// lifecycle (create, remove, replace on root navigation).
//
// The first is the lifetime of the Session itself, where frames are created and
// removed, but share the same cookie jar and navigation history (etc...)
//
// The second is as a container the data needed by the full frame hierarchy, i.e. \
// the root frame and all of its frames (and all of their frames.)
// Multiple concurrent Pages (e.g. an old Page retiring while a new provisional
// Page is loading) are not yet supported; see Page.zig for the intended
// direction.
const Session = @This();
// These are the fields that remain intact for the duration of the Session
browser: *Browser,
arena: Allocator,
history: History,
@@ -59,54 +58,18 @@ storage_shed: storage.Shed,
notification: *Notification,
cookie_jar: storage.Cookie.Jar,
// These are the fields that get reset whenever the Session's frame (the root) is reset.
factory: Factory,
frame_arena: Allocator,
// Origin map for same-origin context sharing. Scoped to the root frame lifetime.
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
// Identity tracking for the main world. All main world contexts share this,
// ensuring object identity works across same-origin frames.
identity: js.Identity = .{},
// Shared finalizer callbacks across all Identities. Keyed by Zig instance ptr.
// This ensures objects are only freed when ALL v8 wrappers are gone.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
// Pool for FinalizerCallback.Identity structs. These must survive frame resets
// so V8 weak callbacks can validate the FC before dereferencing it.
fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity),
// Tracked global v8 objects that need to be released on cleanup.
// Lives at Session level so objects can outlive individual Identities.
globals: std.ArrayList(v8.Global) = .empty,
// Temporary v8 globals that can be released early. Key is global.data_ptr.
// Lives at Session level so objects holding Temps can outlive individual Identities.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Shared resources for all frames in this session.
// These live for the duration of the frame tree (root + frames).
// Shared allocator. Used by Session itself and borrowed by Pages.
arena_pool: *ArenaPool,
frame: ?Frame,
// Pool for FinalizerCallback.Identity structs. These must survive Page
// teardowns so V8 weak callbacks can validate the FC before dereferencing it.
fc_identity_pool: std.heap.MemoryPool(FinalizerCallback.Identity),
// Double buffer so that, as we process one list of queued navigations, new entries
// are added to the separate buffer. This ensures that we don't end up with
// endless navigation loops AND that we don't invalidate the list while iterating
// if a new entry gets appended
queued_navigation_1: std.ArrayList(*Frame),
queued_navigation_2: std.ArrayList(*Frame),
// pointer to either queued_navigation_1 or queued_navigation_2
queued_navigation: *std.ArrayList(*Frame),
// Temporary buffer for about:blank navigations during processing.
// We process async navigations first (safe from re-entrance), then sync
// about:blank navigations (which may add to queued_navigation).
queued_queued_navigation: std.ArrayList(*Frame),
// The currently-active Page. Null when no Page exists (between removePage
// and createPage, or at startup).
page: ?Page,
// IDs. Kept at Session level so IDs can remain unique across Page replacements.
frame_id_gen: u32 = 0,
loader_id_gen: u32 = 0,
@@ -117,57 +80,47 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
const arena = try arena_pool.acquire(.small, "Session");
errdefer arena_pool.release(arena);
const frame_arena = try arena_pool.acquire(.large, "Session.frame_arena");
errdefer arena_pool.release(frame_arena);
self.* = .{
.frame = null,
.page = null,
.arena = arena,
.arena_pool = arena_pool,
.frame_arena = frame_arena,
.factory = Factory.init(frame_arena),
.history = .{},
// The prototype (EventTarget) for Navigation is created when a Frame is created.
.navigation = .{ ._proto = undefined },
.storage_shed = .{},
.browser = browser,
.queued_navigation = undefined,
.queued_navigation_1 = .{},
.queued_navigation_2 = .{},
.queued_queued_navigation = .{},
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
.fc_identity_pool = .init(allocator),
.cookie_jar = storage.Cookie.Jar.init(allocator),
};
self.queued_navigation = &self.queued_navigation_1;
}
pub fn deinit(self: *Session) void {
if (self.frame != null) {
self.removeFrame();
if (self.page != null) {
self.removePage();
}
self.cookie_jar.deinit();
self.fc_identity_pool.deinit();
self.storage_shed.deinit(self.browser.app.allocator);
self.arena_pool.release(self.frame_arena);
self.arena_pool.release(self.arena);
}
// NOTE: the caller is not the owner of the returned value,
// the pointer on Frame is just returned as a convenience
pub fn createFrame(self: *Session) !*Frame {
lp.assert(self.frame == null, "Session.createFrame - frame not null", .{});
pub fn createPage(self: *Session) !*Frame {
lp.assert(self.page == null, "Session.createPage - page not null", .{});
self.frame = @as(Frame, undefined);
const frame = &self.frame.?;
try Frame.init(frame, self.nextFrameId(), self, null);
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self, self.nextFrameId());
const frame = &page.frame;
// Creates a new NavigationEventTarget for this frame.
try self.navigation.onNewFrame(frame);
if (comptime IS_DEBUG) {
log.debug(.browser, "create frame", .{});
log.debug(.browser, "create page", .{});
}
// start JS env
// Inform CDP the main frame has been created such that additional context for other Worlds can be created as well
@@ -176,19 +129,22 @@ pub fn createFrame(self: *Session) !*Frame {
return frame;
}
pub fn removeFrame(self: *Session) void {
pub fn removePage(self: *Session) void {
// Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one
self.notification.dispatch(.frame_remove, .{});
lp.assert(self.frame != null, "Session.removeFrame - frame is null", .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.frame.?.deinit(false);
self.frame = null;
self.page.?.deinit(false);
self.page = null;
self.navigation.onRemoveFrame();
self.resetFrameResources();
// resetting frame_id_gen preserves previous behavior where removing the
// root page returned us to a clean-slate state.
self.frame_id_gen = 0;
if (comptime IS_DEBUG) {
log.debug(.browser, "remove frame", .{});
log.debug(.browser, "remove page", .{});
}
}
@@ -201,132 +157,49 @@ pub fn releaseArena(self: *Session, allocator: Allocator) void {
}
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
const key = key_ orelse {
var opaque_origin: [36]u8 = undefined;
@import("../id.zig").uuidv4(&opaque_origin);
// Origin.init will dupe opaque_origin. It's fine that this doesn't
// get added to self.origins. In fact, it further isolates it. When the
// context is freed, it'll call session.releaseOrigin which will free it.
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
};
const gop = try self.origins.getOrPut(self.arena, key);
if (gop.found_existing) {
const origin = gop.value_ptr.*;
origin.rc += 1;
return origin;
}
errdefer _ = self.origins.remove(key);
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
gop.key_ptr.* = origin.key;
gop.value_ptr.* = origin;
return origin;
return self.page.?.getOrCreateOrigin(key_);
}
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
const rc = origin.rc;
if (rc == 1) {
_ = self.origins.remove(origin.key);
origin.deinit(self.browser.app);
} else {
origin.rc = rc - 1;
}
return self.page.?.releaseOrigin(origin);
}
/// Reset frame_arena and factory for a clean slate.
/// Called when root frame is removed.
fn resetFrameResources(self: *Session) void {
defer self.browser.env.memoryPressureNotification(.moderate);
self.identity.deinit();
self.identity = .{};
// Force cleanup all remaining finalized objects
{
var it = self.finalizer_callbacks.valueIterator();
while (it.next()) |fc| {
fc.*.deinit(self);
}
self.finalizer_callbacks = .empty;
}
{
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
self.globals = .empty;
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
self.temps = .empty;
}
pub fn replacePage(self: *Session) !*Frame {
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);
}
// Defensive cleanup in case origins leaked
{
const app = self.browser.app;
var it = self.origins.valueIterator();
while (it.next()) |value| {
value.*.deinit(app);
}
self.origins = .empty;
log.debug(.browser, "replace page", .{});
}
self.frame_id_gen = 0;
self.arena_pool.reset(self.frame_arena, 64 * 1024);
self.factory = Factory.init(self.frame_arena);
}
lp.assert(self.page != null, "Session.replacePage null page", .{});
const current = &self.page.?;
lp.assert(current.frame.parent == null, "Session.replacePage with parent", .{});
pub fn replaceFrame(self: *Session) !*Frame {
if (comptime IS_DEBUG) {
log.debug(.browser, "replace frame", .{});
}
lp.assert(self.frame != null, "Session.replaceFrame null frame", .{});
lp.assert(self.frame.?.parent == null, "Session.replaceFrame with parent", .{});
var current = self.frame.?;
const frame_id = current._frame_id;
const frame_id = current.frame._frame_id;
current.deinit(true);
self.page = null;
self.resetFrameResources();
// Preserve prior behavior: frame_id_gen reset on root replacement so a
// subsequent createPage starts from id 1. The captured frame_id is
// passed into Page.init explicitly, so it isn't affected.
self.frame_id_gen = 0;
self.frame = @as(Frame, undefined);
const frame = &self.frame.?;
try Frame.init(frame, frame_id, self, null);
return frame;
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self, frame_id);
return &page.frame;
}
pub fn currentPage(self: *Session) ?*Page {
return &(self.page orelse return null);
}
pub fn currentFrame(self: *Session) ?*Frame {
return &(self.frame orelse return null);
const page = self.currentPage() orelse return null;
return &page.frame;
}
pub fn findFrameByFrameId(self: *Session, frame_id: u32) ?*Frame {
const frame = self.currentFrame() orelse return null;
return findFrameBy(frame, "_frame_id", frame_id);
}
pub fn findFrameByLoaderId(self: *Session, loader_id: u32) ?*Frame {
const frame = self.currentFrame() orelse return null;
return findFrameBy(frame, "_loader_id", loader_id);
}
fn findFrameBy(frame: *Frame, comptime field: []const u8, id: u32) ?*Frame {
if (@field(frame, field) == id) return frame;
for (frame.child_frames.items) |f| {
if (findFrameBy(f, field, id)) |found| {
return found;
}
}
return null;
const page = self.currentPage() orelse return null;
return page.findFrameByFrameId(frame_id);
}
pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
@@ -334,28 +207,19 @@ pub fn runner(self: *Session, opts: Runner.Opts) !Runner {
}
pub fn scheduleNavigation(self: *Session, frame: *Frame) !void {
const list = self.queued_navigation;
// Check if frame is already queued
for (list.items) |existing| {
if (existing == frame) {
// Already queued
return;
}
}
return list.append(self.arena, frame);
return self.page.?.scheduleNavigation(frame);
}
pub fn processQueuedNavigation(self: *Session) !void {
const navigations = self.queued_navigation;
if (self.queued_navigation == &self.queued_navigation_1) {
self.queued_navigation = &self.queued_navigation_2;
const page = self.currentPage() orelse return;
const navigations = page.queued_navigation;
if (page.queued_navigation == &page.queued_navigation_1) {
page.queued_navigation = &page.queued_navigation_2;
} else {
self.queued_navigation = &self.queued_navigation_1;
page.queued_navigation = &page.queued_navigation_1;
}
if (self.frame.?._queued_navigation != null) {
if (page.frame._queued_navigation != null) {
// This is both an optimization and a simplification of sorts. If the
// root frame is navigating, then we don't need to process any other
// navigation. Also, the navigation for the root frame and for a frame
@@ -365,7 +229,7 @@ pub fn processQueuedNavigation(self: *Session) !void {
return self.processRootQueuedNavigation();
}
const about_blank_queue = &self.queued_queued_navigation;
const about_blank_queue = &page.queued_queued_navigation;
defer about_blank_queue.clearRetainingCapacity();
// First pass: process async navigations (non-about:blank)
@@ -401,14 +265,14 @@ pub fn processQueuedNavigation(self: *Session) !void {
// Safety: Remove any about:blank navigations that were queued during
// processing to prevent infinite loops. New navigations have been queued
// in the other buffer.
const new_navigations = self.queued_navigation;
const new_navigations = page.queued_navigation;
var i: usize = 0;
while (i < new_navigations.items.len) {
const frame = new_navigations.items[i];
if (frame._queued_navigation) |qn| {
if (qn.is_about_blank) {
log.warn(.frame, "recursive about blank", .{});
_ = self.queued_navigation.swapRemove(i);
_ = page.queued_navigation.swapRemove(i);
continue;
}
}
@@ -434,10 +298,11 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
const frame_id = frame._frame_id;
const page = self.currentPage().?;
frame.deinit(true);
frame.* = undefined;
try Frame.init(frame, frame_id, self, parent);
try Frame.init(frame, frame_id, page, parent);
errdefer {
for (parent.child_frames.items, 0..) |f, i| {
if (f == frame) {
@@ -462,7 +327,7 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_frame = &self.frame.?;
const current_frame = &self.page.?.frame;
const frame_id = current_frame._frame_id;
// create a copy before the frame is cleared
@@ -471,11 +336,21 @@ fn processRootQueuedNavigation(self: *Session) !void {
defer self.arena_pool.release(qn.arena);
self.removeFrame();
// Dispatch frame_remove (same as removePage) then replace the Page
// in-place, keeping the frame_id stable.
self.notification.dispatch(.frame_remove, .{});
self.page.?.deinit(true);
self.page = null;
self.frame = @as(Frame, undefined);
const new_frame = &self.frame.?;
try Frame.init(new_frame, frame_id, self, null);
self.navigation.onRemoveFrame();
// Preserve prior behavior: the old resetFrameResources reset frame_id_gen.
self.frame_id_gen = 0;
self.page = @as(Page, undefined);
const page = &self.page.?;
try Page.init(page, self, frame_id);
const new_frame = &page.frame;
// Creates a new NavigationEventTarget for this frame.
try self.navigation.onNewFrame(new_frame);
@@ -503,14 +378,14 @@ pub fn nextLoaderId(self: *Session) u32 {
}
// Every finalizable instance of Zig gets 1 FinalizerCallback registered in the
// session. This is to ensure that, if v8 doesn't finalize the value, we can
// release on frame reset.
// Page. This is to ensure that, if v8 doesn't finalize the value, we can
// release on Page teardown.
pub const FinalizerCallback = struct {
page: *Page,
arena: Allocator,
session: *Session,
resolved_ptr_id: usize,
finalizer_ptr_id: usize,
release_ref: *const fn (ptr_id: usize, session: *Session) void,
release_ref: *const fn (ptr_id: usize, page: *Page) void,
// Linked list of Identities referencing this FC.
identities: ?*Identity = null,
@@ -519,10 +394,14 @@ pub const FinalizerCallback = struct {
// For every FinalizerCallback we'll have 1+ FinalizerCallback.Identity: one
// for every identity that gets the instance. In most cases, that'll be 1.
// Allocated from Session.fc_identity_pool so it survives frame resets and
// Allocated from Session.fc_identity_pool so it survives Page teardowns and
// allows the weak callback to safely check the done flag.
pub const Identity = struct {
session: *Session,
// The Page that owns the FinalizerCallback this Identity references.
// Only safe to dereference when `done == false`. When done is true,
// the Page may have been torn down and this pointer is stale.
page: *Page,
identity: *js.Identity,
finalizer_ptr_id: usize,
resolved_ptr_id: usize,
@@ -530,8 +409,8 @@ pub const FinalizerCallback = struct {
done: bool = false,
};
// Called during frame reset to force cleanup regardless of identities.
fn deinit(self: *FinalizerCallback, session: *Session) void {
// Called during Page teardown to force cleanup regardless of identities.
pub fn deinit(self: *FinalizerCallback, page: *Page) void {
// Mark all identities as done so stale V8 weak callbacks
// won't find the wrong FC if resolved_ptr_id is reused.
var id = self.identities;
@@ -539,7 +418,7 @@ pub const FinalizerCallback = struct {
identity.done = true;
id = identity.next;
}
self.release_ref(self.finalizer_ptr_id, session);
session.releaseArena(self.arena);
self.release_ref(self.finalizer_ptr_id, page);
page.releaseArena(self.arena);
}
};

View File

@@ -28,12 +28,12 @@ const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig");
fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void {
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame);
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, frame._page);
frame._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
};
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame);
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, frame._page);
frame._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
};
@@ -196,7 +196,7 @@ pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, frame: *Frame) !void {
};
}
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame);
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, frame._page);
frame._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
};

View File

@@ -30,7 +30,7 @@ pub const Opts = struct {
strip: Opts.Strip = .{},
shadow: Opts.Shadow = .rendered,
pub const Strip = struct {
pub const Strip = packed struct(u3) {
js: bool = false,
ui: bool = false,
css: bool = false,

View File

@@ -278,7 +278,7 @@ fn collectSelectOptions(
const testing = @import("../testing.zig");
fn testForms(html: []const u8) ![]FormInfo {
const frame = try testing.test_session.createFrame();
const frame = try testing.test_session.createPage();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
@@ -289,7 +289,7 @@ fn testForms(html: []const u8) ![]FormInfo {
test "browser.forms: login form" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/login" method="POST">
\\ <input type="email" name="email" required placeholder="Email">
@@ -310,7 +310,7 @@ test "browser.forms: login form" {
test "browser.forms: form with select" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <select name="color">
@@ -329,7 +329,7 @@ test "browser.forms: form with select" {
test "browser.forms: form with textarea" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form method="POST">
\\ <textarea name="message" placeholder="Your message"></textarea>
@@ -343,7 +343,7 @@ test "browser.forms: form with textarea" {
test "browser.forms: empty form skipped" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/empty">
\\ <p>No fields here</p>
@@ -354,7 +354,7 @@ test "browser.forms: empty form skipped" {
test "browser.forms: hidden inputs excluded" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="hidden" name="csrf" value="token123">
@@ -368,7 +368,7 @@ test "browser.forms: hidden inputs excluded" {
test "browser.forms: multiple forms" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form action="/search" method="GET">
\\ <input type="text" name="q" placeholder="Search">
@@ -385,7 +385,7 @@ test "browser.forms: multiple forms" {
test "browser.forms: disabled fields flagged" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="text" name="enabled_field">
@@ -400,7 +400,7 @@ test "browser.forms: disabled fields flagged" {
test "browser.forms: disabled fieldset" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <fieldset disabled>
@@ -417,7 +417,7 @@ test "browser.forms: disabled fieldset" {
test "browser.forms: external field via form attribute" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<input type="text" name="external" form="myform">
\\<form id="myform" action="/submit">
@@ -430,7 +430,7 @@ test "browser.forms: external field via form attribute" {
test "browser.forms: checkbox and radio return value attribute" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="checkbox" name="agree" value="yes" checked>
@@ -447,7 +447,7 @@ test "browser.forms: checkbox and radio return value attribute" {
test "browser.forms: form without action or method" {
defer testing.reset();
defer testing.test_session.removeFrame();
defer testing.test_session.removePage();
const forms = try testForms(
\\<form>
\\ <input type="text" name="q">

View File

@@ -452,8 +452,8 @@ fn getListenerTypes(target: *EventTarget, listener_targets: ListenerTargetMap) [
const testing = @import("../testing.zig");
fn testInteractive(html: []const u8) ![]InteractiveElement {
const frame = try testing.test_session.createFrame();
defer testing.test_session.removeFrame();
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const string = @import("../../string.zig");
const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
@@ -447,10 +448,14 @@ fn tupleFieldName(comptime i: usize) [:0]const u8 {
};
}
fn isPage(comptime T: type) bool {
fn isFrame(comptime T: type) bool {
return T == *Frame or T == *const Frame;
}
fn isPage(comptime T: type) bool {
return T == *Page or T == *const Page;
}
fn isSession(comptime T: type) bool {
return T == *Session or T == *const Session;
}
@@ -460,19 +465,19 @@ fn isExecution(comptime T: type) bool {
}
fn getGlobalArg(comptime T: type, ctx: *Context) T {
if (comptime isPage(T)) {
if (comptime isFrame(T)) {
return switch (ctx.global) {
.frame => |frame| frame,
.worker => unreachable,
};
}
if (comptime isExecution(T)) {
return &ctx.execution;
if (comptime isPage(T)) {
return ctx.page;
}
if (comptime isSession(T)) {
return ctx.session;
if (comptime isExecution(T)) {
return &ctx.execution;
}
@compileError("Unsupported global arg type: " ++ @typeName(T));
@@ -761,17 +766,17 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
return args;
}
// If the last parameter is the Page or Worker, set it, and exclude it
// from our params slice, because we don't want to bind it to
// a JS argument
// If the last parameter is Frame/Page/Session/Execution, set it from
// context and exclude it from our params slice, because we don't want
// to bind it to a JS argument.
const LastParamType = params[params.len - 1].type.?;
if (comptime isPage(LastParamType) or isExecution(LastParamType) or isSession(LastParamType)) {
if (comptime isFrame(LastParamType) or isPage(LastParamType) or isExecution(LastParamType) or isSession(LastParamType)) {
@field(args, tupleFieldName(params.len - 1 + offset)) = getGlobalArg(LastParamType, local.ctx);
break :blk params[0 .. params.len - 1];
}
// we have neither a Page, Execution, nor a JsObject. All params must be
// bound to a JavaScript value.
// we have neither a Frame/Page/Session/Execution nor a JsObject.
// All params must be bound to a JavaScript value.
break :blk params;
};
@@ -818,7 +823,9 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
}
}
if (comptime isPage(param.type.?)) {
if (comptime isFrame(param.type.?)) {
@compileError("Frame must be the last parameter: " ++ @typeName(F));
} else if (comptime isPage(param.type.?)) {
@compileError("Page must be the last parameter: " ++ @typeName(F));
} else if (comptime isExecution(param.type.?)) {
@compileError("Execution must be the last parameter: " ++ @typeName(F));

View File

@@ -27,6 +27,7 @@ const Scheduler = @import("Scheduler.zig");
const Execution = @import("Execution.zig");
const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const ScriptManager = @import("../ScriptManager.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
@@ -69,7 +70,14 @@ pub const GlobalScope = union(enum) {
id: usize,
env: *Env,
global: GlobalScope,
session: *Session,
// The Page this Context belongs to. For main-world frame contexts, this is
// the Page of the frame. For worker contexts, this is the Page of the
// worker's parent frame — a worker's v8 globals and identity tracking live
// on the same Page as its owning frame (worker dies with its page). The
// Session is always reachable via `page.session`.
page: *Page,
isolate: js.Isolate,
// Per-context microtask queue for isolation between contexts
@@ -114,7 +122,7 @@ origin: *Origin,
identity: *js.Identity,
// Allocator to use for identity map operations. For main world contexts this is
// session.frame_arena, for isolated worlds it's the isolated world's arena.
// page.frame_arena, for isolated worlds it's the isolated world's arena.
identity_arena: Allocator,
// Unlike other v8 types, like functions or objects, modules are not shared
@@ -207,7 +215,7 @@ pub fn deinit(self: *Context) void {
v8.v8__Global__Reset(global);
}
self.session.releaseOrigin(self.origin);
self.page.releaseOrigin(self.origin);
// Clear the embedder data so that if V8 keeps this context alive
// (because objects created in it are still referenced), we don't
@@ -234,9 +242,9 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
}
const origin = try self.session.getOrCreateOrigin(key);
const origin = try self.page.getOrCreateOrigin(key);
self.session.releaseOrigin(self.origin);
self.page.releaseOrigin(self.origin);
self.origin = origin;
{
@@ -252,11 +260,11 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
}
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.session.globals.append(self.session.frame_arena, global);
return self.page.globals.append(self.page.frame_arena, global);
}
pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.session.temps.put(self.session.frame_arena, global.data_ptr, global);
return self.page.temps.put(self.page.frame_arena, global.data_ptr, global);
}
pub const IdentityResult = struct {

View File

@@ -275,9 +275,9 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
const context_id = self.context_id;
self.context_id = context_id + 1;
const session = global._session;
const origin = try session.getOrCreateOrigin(null);
errdefer session.releaseOrigin(origin);
const page = global._page;
const origin = try page.getOrCreateOrigin(null);
errdefer page.releaseOrigin(origin);
const context = try context_arena.create(Context);
context.* = .{
@@ -285,7 +285,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
.global = if (comptime is_frame) .{ .frame = global } else .{ .worker = global },
.origin = origin,
.id = context_id,
.session = session,
.page = page,
.isolate = isolate,
.arena = context_arena,
.handle = context_global,
@@ -558,8 +558,8 @@ const PrivateSymbols = struct {
const testing = @import("../../testing.zig");
test "Env: Worker context " {
const session = testing.test_session;
const frame = try session.createFrame();
defer session.removeFrame();
const frame = try session.createPage();
defer session.removePage();
const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", &frame.js.execution);
@@ -573,8 +573,8 @@ test "Env: Worker context " {
test "Env: Frame context" {
const session = testing.test_session;
const frame = try session.createFrame();
defer session.removeFrame();
const frame = try session.createPage();
defer session.removePage();
// Frame already has a context created, use it directly
const ctx = frame.js;

View File

@@ -26,10 +26,13 @@
//! whether it's a Page context or a Worker context.
const std = @import("std");
const lp = @import("lightpanda");
const Context = @import("Context.zig");
const Scheduler = @import("Scheduler.zig");
const Factory = @import("../Factory.zig");
const String = lp.String;
const Allocator = std.mem.Allocator;
const Execution = @This();
@@ -53,3 +56,10 @@ charset: *[]const u8,
pub fn base(self: *const Execution) [:0]const u8 {
return self.context.global.base();
}
pub fn dupeString(self: *const Execution, value: []const u8) ![]const u8 {
if (String.intern(value)) |v| {
return v;
}
return self.arena.dupe(u8, value);
}

View File

@@ -215,7 +215,7 @@ fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Gl
return .{ .handle = global, .temps = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.session.temps };
return .{ .handle = global, .temps = &ctx.page.temps };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {

View File

@@ -20,7 +20,8 @@ const std = @import("std");
const lp = @import("lightpanda");
const string = @import("../../string.zig");
const Session = @import("../Session.zig");
const Page = @import("../Page.zig");
const FinalizerCallback = @import("../Session.zig").FinalizerCallback;
const js = @import("js.zig");
const bridge = @import("bridge.zig");
@@ -270,19 +271,21 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
if (resolved.finalizer) |finalizer| {
const finalizer_ptr_id = finalizer.ptr_id;
const session = ctx.session;
const finalizer_gop = try session.finalizer_callbacks.getOrPut(session.frame_arena, finalizer_ptr_id);
const page = ctx.page;
const session = page.session;
const finalizer_gop = try page.finalizer_callbacks.getOrPut(page.frame_arena, finalizer_ptr_id);
if (finalizer_gop.found_existing == false) {
// This is the first context (and very likely only one) to
// see this Zig instance. We need to create the FinalizerCallback
// so that we can cleanup on frame reset if v8 doesn't finalize.
errdefer _ = session.finalizer_callbacks.remove(finalizer_ptr_id);
// so that we can cleanup on Page teardown if v8 doesn't finalize.
errdefer _ = page.finalizer_callbacks.remove(finalizer_ptr_id);
finalizer.acquire_ref(finalizer_ptr_id);
finalizer_gop.value_ptr.* = try self.createFinalizerCallback(resolved_ptr_id, finalizer_ptr_id, finalizer.release_ref_from_zig);
}
const fc = finalizer_gop.value_ptr.*;
const identity_finalizer = try session.fc_identity_pool.create();
identity_finalizer.* = .{
.page = page,
.session = session,
.identity = ctx.identity,
.finalizer_ptr_id = finalizer_ptr_id,
@@ -1178,7 +1181,7 @@ const Resolved = struct {
ptr_id: usize,
acquire_ref: *const fn (ptr_id: usize) void,
release_ref: *const fn (handle: ?*const v8.WeakCallbackInfo) callconv(.c) void,
release_ref_from_zig: *const fn (ptr_id: usize, session: *Session) void,
release_ref_from_zig: *const fn (ptr_id: usize, page: *Page) void,
};
};
pub fn resolveValue(value: anytype) Resolved {
@@ -1224,12 +1227,12 @@ fn resolveT(comptime T: type, value: *T) Resolved {
fn releaseRef(handle: ?*const v8.WeakCallbackInfo) callconv(.c) void {
const ptr = v8.v8__WeakCallbackInfo__GetParameter(handle.?).?;
const identity_finalizer: *Session.FinalizerCallback.Identity = @ptrCast(@alignCast(ptr));
const identity_finalizer: *FinalizerCallback.Identity = @ptrCast(@alignCast(ptr));
// Identity is allocated from pool, so it's valid even after frame reset.
const session = identity_finalizer.session;
const page = identity_finalizer.page;
const resolved_ptr_id = identity_finalizer.resolved_ptr_id;
defer session.fc_identity_pool.destroy(identity_finalizer);
defer page.session.fc_identity_pool.destroy(identity_finalizer);
// Always clean up the identity map entry
if (identity_finalizer.identity.identity_map.fetchRemove(resolved_ptr_id)) |kv| {
@@ -1237,28 +1240,29 @@ fn resolveT(comptime T: type, value: *T) Resolved {
v8.v8__Global__Reset(&global);
}
// If done, FC was already cleaned up during frame reset. The
// finalizer_ptr_id may have been reused for a new object, so
// we must not look it up in the map.
// If done, FC was already cleaned up during Page teardown.
// The finalizer_ptr_id may have been reused for a new object,
// so we must not look it up in the map. It's also unsafe to
// dereference identity_finalizer.page after done is true.
if (identity_finalizer.done) return;
const finalizer_ptr_id = identity_finalizer.finalizer_ptr_id;
const fc = session.finalizer_callbacks.get(finalizer_ptr_id) orelse return;
const fc = page.finalizer_callbacks.get(finalizer_ptr_id) orelse return;
const identity_count = fc.identity_count;
if (identity_count == 1) {
// Last identity - clean up the FC.
// Remove from map before releaseRef to prevent address reuse issues.
_ = session.finalizer_callbacks.remove(finalizer_ptr_id);
FT.releaseRef(@ptrFromInt(finalizer_ptr_id), session);
session.releaseArena(fc.arena);
_ = page.finalizer_callbacks.remove(finalizer_ptr_id);
FT.releaseRef(@ptrFromInt(finalizer_ptr_id), page);
page.releaseArena(fc.arena);
} else {
fc.identity_count = identity_count - 1;
}
}
fn releaseRefFromZig(ptr_id: usize, session: *Session) void {
FT.releaseRef(@ptrFromInt(ptr_id), session);
fn releaseRefFromZig(ptr_id: usize, page: *Page) void {
FT.releaseRef(@ptrFromInt(ptr_id), page);
}
};
break :blk .{
@@ -1524,17 +1528,17 @@ fn createFinalizerCallback(
// The most specific value where finalizers are defined
// What actually gets acquired / released / deinit
finalizer_ptr_id: usize,
release_ref: *const fn (ptr_id: usize, session: *Session) void,
) !*Session.FinalizerCallback {
const session = self.ctx.session;
release_ref: *const fn (ptr_id: usize, page: *Page) void,
) !*FinalizerCallback {
const page = self.ctx.page;
const arena = try session.getArena(.tiny, "FinalizerCallback");
errdefer session.releaseArena(arena);
const arena = try page.getArena(.tiny, "FinalizerCallback");
errdefer page.releaseArena(arena);
const fc = try arena.create(Session.FinalizerCallback);
const fc = try arena.create(FinalizerCallback);
fc.* = .{
.page = page,
.arena = arena,
.session = session,
.release_ref = release_ref,
.resolved_ptr_id = resolved_ptr_id,
.finalizer_ptr_id = finalizer_ptr_id,

View File

@@ -67,7 +67,7 @@ fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Glo
return .{ .handle = global, .temps = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.session.temps };
return .{ .handle = global, .temps = &ctx.page.temps };
}
pub const Temp = G(.temp);

View File

@@ -57,7 +57,7 @@ fn _toSlice(self: String, comptime null_terminate: bool, allocator: Allocator) !
pub fn toSSO(self: String, comptime global: bool) !(if (global) lp.String.Global else lp.String) {
if (comptime global) {
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.session.frame_arena) };
return .{ .str = try self.toSSOWithAlloc(self.local.ctx.page.frame_arena) };
}
return self.toSSOWithAlloc(self.local.call_arena);
}

View File

@@ -398,7 +398,7 @@ fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Globa
return .{ .handle = global, .temps = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.session.temps };
return .{ .handle = global, .temps = &ctx.page.temps };
}
pub fn toZig(self: Value, comptime T: type) !T {

View File

@@ -858,6 +858,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/EventTarget.zig"),
@import("../webapi/Location.zig"),
@import("../webapi/Navigator.zig"),
@import("../webapi/NavigatorUAData.zig"),
@import("../webapi/net/FormData.zig"),
@import("../webapi/net/Headers.zig"),
@import("../webapi/net/Request.zig"),
@@ -933,10 +934,11 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/streams/WritableStreamDefaultController.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
@import("../webapi/encoding/TextDecoderStream.zig"),
// @import("../webapi/URL.zig"),
@import("../webapi/AbortSignal.zig"),
@import("../webapi/AbortController.zig"),
@import("../webapi/URL.zig"),
@import("../webapi/canvas/OffscreenCanvas.zig"),
// @import("../webapi/Performance.zig"),
// @import("../webapi/AbortSignal.zig"),
// @import("../webapi/AbortController.zig"),
});
// Master list of ALL JS APIs across all contexts.

View File

@@ -31,7 +31,7 @@ pub fn collectLinks(arena: Allocator, root: *Node, frame: *Frame) ![]const []con
var links: std.ArrayList([]const u8) = .empty;
if (Selector.querySelectorAll(root, "a[href]", frame)) |list| {
defer list.deinit(frame._session);
defer list.deinit(frame._page);
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {

View File

@@ -474,8 +474,8 @@ pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !voi
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig");
const frame = try testing.test_session.createFrame();
defer testing.test_session.removeFrame();
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
frame.url = "http://localhost/";
const doc = frame.window._document;
@@ -677,8 +677,8 @@ test "browser.markdown: skip empty links" {
test "browser.markdown: resolve links" {
const testing = @import("../testing.zig");
const frame = try testing.test_session.createFrame();
defer testing.test_session.removeFrame();
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
frame.url = "https://example.com/a/index.html";
const doc = frame.window._document;

View File

@@ -318,8 +318,8 @@ fn collectLink(
const testing = @import("../testing.zig");
fn testStructuredData(html: []const u8) !StructuredData {
const frame = try testing.test_session.createFrame();
defer testing.test_session.removeFrame();
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);

View File

@@ -498,6 +498,28 @@
}
</script>
<script id="CSSStyleSheet_insertRule_out_of_range_index">
{
// Our lack of fully processing sources (e.g. @import, @font-face) means that
// our indexes are sometimes not what libraries expect. As a workaround we
// clamp the index to the end of the rule list. See #2214.
const style = document.createElement('style');
document.head.appendChild(style);
const sheet = style.sheet;
const returned = sheet.insertRule('.out-of-range { color: red; }', 99);
testing.expectEqual(0, returned);
testing.expectEqual(1, sheet.cssRules.length);
testing.expectEqual('.out-of-range', sheet.cssRules[0].selectorText);
// And at the tail of a non-empty sheet
const tail = sheet.insertRule('.tail { color: blue; }', 99);
testing.expectEqual(1, tail);
testing.expectEqual(2, sheet.cssRules.length);
testing.expectEqual('.tail', sheet.cssRules[1].selectorText);
}
</script>
<script id="CSSStyleSheet_replaceSync">
{
const sheet = new CSSStyleSheet();

View File

@@ -0,0 +1,107 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe id=isimple src=set_body/simple.html></iframe>
<script id=simple>
{
testing.onload(() => {
testing.expectEqual(
'<head></head><body><p>replaced</p></body>',
isimple.contentDocument.documentElement.innerHTML,
);
// The original body element is gone; a fresh one is in place.
testing.expectEqual(null, isimple.contentDocument.getElementById('orig'));
testing.expectEqual(true, isimple.contentDocument.body instanceof isimple.contentWindow.HTMLBodyElement);
});
}
</script>
<iframe id=inone src=set_body/no_body.html></iframe>
<script id=no_body>
{
testing.onload(() => {
// Document only had a <head>; setting body should append a fresh body.
testing.expectEqual(
'<head><title>no_body</title></head>\n<body><p>appended</p></body>',
inone.contentDocument.documentElement.innerHTML,
);
});
}
</script>
<iframe id=iempty src=set_body/empty.html></iframe>
<script id=empty>
{
testing.onload(() => {
// Setting body to the empty string yields an empty <body>.
testing.expectEqual(
'<head></head><body></body>',
iempty.contentDocument.documentElement.innerHTML,
);
});
}
</script>
<iframe id=icomplex src=set_body/complex.html></iframe>
<script id=complex>
{
testing.onload(() => {
const doc = icomplex.contentDocument;
// Multiple siblings + text node + attributes survive as body children.
testing.expectEqual(
'<body><h1>title</h1>text node<div id="d" class="x"><span>nested</span></div></body>',
doc.documentElement.querySelector('body').outerHTML,
);
// The replacement body can still be located via document.body.
testing.expectEqual('d', doc.body.querySelector('div').id);
});
}
</script>
<iframe id=iframeset src=set_body/frameset.html></iframe>
<script id=html_wrapper_stripped>
{
testing.onload(() => {
// Author wrapped content in <html><body>…</body></html>; the parser
// should unwrap those so the new body contains just the <p>.
testing.expectEqual(
'<body><p>unwrapped</p></body>',
iframeset.contentDocument.body.outerHTML,
);
});
}
</script>
<script id=no_document_element>
// Build a detached HTMLDocument, strip its documentElement, and verify
// setting body throws HierarchyRequestError.
{
const doc = document.implementation.createHTMLDocument('x');
doc.documentElement.remove();
testing.expectEqual(null, doc.documentElement);
let err = null;
try {
doc.body = '<p>hi</p>';
} catch (e) {
err = e;
}
testing.expectEqual(true, err instanceof DOMException);
testing.expectEqual('HierarchyRequestError', err.name);
}
</script>
<script id=detached_html_document>
// Fresh HTMLDocument with both head and body: replacing body should leave
// head untouched and install a new body.
{
const doc = document.implementation.createHTMLDocument('x');
const originalBody = doc.body;
doc.body = '<span>new</span>';
testing.expectEqual(true, doc.body !== originalBody);
testing.expectEqual('<span>new</span>', doc.body.innerHTML);
// Head (including the auto-created <title>) is preserved.
testing.expectEqual('<title>x</title>', doc.head.innerHTML);
}
</script>

View File

@@ -0,0 +1,5 @@
<html>
<body><div>original</div></body>
<script>
document.body = '<h1>title</h1>text node<div id="d" class="x"><span>nested</span></div>';
</script>

View File

@@ -0,0 +1,5 @@
<html>
<body><div>to be wiped</div></body>
<script>
document.body = '';
</script>

View File

@@ -0,0 +1,7 @@
<html>
<body><div>original</div></body>
<script>
// Author wraps contents in their own <html><body>. Fragment parsing
// should unwrap these so the resulting body holds just the <p>.
document.body = '<html><body><p>unwrapped</p></body></html>';
</script>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head><title>no_body</title></head>
<body><!-- this body is removed before setBody runs --></body>
<script>
document.body.remove();
document.body = '<p>appended</p>';
</script>
</html>

View File

@@ -0,0 +1,5 @@
<html>
<body id=orig><div>original</div></body>
<script>
document.body = '<p>replaced</p>';
</script>

View File

@@ -65,6 +65,49 @@
testing.expectEqual(8, navigator.deviceMemory);
</script>
<script id=userAgentData>
testing.expectEqual(true, navigator.userAgentData !== undefined);
testing.expectEqual(false, navigator.userAgentData.mobile);
testing.expectEqual(true, navigator.userAgentData.platform.length > 0);
const validUAPlatforms = ['macOS', 'Windows', 'Linux', 'FreeBSD', 'Unknown'];
testing.expectEqual(true, validUAPlatforms.includes(navigator.userAgentData.platform));
testing.expectEqual(1, navigator.userAgentData.brands.length);
testing.expectEqual({brand: 'Lightpanda', version: "1"}, navigator.userAgentData.brands[0]);
// Same instance returned on repeated access
testing.expectEqual(true, navigator.userAgentData === navigator.userAgentData);
const json = navigator.userAgentData.toJSON();
testing.expectEqual(false, json.mobile);
testing.expectEqual(navigator.userAgentData.platform, json.platform);
testing.expectEqual(true, Array.isArray(json.brands));
testing.expectEqual('Lightpanda', json.brands[0].brand);
</script>
<script id=userAgentData_highEntropy type=module>
{
const state = await testing.async();
const p = navigator.userAgentData.getHighEntropyValues(['architecture', 'platformVersion', 'fullVersionList']);
testing.expectTrue(p instanceof Promise);
const v = await p;
state.resolve();
await state.done(() => {
testing.expectEqual(false, v.mobile);
testing.expectEqual(navigator.userAgentData.platform, v.platform);
testing.expectEqual(true, Array.isArray(v.brands));
testing.expectEqual(true, Array.isArray(v.fullVersionList));
testing.expectEqual('Lightpanda', v.fullVersionList[0].brand);
testing.expectEqual('1.0.0.0', v.uaFullVersion);
testing.expectEqual(false, v.wow64);
testing.expectEqual(true, Array.isArray(v.formFactor));
testing.expectEqual('Desktop', v.formFactor[0]);
});
}
</script>
<script id=getBattery type=module>
{
const state = await testing.async();

View File

@@ -29,6 +29,27 @@
const response_body_text = await response.text();
const response_clone_text = await response.clone().text();
// AbortController + AbortSignal dispatch (exercises the inline-else dispatch path)
const controller = new AbortController();
const ac_aborted_before = controller.signal.aborted;
let ac_listener_fired = false;
controller.signal.addEventListener('abort', () => { ac_listener_fired = true; });
controller.abort('cancelled');
const ac_aborted_after = controller.signal.aborted;
const ac_reason = String(controller.signal.reason);
// Pre-aborted static constructor + throwIfAborted
const pre = AbortSignal.abort('already-gone');
const pre_aborted = pre.aborted;
let pre_threw = false;
try { pre.throwIfAborted(); } catch (_) { pre_threw = true; }
// URL.createObjectURL / revokeObjectURL from a worker
const blob = new Blob(['hello worker'], { type: 'text/plain' });
const blob_url = URL.createObjectURL(blob);
const blob_url_is_blob = blob_url.startsWith('blob:');
URL.revokeObjectURL(blob_url);
postMessage({
ok: true,
results: {
@@ -47,6 +68,13 @@
response_headers_content_type: response.headers.get('content-type'),
response_body_text,
response_clone_text,
ac_aborted_before,
ac_aborted_after,
ac_listener_fired,
ac_reason,
pre_aborted,
pre_threw,
blob_url_is_blob,
},
});
} catch (e) {

View File

@@ -209,6 +209,17 @@
testing.expectEqual('text/plain', r.response_headers_content_type);
testing.expectEqual('response body', r.response_body_text);
testing.expectEqual('response body', r.response_clone_text);
// AbortController / AbortSignal
testing.expectEqual(false, r.ac_aborted_before);
testing.expectEqual(true, r.ac_aborted_after);
testing.expectEqual(true, r.ac_listener_fired);
testing.expectEqual('cancelled', r.ac_reason);
testing.expectEqual(true, r.pre_aborted);
testing.expectEqual(true, r.pre_threw);
// URL.createObjectURL / revokeObjectURL
testing.expectEqual(true, r.blob_url_is_blob);
});
}
</script>

View File

@@ -553,9 +553,11 @@ fn execClick(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A
// If the click triggered a navigation (e.g. form submission, link click),
// wait for it to complete.
if (session.queued_navigation.items.len != 0) {
var runner = session.runner(.{}) catch return ToolError.InternalError;
runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed;
if (session.currentPage()) |page| {
if (page.queued_navigation.items.len != 0) {
var runner = session.runner(.{}) catch return ToolError.InternalError;
runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed;
}
}
const page = session.currentFrame() orelse return ToolError.FrameNotLoaded;
@@ -711,9 +713,11 @@ fn execPress(session: *lp.Session, registry: *CDPNode.Registry, arena: std.mem.A
};
// Pressing Enter on a form input triggers implicit form submission.
if (session.queued_navigation.items.len != 0) {
var runner = session.runner(.{}) catch return ToolError.InternalError;
runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed;
if (session.currentPage()) |p| {
if (p.queued_navigation.items.len != 0) {
var runner = session.runner(.{}) catch return ToolError.InternalError;
runner.wait(.{ .ms = 10000, .until = .done }) catch return ToolError.NavigationFailed;
}
}
const current_page = session.currentFrame() orelse return ToolError.FrameNotLoaded;
@@ -882,11 +886,11 @@ fn ensurePage(session: *lp.Session, registry: *CDPNode.Registry, url: ?[:0]const
}
fn performGoto(session: *lp.Session, registry: *CDPNode.Registry, url: [:0]const u8, timeout: ?u32, waitUntil: ?lp.Config.WaitUntil) ToolError!void {
if (session.frame != null) {
if (session.page != null) {
registry.reset();
session.removeFrame();
session.removePage();
}
const page = session.createFrame() catch return ToolError.NavigationFailed;
const page = session.createPage() catch return ToolError.NavigationFailed;
_ = page.navigate(url, .{
.reason = .address_bar,
.kind = .{ .push = null },

View File

@@ -19,16 +19,17 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const AbortSignal = @import("AbortSignal.zig");
const Execution = js.Execution;
const AbortController = @This();
_signal: *AbortSignal,
pub fn init(frame: *Frame) !*AbortController {
const signal = try AbortSignal.init(frame);
return frame._factory.create(AbortController{
pub fn init(exec: *const Execution) !*AbortController {
const signal = try AbortSignal.init(exec);
return exec._factory.create(AbortController{
._signal = signal,
});
}
@@ -37,8 +38,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal {
return self._signal;
}
pub fn abort(self: *AbortController, reason_: ?js.Value.Global, frame: *Frame) !void {
try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, frame);
pub fn abort(self: *AbortController, reason_: ?js.Value.Global, exec: *const Execution) !void {
try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec);
}
pub const JsApi = struct {

View File

@@ -20,12 +20,12 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Event = @import("Event.zig");
const EventTarget = @import("EventTarget.zig");
const log = lp.log;
const Execution = js.Execution;
const AbortSignal = @This();
@@ -34,8 +34,8 @@ _aborted: bool = false,
_reason: Reason = .undefined,
_on_abort: ?js.Function.Global = null,
pub fn init(frame: *Frame) !*AbortSignal {
return frame._factory.eventTarget(AbortSignal{
pub fn init(exec: *const Execution) !*AbortSignal {
return exec._factory.eventTarget(AbortSignal{
._proto = undefined,
});
}
@@ -60,7 +60,7 @@ pub fn asEventTarget(self: *AbortSignal) *EventTarget {
return self._proto;
}
pub fn abort(self: *AbortSignal, reason_: ?Reason, frame: *Frame) !void {
pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void {
if (self._aborted) {
return;
}
@@ -71,36 +71,40 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, frame: *Frame) !void {
if (reason_) |reason| {
switch (reason) {
.js_val => |js_val| self._reason = .{ .js_val = js_val },
.string => |str| self._reason = .{ .string = try frame.dupeString(str) },
.string => |str| self._reason = .{ .string = try exec.dupeString(str) },
.undefined => self._reason = reason,
}
} else {
self._reason = .{ .string = "AbortError" };
}
// Dispatch abort event
const target = self.asEventTarget();
if (frame._event_manager.hasDirectListeners(target, "abort", self._on_abort)) {
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, frame);
try frame._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" });
const on_abort = self._on_abort;
switch (exec.context.global) {
inline else => |g| {
if (g._event_manager.hasDirectListeners(target, "abort", on_abort)) {
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, g._page);
try g.dispatch(target, event, on_abort, .{ .context = "abort signal" });
}
},
}
}
// Static method to create an already-aborted signal
pub fn createAborted(reason_: ?js.Value.Global, frame: *Frame) !*AbortSignal {
const signal = try init(frame);
try signal.abort(if (reason_) |r| .{ .js_val = r } else null, frame);
pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortSignal {
const signal = try init(exec);
try signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec);
return signal;
}
pub fn createTimeout(delay: u32, frame: *Frame) !*AbortSignal {
const callback = try frame.arena.create(TimeoutCallback);
pub fn createTimeout(delay: u32, exec: *const Execution) !*AbortSignal {
const callback = try exec.arena.create(TimeoutCallback);
callback.* = .{
.frame = frame,
.signal = try init(frame),
.exec = exec,
.signal = try init(exec),
};
try frame.js.scheduler.add(callback, TimeoutCallback.run, delay, .{
try exec._scheduler.add(callback, TimeoutCallback.run, delay, .{
.name = "AbortSignal.timeout",
});
@@ -111,8 +115,8 @@ const ThrowIfAborted = union(enum) {
exception: js.Exception,
undefined: void,
};
pub fn throwIfAborted(self: *const AbortSignal, frame: *Frame) !ThrowIfAborted {
const local = frame.js.local.?;
pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIfAborted {
const local = exec.context.local.?;
if (self._aborted) {
const exception = switch (self._reason) {
@@ -132,12 +136,12 @@ const Reason = union(enum) {
};
const TimeoutCallback = struct {
frame: *Frame,
exec: *const Execution,
signal: *AbortSignal,
fn run(ctx: *anyopaque) !?u32 {
const self: *TimeoutCallback = @ptrCast(@alignCast(ctx));
self.signal.abort(.{ .string = "TimeoutError" }, self.frame) catch |err| {
self.signal.abort(.{ .string = "TimeoutError" }, self.exec) catch |err| {
log.warn(.app, "abort signal timeout", .{ .err = err });
};
return null;

View File

@@ -20,8 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Session = @import("../Session.zig");
const Page = @import("../Page.zig");
const Node = @import("Node.zig");
const Range = @import("Range.zig");
@@ -49,15 +48,15 @@ pub fn acquireRef(self: *AbstractRange) void {
self._rc.acquire();
}
pub fn deinit(self: *AbstractRange, session: *Session) void {
if (session.findFrameByLoaderId(self._frame_loader_id)) |frame| {
pub fn deinit(self: *AbstractRange, page: *Page) void {
if (page.findFrameByLoaderId(self._frame_loader_id)) |frame| {
frame._live_ranges.remove(&self._range_link);
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *AbstractRange, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *AbstractRange, page: *Page) void {
self._rc.release(self, page);
}
pub const Type = union(enum) {

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Session = @import("../Session.zig");
const Page = @import("../Page.zig");
const Mime = @import("../Mime.zig");
@@ -61,7 +61,8 @@ const InitOptions = struct {
/// Creates a new Blob from JS values with optional MIME validation.
/// This is the JS Constructor
pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, session: *Session) !*Blob {
pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob {
const session = page.session;
const arena = try session.getArena(.large, "Blob");
errdefer session.releaseArena(arena);
@@ -94,9 +95,9 @@ pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, session: *Session) !
}
/// Creates a new Blob from raw byte slices (for internal Zig use).
pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, session: *Session) !*Blob {
const arena = try session.getArena(.large, "Blob");
errdefer session.releaseArena(arena);
pub fn initFromBytes(data: []const u8, content_type: []const u8, validate_mime: bool, page: *Page) !*Blob {
const arena = try page.getArena(.large, "Blob");
errdefer page.releaseArena(arena);
const mime = try validateMimeType(arena, content_type, validate_mime);
@@ -137,12 +138,12 @@ fn validateMimeType(arena: Allocator, mime_type: []const u8, full_validation: bo
return buf;
}
pub fn deinit(self: *Blob, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *Blob, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *Blob, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *Blob, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *Blob) void {
@@ -291,7 +292,7 @@ pub fn slice(
start_: ?i32,
end_: ?i32,
content_type_: ?[]const u8,
session: *Session,
page: *Page,
) !*Blob {
const data = self._slice;
@@ -312,7 +313,7 @@ pub fn slice(
break :blk @min(data.len, @max(start, @as(u31, @intCast(requested_end))));
};
return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, session);
return Blob.initFromBytes(data[start..end], content_type_ orelse "", false, page);
}
/// Returns the size of the Blob in bytes.

View File

@@ -353,12 +353,12 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !*
const normalized = std.ascii.lowerString(&frame.buf, event_type);
if (std.mem.eql(u8, normalized, "event") or std.mem.eql(u8, normalized, "events") or std.mem.eql(u8, normalized, "htmlevents")) {
return Event.init("", null, frame);
return Event.init("", null, frame._page);
}
if (std.mem.eql(u8, normalized, "customevent") or std.mem.eql(u8, normalized, "customevents")) {
const CustomEvent = @import("event/CustomEvent.zig");
return (try CustomEvent.init("", null, frame)).asEvent();
return (try CustomEvent.init("", null, frame._page)).asEvent();
}
if (std.mem.eql(u8, normalized, "keyboardevent")) {
@@ -378,7 +378,7 @@ pub fn createEvent(_: *const Document, event_type: []const u8, frame: *Frame) !*
if (std.mem.eql(u8, normalized, "messageevent")) {
const MessageEvent = @import("event/MessageEvent.zig");
return (try MessageEvent.init("", null, frame._session)).asEvent();
return (try MessageEvent.init("", null, frame._page)).asEvent();
}
if (std.mem.eql(u8, normalized, "uievent") or std.mem.eql(u8, normalized, "uievents")) {

View File

@@ -20,8 +20,8 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const EventTarget = @import("EventTarget.zig");
@@ -89,16 +89,16 @@ pub const Options = struct {
composed: bool = false,
};
pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*Event {
const arena = try frame.getArena(.tiny, "Event");
errdefer frame.releaseArena(arena);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
const arena = try page.getArena(.tiny, "Event");
errdefer page.releaseArena(arena);
const str = try String.init(arena, typ, .{});
return initWithTrusted(arena, str, opts_, false);
}
pub fn initTrusted(typ: String, opts_: ?Options, frame: *Frame) !*Event {
const arena = try frame.getArena(.tiny, "Event.trusted");
errdefer frame.releaseArena(arena);
pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*Event {
const arena = try page.getArena(.tiny, "Event.trusted");
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true);
}
@@ -145,13 +145,12 @@ pub fn acquireRef(self: *Event) void {
self._rc.acquire();
}
/// Force cleanup on Session shutdown.
pub fn deinit(self: *Event, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *Event, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *Event, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *Event, page: *Page) void {
self._rc.release(self, page);
}
pub fn as(self: *Event, comptime T: type) *T {

View File

@@ -19,7 +19,7 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Session = @import("../Session.zig");
const Page = @import("../Page.zig");
const EventManager = @import("../EventManager.zig");
const Event = @import("Event.zig");
@@ -52,8 +52,8 @@ pub const Type = union(enum) {
websocket: *@import("net/WebSocket.zig"),
};
pub fn init(session: *Session) !*EventTarget {
return session.factory.create(EventTarget{
pub fn init(page: *Page) !*EventTarget {
return page.factory.create(EventTarget{
._type = .generic,
});
}
@@ -67,10 +67,10 @@ pub fn dispatchEvent(self: *EventTarget, event: *Event, exec: *js.Execution) !bo
switch (exec.context.global) {
.frame => |frame| {
event.acquireRef();
defer _ = event.releaseRef(frame._session);
defer _ = event.releaseRef(frame._page);
try frame._event_manager.dispatch(self, event);
},
.worker => |wgs| try wgs.dispatch(self, event, null),
.worker => |wgs| try wgs.dispatch(self, event, null, .{}),
}
return !event._cancelable or !event._prevent_default;
}
@@ -101,8 +101,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi
};
switch (exec.context.global) {
.frame => |frame| _ = try frame._event_manager.register(self, typ, em_callback, options),
.worker => |wgs| _ = try wgs._event_manager.register(self, typ, em_callback, options),
inline else => |g| _ = try g._event_manager.register(self, typ, em_callback, options),
}
}
@@ -138,8 +137,7 @@ pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?Even
};
switch (exec.context.global) {
.frame => |frame| frame._event_manager.remove(self, typ, em_callback, use_capture),
.worker => |wgs| wgs._event_manager.remove(self, typ, em_callback, use_capture),
inline else => |g| g._event_manager.remove(self, typ, em_callback, use_capture),
}
}

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Session = @import("../Session.zig");
const Page = @import("../Page.zig");
const Blob = @import("Blob.zig");
@@ -29,10 +29,11 @@ const File = @This();
_proto: *Blob,
// TODO: Implement File API.
pub fn init(session: *Session) !*File {
pub fn init(page: *Page) !*File {
const session = page.session;
const arena = try session.getArena(.tiny, "File");
errdefer session.releaseArena(arena);
return session.factory.blob(arena, File{ ._proto = undefined });
return page.factory.blob(arena, File{ ._proto = undefined });
}
pub const JsApi = struct {

View File

@@ -21,8 +21,8 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const EventTarget = @import("EventTarget.zig");
const ProgressEvent = @import("event/ProgressEvent.zig");
const Blob = @import("Blob.zig");
@@ -73,7 +73,7 @@ pub fn init(frame: *Frame) !*FileReader {
});
}
pub fn deinit(self: *FileReader, session: *Session) void {
pub fn deinit(self: *FileReader, page: *Page) void {
if (self._on_abort) |func| func.release();
if (self._on_error) |func| func.release();
if (self._on_load) |func| func.release();
@@ -81,11 +81,11 @@ pub fn deinit(self: *FileReader, session: *Session) void {
if (self._on_load_start) |func| func.release();
if (self._on_progress) |func| func.release();
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *FileReader, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *FileReader, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *FileReader) void {

View File

@@ -57,8 +57,30 @@ pub fn getHead(self: *HTMLDocument) ?*Element.Html.Head {
}
pub fn getBody(self: *HTMLDocument) ?*Element.Html.Body {
const doc_el = self._proto.getDocumentElement() orelse return null;
var child = doc_el.asNode().firstChild();
const document_element = self._proto.getDocumentElement() orelse return null;
return findBodyForDoc(document_element);
}
pub fn setBody(self: *HTMLDocument, html: []const u8, frame: *Frame) !void {
const document_element = self._proto.getDocumentElement() orelse return error.HierarchyError;
// Build a fresh <body> holding the parsed HTML as its children. Fragment
// parsing strips any <html>/<body>/<head> wrappers the author included.
const new_body_node = try frame.createElementNS(.html, "body", null);
if (html.len > 0) {
try frame.parseHtmlAsChildren(new_body_node, html);
}
const document_node = document_element.asNode();
if (findBodyForDoc(document_element)) |current| {
_ = try document_node.replaceChild(new_body_node, current.asNode(), frame);
} else {
_ = try document_node.appendChild(new_body_node, frame);
}
}
fn findBodyForDoc(document_element: *Element) ?*Element.Html.Body {
var child = document_element.asNode().firstChild();
while (child) |node| {
if (node.is(Element.Html.Body)) |body| {
return body;
@@ -276,7 +298,7 @@ pub const JsApi = struct {
pub const dir = bridge.accessor(HTMLDocument.getDir, HTMLDocument.setDir, .{});
pub const head = bridge.accessor(HTMLDocument.getHead, null, .{});
pub const body = bridge.accessor(HTMLDocument.getBody, null, .{});
pub const body = bridge.accessor(HTMLDocument.getBody, HTMLDocument.setBody, .{ .dom_exception = true });
pub const lang = bridge.accessor(HTMLDocument.getLang, HTMLDocument.setLang, .{});
pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{});
pub const images = bridge.accessor(HTMLDocument.getImages, null, .{});

View File

@@ -20,8 +20,8 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
@@ -110,18 +110,18 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, frame: *Frame) !
return self;
}
pub fn deinit(self: *IntersectionObserver, session: *Session) void {
pub fn deinit(self: *IntersectionObserver, page: *Page) void {
self._callback.release();
for (self._pending_entries.items) |entry| {
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
entry.deinit(session);
entry.deinit(page);
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *IntersectionObserver, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *IntersectionObserver, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *IntersectionObserver) void {
@@ -164,7 +164,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v
while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j);
entry.deinit(frame._session);
entry.deinit(frame._page);
} else {
j += 1;
}
@@ -180,7 +180,7 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) v
pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void {
for (self._pending_entries.items) |entry| {
entry.deinit(frame._session);
entry.deinit(frame._page);
}
self._pending_entries.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity();
@@ -330,12 +330,12 @@ pub const IntersectionObserverEntry = struct {
_intersection_ratio: f64,
_is_intersecting: bool,
pub fn deinit(self: *IntersectionObserverEntry, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *IntersectionObserverEntry, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *IntersectionObserverEntry, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *IntersectionObserverEntry, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *IntersectionObserverEntry) void {

View File

@@ -131,7 +131,7 @@ const PostMessageCallback = struct {
.data = .{ .value = self.message },
.origin = "",
.source = null,
}, frame._session) catch |err| {
}, frame._page) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
}).asEvent();

View File

@@ -20,8 +20,8 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
@@ -86,18 +86,18 @@ pub fn init(callback: js.Function.Temp, frame: *Frame) !*MutationObserver {
return self;
}
pub fn deinit(self: *MutationObserver, session: *Session) void {
pub fn deinit(self: *MutationObserver, page: *Page) void {
for (self._pending_records.items) |record| {
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
record.deinit(session);
record.deinit(page);
}
self._callback.release();
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *MutationObserver, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *MutationObserver, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *MutationObserver) void {
@@ -178,7 +178,7 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
pub fn disconnect(self: *MutationObserver, frame: *Frame) void {
for (self._pending_records.items) |record| {
record.deinit(frame._session);
record.deinit(frame._page);
}
self._pending_records.clearRetainingCapacity();
@@ -375,11 +375,11 @@ pub const MutationRecord = struct {
characterData,
};
pub fn deinit(self: *MutationRecord, session: *Session) void {
pub fn deinit(self: *MutationRecord, session: *Page) void {
session.releaseArena(self._arena);
}
pub fn releaseRef(self: *MutationRecord, session: *Session) void {
pub fn releaseRef(self: *MutationRecord, session: *Page) void {
self._rc.release(self, session);
}

View File

@@ -26,6 +26,7 @@ const Frame = @import("../Frame.zig");
const PluginArray = @import("PluginArray.zig");
const Permissions = @import("Permissions.zig");
const StorageManager = @import("StorageManager.zig");
const NavigatorUAData = @import("NavigatorUAData.zig");
const log = lp.log;
@@ -34,6 +35,7 @@ _pad: bool = false,
_plugins: PluginArray = .{},
_permissions: Permissions = .{},
_storage: StorageManager = .{},
_ua_data: NavigatorUAData = .{},
pub const init: Navigator = .{};
@@ -72,6 +74,10 @@ pub fn getStorage(self: *Navigator) *StorageManager {
return &self._storage;
}
pub fn getUserAgentData(self: *Navigator) *NavigatorUAData {
return &self._ua_data;
}
pub fn getBattery(_: *const Navigator, frame: *Frame) !js.Promise {
log.info(.not_implemented, "navigator.getBattery", .{});
return frame.js.local.?.rejectErrorPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
@@ -182,6 +188,7 @@ pub const JsApi = struct {
pub const getBattery = bridge.function(Navigator.getBattery, .{});
pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{});
pub const storage = bridge.accessor(Navigator.getStorage, null, .{});
pub const userAgentData = bridge.accessor(Navigator.getUserAgentData, null, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -0,0 +1,134 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const builtin = @import("builtin");
const Config = @import("../../Config.zig");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const NavigatorUAData = @This();
_pad: bool = false,
const Brand = struct {
brand: []const u8,
version: []const u8,
};
pub fn getBrands(_: *const NavigatorUAData) []const Brand {
return brandList();
}
pub fn getMobile(_: *const NavigatorUAData) bool {
return false;
}
pub fn getPlatform(_: *const NavigatorUAData) []const u8 {
return uaPlatform();
}
pub fn toJSON(_: *const NavigatorUAData) struct {
brands: []const Brand,
mobile: bool,
platform: []const u8,
} {
return .{
.mobile = false,
.brands = brandList(),
.platform = uaPlatform(),
};
}
pub fn getHighEntropyValues(_: *const NavigatorUAData, hints: []const []const u8, frame: *Frame) !js.Promise {
// This should always return `brands` + `mobile` + `platform` and then whatever
// "hints" field is requested (assuming the browser has permission), but it's
// also valid to just return everything.
_ = hints;
return frame.js.local.?.resolvePromise(.{
.brands = brandList(),
.mobile = false,
.platform = uaPlatform(),
.architecture = uaArchitecture(),
.bitness = uaBitness(),
.model = "",
.platformVersion = "",
.uaFullVersion = "1.0.0.0",
.fullVersionList = brandList(),
.wow64 = false,
.formFactor = [_][]const u8{"Desktop"},
});
}
fn brandList() []const Brand {
const out = comptime blk: {
const src = &Config.HttpHeaders.brands;
var arr: [src.len]Brand = undefined;
for (src, 0..) |b, i| {
arr[i] = .{ .brand = b.brand, .version = b.version };
}
const final = arr;
break :blk final;
};
return &out;
}
fn uaPlatform() []const u8 {
return switch (builtin.os.tag) {
.macos => "macOS",
.windows => "Windows",
.linux => "Linux",
.freebsd => "FreeBSD",
else => "Unknown",
};
}
fn uaArchitecture() []const u8 {
return switch (builtin.cpu.arch) {
.x86, .x86_64 => "x86",
.aarch64, .aarch64_be, .arm, .armeb => "arm",
else => "",
};
}
fn uaBitness() []const u8 {
return switch (builtin.cpu.arch) {
.x86_64, .aarch64, .aarch64_be, .powerpc64, .powerpc64le, .riscv64 => "64",
else => "32",
};
}
pub const JsApi = struct {
pub const bridge = js.Bridge(NavigatorUAData);
pub const Meta = struct {
pub const name = "NavigatorUAData";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const brands = bridge.accessor(NavigatorUAData.getBrands, null, .{});
pub const mobile = bridge.accessor(NavigatorUAData.getMobile, null, .{});
pub const platform = bridge.accessor(NavigatorUAData.getPlatform, null, .{});
pub const toJSON = bridge.function(NavigatorUAData.toJSON, .{});
pub const getHighEntropyValues = bridge.function(NavigatorUAData.getHighEntropyValues, .{});
};

View File

@@ -18,9 +18,10 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const Allocator = std.mem.Allocator;
@@ -56,12 +57,12 @@ const PermissionStatus = struct {
_name: []const u8,
_state: []const u8,
pub fn deinit(self: *PermissionStatus, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *PermissionStatus, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *PermissionStatus, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *PermissionStatus, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *PermissionStatus) void {

View File

@@ -20,8 +20,8 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const Range = @import("Range.zig");
const AbstractRange = @import("AbstractRange.zig");
@@ -39,15 +39,15 @@ _direction: SelectionDirection = .none,
pub const init: Selection = .{};
pub fn deinit(self: *Selection, session: *Session) void {
pub fn deinit(self: *Selection, page: *Page) void {
if (self._range) |r| {
r.asAbstractRange().releaseRef(session);
r.asAbstractRange().releaseRef(page);
self._range = null;
}
}
pub fn releaseRef(self: *Selection, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *Selection, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *Selection) void {
@@ -55,7 +55,7 @@ pub fn acquireRef(self: *Selection) void {
}
fn dispatchSelectionChangeEvent(frame: *Frame) !void {
const event = try Event.init("selectionchange", .{}, frame);
const event = try Event.init("selectionchange", .{}, frame._page);
try frame._event_manager.dispatch(frame.document.asEventTarget(), event);
}
@@ -703,7 +703,7 @@ pub fn toString(self: *const Selection, frame: *Frame) ![]const u8 {
fn setRange(self: *Selection, new_range: ?*Range, frame: *Frame) void {
if (self._range) |existing| {
_ = existing.asAbstractRange().releaseRef(frame._session);
_ = existing.asAbstractRange().releaseRef(frame._page);
}
if (new_range) |nr| {
nr.asAbstractRange().acquireRef();

View File

@@ -160,8 +160,8 @@ pub fn TreeWalker(comptime mode: Mode) type {
test "TreeWalker: skipChildren" {
const testing = @import("../../testing.zig");
const frame = try testing.test_session.createFrame();
defer testing.test_session.removeFrame();
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
const doc = frame.window._document;
// <div>

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const js = @import("../js/js.zig");
const U = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const URLSearchParams = @import("net/URLSearchParams.zig");
const Blob = @import("Blob.zig");
const Execution = js.Execution;
@@ -248,28 +247,36 @@ pub fn canParse(url: []const u8, base_: ?[]const u8) bool {
return U.isCompleteHTTPUrl(url);
}
pub fn createObjectURL(blob: *Blob, frame: *Frame) ![]const u8 {
pub fn createObjectURL(blob: *Blob, exec: *const Execution) ![]const u8 {
var uuid_buf: [36]u8 = undefined;
@import("../../id.zig").uuidv4(&uuid_buf);
const blob_url = try std.fmt.allocPrint(
frame.arena,
"blob:{s}/{s}",
.{ frame.origin orelse "null", uuid_buf },
);
try frame._blob_urls.put(frame.arena, blob_url, blob);
blob.acquireRef();
return blob_url;
switch (exec.context.global) {
inline else => |g| {
const blob_url = try std.fmt.allocPrint(
g.arena,
"blob:{s}/{s}",
.{ g.origin orelse "null", uuid_buf },
);
try g._blob_urls.put(g.arena, blob_url, blob);
blob.acquireRef();
return blob_url;
},
}
}
pub fn revokeObjectURL(url: []const u8, frame: *Frame) void {
pub fn revokeObjectURL(url: []const u8, exec: *const Execution) void {
// Per spec: silently ignore non-blob URLs
if (!std.mem.startsWith(u8, url, "blob:")) {
return;
}
if (frame._blob_urls.fetchRemove(url)) |entry| {
entry.value.releaseRef(frame._session);
switch (exec.context.global) {
inline else => |g| {
if (g._blob_urls.fetchRemove(url)) |entry| {
entry.value.releaseRef(g._page);
}
},
}
}

View File

@@ -362,7 +362,7 @@ pub fn reportError(self: *Window, err: js.Value, frame: *Frame) !void {
.message = err.toStringSlice() catch "Unknown error",
.bubbles = false,
.cancelable = true,
}, frame._session);
}, frame._page);
// Invoke window.onerror callback if set (per WHATWG spec, this is called
// with 5 arguments: message, source, lineno, colno, error)
@@ -530,17 +530,16 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void
frame,
struct {
fn dispatch(_frame: *anyopaque) anyerror!?u32 {
const p: *Frame = @ptrCast(@alignCast(_frame));
const pos = &p.window._scroll_pos;
const f: *Frame = @ptrCast(@alignCast(_frame));
const pos = &f.window._scroll_pos;
// If the state isn't scroll, we can ignore safely to throttle
// the events.
if (pos.state != .scroll) {
return null;
}
const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, p);
try p._event_manager.dispatch(p.document.asEventTarget(), event);
const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, f._page);
try f._event_manager.dispatch(f.document.asEventTarget(), event);
pos.state = .end;
return null;
@@ -554,8 +553,8 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void
frame,
struct {
fn dispatch(_frame: *anyopaque) anyerror!?u32 {
const p: *Frame = @ptrCast(@alignCast(_frame));
const pos = &p.window._scroll_pos;
const f: *Frame = @ptrCast(@alignCast(_frame));
const pos = &f.window._scroll_pos;
// Dispatch only if the state is .end.
// If a scroll is pending, retry in 10ms.
// If the state is .end, the event has been dispatched, so
@@ -565,9 +564,8 @@ pub fn scrollTo(self: *Window, opts: ScrollToOpts, y: ?i32, frame: *Frame) !void
.end => {},
.done => return null,
}
const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, p);
try p._event_manager.dispatch(p.document.asEventTarget(), event);
const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = true }, f._page);
try f._event_manager.dispatch(f.document.asEventTarget(), event);
pos.state = .done;
return null;
@@ -622,7 +620,7 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.
const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, frame._session)).asEvent();
}, frame._page)).asEvent();
try frame._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" });
}
}
@@ -811,7 +809,7 @@ const PostMessageCallback = struct {
.source = self.source,
.bubbles = false,
.cancelable = false,
}, frame._session)).asEvent();
}, frame._page)).asEvent();
try frame._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" });
}

View File

@@ -24,7 +24,6 @@ const http = @import("../../network/http.zig");
const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const Session = @import("../Session.zig");
const HttpClient = @import("../HttpClient.zig");
const Blob = @import("Blob.zig");
@@ -71,7 +70,7 @@ pub fn init(url: []const u8, exec: *Execution) !*Worker {
errdefer session.releaseArena(arena);
const resolved_url = try URL.resolve(arena, exec.url.*, url, .{});
const self = try session.factory.eventTargetWithAllocator(arena, Worker{
const self = try frame._page.factory.eventTargetWithAllocator(arena, Worker{
._arena = arena,
._proto = undefined,
._frame = frame,
@@ -216,7 +215,6 @@ fn fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Tem
fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Temp) !void {
const frame = self._frame;
const session = frame._session;
const target = self.asEventTarget();
const on_error = self._on_error;
@@ -232,7 +230,7 @@ fn _fireErrorEvent(self: *Worker, message: []const u8, error_value: ?js.Value.Te
.filename = self._url,
.bubbles = false,
.cancelable = true,
}, session);
}, frame._page);
try frame._event_manager.dispatchDirect(target, error_event.asEvent(), on_error, .{
.context = "Worker.onerror",
@@ -354,7 +352,7 @@ const ReceiveMessageCallback = struct {
.data = .{ .string = @errorName(err) },
.bubbles = false,
.cancelable = false,
}, frame._session)).asEvent();
}, frame._page)).asEvent();
try frame._event_manager.dispatchDirect(target, event, on_messageerror, .{ .context = "Worker.messageerror" });
return null;
};
@@ -371,7 +369,7 @@ const ReceiveMessageCallback = struct {
.data = .{ .value = data },
.bubbles = false,
.cancelable = false,
}, frame._session)).asEvent();
}, frame._page)).asEvent();
try frame._event_manager.dispatchDirect(target, event, on_message, .{ .context = "Worker.receiveMessage" });

View File

@@ -24,10 +24,12 @@ const std = @import("std");
const lp = @import("lightpanda");
const JS = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Factory = @import("../Factory.zig");
const Session = @import("../Session.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const Blob = @import("Blob.zig");
const Worker = @import("Worker.zig");
const Crypto = @import("Crypto.zig");
const Console = @import("Console.zig");
@@ -47,16 +49,22 @@ const WorkerGlobalScope = @This();
// can access these the same for a Page of a WGS.
// These fields represent the "Page"-like component of the WGS
_session: *Session,
_page: *Page,
_factory: *Factory,
_identity: JS.Identity = .{},
arena: Allocator,
call_arena: Allocator,
url: [:0]const u8,
// Same-origin constraint: a worker's origin is inherited from its parent frame.
origin: ?[]const u8 = null,
buf: [1024]u8 = undefined, // same size as frame.buf
// Document charset (matches Page.charset). Workers default to UTF-8.
charset: []const u8 = "UTF-8",
js: *JS.Context,
// Blob URL registry for URL.createObjectURL/revokeObjectURL.
_blob_urls: std.StringHashMapUnmanaged(*Blob) = .{},
// Reference back to the Worker object (for postMessage to frame)
_worker: *Worker,
@@ -76,18 +84,21 @@ _on_messageerror: ?JS.Function.Global = null,
pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
const arena = worker._arena;
const parent = worker._frame;
const session = worker._frame._session;
const factory = &session.factory;
const call_arena = try session.getArena(.small, "WorkerGlobalScope.call_arena");
errdefer session.releaseArena(call_arena);
const factory = parent._factory;
const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{
.url = url,
.arena = arena,
.origin = parent.origin,
.js = undefined,
.call_arena = call_arena,
._session = session,
._page = parent._page,
._identity = .{},
._proto = undefined,
._factory = factory,
@@ -107,9 +118,13 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
pub fn deinit(self: *WorkerGlobalScope) void {
self._identity.deinit();
const session = self._session;
session.browser.env.destroyContext(self.js);
session.releaseArena(self.call_arena);
const page = self._page;
var it = self._blob_urls.valueIterator();
while (it.next()) |blob| {
blob.*.releaseRef(page);
}
page.session.browser.env.destroyContext(self.js);
page.releaseArena(self.call_arena);
}
pub fn base(self: *const WorkerGlobalScope) [:0]const u8 {
@@ -123,15 +138,21 @@ pub fn asEventTarget(self: *WorkerGlobalScope) *EventTarget {
const Event = @import("Event.zig");
// Dispatch an event to listeners on the given target within this worker context.
pub fn dispatch(self: *WorkerGlobalScope, target: *EventTarget, event: *Event, handler: anytype) !void {
pub fn dispatch(
self: *WorkerGlobalScope,
target: *EventTarget,
event: *Event,
handler: anytype,
comptime opts: EventManagerBase.DispatchDirectOptions,
) !void {
try self._event_manager.dispatchDirect(
self.call_arena,
self.js,
target,
event,
handler,
self._session,
.{},
self._page,
opts,
);
}
@@ -264,8 +285,8 @@ pub fn unhandledPromiseRejection(self: *WorkerGlobalScope, no_handler: bool, rej
const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, self._session)).asEvent();
try self.dispatch(target, event, attribute_callback);
}, self._page)).asEvent();
try self.dispatch(target, event, attribute_callback, .{});
}
}
@@ -281,7 +302,7 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void {
.message = err.toStringSlice() catch "Unknown error",
.bubbles = false,
.cancelable = true,
}, self._session);
}, self._page);
// Invoke onerror callback if set (per WHATWG spec, this is called
// with 5 arguments: message, source, lineno, colno, error)
@@ -311,7 +332,7 @@ pub fn reportError(self: *WorkerGlobalScope, err: JS.Value) !void {
event._prevent_default = prevent_default;
// Pass null as handler: onerror was already called above with 5 args.
// We still dispatch so that addEventListener('error', ...) listeners fire.
try self.dispatch(self.asEventTarget(), event, null);
try self.dispatch(self.asEventTarget(), event, null, .{});
if (comptime builtin.is_test == false) {
if (!event._prevent_default) {
@@ -374,8 +395,8 @@ const ReceiveMessageCallback = struct {
const event = (try MessageEvent.initTrusted(comptime .wrap("messageerror"), .{
.bubbles = false,
.cancelable = false,
}, worker_scope._session)).asEvent();
try worker_scope.dispatch(target, event, on_messageerror);
}, worker_scope._page)).asEvent();
try worker_scope.dispatch(target, event, on_messageerror, .{});
return null;
}
@@ -391,8 +412,8 @@ const ReceiveMessageCallback = struct {
.data = .{ .value = self.data.? },
.bubbles = false,
.cancelable = false,
}, worker_scope._session)).asEvent();
try worker_scope.dispatch(target, event, on_message);
}, worker_scope._page)).asEvent();
try worker_scope.dispatch(target, event, on_message, .{});
return null;
}
};

View File

@@ -19,8 +19,8 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
@@ -64,12 +64,12 @@ pub fn init(frame: *Frame) !*Animation {
return self;
}
pub fn deinit(self: *Animation, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *Animation, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *Animation, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *Animation, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *Animation) void {
@@ -211,7 +211,7 @@ fn update(ctx: *anyopaque) !?u32 {
}
// No future change scheduled, set the object weak for garbage collection.
self.releaseRef(self._frame._session);
self.releaseRef(self._frame._page);
return null;
}

View File

@@ -18,11 +18,12 @@
const std = @import("std");
const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Blob = @import("../Blob.zig");
const OffscreenCanvasRenderingContext2D = @import("OffscreenCanvasRenderingContext2D.zig");
const Execution = js.Execution;
/// https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas
const OffscreenCanvas = @This();
@@ -37,8 +38,8 @@ const DrawingContext = union(enum) {
@"2d": *OffscreenCanvasRenderingContext2D,
};
pub fn constructor(width: u32, height: u32, frame: *Frame) !*OffscreenCanvas {
return frame._factory.create(OffscreenCanvas{
pub fn constructor(width: u32, height: u32, exec: *Execution) !*OffscreenCanvas {
return exec._factory.create(OffscreenCanvas{
._width = width,
._height = height,
});
@@ -60,9 +61,9 @@ pub fn setHeight(self: *OffscreenCanvas, value: u32) void {
self._height = value;
}
pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, frame: *Frame) !?DrawingContext {
pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, exec: *Execution) !?DrawingContext {
if (std.mem.eql(u8, context_type, "2d")) {
const ctx = try frame._factory.create(OffscreenCanvasRenderingContext2D{});
const ctx = try exec._factory.create(OffscreenCanvasRenderingContext2D{});
return .{ .@"2d" = ctx };
}
@@ -71,9 +72,9 @@ pub fn getContext(_: *OffscreenCanvas, context_type: []const u8, frame: *Frame)
/// Returns a Promise that resolves to a Blob containing the image.
/// Since we have no actual rendering, this returns an empty blob.
pub fn convertToBlob(_: *OffscreenCanvas, frame: *Frame) !js.Promise {
const blob = try Blob.init(null, null, frame._session);
return frame.js.local.?.resolvePromise(blob);
pub fn convertToBlob(_: *OffscreenCanvas, exec: *Execution) !js.Promise {
const blob = try Blob.init(null, null, exec.context.page);
return exec.context.local.?.resolvePromise(blob);
}
/// Returns an ImageBitmap with the rendered content (stub).

View File

@@ -19,9 +19,10 @@
const std = @import("std");
const js = @import("../../js/js.zig");
const Node = @import("../Node.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Node = @import("../Node.zig");
const GenericIterator = @import("iterator.zig").Entry;
// Optimized for node.childNodes, which has to be a live list.
@@ -55,8 +56,8 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
return self;
}
pub fn deinit(self: *const ChildNodes, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *const ChildNodes, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn length(self: *ChildNodes, frame: *Frame) !u32 {

View File

@@ -20,8 +20,9 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Node = @import("../Node.zig");
const ChildNodes = @import("ChildNodes.zig");
@@ -41,16 +42,16 @@ _data: union(enum) {
},
_rc: lp.RC(u32) = .{},
pub fn deinit(self: *NodeList, session: *Session) void {
pub fn deinit(self: *NodeList, page: *Page) void {
switch (self._data) {
.child_nodes => |cn| cn.deinit(session),
.selector_list => |list| list.deinit(session),
.child_nodes => |cn| cn.deinit(page),
.selector_list => |list| list.deinit(page),
else => {},
}
}
pub fn releaseRef(self: *NodeList, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *NodeList, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *NodeList) void {
@@ -115,12 +116,12 @@ const Iterator = struct {
const Entry = struct { u32, *Node };
pub fn deinit(self: *Iterator, session: *Session) void {
self.list.deinit(session);
pub fn deinit(self: *Iterator, page: *Page) void {
self.list.deinit(page);
}
pub fn releaseRef(self: *Iterator, session: *Session) void {
self.list.releaseRef(session);
pub fn releaseRef(self: *Iterator, page: *Page) void {
self.list.releaseRef(page);
}
pub fn acquireRef(self: *Iterator) void {

View File

@@ -18,9 +18,11 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Execution = js.Execution;
pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
@@ -48,15 +50,15 @@ pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
return self;
}
pub fn deinit(self: *Self, session: *Session) void {
pub fn deinit(self: *Self, page: *Page) void {
if (@hasDecl(Inner, "releaseRef")) {
self._inner.releaseRef(session);
self._inner.releaseRef(page);
}
session.factory.destroy(self);
page.factory.destroy(self);
}
pub fn releaseRef(self: *Self, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *Self, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *Self) void {

View File

@@ -79,7 +79,7 @@ pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule {
}
pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, frame: *Frame) !u32 {
const index = maybe_index orelse 0;
const requested_index = maybe_index orelse 0;
var it = Parser.parseStylesheet(rule);
const parsed_rule = it.next() orelse {
if (it.has_skipped_at_rule) {
@@ -88,7 +88,7 @@ pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, fra
// CSS parser. To prevent JS apps (like Expo/Reanimated) from crashing
// during initialization, we simulate a successful insertion by returning
// the requested index.
return index;
return requested_index;
}
return error.SyntaxError;
};
@@ -103,6 +103,16 @@ pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, fra
try style.setCssText(parsed_rule.block, frame);
const rules = try self.getCssRules(frame);
// Per spec, an index > rules.length should throw IndexSizeError. But because
// we don't process @import and @font-face, indexes that code hard-codes can
// be off. As a workaround, we clamp to the tail.
// See #2214 (and the sibling #1970 / #1972 tolerance for at-rules).
const length = rules.length();
const index = if (requested_index > length) length else requested_index;
if (index != requested_index) {
log.debug(.not_implemented, "insertRule clamped index", .{});
}
try rules.insert(index, style_rule._proto, frame);
// Notify StyleManager that rules have changed

View File

@@ -18,9 +18,10 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Allocator = std.mem.Allocator;
@@ -44,12 +45,12 @@ pub fn init(family: []const u8, source: []const u8, frame: *Frame) !*FontFace {
return self;
}
pub fn deinit(self: *FontFace, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *FontFace, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *FontFace, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *FontFace, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *FontFace) void {

View File

@@ -18,12 +18,15 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const FontFace = @import("FontFace.zig");
const EventTarget = @import("../EventTarget.zig");
const Event = @import("../Event.zig");
const EventTarget = @import("../EventTarget.zig");
const FontFace = @import("FontFace.zig");
const Allocator = std.mem.Allocator;
@@ -43,12 +46,12 @@ pub fn init(frame: *Frame) !*FontFaceSet {
});
}
pub fn deinit(self: *FontFaceSet, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *FontFaceSet, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *FontFaceSet, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *FontFaceSet, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *FontFaceSet) void {
@@ -80,13 +83,13 @@ pub fn load(self: *FontFaceSet, font: []const u8, frame: *Frame) !js.Promise {
// Dispatch loading event
const target = self.asEventTarget();
if (frame._event_manager.hasDirectListeners(target, "loading", null)) {
const event = try Event.initTrusted(comptime .wrap("loading"), .{}, frame);
const event = try Event.initTrusted(comptime .wrap("loading"), .{}, frame._page);
try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" });
}
// Dispatch loadingdone event
if (frame._event_manager.hasDirectListeners(target, "loadingdone", null)) {
const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, frame);
const event = try Event.initTrusted(comptime .wrap("loadingdone"), .{}, frame._page);
try frame._event_manager.dispatchDirect(target, event, null, .{ .context = "load font face set" });
}

View File

@@ -27,6 +27,8 @@ const CanvasRenderingContext2D = @import("../../canvas/CanvasRenderingContext2D.
const WebGLRenderingContext = @import("../../canvas/WebGLRenderingContext.zig");
const OffscreenCanvas = @import("../../canvas/OffscreenCanvas.zig");
const Execution = js.Execution;
const Canvas = @This();
_proto: *HtmlElement,
_cached: ?DrawingContext = null,
@@ -97,10 +99,10 @@ pub fn getContext(self: *Canvas, context_type: []const u8, frame: *Frame) !?Draw
/// Transfers control of the canvas to an OffscreenCanvas.
/// Returns an OffscreenCanvas with the same dimensions.
pub fn transferControlToOffscreen(self: *Canvas, frame: *Frame) !*OffscreenCanvas {
pub fn transferControlToOffscreen(self: *Canvas, exec: *Execution) !*OffscreenCanvas {
const width = self.getWidth();
const height = self.getHeight();
return OffscreenCanvas.constructor(width, height, frame);
return OffscreenCanvas.constructor(width, height, exec);
}
pub const JsApi = struct {

View File

@@ -102,7 +102,7 @@ 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);
const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
@@ -372,7 +372,7 @@ pub fn setAutocomplete(self: *Input, autocomplete: []const u8, frame: *Frame) !v
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);
const event = try Event.init("select", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}

View File

@@ -166,7 +166,7 @@ pub fn load(self: *Media, frame: *Frame) !void {
}
fn dispatchEvent(self: *Media, name: []const u8, frame: *Frame) !void {
const event = try Event.init(name, .{ .bubbles = false, .cancelable = false }, frame);
const event = try Event.init(name, .{ .bubbles = false, .cancelable = false }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}

View File

@@ -52,7 +52,7 @@ pub fn setOnSelectionChange(self: *TextArea, listener: ?js.Function) !void {
}
fn dispatchSelectionChangeEvent(self: *TextArea, frame: *Frame) !void {
const event = try Event.init("selectionchange", .{ .bubbles = true }, frame);
const event = try Event.init("selectionchange", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}
@@ -142,7 +142,7 @@ pub fn setRequired(self: *TextArea, required: bool, frame: *Frame) !void {
pub fn select(self: *TextArea, frame: *Frame) !void {
const len = if (self._value) |v| @as(u32, @intCast(v.len)) else 0;
try self.setSelectionRange(0, len, null, frame);
const event = try Event.init("select", .{ .bubbles = true }, frame);
const event = try Event.init("select", .{ .bubbles = true }, frame._page);
try frame._event_manager.dispatch(self.asElement().asEventTarget(), event);
}

View File

@@ -18,10 +18,12 @@
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const html5ever = @import("../../parser/html5ever.zig");
const Session = @import("../../Session.zig");
const Allocator = std.mem.Allocator;
const TextDecoder = @This();
@@ -41,7 +43,7 @@ const InitOpts = struct {
ignoreBOM: bool = false,
};
pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDecoder {
pub fn init(label_: ?[]const u8, opts_: ?InitOpts, page: *Page) !*TextDecoder {
const label = label_ orelse "utf-8";
const info = html5ever.encoding_for_label(label.ptr, label.len);
@@ -55,8 +57,8 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDeco
return error.RangeError;
}
const arena = try session.getArena(.large, "TextDecoder");
errdefer session.releaseArena(arena);
const arena = try page.getArena(.large, "TextDecoder");
errdefer page.releaseArena(arena);
const opts = opts_ orelse InitOpts{};
const self = try arena.create(TextDecoder);
@@ -73,15 +75,15 @@ pub fn init(label_: ?[]const u8, opts_: ?InitOpts, session: *Session) !*TextDeco
return self;
}
pub fn deinit(self: *TextDecoder, session: *Session) void {
pub fn deinit(self: *TextDecoder, page: *Page) void {
if (self._decoder) |decoder| {
html5ever.encoding_decoder_free(decoder);
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *TextDecoder, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *TextDecoder, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *TextDecoder) void {

View File

@@ -20,8 +20,9 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Event = @import("../Event.zig");
const String = lp.String;
@@ -39,13 +40,13 @@ const CustomEventOptions = struct {
const Options = Event.inheritOptions(CustomEvent, CustomEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, frame: *Frame) !*CustomEvent {
const arena = try frame.getArena(.tiny, "CustomEvent");
errdefer frame.releaseArena(arena);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*CustomEvent {
const arena = try page.getArena(.tiny, "CustomEvent");
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
const opts = opts_ orelse Options{};
const event = try frame._factory.event(
const event = try page.factory.event(
arena,
type_string,
CustomEvent{
@@ -75,21 +76,21 @@ pub fn initCustomEvent(
self._detail = detail_;
}
pub fn deinit(self: *CustomEvent, session: *Session) void {
pub fn deinit(self: *CustomEvent, page: *Page) void {
if (self._detail) |d| {
d.release();
}
self._proto.deinit(session);
self._proto.deinit(page);
}
pub fn releaseRef(self: *CustomEvent, page: *Page) void {
self._proto._rc.release(self, page);
}
pub fn acquireRef(self: *CustomEvent) void {
self._proto.acquireRef();
}
pub fn releaseRef(self: *CustomEvent, session: *Session) void {
self._proto._rc.release(self, session);
}
pub fn asEvent(self: *CustomEvent) *Event {
return self._proto;
}

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Session = @import("../../Session.zig");
const Page = @import("../../Page.zig");
const Event = @import("../Event.zig");
@@ -47,23 +47,23 @@ pub const ErrorEventOptions = struct {
const Options = Event.inheritOptions(ErrorEvent, ErrorEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*ErrorEvent {
const arena = try session.getArena(.small, "ErrorEvent");
errdefer session.releaseArena(arena);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*ErrorEvent {
const arena = try page.getArena(.small, "ErrorEvent");
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, opts_, false, session);
return initWithTrusted(arena, type_string, opts_, false, page);
}
pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*ErrorEvent {
const arena = try session.getArena(.small, "ErrorEvent.trusted");
errdefer session.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true, session);
pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*ErrorEvent {
const arena = try page.getArena(.small, "ErrorEvent.trusted");
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, session: *Session) !*ErrorEvent {
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*ErrorEvent {
const opts = opts_ orelse Options{};
const event = try session.factory.event(
const event = try page.factory.event(
arena,
typ,
ErrorEvent{
@@ -81,21 +81,21 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
return event;
}
pub fn deinit(self: *ErrorEvent, session: *Session) void {
pub fn deinit(self: *ErrorEvent, page: *Page) void {
if (self._error) |e| {
e.release();
}
self._proto.deinit(session);
self._proto.deinit(page);
}
pub fn releaseRef(self: *ErrorEvent, page: *Page) void {
self._proto._rc.release(self, page);
}
pub fn acquireRef(self: *ErrorEvent) void {
self._proto.acquireRef();
}
pub fn releaseRef(self: *ErrorEvent, session: *Session) void {
self._proto._rc.release(self, session);
}
pub fn asEvent(self: *ErrorEvent) *Event {
return self._proto;
}

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Session = @import("../../Session.zig");
const Page = @import("../../Page.zig");
const Event = @import("../Event.zig");
const Window = @import("../Window.zig");
@@ -50,23 +50,23 @@ pub const Data = union(enum) {
const Options = Event.inheritOptions(MessageEvent, MessageEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*MessageEvent {
const arena = try session.getArena(.small, "MessageEvent");
errdefer session.releaseArena(arena);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*MessageEvent {
const arena = try page.getArena(.small, "MessageEvent");
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, opts_, false, session);
return initWithTrusted(arena, type_string, opts_, false, page);
}
pub fn initTrusted(typ: String, opts_: ?Options, session: *Session) !*MessageEvent {
const arena = try session.getArena(.small, "MessageEvent.trusted");
errdefer session.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true, session);
pub fn initTrusted(typ: String, opts_: ?Options, page: *Page) !*MessageEvent {
const arena = try page.getArena(.small, "MessageEvent.trusted");
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, opts_, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, session: *Session) !*MessageEvent {
fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool, page: *Page) !*MessageEvent {
const opts = opts_ orelse Options{};
const event = try session.factory.event(
const event = try page.factory.event(
arena,
typ,
MessageEvent{
@@ -81,23 +81,23 @@ fn initWithTrusted(arena: Allocator, typ: String, opts_: ?Options, trusted: bool
return event;
}
pub fn deinit(self: *MessageEvent, session: *Session) void {
pub fn deinit(self: *MessageEvent, page: *Page) void {
if (self._data) |d| {
switch (d) {
.value => |js_val| js_val.release(),
.blob => |blob| blob.releaseRef(session),
.blob => |blob| blob.releaseRef(page),
.string, .arraybuffer => {},
}
}
self._proto.deinit(session);
self._proto.deinit(page);
}
pub fn acquireRef(self: *MessageEvent) void {
self._proto.acquireRef();
}
pub fn releaseRef(self: *MessageEvent, session: *Session) void {
self._proto._rc.release(self, session);
pub fn releaseRef(self: *MessageEvent, page: *Page) void {
self._proto._rc.release(self, page);
}
pub fn asEvent(self: *MessageEvent) *Event {

View File

@@ -19,7 +19,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Session = @import("../../Session.zig");
const Page = @import("../../Page.zig");
const Event = @import("../Event.zig");
@@ -38,13 +38,13 @@ const PromiseRejectionEventOptions = struct {
const Options = Event.inheritOptions(PromiseRejectionEvent, PromiseRejectionEventOptions);
pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*PromiseRejectionEvent {
const arena = try session.getArena(.tiny, "PromiseRejectionEvent");
errdefer session.releaseArena(arena);
pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*PromiseRejectionEvent {
const arena = try page.getArena(.tiny, "PromiseRejectionEvent");
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
const opts = opts_ orelse Options{};
const event = try session.factory.event(
const event = try page.factory.event(
arena,
type_string,
PromiseRejectionEvent{
@@ -58,24 +58,24 @@ pub fn init(typ: []const u8, opts_: ?Options, session: *Session) !*PromiseReject
return event;
}
pub fn deinit(self: *PromiseRejectionEvent, session: *Session) void {
pub fn deinit(self: *PromiseRejectionEvent, page: *Page) void {
if (self._reason) |r| {
r.release();
}
if (self._promise) |p| {
p.release();
}
self._proto.deinit(session);
self._proto.deinit(page);
}
pub fn releaseRef(self: *PromiseRejectionEvent, page: *Page) void {
self._proto._rc.release(self, page);
}
pub fn acquireRef(self: *PromiseRejectionEvent) void {
self._proto.acquireRef();
}
pub fn releaseRef(self: *PromiseRejectionEvent, session: *Session) void {
self._proto._rc.release(self, session);
}
pub fn asEvent(self: *PromiseRejectionEvent) *Event {
return self._proto;
}

View File

@@ -62,7 +62,7 @@ pub fn init(input: Input, options: ?InitOpts, frame: *Frame) !js.Promise {
}
const response = try Response.init(null, .{ .status = 0 }, &frame.js.execution);
errdefer response.deinit(frame._session);
errdefer response.deinit(frame._page);
const fetch = try response._arena.create(Fetch);
fetch.* = .{
@@ -248,7 +248,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
// clear this. (defer since `self is in the response's arena).
defer if (self._owns_response) {
response.deinit(self._frame._session);
response.deinit(self._frame._page);
};
var ls: js.Local.Scope = undefined;
@@ -269,7 +269,7 @@ fn httpShutdownCallback(ctx: *anyopaque) void {
if (self._owns_response) {
var response = self._response;
response._http_response = null;
response.deinit(self._frame._session);
response.deinit(self._frame._page);
// Do not access `self` after this point: the Fetch struct was
// allocated from response._arena which has been released.
}

View File

@@ -175,7 +175,7 @@ pub fn blob(self: *Request, exec: *const Execution) !js.Promise {
const headers = try self.getHeaders(exec);
const content_type = try headers.get("content-type", exec) orelse "";
const b = try Blob.initFromBytes(body, content_type, true, exec.context.session);
const b = try Blob.initFromBytes(body, content_type, true, exec.context.page);
return exec.context.local.?.resolvePromise(b);
}

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Session = @import("../../Session.zig");
const Page = @import("../../Page.zig");
const HttpClient = @import("../../HttpClient.zig");
const Blob = @import("../Blob.zig");
@@ -72,7 +72,7 @@ pub const BodyInit = union(enum) {
};
pub fn init(body_: ?BodyInit, opts_: ?InitOpts, exec: *const Execution) !*Response {
const session = exec.context.session;
const session = exec.context.page.session;
const arena = try session.getArena(.large, "Response");
errdefer session.releaseArena(arena);
@@ -109,16 +109,16 @@ pub fn init(body_: ?BodyInit, opts_: ?InitOpts, exec: *const Execution) !*Respon
return self;
}
pub fn deinit(self: *Response, session: *Session) void {
pub fn deinit(self: *Response, page: *Page) void {
if (self._http_response) |resp| {
resp.abort(error.Abort);
self._http_response = null;
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *Response, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *Response, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *Response) void {
@@ -321,7 +321,7 @@ pub fn blob(self: *const Response, exec: *const Execution) !js.Promise {
.stream => return local.rejectPromise(.{ .type_error = "Cannot read blob from stream body" }),
};
const content_type = try self._headers.get("content-type", exec) orelse "";
const b = try Blob.initFromBytes(body, content_type, true, exec.context.session);
const b = try Blob.initFromBytes(body, content_type, true, exec.context.page);
return local.resolvePromise(b);
}
@@ -336,7 +336,7 @@ pub fn bytes(self: *const Response, exec: *const Execution) !js.Promise {
}
pub fn clone(self: *const Response, exec: *const Execution) !*Response {
const session = exec.context.session;
const session = exec.context.page.session;
const body_len = switch (self._body) {
.bytes => |b| b.len,
.empty => 0,

View File

@@ -24,14 +24,15 @@ const http = @import("../../../network/http.zig");
const js = @import("../../js/js.zig");
const Blob = @import("../Blob.zig");
const URL = @import("../../URL.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const HttpClient = @import("../../HttpClient.zig");
const Event = @import("../Event.zig");
const EventTarget = @import("../EventTarget.zig");
const MessageEvent = @import("../event/MessageEvent.zig");
const CloseEvent = @import("../event/CloseEvent.zig");
const MessageEvent = @import("../event/MessageEvent.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
@@ -160,7 +161,7 @@ pub fn init(url: []const u8, protocols: [][]const u8, frame: *Frame) !*WebSocket
return self;
}
pub fn deinit(self: *WebSocket, session: *Session) void {
pub fn deinit(self: *WebSocket, page: *Page) void {
self.cleanup();
if (self._on_open) |func| {
@@ -177,14 +178,14 @@ pub fn deinit(self: *WebSocket, session: *Session) void {
}
for (self._send_queue.items) |msg| {
msg.deinit(session);
msg.deinit(page);
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *WebSocket, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *WebSocket, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *WebSocket) void {
@@ -235,7 +236,7 @@ fn cleanup(self: *WebSocket) void {
self._http_client.removeConn(conn);
self._req_headers.deinit();
self._conn = null;
self.releaseRef(self._frame._session);
self.releaseRef(self._frame._page);
self._send_queue.clearRetainingCapacity();
}
}
@@ -303,8 +304,8 @@ pub fn send(self: *WebSocket, data: SendData) !void {
switch (data) {
.blob => |blob| {
const arena = try self._frame._session.getArena(blob._slice.len, "WebSocket.message");
errdefer self._frame._session.releaseArena(arena);
const arena = try self._frame.getArena(blob._slice.len, "WebSocket.message");
errdefer self._frame.releaseArena(arena);
try self.queueMessage(.{ .binary = .{
.arena = arena,
.data = try arena.dupe(u8, blob._slice),
@@ -312,8 +313,8 @@ pub fn send(self: *WebSocket, data: SendData) !void {
},
.js_val => |js_val| {
if (js_val.isString()) |str| {
const arena = try self._frame._session.getArena(str.len(), "WebSocket.message");
errdefer self._frame._session.releaseArena(arena);
const arena = try self._frame.getArena(str.len(), "WebSocket.message");
errdefer self._frame.releaseArena(arena);
try self.queueMessage(.{ .text = .{
.arena = arena,
.data = try str.toSliceWithAlloc(arena),
@@ -322,8 +323,8 @@ pub fn send(self: *WebSocket, data: SendData) !void {
const binary = try js_val.toZig(BinaryData);
const buffer = binary.asBuffer();
const arena = try self._frame._session.getArena(buffer.len, "WebSocket.message");
errdefer self._frame._session.releaseArena(arena);
const arena = try self._frame.getArena(buffer.len, "WebSocket.message");
errdefer self._frame.releaseArena(arena);
try self.queueMessage(.{ .binary = .{
.arena = arena,
.data = try arena.dupe(u8, buffer),
@@ -452,7 +453,7 @@ fn dispatchOpenEvent(self: *WebSocket) !void {
const target = self.asEventTarget();
if (frame._event_manager.hasDirectListeners(target, "open", self._on_open)) {
const event = try Event.initTrusted(comptime .wrap("open"), .{}, frame);
const event = try Event.initTrusted(comptime .wrap("open"), .{}, frame._page);
try frame._event_manager.dispatchDirect(target, event, self._on_open, .{ .context = "WebSocket open" });
}
}
@@ -466,7 +467,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF
switch (self._binary_type) {
.arraybuffer => .{ .arraybuffer = .{ .values = data } },
.blob => blk: {
const blob = try Blob.initFromBytes(data, "", false, frame._session);
const blob = try Blob.initFromBytes(data, "", false, frame._page);
blob.acquireRef();
break :blk .{ .blob = blob };
},
@@ -477,7 +478,7 @@ fn dispatchMessageEvent(self: *WebSocket, data: []const u8, frame_type: http.WsF
const event = try MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = msg_data,
.origin = "",
}, frame._session);
}, frame._page);
try frame._event_manager.dispatchDirect(target, event.asEvent(), self._on_message, .{ .context = "WebSocket message" });
}
}
@@ -487,7 +488,7 @@ fn dispatchErrorEvent(self: *WebSocket) !void {
const target = self.asEventTarget();
if (frame._event_manager.hasDirectListeners(target, "error", self._on_error)) {
const event = try Event.initTrusted(comptime .wrap("error"), .{}, frame);
const event = try Event.initTrusted(comptime .wrap("error"), .{}, frame._page);
try frame._event_manager.dispatchDirect(target, event, self._on_error, .{ .context = "WebSocket error" });
}
}
@@ -575,7 +576,7 @@ fn writeContent(self: *WebSocket, conn: *http.Connection, buf: []u8, byte_msg: M
if (self._send_offset >= byte_msg.data.len) {
const removed = self._send_queue.orderedRemove(0);
removed.deinit(self._frame._session);
removed.deinit(self._frame._page);
if (comptime IS_DEBUG) {
log.debug(.websocket, "send complete", .{ .url = self._url, .len = byte_msg.data.len, .queue = self._send_queue.items.len });
}
@@ -718,9 +719,9 @@ const Message = union(enum) {
arena: Allocator,
data: []const u8,
};
fn deinit(self: Message, session: *Session) void {
fn deinit(self: Message, page: *Page) void {
switch (self) {
.text, .binary => |msg| session.releaseArena(msg.arena),
.text, .binary => |msg| page.releaseArena(msg.arena),
.close => {},
}
}

View File

@@ -25,8 +25,8 @@ const http = @import("../../../network/http.zig");
const URL = @import("../../URL.zig");
const Mime = @import("../../Mime.zig");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Node = @import("../Node.zig");
const Event = @import("../Event.zig");
@@ -100,7 +100,7 @@ pub fn init(frame: *Frame) !*XMLHttpRequest {
return self;
}
pub fn deinit(self: *XMLHttpRequest, session: *Session) void {
pub fn deinit(self: *XMLHttpRequest, page: *Page) void {
if (self._http_response) |resp| {
resp.abort(error.Abort);
self._http_response = null;
@@ -135,19 +135,19 @@ pub fn deinit(self: *XMLHttpRequest, session: *Session) void {
}
}
session.releaseArena(self._arena);
page.releaseArena(self._arena);
}
fn releaseSelfRef(self: *XMLHttpRequest) void {
if (self._active_request == false) {
return;
}
self.releaseRef(self._frame._session);
self.releaseRef(self._frame._page);
self._active_request = false;
}
pub fn releaseRef(self: *XMLHttpRequest, session: *Session) void {
self._rc.release(self, session);
pub fn releaseRef(self: *XMLHttpRequest, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *XMLHttpRequest) void {
@@ -588,7 +588,7 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, frame: *Frame) !void {
const target = self.asEventTarget();
if (frame._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) {
const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame);
const event = try Event.initTrusted(.wrap("readystatechange"), .{}, frame._page);
try frame._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" });
}
}

View File

@@ -18,8 +18,8 @@
const std = @import("std");
const Page = @import("../../Page.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Node = @import("../Node.zig");
const Part = @import("Selector.zig").Part;
@@ -41,8 +41,8 @@ pub const EntryIterator = GenericIterator(Iterator, null);
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
pub fn deinit(self: *const List, session: *Session) void {
session.releaseArena(self._arena);
pub fn deinit(self: *const List, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn collect(

View File

@@ -1366,7 +1366,7 @@ test "AXNode: writer" {
defer registry.deinit();
var frame = try testing.pageTest("cdp/dom3.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var doc = frame.window._document;
const node = try registry.register(doc.asNode());
@@ -1440,7 +1440,7 @@ test "AXNode: writer prunes hidden and resolves labels" {
defer registry.deinit();
var frame = try testing.pageTest("cdp/ax_tree.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var doc = frame.window._document;
const node = try registry.register(doc.asNode());

View File

@@ -223,6 +223,7 @@ fn dispatchCommand(command: *Command, method: []const u8) !void {
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
asUint(u40, "Audit") => return @import("domains/audit.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
@@ -836,7 +837,7 @@ const IsolatedWorld = struct {
// The isolate world must share at least some of the state with the related frame, specifically the DocumentHTML
// (assuming grantUniversalAccess will be set to True!).
// We just created the world and the frame. The frame's state lives in the session, but is update on navigation.
// This also means this pointer becomes invalid after removeFrame until a new frame is created.
// This also means this pointer becomes invalid after removePage until a new frame is created.
// Currently we have only 1 frame and thus also only 1 state in the isolate world.
pub fn createContext(self: *IsolatedWorld, frame: *Frame) !*js.Context {
if (self.context == null) {

View File

@@ -346,7 +346,7 @@ test "cdp Node: Registry register" {
try testing.expectEqual(0, registry.lookup_by_node.count());
var frame = try testing.pageTest("cdp/registry1.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var doc = frame.window._document;
{
@@ -403,12 +403,12 @@ test "cdp Node: search list" {
{
var frame = try testing.pageTest("cdp/registry2.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var doc = frame.window._document;
{
const l1 = try doc.querySelectorAll(.wrap("a"), frame);
defer l1.deinit(frame._session);
defer l1.deinit(frame._page);
const s1 = try search_list.create(l1._nodes);
try testing.expectEqual("1", s1.name);
try testing.expectEqualSlices(u32, &.{ 1, 2 }, s1.node_ids);
@@ -419,7 +419,7 @@ test "cdp Node: search list" {
{
const l2 = try doc.querySelectorAll(.wrap("#a1"), frame);
defer l2.deinit(frame._session);
defer l2.deinit(frame._page);
const s2 = try search_list.create(l2._nodes);
try testing.expectEqual("2", s2.name);
try testing.expectEqualSlices(u32, &.{1}, s2.node_ids);
@@ -427,7 +427,7 @@ test "cdp Node: search list" {
{
const l3 = try doc.querySelectorAll(.wrap("#a2"), frame);
defer l3.deinit(frame._session);
defer l3.deinit(frame._page);
const s3 = try search_list.create(l3._nodes);
try testing.expectEqual("3", s3.name);
try testing.expectEqualSlices(u32, &.{2}, s3.node_ids);
@@ -443,7 +443,7 @@ test "cdp Node: Writer" {
defer registry.deinit();
var frame = try testing.pageTest("cdp/registry3.html", .{});
defer frame._session.removeFrame();
defer frame._session.removePage();
var doc = frame.window._document;
{

39
src/cdp/domains/audit.zig Normal file
View File

@@ -0,0 +1,39 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const CDP = @import("../CDP.zig");
pub fn processMessage(cmd: *CDP.Command) !void {
const action = std.meta.stringToEnum(enum {
enable,
disable,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return enable(cmd),
.disable => return disable(cmd),
}
}
fn enable(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}
fn disable(cmd: *CDP.Command) !void {
return cmd.sendResult(null, .{});
}

View File

@@ -101,7 +101,7 @@ fn performSearch(cmd: *CDP.Command) !void {
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
const frame = bc.session.currentFrame() orelse return error.FrameNotLoaded;
const list = try Selector.querySelectorAll(frame.window._document.asNode(), params.query, frame);
defer list.deinit(frame._session);
defer list.deinit(frame._page);
const search = try bc.node_search_list.create(list._nodes);
@@ -252,7 +252,7 @@ fn querySelectorAll(cmd: *CDP.Command) !void {
};
const selected_nodes = try Selector.querySelectorAll(node.dom, params.selector, frame);
defer selected_nodes.deinit(frame._session);
defer selected_nodes.deinit(frame._page);
const nodes = selected_nodes._nodes;

View File

@@ -299,7 +299,7 @@ test "cdp.lp: getMarkdown" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
_ = try bc.session.createFrame();
_ = try bc.session.createPage();
try ctx.processMessage(.{
.id = 1,
@@ -315,7 +315,7 @@ test "cdp.lp: getInteractiveElements" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
_ = try bc.session.createFrame();
_ = try bc.session.createPage();
try ctx.processMessage(.{
.id = 1,
@@ -331,7 +331,7 @@ test "cdp.lp: getStructuredData" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
_ = try bc.session.createFrame();
_ = try bc.session.createPage();
try ctx.processMessage(.{
.id = 1,
@@ -347,7 +347,7 @@ test "cdp.lp: action tools" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const frame = try bc.session.createFrame();
const frame = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
var runner = try bc.session.runner(.{});
@@ -408,7 +408,7 @@ test "cdp.lp: waitForSelector" {
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const frame = try bc.session.createFrame();
const frame = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_wait_for_selector.html";
try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
var runner = try bc.session.runner(.{});

View File

@@ -187,7 +187,7 @@ fn getCookies(cmd: *CDP.Command) !void {
const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{};
// If not specified, use the URLs of the page and all of its subframes. TODO subframes
const frame_url = if (bc.session.frame) |frame| frame.url else null;
const frame_url = if (bc.session.currentFrame()) |frame| frame.url else null;
const param_urls = params.urls orelse &[_][:0]const u8{frame_url orelse return error.InvalidParams};
var urls = try std.ArrayList(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len);
@@ -239,7 +239,7 @@ pub fn httpRequestFail(bc: *CDP.BrowserContext, msg: *const Notification.Request
// Isn't possible to do a network request within a Browser (which our
// notification is tied to), without a frame.
lp.assert(bc.session.frame != null, "CDP.network.httpRequestFail null frame", .{});
lp.assert(bc.session.page != null, "CDP.network.httpRequestFail null frame", .{});
// We're missing a bunch of fields, but, for now, this seems like enough
try bc.cdp.sendEvent("Network.loadingFailed", .{

View File

@@ -212,7 +212,7 @@ fn close(cmd: *CDP.Command) !void {
const target_id = bc.target_id orelse return error.TargetNotLoaded;
// can't be null if we have a target_id
lp.assert(bc.session.frame != null, "CDP.frame.close null frame", .{});
lp.assert(bc.session.page != null, "CDP.frame.close null frame", .{});
try cmd.sendResult(.{}, .{});
@@ -235,7 +235,7 @@ fn close(cmd: *CDP.Command) !void {
bc.session_id = null;
}
bc.session.removeFrame();
bc.session.removePage();
for (bc.isolated_worlds.items) |world| {
world.deinit();
}
@@ -307,7 +307,7 @@ fn navigate(cmd: *CDP.Command) !void {
isolated_world.identity.deinit();
isolated_world.identity = .{};
}
frame = try session.replaceFrame();
frame = try session.replacePage();
}
const encoded_url = try URL.ensureEncoded(frame.call_arena, params.url, "UTF-8");
@@ -333,7 +333,7 @@ fn doReload(cmd: *CDP.Command) !void {
const session = bc.session;
var frame = session.currentFrame() orelse return error.FrameNotLoaded;
// Dupe URL before replaceFrame() frees the old frame's arena.
// Dupe URL before replacePage() frees the old frame's arena.
const reload_url = try cmd.arena.dupeZ(u8, frame.url);
if (frame._load_state != .waiting) {
@@ -343,7 +343,7 @@ fn doReload(cmd: *CDP.Command) !void {
isolated_world.identity.deinit();
isolated_world.identity = .{};
}
frame = try session.replaceFrame();
frame = try session.replacePage();
}
try frame.navigate(reload_url, .{

View File

@@ -171,12 +171,12 @@ fn createTarget(cmd: *CDP.Command) !void {
}
// if target_id is null, we should never have a blank frame
lp.assert(bc.session.frame == null, "CDP.target.createTarget not null page", .{});
lp.assert(bc.session.page == null, "CDP.target.createTarget not null page", .{});
// if target_id is null, we should never have a session_id
lp.assert(bc.session_id == null, "CDP.target.createTarget not null session_id", .{});
const frame = try bc.session.createFrame();
const frame = try bc.session.createPage();
// the target_id == the frame_id of the "root" frame
const frame_id = id.toFrameId(frame._frame_id);
@@ -284,7 +284,7 @@ fn closeTarget(cmd: *CDP.Command) !void {
}
// can't be null if we have a target_id
lp.assert(bc.session.frame != null, "CDP.target.closeTarget null frame", .{});
lp.assert(bc.session.page != null, "CDP.target.closeTarget null frame", .{});
try cmd.sendResult(.{ .success = true }, .{ .include_session_id = false });
@@ -305,7 +305,7 @@ fn closeTarget(cmd: *CDP.Command) !void {
bc.session_id = null;
}
bc.session.removeFrame();
bc.session.removePage();
for (bc.isolated_worlds.items) |world| {
world.deinit();
}
@@ -626,7 +626,7 @@ test "cdp.target: closeTarget" {
}
// pretend we createdTarget first
_ = try bc.session.createFrame();
_ = try bc.session.createPage();
bc.target_id = "TID-000000000A".*;
{
try ctx.processMessage(.{ .id = 10, .method = "Target.closeTarget", .params = .{ .targetId = "TID-8" } });
@@ -636,7 +636,7 @@ test "cdp.target: closeTarget" {
{
try ctx.processMessage(.{ .id = 11, .method = "Target.closeTarget", .params = .{ .targetId = "TID-000000000A" } });
try ctx.expectSentResult(.{ .success = true }, .{ .id = 11 });
try testing.expectEqual(null, bc.session.frame);
try testing.expectEqual(null, bc.session.page);
try testing.expectEqual(null, bc.target_id);
}
}
@@ -657,7 +657,7 @@ test "cdp.target: attachToTarget" {
}
// pretend we createdTarget first
_ = try bc.session.createFrame();
_ = try bc.session.createPage();
bc.target_id = "TID-000000000B".*;
{
try ctx.processMessage(.{ .id = 10, .method = "Target.attachToTarget", .params = .{ .targetId = "TID-8" } });
@@ -701,7 +701,7 @@ test "cdp.target: getTargetInfo" {
}
// pretend we createdTarget first
_ = try bc.session.createFrame();
_ = try bc.session.createPage();
bc.target_id = "TID-000000000C".*;
{
try ctx.processMessage(.{ .id = 10, .method = "Target.getTargetInfo", .params = .{ .targetId = "TID-8" } });

View File

@@ -93,7 +93,7 @@ const TestContext = struct {
if (bc.target_id == null) {
bc.target_id = "TID-000000000Z".*;
}
const frame = try bc.session.createFrame();
const frame = try bc.session.createPage();
const full_url = try std.fmt.allocPrintSentinel(
base.arena_allocator,
"http://127.0.0.1:9582/src/browser/tests/{s}",
@@ -204,7 +204,7 @@ const TestContext = struct {
if (self.cdp_) |*cdp__| {
if (cdp__.browser_context) |*bc| {
if (bc.session.frame != null) {
if (bc.session.page != null) {
var runner = try bc.session.runner(.{});
_ = try runner.tick(.{ .ms = 1000 });
}

592
src/cli.zig Normal file
View File

@@ -0,0 +1,592 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Allocator = std.mem.Allocator;
const lp = @import("lightpanda");
const log = lp.log;
/// Comptime CLI builder that generates a tagged union parser from a
/// declarative command recipe. Each command becomes a union variant whose
/// payload is a struct with one field per option.
///
/// ## Command descriptor fields
///
/// - `name: []const u8` — canonical command name on the command line.
/// - `options: tuple` — tuple of option descriptors (see below). Use `.{}`
/// for none.
/// - `shared_options: tuple` (optional) — extra options merged into this
/// command. Useful for common flags shared across commands.
/// - `positional: struct` (optional) — a single positional argument with
/// `.name` and `.type`. Type must be an optional pointer-to-u8 slice
/// (e.g. `?[:0]const u8`). Positionals can appear anywhere in argv and
/// must be provided; a missing positional returns `error.MissingArgument`.
///
/// ## Option descriptor fields
///
/// - `name: []const u8` — snake_case field name. Both `--snake_case` and
/// `--kebab-case` are accepted on the command line.
/// - `type` — the Zig type of the parsed value (see supported types below).
/// - `default` (optional) — compile-time default when the flag is absent.
/// Rules vary by type; see the defaults section below.
/// - `multiple: bool` (optional) — when `true`, the field becomes a
/// `std.ArrayList(type)` and each occurrence appends. Not supported for
/// `bool` or packed-struct options.
/// - `validator: fn` (optional) — custom parse function that replaces the
/// built-in type switch. See the validator section below.
///
/// ## Supported types and their defaults
///
/// - `bool` — presence flips the field to the opposite of its `default`
/// (so a flag with `default = true` acts as a disable switch). Defaults
/// to `false` when no `default` is given. `?bool` is not allowed.
/// - Integers (`u8`, `u16`, `u31`, `usize`, etc.) — parsed with
/// `std.fmt.parseInt`. Requires `default` unless wrapped in `?`.
/// - `[]const u8`, `[:0]const u8` (and mutable variants) — string slices
/// duped from argv. Sentinel is preserved. Requires `default` unless `?`.
/// - Enums — parsed via `std.meta.stringToEnum`. Returns
/// `error.UnknownArgument` on a bad value. Requires `default` unless `?`.
/// - Packed structs of `bool` fields — parsed from a comma-separated list
/// (e.g. `--strip-mode js,css`). The literal `"full"` sets every field.
/// Unknown names return `error.UnknownArgument`. Requires `default`.
/// `multiple` is not supported.
/// - Optional types default to `null` when `default` is omitted.
///
/// ## Validators
///
/// A `validator` is a custom parse function that takes over argument
/// consumption for an option. Its signature depends on whether `multiple`
/// is set:
///
/// - Single: `fn (Allocator, *ArgIterator) !T` — returns the parsed value.
/// - Multiple: `fn (Allocator, *ArgIterator, *std.ArrayList(T)) !void` —
/// appends directly into the list.
///
/// When a validator is present, the built-in type switch is skipped entirely.
/// The validator owns advancing the iterator and is free to peek ahead.
///
/// ## Example
///
/// ```zig
/// const StripMode = packed struct(u2) {
/// js: bool = false,
/// css: bool = false,
/// };
///
/// const WaitUntil = enum { load, domcontentloaded, networkidle };
///
/// const CommonOptions = .{
/// .{ .name = "verbose", .type = bool },
/// .{ .name = "log_level", .type = ?log.Level },
/// .{ .name = "timeout", .type = u31, .default = 30 },
/// };
///
/// const Cli = cli.Builder(.{
/// .{
/// .name = "serve",
/// .options = .{
/// .{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
/// .{ .name = "port", .type = u16, .default = 9222 },
/// },
/// .shared_options = CommonOptions,
/// },
/// .{
/// .name = "fetch",
/// .positional = .{ .name = "url", .type = ?[:0]const u8 },
/// .options = .{
/// .{ .name = "dump", .type = ?DumpFormat, .validator = dumpValidator },
/// .{ .name = "strip_mode", .type = StripMode, .default = .{} },
/// .{ .name = "wait_until", .type = ?WaitUntil },
/// .{ .name = "extra_header", .type = []const u8, .multiple = true },
/// },
/// .shared_options = CommonOptions,
/// },
/// .{ .name = "version", .options = .{} },
/// .{ .name = "help", .options = .{} },
/// });
///
/// const _, const cmd = try Cli.parse(arena);
/// switch (cmd) {
/// .serve => |opts| listen(opts.host, opts.port),
/// .fetch => |opts| fetch(opts.url.?, opts.dump),
/// .version => printVersion(),
/// .help => printHelp(),
/// }
/// ```
pub fn Builder(comptime commands: anytype) type {
return struct {
const Self = @This();
/// Enum type for provided commands.
pub const Enum = blk: {
var enum_fields: [commands.len]std.builtin.Type.EnumField = undefined;
for (commands, 0..) |command, i| {
enum_fields[i] = .{ .name = command.name, .value = i };
}
break :blk @Type(.{
.@"enum" = .{
.decls = &.{},
.fields = &enum_fields,
.is_exhaustive = true,
.tag_type = std.math.IntFittingRange(0, commands.len),
},
});
};
/// Creates an array of `StructField` out of given options.
fn optionsToStructFields(comptime options: anytype) [options.len]std.builtin.Type.StructField {
var fields: [options.len]std.builtin.Type.StructField = undefined;
inline for (options, 0..) |option, j| {
// Whether prefer `ArrayList` for the option.
const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple;
// Whether option has a default value.
const has_default = @hasField(@TypeOf(option), "default");
const T = if (is_multiple) std.ArrayList(option.type) else option.type;
const default = blk: {
if (is_multiple) {
// We currently don't allow default values for lists.
if (has_default) {
@compileError("`default` is not allowed for lists");
}
// Multiples are always initialized the same.
break :blk @as(*const anyopaque, @ptrCast(&@as(T, .{})));
}
switch (@typeInfo(option.type)) {
.optional => |optional| {
if (optional.child == bool) {
@compileError("?bool is not supported, prefer enum");
}
// If type is an optional type without default value, prefer null.
if (!has_default) {
break :blk @as(*const anyopaque, @ptrCast(&@as(T, null)));
}
// We have default value for an optional.
break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default)));
},
.bool => {
// Prefer `false` if no default.
const default = if (has_default) option.default else false;
break :blk @as(*const anyopaque, @ptrCast(&@as(T, default)));
},
inline else => {
if (!has_default) {
@compileError("option `" ++ option.name ++ "` is not optional type and has no default value");
}
break :blk @as(*const anyopaque, @ptrCast(&@as(T, option.default)));
},
}
};
fields[j] = .{
.name = option.name,
.type = T,
.default_value_ptr = default,
.is_comptime = false,
.alignment = @alignOf(T),
};
}
return fields;
}
/// Union type for provided commands.
pub const Union = blk: {
var union_fields: [commands.len]std.builtin.Type.UnionField = undefined;
for (commands, 0..) |command, i| {
const Command = @TypeOf(command);
const options = command.options;
const fields = optionsToStructFields(options) ++
(if (@hasField(Command, "shared_options"))
optionsToStructFields(command.shared_options)
else
.{}) ++
(if (@hasField(Command, "positional"))
[1]std.builtin.Type.StructField{
.{
.name = command.positional.name,
.type = command.positional.type,
.default_value_ptr = @ptrCast(&@as(command.positional.type, null)),
.is_comptime = false,
.alignment = @alignOf(command.positional.type),
},
}
else
.{});
const T = @Type(.{
.@"struct" = .{
.decls = &.{},
.fields = &fields,
.is_tuple = false,
.layout = .auto,
},
});
union_fields[i] = .{ .name = command.name, .type = T, .alignment = @alignOf(T) };
}
break :blk @Type(.{
.@"union" = .{
.decls = &.{},
.fields = &union_fields,
.layout = .auto,
.tag_type = Enum,
},
});
};
/// Parses executable name, command and options via single call.
pub fn parse(allocator: Allocator) !struct { []const u8, Union } {
var args = try std.process.argsWithAllocator(allocator);
defer args.deinit();
const exec_name = std.fs.path.basename(args.next().?);
const cmd_str: []const u8 = args.next() orelse return error.MissingCommand;
inline for (commands) |command| {
// Match a command.
if (std.mem.eql(u8, cmd_str, command.name)) {
return .{ exec_name, try parseCommand(allocator, command, &args) };
}
}
// Last resort, try sniffing.
const command_enum = try sniffCommand(cmd_str);
// `help` takes no arguments; short-circuit so the sniffed flag
// isn't re-parsed as an unknown option.
if (command_enum == .help) {
return .{ exec_name, .{ .help = .{} } };
}
// "cmd_str" wasn't a command but an option. We can't reset args, but
// we can create a new one. Not great, but this fallback is temporary
// as we transition to this command mode approach.
args.deinit();
args = try std.process.argsWithAllocator(allocator);
// Skip the `exec_name`.
_ = args.skip();
inline for (commands) |command| {
if (std.mem.eql(u8, @tagName(command_enum), command.name)) {
return .{ exec_name, try parseCommand(allocator, command, &args) };
}
}
unreachable;
}
/// Try to sniff the command out of given option.
/// Only exists for legacy reasons; hence hardcoded.
fn sniffCommand(cmd_str: []const u8) error{UnknownCommand}!Enum {
if (std.mem.startsWith(u8, cmd_str, "--") == false) {
return .fetch;
}
// Fetch heuristics.
inline for (.{
"--dump",
"--strip-mode",
"--strip_mode",
"--with-base",
"--with_base",
"--with-frames",
"--with_frames",
}) |heuristic| {
if (std.mem.eql(u8, cmd_str, heuristic)) {
return .fetch;
}
}
// Serve heuristics.
inline for (.{
"--host",
"--port",
"--timeout",
}) |heuristic| {
if (std.mem.eql(u8, cmd_str, heuristic)) {
return .serve;
}
}
// Legacy `--help` flag maps to the `help` command.
if (std.mem.eql(u8, cmd_str, "--help")) {
return .help;
}
return error.UnknownCommand;
}
/// Parses the command with its options.
fn parseCommand(
allocator: Allocator,
command: anytype,
args: *std.process.ArgIterator,
) !Union {
const Command = @FieldType(Union, command.name);
var c = Command{};
const options = blk: {
if (@hasField(@TypeOf(command), "shared_options")) {
break :blk command.options ++ command.shared_options;
}
break :blk command.options;
};
iter_args: while (args.next()) |option_name| {
inline for (options) |option| {
// We allow both `--my-option` and `--my_option` variants;
// assuming given `option` struct prefer snake_case for `name`.
const kebab_cased = comptime casing: {
var output: [option.name.len]u8 = undefined;
@memcpy(&output, option.name);
std.mem.replaceScalar(u8, &output, '_', '-');
break :casing "--" ++ output;
};
// Match an option.
const match =
std.mem.eql(u8, option_name, "--" ++ option.name) or
std.mem.eql(u8, option_name, kebab_cased);
if (match) {
const T = option.type;
const option_info = blk: {
const info = @typeInfo(T);
// If wrapped in optional, prefer the child type.
if (info == .optional) break :blk @typeInfo(info.optional.child);
break :blk info;
};
const is_multiple = @hasField(@TypeOf(option), "multiple") and option.multiple;
const has_validator = @hasField(@TypeOf(option), "validator");
// Prefer custom validator logic instead.
if (has_validator) {
const validator = option.validator;
if (is_multiple) {
// Pass the list.
try @call(.auto, validator, .{ allocator, args, &@field(c, option.name) });
} else {
// Receive the value from return.
const v = try @call(.auto, validator, .{ allocator, args });
@field(c, option.name) = v;
}
} else {
switch (option_info) {
.int => |int| {
const Int = std.meta.Int(int.signedness, int.bits);
const str = args.next() orelse return error.MissingArgument;
const v = std.fmt.parseInt(Int, str, 10) catch |err| {
switch (err) {
error.Overflow => log.fatal(.app, "range overflow", .{ .arg = kebab_cased, .value = str }),
error.InvalidCharacter => log.fatal(.app, "invalid character", .{ .arg = kebab_cased, .value = str }),
}
continue :iter_args;
};
if (is_multiple) {
// Push to ArrayList.
try @field(c, option.name).append(allocator, v);
} else {
@field(c, option.name) = v;
}
},
.pointer => |pointer| {
const not_u8_slice = pointer.child != u8 or pointer.size != .slice;
if (not_u8_slice) {
@compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported");
}
const v = blk: {
const str = args.next() orelse return error.MissingArgument;
// DupeZ branch.
if (comptime pointer.sentinel()) |sentinel| {
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1);
@memcpy(buf[0..str.len], str);
buf[str.len] = sentinel;
break :blk buf[0..str.len :sentinel];
}
// Dupe branch.
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len);
@memcpy(buf, str);
break :blk buf;
};
if (is_multiple) {
try @field(c, option.name).append(allocator, v);
} else {
@field(c, option.name) = v;
}
},
.@"struct" => |_struct| {
// Don't support multiple for structs for now.
if (is_multiple) {
@compileError("multiple option is not supported for structs");
}
const not_packed = _struct.layout != .@"packed";
if (not_packed) {
@compileError("only packed structs are allowed");
}
const str = args.next() orelse return error.MissingArgument;
if (std.mem.eql(u8, str, "full")) {
// "full" sets all the fields of packed struct.
const Int = _struct.backing_integer.?;
@field(c, option.name) = @bitCast(@as(Int, std.math.maxInt(Int)));
} else {
// Parse given args.
var it = std.mem.tokenizeScalar(u8, str, ',');
outer: while (it.next()) |part| {
const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace);
inline for (_struct.fields) |f| {
lp.assert(f.type == bool, "all fields of packed struct must be boolean", .{
.option = option.name,
.field = f.name,
});
if (std.mem.eql(u8, trimmed, @as([]const u8, f.name))) {
@field(@field(c, option.name), f.name) = true;
continue :outer;
}
}
// Invalid option choice.
log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = trimmed });
}
}
},
.@"enum" => {
const E = switch (@typeInfo(T)) {
.optional => |optional| optional.child,
inline else => T,
};
const str = args.next() orelse return error.MissingArgument;
const v = std.meta.stringToEnum(E, str) orelse {
log.fatal(.app, "invalid option choice", .{ .arg = kebab_cased, .value = str });
continue :iter_args;
};
if (is_multiple) {
try @field(c, option.name).append(allocator, v);
} else {
@field(c, option.name) = v;
}
},
.bool => {
if (is_multiple) {
@compileError("multiple option is not supported for booleans");
}
const default = blk: {
if (@hasField(@TypeOf(option), "default")) {
break :blk option.default;
}
break :blk false;
};
// Set opposite of the default.
@field(c, option.name) = !default;
},
else => {},
}
}
continue :iter_args;
}
}
// Encountered an option we don't know of.
if (std.mem.startsWith(u8, option_name, "--")) {
log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name });
return error.UnknownOption;
}
// Parse positional arg if provided; can be given out of order:
//
// lightpanda fetch --wait-ms 2_000 "https://lightpanda.io" --dump "html"
// ---------------------------------^
if (comptime @hasField(@TypeOf(command), "positional")) {
const positional = command.positional;
// Already given one.
if (@field(c, positional.name) != null) {
return error.TooManyPositionalArguments;
}
// The positional must be an optional type.
const info = @typeInfo(@typeInfo(positional.type).optional.child);
const str = @as([]const u8, option_name);
switch (info) {
.pointer => |pointer| {
const not_u8_slice = pointer.child != u8 or pointer.size != .slice;
if (not_u8_slice) {
@compileError("Only []u8, []const u8, [:sentinel]u8 and [:sentinel]const u8 pointers are supported");
}
const v = blk: {
// DupeZ branch.
if (comptime pointer.sentinel()) |sentinel| {
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len + 1);
@memcpy(buf[0..str.len], str);
buf[str.len] = sentinel;
break :blk buf[0..str.len :sentinel];
}
// Dupe branch.
const buf = try allocator.alignedAlloc(u8, .fromByteUnits(pointer.alignment), str.len);
@memcpy(buf, str);
break :blk buf;
};
@field(c, positional.name) = v;
},
inline else => @compileError("not supported"),
}
} else {
log.fatal(.app, "unknown argument", .{ .mode = command.name, .arg = option_name });
return error.UnknownOption;
}
}
// Parsing is complete and positional is null.
const positional_is_null = @hasField(@TypeOf(command), "positional") and @field(c, command.positional.name) == null;
if (positional_is_null) {
return error.MissingArgument;
}
return @unionInit(Union, command.name, c);
}
};
}

View File

@@ -23,12 +23,14 @@ pub const App = @import("App.zig");
pub const Network = @import("network/Network.zig");
pub const Server = @import("Server.zig");
pub const Config = @import("Config.zig");
pub const URL = @import("browser/URL.zig");
pub const String = @import("string.zig").String;
pub const Notification = @import("Notification.zig");
pub const URL = @import("browser/URL.zig");
pub const Page = @import("browser/Page.zig");
pub const Frame = @import("browser/Frame.zig");
pub const Browser = @import("browser/Browser.zig");
pub const Session = @import("browser/Session.zig");
pub const Notification = @import("Notification.zig");
pub const js = @import("browser/js/js.zig");
pub const dump = @import("browser/dump.zig");
@@ -41,14 +43,14 @@ pub const forms = @import("browser/forms.zig");
pub const actions = @import("browser/actions.zig");
pub const structured_data = @import("browser/structured_data.zig");
pub const tools = @import("browser/tools.zig");
pub const HttpClient = @import("browser/HttpClient.zig");
pub const mcp = @import("mcp.zig");
pub const agent = @import("agent.zig");
pub const cookies = @import("cookies.zig");
pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig");
pub const HttpClient = @import("browser/HttpClient.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const FetchOpts = struct {
@@ -82,7 +84,7 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void {
}
}
const frame = try session.createFrame();
const frame = try session.createPage();
// // Comment this out to get a profile of the JS code in v8/profile.json.
// // You can open this in Chrome's profiler.
@@ -264,7 +266,7 @@ pub fn RC(comptime T: type) type {
self._refs += 1;
}
pub fn release(self: *@This(), value: anytype, session: *Session) void {
pub fn release(self: *@This(), value: anytype, page: *Page) void {
assert(self._refs > 0, "release overflow", .{ .type = @typeName(@TypeOf(value)) });
const refs = self._refs - 1;
@@ -272,7 +274,7 @@ pub fn RC(comptime T: type) type {
if (refs > 0) {
return;
}
value.deinit(session);
value.deinit(page);
}
pub fn format(self: @This(), writer: *std.Io.Writer) !void {

View File

@@ -55,7 +55,7 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
switch (args.mode) {
.help => {
args.printUsageAndExit(args.mode.help);
args.printUsageAndExit(true);
return std.process.cleanExit();
},
.version => {
@@ -72,9 +72,9 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
if (args.logFormat()) |lf| {
log.opts.format = lf;
}
if (args.logFilterScopes()) |lfs| {
log.opts.filter_scopes = lfs;
}
// Set log filter scopes.
log.opts.filter_scopes = args.logFilterScopes().items;
// must be installed before any other threads
const sighandler = try main_arena.create(SigHandler);
@@ -118,16 +118,16 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
},
.fetch => |opts| {
const url = opts.url;
log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump_mode, .url = url, .snapshot = app.snapshot.fromEmbedded() });
log.debug(.app, "startup", .{ .mode = "fetch", .dump_mode = opts.dump, .url = url, .snapshot = app.snapshot.fromEmbedded() });
var fetch_opts = lp.FetchOpts{
.wait_ms = opts.wait_ms,
.wait_until = opts.wait_until,
.wait_script = opts.wait_script,
.wait_selector = opts.wait_selector,
.dump_mode = opts.dump_mode,
.dump_mode = opts.dump,
.dump = .{
.strip = opts.strip,
.strip = opts.strip_mode,
.with_base = opts.with_base,
.with_frames = opts.with_frames,
},
@@ -135,11 +135,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
var stdout = std.fs.File.stdout();
var writer = stdout.writer(&.{});
if (opts.dump_mode != null) {
if (opts.dump != null) {
fetch_opts.writer = &writer.interface;
}
var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url, fetch_opts });
var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url.?, fetch_opts });
defer worker_thread.join();
app.network.run();

View File

@@ -33,10 +33,8 @@ pub fn main() !void {
}
lp.log.opts.level = .warn;
const config = try lp.Config.init(allocator, "legacy-test", .{ .serve = .{
.common = .{
.tls_verify_host = false,
.user_agent_suffix = "internal-tester",
},
.insecure_disable_tls_host_verification = true,
.user_agent_suffix = "internal-tester",
} });
defer config.deinit(allocator);
@@ -94,8 +92,8 @@ pub fn main() !void {
pub fn run(allocator: Allocator, file: []const u8, session: *lp.Session) !void {
const url = try std.fmt.allocPrintSentinel(allocator, "http://localhost:9589/{s}", .{file}, 0);
const frame = try session.createFrame();
defer session.removeFrame();
const frame = try session.createPage();
defer session.removePage();
var ls: lp.js.Local.Scope = undefined;
frame.js.localScope(&ls);

View File

@@ -106,7 +106,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
defer server.deinit();
const frame = &server.session.frame.?;
const frame = server.session.currentFrame().?;
{
// Test Click
@@ -424,7 +424,7 @@ fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server {
var server = try Server.init(testing.allocator, testing.test_app, writer);
errdefer server.deinit();
const frame = try server.session.createFrame();
const frame = try server.session.createPage();
try frame.navigate(url, .{});
var runner = try server.session.runner(.{});

View File

@@ -394,8 +394,8 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void {
}
fn runWebApiTest(test_file: [:0]const u8) !void {
const frame = try test_session.createFrame();
defer test_session.removeFrame();
const frame = try test_session.createPage();
defer test_session.removePage();
const url = try std.fmt.allocPrintSentinel(
arena_allocator,
@@ -453,8 +453,8 @@ const PageTestOpts = struct {
wait_until_done: bool = true,
};
pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Frame {
const frame = try test_session.createFrame();
errdefer test_session.removeFrame();
const frame = try test_session.createPage();
errdefer test_session.removePage();
const url = try std.fmt.allocPrintSentinel(
arena_allocator,
@@ -491,11 +491,9 @@ test "tests:beforeAll" {
const test_allocator = @import("root").tracking_allocator;
test_config = try Config.init(test_allocator, "test", .{ .serve = .{
.common = .{
.tls_verify_host = false,
.user_agent_suffix = "internal-tester",
.ws_max_concurrent = 50,
},
.insecure_disable_tls_host_verification = true,
.user_agent_suffix = "internal-tester",
.ws_max_concurrent = 50,
} });
test_app = try App.init(test_allocator, &test_config);