From 233d08cd6ea5c9336e299de09378a44e6c7afb48 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 15:23:25 +0200 Subject: [PATCH 01/15] ci: generate a .deb package on tag --- .github/workflows/release.yml | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62f75198..0a5e7d89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,3 +201,72 @@ jobs: artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst tag: ${{ env.RELEASE }} makeLatest: true + + package-debian: + if: github.ref_type == 'tag' + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + deb_arch: amd64 + - arch: aarch64 + deb_arch: arm64 + + env: + ARCH: ${{ matrix.arch }} + DEB_ARCH: ${{ matrix.deb_arch }} + OS: linux + + needs: build-linux + runs-on: ubuntu-22.04 + container: debian:stable-slim + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Install packaging deps + run: | + apt-get update + apt-get install -y --no-install-recommends dpkg-dev + + - name: Download linux binary + uses: actions/download-artifact@v4 + with: + name: lightpanda-${{ env.ARCH }}-${{ env.OS }} + path: . + + - name: Build Debian package + run: | + RAW_VERSION="${{ env.RELEASE }}" + PKGVER="${RAW_VERSION#v}" + echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" + + ROOT="lightpanda_${PKGVER}_${DEB_ARCH}" + mkdir -p "$ROOT/DEBIAN" "$ROOT/usr/bin" "$ROOT/usr/share/doc/lightpanda" + + install -m755 "lightpanda-${ARCH}-${OS}" "$ROOT/usr/bin/lightpanda" + install -m644 LICENSE "$ROOT/usr/share/doc/lightpanda/copyright" + + cat > "$ROOT/DEBIAN/control" <= 2.35) + Maintainer: Lightpanda + Homepage: https://lightpanda.io + Description: Lightpanda, headless browser built for AI and automation + EOF + + dpkg-deb --build --root-owner-group "$ROOT" + + - name: Upload Debian package to release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + artifacts: lightpanda_${{ env.PKGVER }}_${{ env.DEB_ARCH }}.deb + tag: ${{ env.RELEASE }} + makeLatest: true From 8ba960ecbc93be401feae632f76d98faae6aa12b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Apr 2026 21:29:41 +0800 Subject: [PATCH 02/15] window.open Adds limited support for window.open. This leverages the new page container and behaves similarly to an iframe. There are many things not implemented, but the most significant are: 1- target=window_name or target=_blank don't work (but this could be added pretty easily I think) 2- Windows (which are is just a Frame) are shutdown when the Page is shutdown. They would need to be owned by the Session (rather than the Page), but I'm not really confident in that right now. 3- No CDP testing. There are maybe CDP-specific messages we need to emit (or maybe messages that we shouldn't emit). As-is, this should help with common cases where: 1. window.open is called but doesn't matter (won't give a JS error) 2. window.open is short-lived, or, more specifically, lives only for the duration of the page that opened it (e.g. a login popup). 3. WPT tests! (because most of these fit in #2). --- src/browser/Frame.zig | 67 ++++++++++++- src/browser/Page.zig | 23 +++++ src/browser/Session.zig | 46 +++++++++ src/browser/tests/window/open.html | 109 ++++++++++++++++++++ src/browser/webapi/Window.zig | 155 ++++++++++++++++++++++++++++- 5 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 src/browser/tests/window/open.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index dedb522e..bb23a452 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -532,6 +532,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]); } else if (self.parent) |parent| { self.origin = parent.origin; + } else if (self.window._opener) |opener| { + self.origin = opener._frame.origin; } else { self.origin = null; } @@ -1289,6 +1291,69 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { } } +const OpenPopupOpts = struct { + url: []const u8, + name: []const u8, + opener: ?*Window, +}; + +// Create a new top-level browsing context as a sibling of the root frame. +// The popup shares the Page's arena, factory, and identity map, but has no +// parent and is not attached to the frame tree — it lives in page.popups. +pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { + const page = self._page; + const session = self._session; + + const resolved_url: [:0]const u8 = blk: { + if (opts.url.len == 0) { + break :blk "about:blank"; + } + if (std.mem.eql(u8, opts.url, "about:blank")) { + break :blk "about:blank"; + } + const frame_base = base_blk: { + var frame = self; + while (true) { + const maybe_base = frame.base(); + if (!std.mem.eql(u8, maybe_base, "about:blank")) { + break :base_blk maybe_base; + } + frame = frame.parent orelse break :base_blk ""; + } + }; + break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .always_dupe = true, .encoding = self.charset }); + }; + + const popup = try page.frame_arena.create(Frame); + errdefer page.frame_arena.destroy(popup); + + const frame_id = session.nextFrameId(); + try Frame.init(popup, frame_id, page, null); + errdefer popup.deinit(true); + + popup.window._opener = opts.opener; + if (opts.name.len > 0 and + !std.ascii.eqlIgnoreCase(opts.name, "_blank") and + !std.ascii.eqlIgnoreCase(opts.name, "_self") and + !std.ascii.eqlIgnoreCase(opts.name, "_parent") and + !std.ascii.eqlIgnoreCase(opts.name, "_top")) + { + popup.window._name = try page.frame_arena.dupe(u8, opts.name); + } + + const popup_index = page.popups.items.len; + try page.popups.append(page.frame_arena, popup); + // not impossible that navigate adds popups, so remove by index + errdefer _ = page.popups.swapRemove(popup_index); + + popup.navigate(resolved_url, .{ .reason = .script }) catch |err| { + log.warn(.frame, "popup navigate failure", .{ .url = resolved_url, .err = err }); + return err; + }; + + return popup; +} + pub fn domChanged(self: *Frame) void { self.version += 1; @@ -3390,7 +3455,7 @@ pub const QueuedNavigation = struct { /// to the appropriateFrame to navigate. /// Returns null if the target is "_blank" (which would open a new window/tab). /// Note: Callers should handle empty target separately (for owner document resolution). -fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame { +pub fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame { if (std.ascii.eqlIgnoreCase(target_name, "_self")) { return self; } diff --git a/src/browser/Page.zig b/src/browser/Page.zig index af58f34a..929cd07c 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -88,6 +88,14 @@ queued_queued_navigation: std.ArrayList(*Frame) = .empty, // The root Frame of this Page. Non-optional — a Page always has a root frame. frame: Frame, +// Popup Frames opened by window.open. They are top-level browsing contexts +// (parent == null, no iframe element) but share this Page's factory, arena, +// and identity map. +// Their lifetime is bound to the Page: on Page.deinit they +// are torn down. TODO: this is far from correct. An new window shouldn't be tied +// to the original page like this. +popups: std.ArrayList(*Frame) = .empty, + // 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"); @@ -107,6 +115,11 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void { // 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 { + for (self.popups.items) |popup| { + popup.deinit(abort_http); + } + self.popups = .empty; + self.frame.deinit(abort_http); const session = self.session; @@ -217,6 +230,16 @@ pub fn findFrameByFrameId(self: *Page, frame_id: u32) ?*Frame { return findFrameBy(&self.frame, "_frame_id", frame_id); } +// Returns the popup Frame registered under `name`, or null. +pub fn findPopupByName(self: *Page, name: []const u8) ?*Frame { + for (self.popups.items) |popup| { + if (std.mem.eql(u8, popup.window._name, name)) { + return popup; + } + } + return null; +} + pub fn findFrameByLoaderId(self: *Page, loader_id: u32) ?*Frame { return findFrameBy(&self.frame, "_loader_id", loader_id); } diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 17f2e221..421b9b25 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -281,6 +281,13 @@ pub fn processQueuedNavigation(self: *Session) !void { } fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void { + // Popups live on the Page as top-level browsing contexts without a + // parent or iframe element. Their re-navigation path is simpler than + // iframes — no parent bookkeeping to patch. + if (frame.parent == null and frame.iframe == null) { + return self.processPopupNavigation(frame, qn); + } + lp.assert(frame.parent != null, "root queued navigation", .{}); const iframe = frame.iframe.?; @@ -331,6 +338,45 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) }; } +// Re-navigates a popup Frame in place. The Frame pointer stays stable +// (scripts in the opener may hold a cached Window ref — though the Window +// object inside is replaced, matching how iframes behave on navigation). +fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void { + frame._queued_navigation = null; + defer self.releaseArena(qn.arena); + + // Preserve popup identity fields. _name lives in the Page arena and + // survives Frame.deinit; _opener is just a pointer. + const saved_name = frame.window._name; + const saved_opener = frame.window._opener; + const frame_id = frame._frame_id; + const page = self.currentPage().?; + + frame.deinit(true); + frame.* = undefined; + + errdefer { + // If re-init fails, drop from popups so we don't leave a corpse. + for (page.popups.items, 0..) |p, i| { + if (p == frame) { + _ = page.popups.swapRemove(i); + break; + } + } + } + + try Frame.init(frame, frame_id, page, null); + errdefer frame.deinit(true); + + frame.window._name = saved_name; + frame.window._opener = saved_opener; + + frame.navigate(qn.url, qn.opts) catch |err| { + log.err(.browser, "queued popup navigation error", .{ .err = err }); + return err; + }; +} + fn processRootQueuedNavigation(self: *Session) !void { const current_frame = &self.page.?.frame; const frame_id = current_frame._frame_id; diff --git a/src/browser/tests/window/open.html b/src/browser/tests/window/open.html new file mode 100644 index 00000000..4bc0b72d --- /dev/null +++ b/src/browser/tests/window/open.html @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 31efb8ff..e02ba550 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -95,6 +95,18 @@ _scroll_pos: struct { // A cross origin wrapper for this window _cross_origin_wrapper: CrossOriginWindow, +// The Window that called window.open to create this one. Null for the root +// window, for noopener popups, and cleared if the opener is torn down while +// we're still alive. Only valid if `!_opener.?._closed`. +_opener: ?*Window = null, + +// True after our Frame has been deinit'd by window.close. Many things on the +// window become invalid once this is true. +_closed: bool = false, + +// Popup name (owned by page.arena) +_name: []const u8 = "", + pub fn asEventTarget(self: *Window) *EventTarget { return self._proto; } @@ -111,6 +123,25 @@ pub fn getWindow(self: *Window) *Window { return self; } +pub fn getOpener(self: *Window, frame: *Frame) ?Access { + const opener = self._opener orelse return null; + if (opener._closed) return null; + return Access.init(frame.window, opener); +} + +pub fn getClosed(self: *const Window) bool { + return self._closed; +} + +pub fn getName(self: *const Window) []const u8 { + return self._name; +} + +pub fn setName(self: *Window, name: []const u8, frame: *Frame) !void { + // Store in the Page's frame arena so the slice outlives any call_arena. + self._name = try frame.arena.dupe(u8, name); +} + pub fn getTop(self: *Window, frame: *Frame) Access { var p = self._frame; while (p.parent) |parent| { @@ -424,6 +455,108 @@ pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[] return CSSStyleProperties.init(element, true, frame); } +// window.open(url?, target?, features?) — v1 scope: +// * Always creates a new popup Frame on the Page (sibling to the root). +// * Honors `noopener` / `noreferrer` tokens in `features` (opener=null, +// return value=null). Geometry (width, height, ...) ignored. +// * `target` values `_self` / `_parent` / `_top` navigate the current frame. +// Any other value is treated as a popup name; reusing a live name +// navigates the existing popup instead of spawning a new one. +// * `url` empty or missing opens about:blank. +pub fn open(self: *Window, url_: ?[]const u8, target_: ?[]const u8, features_: ?[]const u8, frame: *Frame) !?Access { + const raw_url = url_ orelse ""; + const target = target_ orelse ""; + const features = features_ orelse ""; + + const no_opener = hasFeatureToken(features, "noopener") or hasFeatureToken(features, "noreferrer"); + + // _self / _parent / _top navigate the current browsing context. + if (std.ascii.eqlIgnoreCase(target, "_self") or + std.ascii.eqlIgnoreCase(target, "_parent") or + std.ascii.eqlIgnoreCase(target, "_top")) + { + const nav_target = frame.resolveTargetFrame(target) orelse frame; + const nav_url = if (raw_url.len == 0) "about:blank" else raw_url; + try frame.scheduleNavigation(nav_url, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .script = nav_target }); + + if (no_opener) { + return null; + } + + return Access.init(frame.window, nav_target.window); + } + + const page = frame._page; + + // Name-based reuse: if a popup with this name already exists, reuse it. + // `_blank` is reserved and never reuses. + const is_named = target.len > 0 and !std.ascii.eqlIgnoreCase(target, "_blank"); + if (is_named) { + if (page.findPopupByName(target)) |existing| { + if (raw_url.len > 0) { + try existing.scheduleNavigation(raw_url, .{ + .reason = .script, + .kind = .{ .push = null }, + }, .{ .script = existing }); + } + if (no_opener) { + return null; + } + return Access.init(frame.window, existing.window); + } + } + + // Spawn a new popup Frame as a sibling of the root. + const popup = try frame.openPopup(.{ + .url = raw_url, + .name = target, + .opener = if (no_opener) null else self, + }); + + if (no_opener) { + return null; + } + return Access.init(frame.window, popup.window); +} + +pub fn close(self: *Window) void { + if (self._closed) { + return; + } + + // Per spec, close() is only honored on script-opened windows. That + // maps exactly to membership in page.popups. + const frame = self._frame; + const page = frame._page; + + var popup_index: usize = 0; + while (popup_index < page.popups.items.len) : (popup_index += 1) { + if (page.popups.items[popup_index] == frame) { + break; + } + } else return; + + self._closed = true; + + // Any live Window holding us as its opener must drop the reference — + // our Frame is about to go away, and a stale _frame deref on their + // side would crash. + for (page.popups.items) |popup| { + if (popup.window._opener == self) { + popup.window._opener = null; + } + } + if (page.frame.window._opener == self) { + page.frame.window._opener = null; + } + + _ = page.popups.swapRemove(popup_index); + frame.deinit(true); +} + pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, frame: *Frame) !void { // For now, we ignore targetOrigin checking and just dispatch the message // In a full implementation, we would validate the origin @@ -833,6 +966,20 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global { }; } +// Checks whether a window.open features string contains a token, matched +// case-insensitively on whole-token boundaries (comma or whitespace separated). +// The features syntax is legacy and loose; the only tokens we interpret are +// noopener and noreferrer. +fn hasFeatureToken(features: []const u8, token: []const u8) bool { + var it = std.mem.tokenizeAny(u8, features, " \t\r\n,"); + while (it.next()) |raw| { + // Trim a trailing =value if present — we only need the key. + const key = if (std.mem.indexOfScalarPos(u8, raw, 0, '=')) |eq| raw[0..eq] else raw; + if (std.ascii.eqlIgnoreCase(key, token)) return true; + } + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Window); @@ -912,9 +1059,11 @@ pub const JsApi = struct { pub const innerHeight = bridge.property(1080, .{ .template = false }); pub const devicePixelRatio = bridge.property(1, .{ .template = false }); - // This should return a window-like object in specific conditions. Would be - // pretty complicated to properly support I think. - pub const opener = bridge.property(null, .{ .template = false }); + pub const opener = bridge.accessor(Window.getOpener, null, .{}); + pub const closed = bridge.accessor(Window.getClosed, null, .{}); + pub const name = bridge.accessor(Window.getName, Window.setName, .{}); + pub const open = bridge.function(Window.open, .{}); + pub const close = bridge.function(Window.close, .{}); pub const alert = bridge.function(struct { fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void { From 876712730869f7bd21c9ffd5cb719580e40b2944 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 15:37:02 +0200 Subject: [PATCH 03/15] ci: split package release into sub files --- .github/workflows/package-archlinux.yml | 79 ++++++++++++++ .github/workflows/package-debian.yml | 78 ++++++++++++++ .github/workflows/release.yml | 133 +----------------------- 3 files changed, 159 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/package-archlinux.yml create mode 100644 .github/workflows/package-debian.yml diff --git a/.github/workflows/package-archlinux.yml b/.github/workflows/package-archlinux.yml new file mode 100644 index 00000000..dba6d2d0 --- /dev/null +++ b/.github/workflows/package-archlinux.yml @@ -0,0 +1,79 @@ +name: package archlinux + +on: + workflow_call: + +permissions: + contents: write + +env: + RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} + +jobs: + package: + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + + env: + ARCH: ${{ matrix.arch }} + OS: linux + + runs-on: ubuntu-22.04 + container: archlinux:latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Install packaging deps + run: pacman -Syu --noconfirm --needed base-devel sudo + + - name: Download linux binary + uses: actions/download-artifact@v4 + with: + name: lightpanda-${{ env.ARCH }}-${{ env.OS }} + path: . + + - name: Build Arch package + run: | + useradd -m builder + echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + + RAW_VERSION="${{ env.RELEASE }}" + PKGVER="${RAW_VERSION#v}" + PKGREL="1" + echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" + echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV" + + mkdir -p pkg + cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/ + cp LICENSE pkg/ + + cat > pkg/PKGBUILD <> "$GITHUB_ENV" + + ROOT="lightpanda_${PKGVER}_${DEB_ARCH}" + mkdir -p "$ROOT/DEBIAN" "$ROOT/usr/bin" "$ROOT/usr/share/doc/lightpanda" + + install -m755 "lightpanda-${ARCH}-${OS}" "$ROOT/usr/bin/lightpanda" + install -m644 LICENSE "$ROOT/usr/share/doc/lightpanda/copyright" + + cat > "$ROOT/DEBIAN/control" <= 2.35) + Maintainer: Lightpanda + Homepage: https://lightpanda.io + Description: Lightpanda, headless browser built for AI and automation + EOF + + dpkg-deb --build --root-owner-group "$ROOT" + + - name: Upload Debian package to release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + artifacts: lightpanda_${{ env.PKGVER }}_${{ env.DEB_ARCH }}.deb + tag: ${{ env.RELEASE }} + makeLatest: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a5e7d89..11e4b87e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,139 +134,10 @@ jobs: package-archlinux: if: github.ref_type == 'tag' - strategy: - fail-fast: false - matrix: - arch: [x86_64, aarch64] - - env: - ARCH: ${{ matrix.arch }} - OS: linux - needs: build-linux - runs-on: ubuntu-22.04 - container: archlinux:latest - timeout-minutes: 10 - - steps: - - uses: actions/checkout@v6 - - - name: Install packaging deps - run: pacman -Syu --noconfirm --needed base-devel sudo - - - name: Download linux binary - uses: actions/download-artifact@v4 - with: - name: lightpanda-${{ env.ARCH }}-${{ env.OS }} - path: . - - - name: Build Arch package - run: | - useradd -m builder - echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers - - RAW_VERSION="${{ env.RELEASE }}" - PKGVER="${RAW_VERSION#v}" - PKGREL="1" - echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" - echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV" - - mkdir -p pkg - cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/ - cp LICENSE pkg/ - - cat > pkg/PKGBUILD <> "$GITHUB_ENV" - - ROOT="lightpanda_${PKGVER}_${DEB_ARCH}" - mkdir -p "$ROOT/DEBIAN" "$ROOT/usr/bin" "$ROOT/usr/share/doc/lightpanda" - - install -m755 "lightpanda-${ARCH}-${OS}" "$ROOT/usr/bin/lightpanda" - install -m644 LICENSE "$ROOT/usr/share/doc/lightpanda/copyright" - - cat > "$ROOT/DEBIAN/control" <= 2.35) - Maintainer: Lightpanda - Homepage: https://lightpanda.io - Description: Lightpanda, headless browser built for AI and automation - EOF - - dpkg-deb --build --root-owner-group "$ROOT" - - - name: Upload Debian package to release - uses: ncipollo/release-action@v1 - with: - allowUpdates: true - artifacts: lightpanda_${{ env.PKGVER }}_${{ env.DEB_ARCH }}.deb - tag: ${{ env.RELEASE }} - makeLatest: true + uses: ./.github/workflows/package-debian.yml From 1d806475c46a66c5a2f303e074012ee3ce8fbadc Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 27 Apr 2026 07:08:01 +0200 Subject: [PATCH 04/15] page: make handleJavaScriptDialog drive confirm/prompt return values Page.handleJavaScriptDialog previously responded -32000 "No dialog is showing" regardless of whether a dialog was open, leaving CDP clients no way to influence the JS-side return value of confirm() / prompt(). PR #2085 wired up the Page.javascriptDialogOpening event but explicitly deferred the return-value override since true Chrome semantics require suspending V8 mid-execution. Add a pre-arm model that fits the auto-dismiss architecture without runtime suspension: handleJavaScriptDialog stashes {accept, promptText} on the BrowserContext; when the next JS dialog dispatches the javascript_dialog_opening notification, the listener pops the stash and fills it into the dispatch's response output param so Window.confirm / prompt return the CDP client's choice. Without a pre-arm, headless auto-dismiss values from PR #2085 are preserved (confirm->false, prompt->null, alert->void). Closes #2260 --- src/Notification.zig | 14 +++ src/browser/tests/cdp/dialog.html | 1 + src/browser/webapi/Window.zig | 16 ++- src/cdp/CDP.zig | 7 ++ src/cdp/domains/page.zig | 163 ++++++++++++++++++++++++++++-- 5 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 src/browser/tests/cdp/dialog.html diff --git a/src/Notification.zig b/src/Notification.zig index d01d29ce..caa8c20d 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -197,6 +197,20 @@ pub const JavascriptDialogOpening = struct { url: [:0]const u8, message: []const u8, dialog_type: []const u8, + // Output param. The CDP listener may set this from a pre-armed response + // queued by Page.handleJavaScriptDialog. The dispatcher (alert/confirm/ + // prompt in Window.zig) reads it back to decide what to return to JS. + // Headless mode auto-dismisses if no listener fills it in: confirm→false, + // prompt→null, alert→void (default-zero DialogResponse). + response: *DialogResponse, +}; + +pub const DialogResponse = struct { + accept: bool = false, + // Set when the CDP client sent a `promptText` with `accept: true`. Memory + // is owned by whoever filled in the response (typically the BrowserContext + // arena) and must outlive a single dispatch call. + prompt_text: ?[]const u8 = null, }; pub fn init(allocator: Allocator) !*Notification { diff --git a/src/browser/tests/cdp/dialog.html b/src/browser/tests/cdp/dialog.html new file mode 100644 index 00000000..ac172584 --- /dev/null +++ b/src/browser/tests/cdp/dialog.html @@ -0,0 +1 @@ +

dialog test page

diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 31efb8ff..38fc37cc 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -44,6 +44,7 @@ const Element = @import("Element.zig"); const CSSStyleProperties = @import("css/CSSStyleProperties.zig"); const CustomElementRegistry = @import("CustomElementRegistry.zig"); const Selection = @import("Selection.zig"); +const Notification = @import("../../Notification.zig"); const log = lp.log; const IS_DEBUG = builtin.mode == .Debug; @@ -918,31 +919,42 @@ pub const JsApi = struct { pub const alert = bridge.function(struct { fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void { + var response: Notification.DialogResponse = .{}; frame._session.notification.dispatch(.javascript_dialog_opening, &.{ .url = frame.url, .message = message orelse "", .dialog_type = "alert", + .response = &response, }); + // Return value is void; we still pop a pre-armed response so the + // CDP client's pre-arm doesn't leak across to the next dialog. } }.alert, .{}); pub const confirm = bridge.function(struct { fn confirm(_: *const Window, message: ?[]const u8, frame: *Frame) bool { + var response: Notification.DialogResponse = .{}; frame._session.notification.dispatch(.javascript_dialog_opening, &.{ .url = frame.url, .message = message orelse "", .dialog_type = "confirm", + .response = &response, }); - return false; + return response.accept; } }.confirm, .{}); pub const prompt = bridge.function(struct { fn prompt(_: *const Window, message: ?[]const u8, _: ?[]const u8, frame: *Frame) ?[]const u8 { + var response: Notification.DialogResponse = .{}; frame._session.notification.dispatch(.javascript_dialog_opening, &.{ .url = frame.url, .message = message orelse "", .dialog_type = "prompt", + .response = &response, }); - return null; + if (!response.accept) return null; + // promptText omitted with accept=true is "" per CDP spec + // (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-handleJavaScriptDialog). + return response.prompt_text orelse ""; } }.prompt, .{}); diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 16a08cc9..6bfff798 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -386,6 +386,13 @@ pub const BrowserContext = struct { notification: *Notification, + // Pre-armed response for the next JS dialog (alert/confirm/prompt). + // Set by Page.handleJavaScriptDialog; consumed (and cleared) when the + // next javascript_dialog_opening notification is dispatched. Strings + // are duplicated into self.arena so they outlive the CDP command's + // own message arena. + pending_dialog_response: ?Notification.DialogResponse = null, + fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void { const allocator = cdp.allocator; diff --git a/src/cdp/domains/page.zig b/src/cdp/domains/page.zig index b0aae79e..ff589d32 100644 --- a/src/cdp/domains/page.zig +++ b/src/cdp/domains/page.zig @@ -675,19 +675,56 @@ fn sendPageLifecycle(bc: *CDP.BrowserContext, name: []const u8, timestamp: u64, } // https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-handleJavaScriptDialog +// +// Lightpanda's dialogs auto-dismiss in headless mode (window.alert/confirm/ +// prompt return immediately rather than blocking the JS runtime). This means +// the standard CDP flow — open dialog, wait for client, resume JS with the +// client's choice — would require suspending V8 mid-execution, which the +// engine isn't structured for today (#2082 / PR #2085 deferred this work). +// +// To still let CDP clients influence the return value we use a pre-arm +// model: the client sends Page.handleJavaScriptDialog *before* triggering +// the JS that opens the dialog. We stash {accept, promptText} on the +// BrowserContext; when the next dialog dispatches the +// `javascript_dialog_opening` notification, the listener pops the stash and +// fills it into the dispatch's response output param. Window.alert/confirm/ +// prompt then return that value. +// +// Without a pre-armed response, behavior is unchanged from PR #2085: +// confirm→false, prompt→null, alert→void. fn handleJavaScriptDialog(cmd: *CDP.Command) !void { - // Dialogs auto-dismiss in headless mode. By the time the CDP client - // sends this command, the dialog has already returned and there is - // no pending dialog to accept or dismiss. - _ = try cmd.params(struct { + const params = (try cmd.params(struct { accept: bool, promptText: ?[]const u8 = null, - }); - return cmd.sendError(-32000, "No dialog is showing", .{}); + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + + // Duplicate promptText into the BrowserContext arena so it outlives the + // CDP command's own message arena (the dialog may fire on a later command). + const prompt_text: ?[]const u8 = if (params.promptText) |t| + try bc.arena.dupe(u8, t) + else + null; + + bc.pending_dialog_response = .{ + .accept = params.accept, + .prompt_text = prompt_text, + }; + + return cmd.sendResult(null, .{}); } // https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-javascriptDialogOpening pub fn javascriptDialogOpening(bc: anytype, event: *const Notification.JavascriptDialogOpening) !void { + // Pop any pre-armed response onto the dispatch's output param so the + // calling alert/confirm/prompt returns the CDP client's choice. Cleared + // unconditionally — a stash applies to exactly one dialog. + if (bc.pending_dialog_response) |pending| { + event.response.* = pending; + bc.pending_dialog_response = null; + } + const session_id = bc.session_id orelse return; var cdp = bc.cdp; @@ -1054,3 +1091,117 @@ test "cdp.frame: addScriptToEvaluateOnNewDocument" { try testing.expectEqual(2, try test_val.toI32()); } } + +test "cdp.page: handleJavaScriptDialog accepts/dismisses without an open dialog" { + var ctx = try testing.context(); + defer ctx.deinit(); + + { + // Without a BrowserContext: error (matches other Page handlers' shape). + try ctx.processMessage(.{ .id = 1, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true } }); + try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 1 }); + } + + _ = try ctx.loadBrowserContext(.{ .id = "BID-D1", .url = "cdp/dialog.html", .target_id = "FID-000000000X".* }); + + { + // Pre-arming with accept=true succeeds (was -32000 "No dialog is showing" + // before this fix). Headless browsers auto-dismiss, so the CDP client + // sends Page.handleJavaScriptDialog *before* the JS that opens the + // dialog — handler stashes the response on the BrowserContext. + try ctx.processMessage(.{ .id = 2, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true } }); + try ctx.expectSentResult(null, .{ .id = 2 }); + } + + { + // Pre-arming with accept=false also succeeds. + try ctx.processMessage(.{ .id = 3, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = false } }); + try ctx.expectSentResult(null, .{ .id = 3 }); + } + + { + // Pre-arming with a promptText also succeeds. The string is dup'd into + // the BrowserContext arena so it survives until the dialog dispatches. + try ctx.processMessage(.{ .id = 4, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true, .promptText = "hello" } }); + try ctx.expectSentResult(null, .{ .id = 4 }); + } +} + +test "cdp.page: handleJavaScriptDialog controls confirm/prompt/alert return values" { + var ctx = try testing.context(); + defer ctx.deinit(); + + var bc = try ctx.loadBrowserContext(.{ .id = "BID-D2", .url = "cdp/dialog.html", .target_id = "FID-000000000X".* }); + + const frame = bc.session.currentFrame() orelse unreachable; + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + // ---- confirm: accept=true makes confirm() return true ---- + try ctx.processMessage(.{ .id = 1, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true } }); + try ctx.expectSentResult(null, .{ .id = 1 }); + + const c_accept = try ls.local.exec("confirm('proceed?')", null); + try testing.expectEqual(true, c_accept.toBool()); + try ctx.expectSentEvent("Page.javascriptDialogOpening", .{ + .message = "proceed?", + .type = "confirm", + .hasBrowserHandler = false, + .defaultPrompt = "", + }, .{ .session_id = "SID-X" }); + + // ---- confirm: accept=false makes confirm() return false ---- + try ctx.processMessage(.{ .id = 2, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = false } }); + try ctx.expectSentResult(null, .{ .id = 2 }); + + const c_dismiss = try ls.local.exec("confirm('again?')", null); + try testing.expectEqual(false, c_dismiss.toBool()); + + // ---- confirm: no pre-arm preserves PR #2085 default (false) ---- + const c_default = try ls.local.exec("confirm('default?')", null); + try testing.expectEqual(false, c_default.toBool()); + + // ---- prompt: accept=true with promptText returns the text ---- + try ctx.processMessage(.{ .id = 3, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true, .promptText = "hello" } }); + try ctx.expectSentResult(null, .{ .id = 3 }); + + const p_text = try ls.local.exec("prompt('name?')", null); + const p_text_str = try p_text.toStringSlice(); + try testing.expectEqualSlices(u8, "hello", p_text_str); + + // ---- prompt: accept=true without promptText returns "" per CDP spec ---- + try ctx.processMessage(.{ .id = 4, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true } }); + try ctx.expectSentResult(null, .{ .id = 4 }); + + const p_empty = try ls.local.exec("prompt('name?')", null); + const p_empty_str = try p_empty.toStringSlice(); + try testing.expectEqualSlices(u8, "", p_empty_str); + + // ---- prompt: accept=false makes prompt() return null ---- + try ctx.processMessage(.{ .id = 5, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = false } }); + try ctx.expectSentResult(null, .{ .id = 5 }); + + const p_dismiss = try ls.local.exec("prompt('cancel?')", null); + try testing.expect(p_dismiss.isNull()); + + // ---- prompt: no pre-arm preserves PR #2085 default (null) ---- + const p_default = try ls.local.exec("prompt('default?')", null); + try testing.expect(p_default.isNull()); + + // ---- alert: dispatches the event but has no return value to override ---- + try ctx.processMessage(.{ .id = 6, .method = "Page.handleJavaScriptDialog", .params = .{ .accept = true } }); + try ctx.expectSentResult(null, .{ .id = 6 }); + _ = try ls.local.exec("alert('important')", null); + try ctx.expectSentEvent("Page.javascriptDialogOpening", .{ + .message = "important", + .type = "alert", + }, .{ .session_id = "SID-X" }); + + // ---- pending response is consumed by exactly one dialog ---- + // After the alert above pops the pre-arm, the next confirm sees no pending + // and falls back to the default (false) — the alert MUST clear pending so + // the response doesn't leak across dialogs. + const c_after_alert = try ls.local.exec("confirm('leak?')", null); + try testing.expectEqual(false, c_after_alert.toBool()); +} From d4479c0383830b0366b90cccf00cde56a6436efc Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Mon, 27 Apr 2026 23:08:56 +0200 Subject: [PATCH 05/15] ci: retrigger after pre-existing demo-scripts flake (lightpanda-io/demo#167) From 0a4c2a2743982b450687df23f854d76591c6883d Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Tue, 28 Apr 2026 06:31:14 +0200 Subject: [PATCH 06/15] css: apply UA stylesheet display:none defaults for unrendered elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StyleManager.hasDisplayNone` honored only the `[hidden]` UA-stylesheet rule; the rest of HTML Rendering §15.3.1 ("Hidden elements") was unimplemented. As a result `getComputedStyle(headEl).display === "block"` and `el.checkVisibility()` returned `true` for ``, ` + + + + + + + + + + + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + + + + + + diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index f5fe00d5..d38710f2 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -1114,6 +1114,7 @@ const testing = @import("../../../../testing.zig"); test "WebApi: HTML.Input" { try testing.htmlRunner("element/html/input.html", .{}); try testing.htmlRunner("element/html/input_click.html", .{}); + try testing.htmlRunner("element/html/input_image_submit.html", .{}); try testing.htmlRunner("element/html/input_radio.html", .{}); try testing.htmlRunner("element/html/input-attrs.html", .{}); } From 82497a78a808579e3dc1de2184f0a2353d6e84d3 Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Wed, 29 Apr 2026 03:36:41 +0200 Subject: [PATCH 11/15] selector: walk fieldset/optgroup ancestors when matching :disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `:disabled` and `:enabled` pseudo-class matchers in `webapi/selector/List.zig` only checked the element's own `disabled` content attribute. Per HTML "selector-disabled" + "concept-fe-disabled" + "concept-option-disabled", a form control inside
matches :disabled (with the first- exception), and an matches :disabled. Route both pseudo-classes through `Element.isDisabled` (which already walks the fieldset chain) and extend `isDisabled` to cover the case. . It does NOT inherit from +
x
+
y
+z + + +