Correctly encapsulate events that happen within a shadowroot to stop from
leaking out. This involves calculating the related_target relative to the
potential node receiving the event.
Also Event.composedPath now re-uses the same path-building logic as EventManager
(previously, the same-ish algo in both places, that were out of sync).
Some smaller changes:
- input.list getter (showed up in the shadow dom wpt tests)
- activeElement getter added to ShadowDOM
- document activeElement is ShadowDOM-aware and won't cross
On a redirect, recalculate the Referer header based on the new target.
Also, allow SVG anchors to be clicked (doesn't seem related, but it came up
in referrer-policy WPT tests).
checkIdleNotifications was only called for conditions still pending, but
an idle notification needs a check 500ms+ after the condition first held
(Frame.IdleNotification). On a quiet page the same tick both starts that
hold and resolves the condition via is_done, so nothing advanced the
state machine for the rest of the wait. The CDP pump waits in 1s slices
(CDP.pageWait), so Page.lifecycleEvent networkIdle/networkAlmostIdle was
starved until a later slice built a fresh condition.
That event is what puppeteer's networkidle0/networkidle2 and
playwright's networkidle block on. Measured with puppeteer against
lightpanda serve, goto(waitUntil: networkidle0) on example.com, median
of 5: 2001ms before, 595ms after (the page loads in ~100ms, so ~600ms is
the floor: load plus the 500ms hold). Every pre-fix run landed within
1999-2195ms - the stall is quantized to whole pump slices, not network
variance. An ad-heavy page is unaffected either way (3196ms vs 2859ms,
overlapping ranges): pending timers keep is_done false, so its condition
never resolved early to begin with.
Non-CDP waits are deliberately unchanged: waitForFrame(.networkidle) on
a quiet page still resolves immediately via is_done rather than serving
the 500ms hold. Agent/MCP waitForState and fetch --wait-until networkidle
want "settled now", not chrome's lifecycle heuristic.
Per spec, CustomElement creation/upgrade errors should be reported to the window
This also adds more (mostly edge case) validation, such as an element that fails
to be upgraded can never be upgraded again.
Pulls in the latest three zenai commits:
- f721fd4 feat(retry): honor Retry-After hints on retryable responses
- 103f5e1 feat(retry): treat 408 request timeout as retryable
- 75597b3 feat(types): sync with upstream Go SDK releases
Some models (gpt-5.x via codex) zero-fill optional tool params instead
of omitting them, so every tree/markdown/html call carried
backendNodeId: 0, failed with NodeNotFound, and got retried blind --
one 'go to hacker news' burned 19 tool calls and 18 full page loads.
Registry ids start at 1, so 0 can never name a real node. Make
0-means-omitted part of the tool contract: state it in the schema
descriptions and normalize it in parseValue. Required ids
(nodeDetails) are untouched.
In-band guidance alone was not enough -- the model re-emitted the
identical call even when the error spelled out the fix -- but keep it
for genuinely stale ids: NodeNotFound and FrameNotLoaded now carry an
actionable message in the agent, slash-command, and MCP paths instead
of a bare error name.
fetchThread and mcpThread caught their errors, logged fatal, and
returned void; the main thread joined and exited 0. A fetch whose
--wait-selector timed out (or any lp.fetch/browser-init error) printed
$level=fatal and still reported success to the shell. Same for mcp
stdio errors, and for three fatal paths in the mcp HTTP branch that ran
on the main thread and returned void.
Threads now hand the error back through an out-pointer (the agentThread
failed/cancelled pattern) and the dispatch returns it after join; the
mcp main-thread paths return their errors directly.
Clean mcp stdin EOF is unaffected: processRequests returns normally on
EOF (router.zig takeDelimiter orelse break), so only real failures
reach the exit code.
Out of scope: a failed navigation in fetch still exits 0 by design —
it renders the synthetic "Navigation failed" error page and dumps it;
--json carries the status for detection.
The implicit default was done (full quiescence), capped at --wait-ms.
Pages with constant background activity (ad timers, live sockets) never
go quiescent, so every default fetch of such a page silently rode the
full 5s cap; the timeout is non-fatal (Runner.waitForAll discards
WaitResult.timeout), so the default was effectively "wait 5s, then dump
whatever is there". load matches the agent/MCP default_nav_wait and its
rationale.
Measured on an ad-heavy news hub (ReleaseFast, interleaved runs):
default now ~3.2s vs 5.8-5.9s with done, with the load-time dump
containing slightly more of the extracted content, not less (the extra
seconds of ad-script execution mutate the page, they do not add to it).
A quiet page dumps byte-identical output under both.
Explicit --wait-until done|networkidle and --wait-selector/--wait-script
are unchanged; the fetch() doc comment now describes when error.Timeout
can actually be raised (selector/script phases only).
The `run` subcommand is the intended way to replay a recorded
PandaScript (`agent <script>.js` still works, but `run` carries no LLM
options and states the intent). Update the generated script-writing
skill, the recorder doc comment, and the README accordingly.
Fixes https://github.com/lightpanda-io/browser/issues/3141
We now capture request body provided they are <= 64KB (arbitrary limit, we can
revisit, but this doesn't seem to be an overly common request and this pins
the bodies in memory until frame end, so I wanted to take it slowly).
Adds Network.getRequestPostData and the `postData` and the `postDataEntries`
fields to the Request object.