We want to take in account the pending frame in Runner._tick to continue
to process. If we use only the current frame, we will immediately return
in case of navigation.
During a root navigation, we keep the existing page active until we get
the headers callback from the pending page. Then
Session.commitPendingPage makes the switch.
It delays the deinit of CPD execution context to handle JS execution in
the meantime.
Now session has an array of two pages, _active_idx points to the main
page.
Both active and pending pages share the same frame_id, it must remains
stable. So this PR adds a Request.protect_from_abort to avoid removing
the request form the pending page when deinit the previous active page.
When a CDP client pre-arms `LP.handleJavaScriptDialog {accept: true}` with
no `promptText` and the page subsequently calls `window.prompt(message,
defaultText)`, Lightpanda discarded the dialog's `defaultText` argument
and returned `""`. Chrome's behavior is to surface `defaultText` as the
prompt's return value — that's the natural "user accepted without typing
anything" outcome per the HTML simple-dialog algorithm.
The fix is one line in `Window.zig`'s `prompt` JS binding: keep the
second argument named (`default_text` instead of `_`) and use it as the
fallback. Pre-armed `promptText` still wins when the client supplies it;
`accept = false` still returns null regardless. When neither
`promptText` nor `defaultText` is provided, the binding still returns
`""` per the CDP spec.
Adds four new assertions to the existing `cdp.lp` integration test
covering the matrix: defaultText fallback (no promptText), promptText
overrides defaultText, accept=false ignores defaultText, and the
existing no-defaultText case continues to return `""`.
Per upstream review (#2261): Page.handleJavaScriptDialog is a standard
CDP method that reactive Chrome-style drivers send in response to a
Page.javascriptDialogOpening event. Repurposing it for the proactive
pre-arm flow risks surprising those drivers — the next dialog they
trigger would be blindly accepted.
Move the pre-arm handler to LP.handleJavaScriptDialog so only
Lightpanda-aware clients opt in. Page.handleJavaScriptDialog reverts
to the upstream stub returning -32000 "No dialog is showing" so the
reactive surface is unchanged. The Page.javascriptDialogOpening event
listener still pops the BrowserContext stash and fills the response
output param — only the setter moved.
Tests rename cdp.page: → cdp.lp: and target LP.handleJavaScriptDialog;
behavior coverage is identical.
`StyleManager.hasDisplayNone` honored only the `[hidden]` UA-stylesheet
rule; the rest of HTML Rendering §15.3.1 ("Hidden elements") was
unimplemented. As a result `getComputedStyle(headEl).display === "block"`
and `el.checkVisibility()` returned `true` for `<head>`, `<script>`,
`<style>`, `<link>`, `<meta>`, `<title>`, `<noscript>`, `<template>`,
`<param>`, `<source>`, `<track>`, `<area>`, `<datalist>`,
`<input type="hidden">`, and the non-`<summary>` direct children of a
closed `<details>`.
Add a `matchesUaDisplayNoneRule` helper consulted at the top of
`isElementHidden` so both `getComputedStyle().display` (via
`hasDisplayNone`) and `el.checkVisibility()` (via `isHidden`'s ancestor
walker) honor the same UA-stylesheet truth. The helper covers the
`[hidden]` attribute, a new `Tag.isHiddenByUaStylesheet()` predicate
mapping the spec's unrendered tag list, the
`input[type="hidden" i]` rule, and the `details:not([open]) > *:not(summary)`
parent-relationship rule. Inline `display` overrides still win per CSS
cascade.
Closes#2293
Anchor click, form submit, and `location.href = ...` assignments queue a
navigation through `Frame.scheduleNavigation`, which then tears down the
originating page and rebuilds the frame in `Session.processRootQueuedNavigation`
before `Frame.navigate` issues the HTTP request. The originator's URL was
discarded with the old arena, so the request went out without a Referer
header — even though the HTML "navigate" algorithm and Fetch §4.5 require
one. `Frame.headersForRequest` (#1449) handled subresource fetches but was
never called from the navigation path.
Capture the originating frame's URL into a new `referer` field on
`NavigateOpts` at scheduling time, dup'd into the `QueuedNavigation` arena
so it survives the page tear-down. `Frame.navigate` adds it as a
`Referer:` header alongside the existing per-request headers. Iframe
initial navigation (`Frame.zig:1282`) also sets `referer = parent.url`
since the parent frame outlives that direct `navigate` call. CDP
`Page.navigate` (`.reason = .address_bar`) and `Page.reload` continue to
omit Referer — matches Chrome.
Closes#2281
If a POST navigation gets redirected (302/303), the page that actually
loads is fetched with GET — but Frame._navigated_options still carries
the original POST method, body, and Content-Type. Page.reload would
then re-POST the form data to the redirect target, which is both
incorrect and dangerous (re-submission of credentials, charges, etc.).
Reset method/body/header on _navigated_options inside
frameHeaderDoneCallback whenever response.redirectCount() > 0. The full
spec-correct version would distinguish 307/308 (preserve method) from
301/302/303 (convert POST→GET), but resubmitting form data is the more
dangerous failure mode — conservative reset matches Chrome's practical
behavior on reload.
Also collapse the prev_body/prev_header extraction in doReload to a
single tuple-destructured blk: block (no behavior change).
Tests: new cdp.frame: reload after POST→redirect drops the POST drives
POST /redirect_to_echo → 302 → /echo_method, then Page.reload, asserts
the second request is GET. /redirect_to_echo route added to
testing.zig. The existing reload-replays-POST test still passes (no
redirect, POST is still replayed).
Per RFC 7231 §7.1.2, when a 3xx response carries a Location header
without a fragment, the user agent must process the redirect as if
the value inherited the fragment of the request URL. URL.resolve
follows RFC 3986 §5.3 which drops the base fragment, so handleRedirect
now reattaches the original fragment when the resolved target has none.
Closes#2263
Page.handleJavaScriptDialog previously responded -32000 "No dialog is
showing" regardless of whether a dialog was open, leaving CDP clients
no way to influence the JS-side return value of confirm() / prompt().
PR #2085 wired up the Page.javascriptDialogOpening event but explicitly
deferred the return-value override since true Chrome semantics require
suspending V8 mid-execution.
Add a pre-arm model that fits the auto-dismiss architecture without
runtime suspension: handleJavaScriptDialog stashes {accept, promptText}
on the BrowserContext; when the next JS dialog dispatches the
javascript_dialog_opening notification, the listener pops the stash and
fills it into the dispatch's response output param so Window.confirm /
prompt return the CDP client's choice. Without a pre-arm, headless
auto-dismiss values from PR #2085 are preserved (confirm->false,
prompt->null, alert->void).
Closes#2260
doReload built a NavigateOpts with only url + kind=.reload; method/body/header
defaulted to GET/null/null, so any prior POST navigation regressed to a GET
on reload. The HTML reload navigation re-fetches the document that produced
the current entry, and Chrome replays the same HTTP request that loaded the
page (including method, body, and Content-Type) — Lightpanda dropped all
three.
Retain the prior request body and content-type header in Frame.NavigatedOpts
(duped into the frame arena), and have doReload capture them into the CDP
command's arena before replacePage() frees the old frame. The reload's
frame.navigate call carries the replayed method/body/header so the request
the page was loaded with is the request that runs again.
Closes#2258
The empty Zig anonymous struct `.{}` serializes to `[]` (tuple → JSON
array), not `{}`. The dispatch path's `InputParams.jsonParse` requires
the params field to begin with `object_begin`, so the previous test
fixtures hit `error.UnexpectedToken` → `error.InvalidJSON` instead of
exercising the production code paths.
Switch the two empty-params test cases to raw JSON string literals,
which the testing helper passes through unchanged (string literals are
pointer types and skip `std.json.Stringify.valueAlloc`). The production
code paths under test are unchanged.
`Network.clearBrowserCookies` had an inverted-logic guard that returned
`InvalidParams` whenever the caller included a `params: {}` field — which
most CDP libraries (chrome-remote-interface, chromedp, etc.) do
unconditionally. The CDP spec defines no parameters for the method, but
JSON-RPC convention is to silently accept extra ones; Chrome and the
sibling `Storage.clearCookies` handler already do. Drop the guard.
`Network.getAllCookies` was missing from the `Network` dispatch enum and
returned `UnknownMethod`. Add a small handler that returns the entire
cookie jar via the existing `CdpStorage.CookieWriter`, mirroring
`Storage.getCookies` minus the `browserContextId` filter (Network
commands are scoped to the current browser context already).
Closes#2254
Follow up to https://github.com/lightpanda-io/browser/pull/2200
This change is actually pretty mundane, but a bunch of files that used to
take a *Session (e.g. every WebAPI releaseRef and deinit) now take a *Page.
This aims to separate the 2 lifetimes currently managed by Session by moving
the "Page" lifetime to a dedicated container: Page. Ultimately, the goal is to
remove the 1-page-per-session limit of the current design. Not to explicitly
support multiple pages per session (though, that's more possible now), but
in order to better emulate Chrome where, during a navigation event, the old and
new page both exist.
Chromium's accessibility tree prunes elements hidden via `display:none`,
`visibility:hidden`, the `hidden` attribute, `inert`, or `aria-hidden="true"`
and we match this behavior. It's correct for most elements, but it breaks
a common form pattern: CSS-only toggle switches and custom radio groups
hide the real `<input>` and style the `<label>` as the interactive surface.
Before, an agent walking the AX tree for a toggle switch saw:
{role: "none", name: "Enable feature", ignored: false}
a generic element with no indication it's interactive and no exposure of
the underlying `checked` state. The input was pruned and the label had no
intrinsic role.
Now, when a `<label>` is associated (via `for=` or by wrapping) with a
hidden checkbox or radio input, the label is promoted to the input's role
and state:
{role: "checkbox", name: "Enable feature",
properties: [..., {checked: "true"}, {focusable: true}, ...]}
Native browsers already forward label clicks to the associated input, so
the label's `backendDOMNodeId` remains a valid click target — no action-
side changes needed.
Scope is intentionally narrow:
- Only checkbox and radio inputs. Other labelable controls (textarea,
select, etc.) don't have the "visible label as interactive surface"
idiom.
- Skipped when the label has an explicit `role=` attribute, to respect
the page's declared semantics.
- Skipped when the input is visible: normal AX tree flow already
surfaces it, and promoting would double-count.
The fixture `src/browser/tests/cdp/ax_tree.html` covers:
- `display:none` checkbox, checked, referenced by `<label for=id>`
- `display:none` radio, checked, referenced by `<label for=id>`
- `visibility:hidden` radio, unchecked, referenced by `<label for=id>`
- Wrapping `<label>` containing a `display:none` checkbox
This is to pave the way for introducing a new "Page" container, which will take
over the page lifecycle currently burdening Session. The ultimate goal of that
is to allow the Session to have multiple pages (mostly for better transitions
between pages), which is hard to do now since the Session has so much state.
This rename was aggressive, e.g. currentPage() -> currentFrame() so that, when
the new Page container is added, you won't see "currentPage()" and wonder:
"Does 'currentPage' mean the new Page container, or the Frame (which
used to be called Page)".
Rename page.id -> page._loader_id and propagate the change throughout. This was
my attempt at pretending that page.id (and page._frame_id) weren't CDP-sepcific.
But they are, and it's a lot cleaner to treat them this way. Might seem
unnecessary, but without this, after page -> frame, you'd end up with:
frame.id
frame._frame_id
Which is weird? What is `frame.id` if it isn't the frame id and if that's the
case, what's frame_id? Now it'll be:
frame._loader_id
frame._frame_id
Which removes the ambiguity, makes the CDP code a bit more obvious, and doesn't
try to hide the fact that these are CDP things that, for now at least, pollute
the code a little.
I asked claude to do for the png what it did for the pdf. The savings aren't as
big AND, it isn't as nice as the original.
I'm tempted to say we should keep the original, but I don't feel strongly about
it and maybe saving a few KB is worth it?