mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge remote-tracking branch 'origin/main' into fix-a22-iscontenteditable
This commit is contained in:
79
.github/workflows/package-archlinux.yml
vendored
Normal file
79
.github/workflows/package-archlinux.yml
vendored
Normal file
@@ -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 <<EOF
|
||||
pkgname=lightpanda
|
||||
pkgver=${PKGVER}
|
||||
pkgrel=${PKGREL}
|
||||
pkgdesc="Lightpanda, headless browser built for AI and automation"
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://lightpanda.io"
|
||||
license=('AGPL-3.0-or-later')
|
||||
options=(!strip !debug)
|
||||
package() {
|
||||
install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda"
|
||||
install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
|
||||
}
|
||||
EOF
|
||||
|
||||
chown -R builder:builder pkg
|
||||
cd pkg
|
||||
sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A
|
||||
|
||||
- name: Upload Arch package to release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst
|
||||
tag: ${{ env.RELEASE }}
|
||||
makeLatest: true
|
||||
78
.github/workflows/package-debian.yml
vendored
Normal file
78
.github/workflows/package-debian.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: package debian
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
|
||||
|
||||
jobs:
|
||||
package:
|
||||
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
|
||||
|
||||
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" <<EOF
|
||||
Package: lightpanda
|
||||
Version: ${PKGVER}
|
||||
Section: web
|
||||
Priority: optional
|
||||
Architecture: ${DEB_ARCH}
|
||||
Depends: libc6 (>= 2.35)
|
||||
Maintainer: Lightpanda <hello@lightpanda.io>
|
||||
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
|
||||
70
.github/workflows/release.yml
vendored
70
.github/workflows/release.yml
vendored
@@ -134,70 +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
|
||||
uses: ./.github/workflows/package-archlinux.yml
|
||||
|
||||
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 <<EOF
|
||||
pkgname=lightpanda
|
||||
pkgver=${PKGVER}
|
||||
pkgrel=${PKGREL}
|
||||
pkgdesc="Lightpanda, headless browser built for AI and automation"
|
||||
arch=('x86_64' 'aarch64')
|
||||
url="https://lightpanda.io"
|
||||
license=('AGPL-3.0-or-later')
|
||||
options=(!strip !debug)
|
||||
package() {
|
||||
install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda"
|
||||
install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
|
||||
}
|
||||
EOF
|
||||
|
||||
chown -R builder:builder pkg
|
||||
cd pkg
|
||||
sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A
|
||||
|
||||
- name: Upload Arch package to release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst
|
||||
tag: ${{ env.RELEASE }}
|
||||
makeLatest: true
|
||||
package-debian:
|
||||
if: github.ref_type == 'tag'
|
||||
needs: build-linux
|
||||
uses: ./.github/workflows/package-debian.yml
|
||||
|
||||
@@ -202,6 +202,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 {
|
||||
|
||||
@@ -534,6 +534,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;
|
||||
}
|
||||
@@ -1338,6 +1340,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;
|
||||
|
||||
@@ -3527,7 +3592,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;
|
||||
}
|
||||
@@ -3648,7 +3713,11 @@ pub fn handleClick(self: *Frame, target: *Node) !void {
|
||||
},
|
||||
.input => |input| {
|
||||
try element.focus(self);
|
||||
if (input._input_type == .submit) {
|
||||
// Per HTML §4.10.18.6.4 "Image Button state (type=image)", clicking an
|
||||
// image button submits its form. The form-data set already gets the
|
||||
// submitter's coordinate fields appended via FormData.collectForm
|
||||
// (see src/browser/webapi/net/FormData.zig).
|
||||
if (input._input_type == .submit or input._input_type == .image) {
|
||||
return self.submitForm(element, input.getForm(self), .{});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -45,6 +45,7 @@ pub const PointerEventsCache = std.AutoHashMapUnmanaged(*Element, bool);
|
||||
const StyleManager = @This();
|
||||
|
||||
const Tag = Element.Tag;
|
||||
const Input = Element.Html.Input;
|
||||
const RuleList = std.MultiArrayList(VisibilityRule);
|
||||
|
||||
frame: *Frame,
|
||||
@@ -234,14 +235,44 @@ pub fn isHidden(self: *StyleManager, el: *Element, cache: ?*VisibilityCache, opt
|
||||
}
|
||||
|
||||
/// Computed display:none for a single element (own property, no ancestor walk).
|
||||
/// Also honors the HTML `hidden` attribute, matching the UA stylesheet rule
|
||||
/// `[hidden] { display: none }`.
|
||||
/// Honors the UA stylesheet rules per HTML Rendering §15.3.1 "Hidden elements"
|
||||
/// via `isElementHidden`.
|
||||
pub fn hasDisplayNone(self: *StyleManager, el: *Element) bool {
|
||||
self.rebuildIfDirty() catch return false;
|
||||
if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true;
|
||||
return self.isElementHidden(el, .{});
|
||||
}
|
||||
|
||||
/// Centralizes UA-stylesheet display:none truth so `getComputedStyle().display`
|
||||
/// (via `hasDisplayNone`) and `el.checkVisibility()` (via `isHidden`) agree.
|
||||
/// Spec: HTML Rendering §15.3.1 "Hidden elements".
|
||||
fn matchesUaDisplayNoneRule(el: *Element) bool {
|
||||
// Tag check first: O(1) switch, exits for the ~95% of elements with
|
||||
// ordinary tags before we touch the attribute list.
|
||||
const tag = el.getTag();
|
||||
if (tag.isHiddenByUaStylesheet()) return true;
|
||||
|
||||
if (el.hasAttributeSafe(comptime .wrap("hidden"))) return true;
|
||||
|
||||
// input[type="hidden" i] { display: none !important }
|
||||
// _input_type is parsed case-insensitively at attribute-set time.
|
||||
if (tag == .input) {
|
||||
if (el.is(Input)) |input| {
|
||||
if (input._input_type == .hidden) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// details:not([open]) > *:not(summary) { display: none }
|
||||
if (tag != .summary) {
|
||||
if (el.parentElement()) |parent| {
|
||||
if (parent.getTag() == .details and !parent.hasAttributeSafe(comptime .wrap("open"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Computed visibility:hidden for an element, considering only the `visibility`
|
||||
/// chain (walks ancestors since `visibility` inherits by default). Ignores
|
||||
/// display:none: an ancestor with display:none means the element isn't
|
||||
@@ -313,6 +344,16 @@ fn isElementHidden(self: *StyleManager, el: *Element, options: CheckVisibilityOp
|
||||
opacity_priority = INLINE_PRIORITY;
|
||||
}
|
||||
|
||||
// UA stylesheet display:none rules (HTML Rendering §15.3.1 "Hidden elements").
|
||||
// Skipped when an inline `display` is set so author overrides win per cascade.
|
||||
// All UA rules are short-circuited here; the spec marks some as `!important`
|
||||
// (`input[type=hidden]`, `noscript`) and others as normal-origin, but author
|
||||
// CSS overriding `<script>`/`<head>`/closed-`<details>` children is rare
|
||||
// enough that uniform treatment is acceptable.
|
||||
if (options.check_display and display_priority != INLINE_PRIORITY) {
|
||||
if (matchesUaDisplayNoneRule(el)) return true;
|
||||
}
|
||||
|
||||
if (display_priority == INLINE_PRIORITY and visibility_priority == INLINE_PRIORITY and opacity_priority == INLINE_PRIORITY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
1
src/browser/tests/cdp/dialog.html
Normal file
1
src/browser/tests/cdp/dialog.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>dialog test page</p>
|
||||
@@ -191,6 +191,115 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ua_unrendered_tags_are_display_none">
|
||||
{
|
||||
// Per HTML Rendering §15.3.1 "Hidden elements", these tags get
|
||||
// display:none from the UA stylesheet:
|
||||
// area, base, datalist, head, link, meta, noscript, param, script,
|
||||
// source, style, template, title, track
|
||||
// (basefont/noembed/noframes/rp are obsolete and not represented in
|
||||
// Lightpanda's Element.Tag enum.)
|
||||
const tags = [
|
||||
'area', 'base', 'datalist', 'link', 'meta', 'noscript', 'param',
|
||||
'script', 'source', 'style', 'template', 'title', 'track',
|
||||
];
|
||||
for (const t of tags) {
|
||||
const el = document.createElement(t);
|
||||
document.body.appendChild(el);
|
||||
testing.expectEqual('none', window.getComputedStyle(el).display);
|
||||
testing.expectEqual(false, el.checkVisibility());
|
||||
el.remove();
|
||||
}
|
||||
// <head> itself
|
||||
testing.expectEqual('none', window.getComputedStyle(document.head).display);
|
||||
testing.expectEqual(false, document.head.checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ua_input_type_hidden_is_display_none">
|
||||
{
|
||||
// input[type="hidden" i] { display: none !important }
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
document.body.appendChild(inp);
|
||||
testing.expectEqual('none', window.getComputedStyle(inp).display);
|
||||
testing.expectEqual(false, inp.checkVisibility());
|
||||
|
||||
// Other input types are visible
|
||||
inp.type = 'text';
|
||||
testing.expectEqual(true, inp.checkVisibility());
|
||||
|
||||
// Spec uses [type=hidden i] (case-insensitive)
|
||||
inp.type = 'HIDDEN';
|
||||
testing.expectEqual(false, inp.checkVisibility());
|
||||
|
||||
inp.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ua_closed_details_hides_non_summary_children">
|
||||
{
|
||||
// details:not([open]) > *:not(summary) { display: none }
|
||||
const details = document.createElement('details');
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'header';
|
||||
const child = document.createElement('p');
|
||||
child.textContent = 'body';
|
||||
details.appendChild(summary);
|
||||
details.appendChild(child);
|
||||
document.body.appendChild(details);
|
||||
|
||||
// Closed: <summary> visible, body child hidden
|
||||
testing.expectEqual(true, summary.checkVisibility());
|
||||
testing.expectEqual(false, child.checkVisibility());
|
||||
testing.expectEqual('none', window.getComputedStyle(child).display);
|
||||
|
||||
// Open: child visible
|
||||
details.setAttribute('open', '');
|
||||
testing.expectEqual(true, child.checkVisibility());
|
||||
|
||||
// Closed again
|
||||
details.removeAttribute('open');
|
||||
testing.expectEqual(false, child.checkVisibility());
|
||||
|
||||
// Descendants of a closed-details child are hidden via the ancestor walk.
|
||||
const grandchild = document.createElement('span');
|
||||
child.appendChild(grandchild);
|
||||
testing.expectEqual(false, grandchild.checkVisibility());
|
||||
|
||||
details.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="hidden_attribute_propagates_through_check_visibility">
|
||||
{
|
||||
// [hidden] { display: none } applies to the element itself...
|
||||
const parent = document.createElement('div');
|
||||
parent.setAttribute('hidden', '');
|
||||
const child = document.createElement('span');
|
||||
parent.appendChild(child);
|
||||
document.body.appendChild(parent);
|
||||
|
||||
testing.expectEqual(false, parent.checkVisibility());
|
||||
// ...and through ancestor walking, hides descendants too.
|
||||
testing.expectEqual(false, child.checkVisibility());
|
||||
|
||||
parent.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="inline_display_overrides_ua_default">
|
||||
{
|
||||
// Author inline `display: block` on a UA-hidden tag wins per CSS cascade
|
||||
// (inline > UA stylesheet).
|
||||
const s = document.createElement('script');
|
||||
s.style.display = 'block';
|
||||
document.body.appendChild(s);
|
||||
testing.expectEqual(true, s.checkVisibility());
|
||||
s.remove();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="deep_nesting">
|
||||
{
|
||||
const levels = 5;
|
||||
|
||||
129
src/browser/tests/element/disabled_inheritance.html
Normal file
129
src/browser/tests/element/disabled_inheritance.html
Normal file
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<form>
|
||||
<fieldset id="fs" disabled>
|
||||
<legend><input id="legend_input" type="text"></legend>
|
||||
<input id="fs_input" type="text">
|
||||
<button id="fs_button" type="button">btn</button>
|
||||
<select id="fs_select"></select>
|
||||
<textarea id="fs_textarea"></textarea>
|
||||
</fieldset>
|
||||
|
||||
<fieldset id="fs_enabled">
|
||||
<legend><input id="enabled_legend_input"></legend>
|
||||
<input id="fs_enabled_input">
|
||||
</fieldset>
|
||||
|
||||
<select id="sel_disabled" disabled>
|
||||
<option id="opt_in_disabled_select">a</option>
|
||||
</select>
|
||||
|
||||
<select id="sel_enabled">
|
||||
<optgroup id="og_disabled" disabled label="g">
|
||||
<option id="opt_in_disabled_optgroup">b</option>
|
||||
</optgroup>
|
||||
<optgroup id="og_enabled" label="h">
|
||||
<option id="opt_in_enabled_optgroup">c</option>
|
||||
</optgroup>
|
||||
<option id="opt_loose">d</option>
|
||||
</select>
|
||||
|
||||
<input id="own_disabled" type="text" disabled>
|
||||
<input id="own_enabled" type="text">
|
||||
</form>
|
||||
|
||||
<div id="div_plain">x</div>
|
||||
<div id="div_with_disabled_attr" disabled>y</div>
|
||||
<span id="span_plain">z</span>
|
||||
|
||||
<script id="fieldset_inheritance">
|
||||
{
|
||||
// Form controls inside <fieldset disabled> match :disabled.
|
||||
testing.expectTrue($('#fs_input').matches(':disabled'));
|
||||
testing.expectTrue($('#fs_button').matches(':disabled'));
|
||||
testing.expectTrue($('#fs_select').matches(':disabled'));
|
||||
testing.expectTrue($('#fs_textarea').matches(':disabled'));
|
||||
|
||||
// The fieldset's first <legend> exempts its descendants.
|
||||
testing.expectFalse($('#legend_input').matches(':disabled'));
|
||||
|
||||
// Enabled fieldset doesn't propagate.
|
||||
testing.expectFalse($('#fs_enabled_input').matches(':disabled'));
|
||||
testing.expectFalse($('#enabled_legend_input').matches(':disabled'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="optgroup_inheritance">
|
||||
{
|
||||
// <option> inside <optgroup disabled> matches :disabled.
|
||||
testing.expectTrue($('#opt_in_disabled_optgroup').matches(':disabled'));
|
||||
|
||||
// <option> outside any disabled ancestor does not.
|
||||
testing.expectFalse($('#opt_in_enabled_optgroup').matches(':disabled'));
|
||||
testing.expectFalse($('#opt_loose').matches(':disabled'));
|
||||
|
||||
// <option> inside <select disabled> (no optgroup) does NOT match :disabled
|
||||
// per HTML "concept-option-disabled" — only own attr or <optgroup disabled>
|
||||
// parent contributes. <select disabled> itself matches, but does not
|
||||
// propagate to its <option> children for selector purposes.
|
||||
testing.expectFalse($('#opt_in_disabled_select').matches(':disabled'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="own_attribute">
|
||||
{
|
||||
testing.expectTrue($('#own_disabled').matches(':disabled'));
|
||||
testing.expectFalse($('#own_enabled').matches(':disabled'));
|
||||
|
||||
// The disabled <fieldset>/<select>/<optgroup> elements themselves match.
|
||||
testing.expectTrue($('#fs').matches(':disabled'));
|
||||
testing.expectTrue($('#sel_disabled').matches(':disabled'));
|
||||
testing.expectTrue($('#og_disabled').matches(':disabled'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="enabled_complement">
|
||||
{
|
||||
// :enabled is the negation of :disabled for elements that have a
|
||||
// disabled concept. An <input> inside <fieldset disabled> is :disabled,
|
||||
// so it must NOT also be :enabled.
|
||||
testing.expectFalse($('#fs_input').matches(':enabled'));
|
||||
testing.expectFalse($('#opt_in_disabled_optgroup').matches(':enabled'));
|
||||
testing.expectTrue($('#fs_enabled_input').matches(':enabled'));
|
||||
testing.expectTrue($('#opt_loose').matches(':enabled'));
|
||||
// Legend descendants are enabled despite the disabled fieldset.
|
||||
testing.expectTrue($('#legend_input').matches(':enabled'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="concept_gate">
|
||||
{
|
||||
// Per HTML "concept-fe-disabled", only listed elements (button, input,
|
||||
// select, textarea, optgroup, option, fieldset) participate in the
|
||||
// disabled concept. Anything else never matches :disabled / :enabled,
|
||||
// even with an own [disabled] attribute.
|
||||
testing.expectFalse($('#div_plain').matches(':disabled'));
|
||||
testing.expectFalse($('#div_plain').matches(':enabled'));
|
||||
testing.expectFalse($('#span_plain').matches(':enabled'));
|
||||
testing.expectFalse($('#div_with_disabled_attr').matches(':disabled'));
|
||||
testing.expectFalse($('#div_with_disabled_attr').matches(':enabled'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="querySelectorAll">
|
||||
{
|
||||
// querySelectorAll(':disabled') should find every disabled element.
|
||||
const ids = Array.from(document.querySelectorAll(':disabled')).map(e => e.id).sort();
|
||||
// Expected (sorted):
|
||||
// fs, fs_button, fs_input, fs_select, fs_textarea,
|
||||
// og_disabled, opt_in_disabled_optgroup,
|
||||
// own_disabled, sel_disabled
|
||||
const expected = [
|
||||
'fs', 'fs_button', 'fs_input', 'fs_select', 'fs_textarea',
|
||||
'og_disabled', 'opt_in_disabled_optgroup',
|
||||
'own_disabled', 'sel_disabled',
|
||||
].sort();
|
||||
testing.expectEqual(JSON.stringify(expected), JSON.stringify(ids));
|
||||
}
|
||||
</script>
|
||||
109
src/browser/tests/element/html/input_image_submit.html
Normal file
109
src/browser/tests/element/html/input_image_submit.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../../testing.js"></script>
|
||||
|
||||
<form id="img-form" action="/should-not-navigate-img" method="get">
|
||||
<input type="hidden" name="hidden_field" value="hv">
|
||||
<input id="img-named" type="image" name="img_btn" src="x.png">
|
||||
</form>
|
||||
|
||||
<form id="img-form-unnamed" action="/should-not-navigate-img2" method="get">
|
||||
<input type="hidden" name="hidden_field" value="hv2">
|
||||
<input id="img-unnamed" type="image" src="x.png">
|
||||
</form>
|
||||
|
||||
<form id="img-form-disabled" action="/should-not-navigate-img3" method="get">
|
||||
<input id="img-disabled" type="image" name="d" src="x.png" disabled>
|
||||
</form>
|
||||
|
||||
<form id="img-form-prevent" action="/should-not-navigate-img4" method="get">
|
||||
<input id="img-prevent" type="image" name="p" src="x.png">
|
||||
</form>
|
||||
|
||||
<form id="img-form-orphan" action="/should-not-navigate-img5" method="get">
|
||||
<input type="hidden" name="orphan_field" value="ov">
|
||||
</form>
|
||||
<input id="img-orphan" type="image" form="img-form-orphan" name="o" src="x.png">
|
||||
|
||||
<script id="image_click_fires_submit_event">
|
||||
{
|
||||
const form = document.getElementById('img-form');
|
||||
let submitFired = false;
|
||||
let submitter = null;
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitFired = true;
|
||||
submitter = e.submitter;
|
||||
});
|
||||
document.getElementById('img-named').click();
|
||||
testing.expectEqual(true, submitFired);
|
||||
testing.expectEqual(document.getElementById('img-named'), submitter);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="image_submitter_appends_named_coordinate_fields">
|
||||
{
|
||||
// The form-data set algorithm appends "name.x" / "name.y" coordinate
|
||||
// entries when the submitter is an image button with a name attribute.
|
||||
// Constructing FormData directly with the submitter exercises the same
|
||||
// path the click handler reaches via Frame.submitForm.
|
||||
const form = document.getElementById('img-form');
|
||||
const fd = new FormData(form, document.getElementById('img-named'));
|
||||
testing.expectEqual('hv', fd.get('hidden_field'));
|
||||
testing.expectEqual('0', fd.get('img_btn.x'));
|
||||
testing.expectEqual('0', fd.get('img_btn.y'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="unnamed_image_submitter_appends_bare_xy_fields">
|
||||
{
|
||||
const form = document.getElementById('img-form-unnamed');
|
||||
const fd = new FormData(form, document.getElementById('img-unnamed'));
|
||||
// An unnamed image submitter contributes bare "x" and "y" entries.
|
||||
testing.expectEqual('0', fd.get('x'));
|
||||
testing.expectEqual('0', fd.get('y'));
|
||||
testing.expectEqual('hv2', fd.get('hidden_field'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="disabled_image_click_does_not_submit">
|
||||
{
|
||||
const form = document.getElementById('img-form-disabled');
|
||||
let submitFired = false;
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitFired = true;
|
||||
});
|
||||
document.getElementById('img-disabled').click();
|
||||
testing.expectEqual(false, submitFired);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="image_click_preventDefault_blocks_navigation">
|
||||
{
|
||||
const form = document.getElementById('img-form-prevent');
|
||||
let submitFired = false;
|
||||
form.addEventListener('submit', (e) => {
|
||||
submitFired = true;
|
||||
e.preventDefault();
|
||||
});
|
||||
document.getElementById('img-prevent').click();
|
||||
// submit event fired, but preventDefault stops the actual navigation.
|
||||
testing.expectEqual(true, submitFired);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="image_with_form_attribute_submits_referenced_form">
|
||||
{
|
||||
const form = document.getElementById('img-form-orphan');
|
||||
let submitFired = false;
|
||||
let submitter = null;
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitFired = true;
|
||||
submitter = e.submitter;
|
||||
});
|
||||
document.getElementById('img-orphan').click();
|
||||
testing.expectEqual(true, submitFired);
|
||||
testing.expectEqual(document.getElementById('img-orphan'), submitter);
|
||||
}
|
||||
</script>
|
||||
109
src/browser/tests/window/open.html
Normal file
109
src/browser/tests/window/open.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
<body></body>
|
||||
|
||||
<script id=basic>
|
||||
{
|
||||
// window.open returns a Window-like object.
|
||||
const w = window.open('about:blank');
|
||||
testing.expectEqual(true, w != null);
|
||||
testing.expectEqual(false, w.closed);
|
||||
|
||||
// The popup is a top-level browsing context.
|
||||
testing.expectEqual(w, w.self);
|
||||
testing.expectEqual(w, w.window);
|
||||
testing.expectEqual(w, w.top);
|
||||
testing.expectEqual(w, w.parent);
|
||||
|
||||
// Opener points back at us.
|
||||
testing.expectEqual(window, w.opener);
|
||||
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=main_opener_is_null>
|
||||
testing.expectEqual(null, window.opener);
|
||||
</script>
|
||||
|
||||
<script id=about_blank_default>
|
||||
{
|
||||
// No-arg open defaults to about:blank.
|
||||
const w = window.open();
|
||||
testing.expectEqual(true, w != null);
|
||||
testing.expectEqual(false, w.closed);
|
||||
w.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=noopener>
|
||||
{
|
||||
// noopener returns null.
|
||||
const w = window.open('about:blank', '_blank', 'noopener');
|
||||
testing.expectEqual(null, w);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=noreferrer>
|
||||
{
|
||||
// noreferrer also returns null (and implies noopener).
|
||||
const w = window.open('about:blank', '_blank', 'noreferrer');
|
||||
testing.expectEqual(null, w);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=named_reuse>
|
||||
{
|
||||
// Second open with the same name reuses the popup — same Window identity.
|
||||
const a = window.open('about:blank', 'myPopup');
|
||||
testing.expectEqual('myPopup', a.name);
|
||||
|
||||
const b = window.open('about:blank', 'myPopup');
|
||||
testing.expectEqual(a, b);
|
||||
|
||||
a.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=close_noop_on_main>
|
||||
// close() on a non-popup window is a no-op.
|
||||
window.close();
|
||||
testing.expectEqual(false, window.closed);
|
||||
</script>
|
||||
|
||||
<script id=double_close>
|
||||
{
|
||||
const w = window.open('about:blank');
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
// Second close is safe.
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=name_setter>
|
||||
{
|
||||
const w = window.open('about:blank');
|
||||
testing.expectEqual('', w.name);
|
||||
w.name = 'renamed';
|
||||
testing.expectEqual('renamed', w.name);
|
||||
|
||||
// Now it can be looked up by name.
|
||||
const again = window.open('about:blank', 'renamed');
|
||||
testing.expectEqual(w, again);
|
||||
w.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=close_drops_opener>
|
||||
{
|
||||
// Closing a popup that was opened from main shouldn't touch window.opener.
|
||||
const w = window.open('about:blank', 'outer');
|
||||
testing.expectEqual(window, w.opener);
|
||||
w.close();
|
||||
testing.expectEqual(true, w.closed);
|
||||
testing.expectEqual(null, window.opener);
|
||||
}
|
||||
</script>
|
||||
@@ -600,11 +600,42 @@ pub fn hasAttributeSafe(self: *const Element, name: String) bool {
|
||||
return attributes.hasSafe(name);
|
||||
}
|
||||
|
||||
// Per HTML "concept-fe-disabled", only listed elements participate in the
|
||||
// disabled concept. Anything else (e.g. <div disabled>) has no disabled
|
||||
// state and never matches :disabled / :enabled.
|
||||
pub fn hasDisabledConcept(self: *const Element) bool {
|
||||
return switch (self.getTag()) {
|
||||
.button, .input, .select, .textarea, .optgroup, .option, .fieldset => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isDisabled(self: *Element) bool {
|
||||
if (!self.hasDisabledConcept()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (self.getAttributeSafe(comptime .wrap("disabled")) != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// <option> takes a different inheritance path: per HTML
|
||||
// "concept-option-disabled" an option is disabled when its parent is an
|
||||
// <optgroup disabled>. It does NOT inherit from <select disabled> or
|
||||
// an ancestor <fieldset disabled>.
|
||||
if (self.getTag() == .option) {
|
||||
if (self.asNode()._parent) |parent_node| {
|
||||
if (parent_node.is(Element)) |parent_el| {
|
||||
if (parent_el.getTag() == .optgroup and
|
||||
parent_el.getAttributeSafe(comptime .wrap("disabled")) != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const element_node = self.asNode();
|
||||
var current: ?*Node = element_node._parent;
|
||||
while (current) |node| {
|
||||
@@ -1663,6 +1694,32 @@ pub const Tag = enum {
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
// UA stylesheet display:none defaults per HTML Rendering §15.3.1
|
||||
// "Hidden elements" (https://html.spec.whatwg.org/multipage/rendering.html#hidden-elements).
|
||||
// The spec also lists basefont, noembed, noframes, rp; those tags are
|
||||
// obsolete and not represented in this enum, so they fall through to
|
||||
// `.unknown`/`.custom` and aren't matched here.
|
||||
pub fn isHiddenByUaStylesheet(self: Tag) bool {
|
||||
return switch (self) {
|
||||
.area,
|
||||
.base,
|
||||
.datalist,
|
||||
.head,
|
||||
.link,
|
||||
.meta,
|
||||
.noscript,
|
||||
.param,
|
||||
.script,
|
||||
.source,
|
||||
.style,
|
||||
.template,
|
||||
.title,
|
||||
.track,
|
||||
=> true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const JsApi = struct {
|
||||
|
||||
@@ -259,7 +259,12 @@ fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: *
|
||||
return writer.writeAll(value);
|
||||
}
|
||||
|
||||
for (value) |b| {
|
||||
var i: usize = 0;
|
||||
while (i < value.len) : (i += 1) {
|
||||
const b = value[i];
|
||||
if (comptime mode == .form) {
|
||||
if (try writeFormLineEnd(value, &i, b, writer)) continue;
|
||||
}
|
||||
if (urlEncodeUnreserved(b, mode)) {
|
||||
try writer.writeByte(b);
|
||||
} else if (b == ' ') {
|
||||
@@ -272,7 +277,12 @@ fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: *
|
||||
|
||||
/// Percent-encode a legacy-encoded value - must also encode & and ; to preserve NCRs.
|
||||
fn urlEncodeValueLegacy(value: []const u8, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void {
|
||||
for (value) |b| {
|
||||
var i: usize = 0;
|
||||
while (i < value.len) : (i += 1) {
|
||||
const b = value[i];
|
||||
if (comptime mode == .form) {
|
||||
if (try writeFormLineEnd(value, &i, b, writer)) continue;
|
||||
}
|
||||
if (urlEncodeUnreserved(b, mode)) {
|
||||
try writer.writeByte(b);
|
||||
} else if (b == ' ') {
|
||||
@@ -286,6 +296,25 @@ fn urlEncodeValueLegacy(value: []const u8, comptime mode: URLEncodeMode, writer:
|
||||
}
|
||||
}
|
||||
|
||||
// HTML form-data set encoding algorithm: every U+000D (CR) not followed by
|
||||
// U+000A (LF), and every U+000A (LF) not preceded by U+000D (CR), is replaced
|
||||
// with the two-byte sequence CR+LF before percent-encoding. Returns true (and
|
||||
// emits "%0D%0A") when `b` is CR or LF; on CR, advances the caller's index
|
||||
// past a following LF so existing CRLF pairs aren't doubled.
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#url-encoded-form-data
|
||||
fn writeFormLineEnd(value: []const u8, i: *usize, b: u8, writer: *std.Io.Writer) !bool {
|
||||
if (b == '\r') {
|
||||
try writer.writeAll("%0D%0A");
|
||||
if (i.* + 1 < value.len and value[i.* + 1] == '\n') i.* += 1;
|
||||
return true;
|
||||
}
|
||||
if (b == '\n') {
|
||||
try writer.writeAll("%0D%0A");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn urlEncodeShouldEscape(value: []const u8, comptime mode: URLEncodeMode) bool {
|
||||
for (value) |b| {
|
||||
if (!urlEncodeUnreserved(b, mode)) {
|
||||
@@ -409,3 +438,90 @@ test "KeyValueList: urlEncode Big5 unmappable character" {
|
||||
// 炣 percent-encoded is %26%2328835%3B
|
||||
try testing.expectString("q=%26%2328835%3B", buf.written());
|
||||
}
|
||||
|
||||
// HTML form-data set encoding algorithm: line endings in entry names and values
|
||||
// are normalized to CRLF — every stray LF (not preceded by CR) and every stray
|
||||
// CR (not followed by LF) is replaced with CR+LF before percent-encoding. The
|
||||
// normalization applies only to .form mode; URLSearchParams (.query) follows
|
||||
// the URL standard's serializer, which doesn't normalize.
|
||||
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#url-encoded-form-data
|
||||
test "KeyValueList: urlEncode .form normalizes stray LF to CRLF" {
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
try list.append(allocator, "msg", "line1\nline2\nline3");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
|
||||
|
||||
try testing.expectString("msg=line1%0D%0Aline2%0D%0Aline3", buf.written());
|
||||
}
|
||||
|
||||
test "KeyValueList: urlEncode .form normalizes stray CR to CRLF" {
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
try list.append(allocator, "msg", "line1\rline2");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
|
||||
|
||||
try testing.expectString("msg=line1%0D%0Aline2", buf.written());
|
||||
}
|
||||
|
||||
test "KeyValueList: urlEncode .form preserves existing CRLF" {
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
try list.append(allocator, "msg", "line1\r\nline2");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
|
||||
|
||||
try testing.expectString("msg=line1%0D%0Aline2", buf.written());
|
||||
}
|
||||
|
||||
test "KeyValueList: urlEncode .form handles mixed line endings" {
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
// CR LF, then bare LF, then bare CR -> three CRLF sequences.
|
||||
try list.append(allocator, "msg", "a\r\nb\nc\rd");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
|
||||
|
||||
try testing.expectString("msg=a%0D%0Ab%0D%0Ac%0D%0Ad", buf.written());
|
||||
}
|
||||
|
||||
test "KeyValueList: urlEncode .form normalizes line endings in entry names" {
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
try list.append(allocator, "n\nm", "v");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
|
||||
|
||||
try testing.expectString("n%0D%0Am=v", buf.written());
|
||||
}
|
||||
|
||||
test "KeyValueList: urlEncode .form normalizes legacy charsets too" {
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
try list.append(allocator, "msg", "a\nb");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.form, allocator, "GBK", &buf.writer);
|
||||
|
||||
try testing.expectString("msg=a%0D%0Ab", buf.written());
|
||||
}
|
||||
|
||||
test "KeyValueList: urlEncode .query does NOT normalize line endings" {
|
||||
// URL standard's application/x-www-form-urlencoded serializer (used by
|
||||
// URLSearchParams) does not perform CRLF normalization — only the HTML
|
||||
// form-data set encoding wrapper does. https://url.spec.whatwg.org/#concept-urlencoded-serializer
|
||||
const allocator = testing.arena_allocator;
|
||||
var list = KeyValueList.init();
|
||||
try list.append(allocator, "msg", "a\nb\rc");
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(allocator);
|
||||
try list.urlEncode(.query, null, "UTF-8", &buf.writer);
|
||||
|
||||
try testing.expectString("msg=a%0Ab%0Dc", buf.written());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -95,6 +96,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 +124,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 +456,121 @@ 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);
|
||||
|
||||
// Drop any pending queued navigation for this frame. Frame.deinit will
|
||||
// release the QueuedNavigation arena, but the entry in page.queued_navigation
|
||||
// would otherwise have processQueuedNavigation re-deinit the popup.
|
||||
if (frame._queued_navigation != null) {
|
||||
for (page.queued_navigation.items, 0..) |f, i| {
|
||||
if (f == frame) {
|
||||
_ = page.queued_navigation.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +980,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,37 +1073,50 @@ 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 {
|
||||
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, .{});
|
||||
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -535,10 +535,10 @@ fn matchesPseudoClass(el: *Node.Element, pseudo: Selector.PseudoClass, scope: *N
|
||||
return input.getChecked();
|
||||
},
|
||||
.disabled => {
|
||||
return el.getAttributeSafe(comptime .wrap("disabled")) != null;
|
||||
return el.isDisabled();
|
||||
},
|
||||
.enabled => {
|
||||
return el.getAttributeSafe(comptime .wrap("disabled")) == null;
|
||||
return el.hasDisabledConcept() and !el.isDisabled();
|
||||
},
|
||||
.indeterminate => {
|
||||
const input = el.is(Node.Element.Html.Input) orelse return false;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -713,13 +713,16 @@ test "cdp.dom: getBoxModel" {
|
||||
});
|
||||
try ctx.expectSentResult(.{ .nodeId = 3 }, .{ .id = 4 });
|
||||
|
||||
// Box model on the <p> nodeId returned above.
|
||||
// Note: nodeId 6 is <head>, which is `display: none` per HTML Rendering
|
||||
// §15.3.1, so its box model is all-zeros — exercise a visible element.
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "DOM.getBoxModel",
|
||||
.params = .{ .nodeId = 6 },
|
||||
.params = .{ .nodeId = 3 },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .model = BoxModel{
|
||||
.content = Quad{ 10.0, 10.0, 15.0, 10.0, 15.0, 15.0, 10.0, 15.0 },
|
||||
.content = Quad{ 25.0, 25.0, 30.0, 25.0, 30.0, 30.0, 25.0, 30.0 },
|
||||
.padding = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
.border = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
.margin = Quad{ 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 },
|
||||
|
||||
@@ -41,6 +41,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
|
||||
fillNode,
|
||||
scrollNode,
|
||||
waitForSelector,
|
||||
handleJavaScriptDialog,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
@@ -54,6 +55,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
|
||||
.fillNode => return fillNode(cmd),
|
||||
.scrollNode => return scrollNode(cmd),
|
||||
.waitForSelector => return waitForSelector(cmd),
|
||||
.handleJavaScriptDialog => return handleJavaScriptDialog(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +295,53 @@ fn waitForSelector(cmd: anytype) !void {
|
||||
}, .{});
|
||||
}
|
||||
|
||||
// Lightpanda-namespaced pre-arm for window.alert/confirm/prompt return values.
|
||||
//
|
||||
// Standard CDP drivers send Page.handleJavaScriptDialog *reactively* in
|
||||
// response to a Page.javascriptDialogOpening event — the dialog suspends
|
||||
// JS, the client picks accept/dismiss, the runtime resumes. Lightpanda's
|
||||
// dialogs auto-dismiss in headless mode (window.alert/confirm/prompt
|
||||
// return immediately rather than blocking V8), so by the time the event
|
||||
// reaches the client, JS has already returned. A reactive call has
|
||||
// nothing left to influence — full Chrome-faithful behavior would
|
||||
// require V8 suspension, which #2082 / PR #2085 deferred.
|
||||
//
|
||||
// LP.handleJavaScriptDialog gives Lightpanda-aware clients a *proactive*
|
||||
// opt-in: the client sets {accept, promptText} *before* triggering the JS
|
||||
// that opens the dialog. The handler stashes the response on the
|
||||
// BrowserContext; when the next dialog dispatches the
|
||||
// `javascript_dialog_opening` notification, the listener in page.zig
|
||||
// pops the stash and fills it into the dispatch's response output param.
|
||||
// window.alert/confirm/prompt then return that value.
|
||||
//
|
||||
// Page.handleJavaScriptDialog continues to return -32000 "No dialog is
|
||||
// showing" so reactive Chrome-style drivers see no semantic change.
|
||||
//
|
||||
// Without a pre-armed response, behavior is unchanged from PR #2085:
|
||||
// confirm→false, prompt→null, alert→void.
|
||||
fn handleJavaScriptDialog(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
accept: bool,
|
||||
promptText: ?[]const u8 = null,
|
||||
})) orelse return error.InvalidParam;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
||||
|
||||
// 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, .{});
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.lp: getMarkdown" {
|
||||
var ctx = try testing.context();
|
||||
@@ -441,3 +490,117 @@ test "cdp.lp: waitForSelector" {
|
||||
const err_obj = (try ctx.getSentMessage(2)).?.object.get("error").?.object;
|
||||
try testing.expect(err_obj.get("code") != null);
|
||||
}
|
||||
|
||||
test "cdp.lp: handleJavaScriptDialog accepts/dismisses without an open dialog" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
// Without a BrowserContext: error (matches other LP handlers' shape).
|
||||
try ctx.processMessage(.{ .id = 1, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
|
||||
try ctx.expectSentError(-31998, "NoBrowserContext", .{ .id = 1 });
|
||||
}
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-D1", .url = "cdp/dialog.html", .target_id = "FID-000000000X".* });
|
||||
|
||||
{
|
||||
// Pre-arming with accept=true succeeds. Headless browsers auto-dismiss,
|
||||
// so the CDP client sends LP.handleJavaScriptDialog *before* the JS
|
||||
// that opens the dialog — handler stashes the response on the
|
||||
// BrowserContext.
|
||||
try ctx.processMessage(.{ .id = 2, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
|
||||
try ctx.expectSentResult(null, .{ .id = 2 });
|
||||
}
|
||||
|
||||
{
|
||||
// Pre-arming with accept=false also succeeds.
|
||||
try ctx.processMessage(.{ .id = 3, .method = "LP.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 = "LP.handleJavaScriptDialog", .params = .{ .accept = true, .promptText = "hello" } });
|
||||
try ctx.expectSentResult(null, .{ .id = 4 });
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp.lp: 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: lp.js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
// ---- confirm: accept=true makes confirm() return true ----
|
||||
try ctx.processMessage(.{ .id = 1, .method = "LP.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 = "LP.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 = "LP.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 = "LP.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 = "LP.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 = "LP.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());
|
||||
}
|
||||
|
||||
@@ -693,6 +693,10 @@ 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.
|
||||
//
|
||||
// Lightpanda-aware clients that want to control confirm/prompt return
|
||||
// values can pre-arm a response via LP.handleJavaScriptDialog instead
|
||||
// (see src/cdp/domains/lp.zig).
|
||||
_ = try cmd.params(struct {
|
||||
accept: bool,
|
||||
promptText: ?[]const u8 = null,
|
||||
@@ -702,6 +706,15 @@ fn handleJavaScriptDialog(cmd: *CDP.Command) !void {
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-javascriptDialogOpening
|
||||
pub fn javascriptDialogOpening(bc: anytype, event: *const Notification.JavascriptDialogOpening) !void {
|
||||
// Pop any response pre-armed via LP.handleJavaScriptDialog 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user