Merge remote-tracking branch 'origin/main' into fix-a22-iscontenteditable

This commit is contained in:
Navid EMAD
2026-04-29 14:40:12 +02:00
22 changed files with 1362 additions and 81 deletions

79
.github/workflows/package-archlinux.yml vendored Normal file
View 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
View 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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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), .{});
}
},

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
<p>dialog test page</p>

View File

@@ -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;

View 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>

View 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>

View 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>

View File

@@ -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 {

View File

@@ -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" {
// &#28835; 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());
}

View File

@@ -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, .{});

View File

@@ -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", .{});
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 },

View File

@@ -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());
}

View File

@@ -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;