Closes the DOMParser gap left as a follow-up in the previous review-fix
commit. DOMParser.parseFromString built its target Document via the
frame's parser without touching `_parse_mode`, so `Build.created` →
`linkAddedCallback` → `loadExternalStylesheet` saw `_parse_mode ==
.document` and fetched/registered sheets on the LIVE frame document for
every stylesheet link in the parsed string.
Bracket both the text/html and XML branches with the same fragment
parse-mode `parseHtmlAsChildren` uses. The existing gate in
`loadExternalStylesheet` already short-circuits on .fragment, so no
change is needed there. Side benefits: parser-emitted scripts in
DOMParser content stop reaching `scriptAddedCallback` against the live
frame, default-script injection skips DOMParser content, and mutation
observers on the live document no longer fan out for parsed nodes —
all of which match what DOMParser should do per spec.
Regression test extended to cover the DOMParser path alongside the
existing innerHTML case.
Refs #2343
Addresses 8 findings from ultrareview on the external stylesheet feature:
* UAF on CDP teardown during syncRequest. `loadExternalStylesheet`
pumps the CDP socket inline, so a `Target.closeTarget` arriving
mid-fetch could drive `Session.removePage` and free the frame
while we still held `self`. Set `_script_manager.base.is_evaluating`
around the call — the same bracket every other syncRequest caller
uses, which is what `Session.removePage`'s reentrancy guard checks.
* Disconnect leak. `link.remove()` left the sheet on
`document.styleSheets` and in the cascade forever; the disconnect
walker had a `<style>` branch but no `<link>` mirror. Common SPA
theme-switch pattern (append new sheet, remove old) was broken.
Added the parallel `else if` branch.
* Fragment-parsed links. `Build.created` fires for parser-instantiated
elements before attachment, including innerHTML / outerHTML /
insertAdjacentHTML / Range.createContextualFragment / <template>
content. Without a guard those fetched against the live document
and registered phantom sheets even when the fragment was never
attached. Added `_parse_mode == .fragment` early-return mirroring
the existing `nodeIsReady` short-circuit. DOMParser is a separate
case (parses with `.document` into a different Document) and is
left as a known follow-up.
* Missing Referer. Every other resource-fetch path
(ScriptManagerBase, XHR, Fetch, WorkerGlobalScope) routes through
`Frame.headersForRequest` to attach the cached `Referer` header.
Many CDNs gate stylesheet delivery on Referer; without it requests
returned 403/302 and the CSS silently failed. Added the call.
* Header OOM leak. `headers.add` between `newHeaders()` and
`syncRequest` (which takes ownership) leaked the initial 3-entry
slist on OOM. Added `errdefer headers.deinit()` mirroring
RobotsLayer.zig:121-122.
* `_href` mutated before parse could fail. On parse error the cached
sheet was left with the new URL but old rules dropped — violated
the "previous sheet intact on failure" invariant the PR description
promises. Moved the `_href` assignment to after `replaceSync`
succeeds. Full atomicity would require a scratch-list pattern in
`CSSStyleSheet.replaceSync` itself; documented as a known limit.
* `_sheet` cached before registration could OOM. If `sheets.add`
failed, `link._sheet` pointed at an unregistered sheet and every
future re-fetch short-circuited via the `orelse` branch, leaving
the sheet permanently unreachable through `document.styleSheets`.
Assign `link._sheet` only after `sheets.add` succeeds.
* Stale CLI help text claimed `--enable-external-stylesheets` was a
no-op surface. Removed the obsolete sentence.
New regression tests cover fragment-parse skip and disconnect
removal+re-add. Full suite 694/694 pass.
Refs #2343
Caught in code review: `loadExternalStylesheet` created a fresh
`CSSStyleSheet` and appended to `document.styleSheets` on every call, so
mutating `link.href` on a connected stylesheet element accumulated stale
sheets — the old rules kept cascading because the previous sheet was
never removed.
Cache the sheet on `Link._sheet` (mirroring `Style._sheet`) and reuse it
via `replaceSync` on re-fetch. First load creates + registers as before;
subsequent loads swap content in place, keeping `document.styleSheets`
length stable.
On fetch failure the cached sheet is untouched — matches browser
behavior where a broken href doesn't invalidate the previously loaded
sheet until the link itself is removed.
Refs #2343
Wires up --enable-external-stylesheets / LP.configureLoading.externalStylesheets
from the prior surface-only commit. When the flag is set, parser- and
JS-created <link rel=stylesheet> elements now synchronously fetch and parse
their href, register a CSSStyleSheet on document.styleSheets, and feed
StyleManager so checkVisibility() reflects external rules. Flag stays
default-off — scrapers that don't need accurate visibility pay nothing.
Frame.loadExternalStylesheet mirrors ScriptManager.addFromElement: same
HttpClient.syncRequest path, same arena ownership, same per-frame
notification + cookie wiring. Body is routed through CSSStyleSheet.replaceSync,
which already parses, populates cssRules, and calls sheetModified() — no
StyleManager changes needed. 2 MiB hard cap on a single sheet body, status
non-2xx and oversize both fire `error` on the link.
Link.Build.created is added so static head <link> elements reach
linkAddedCallback at all — void elements never trigger nodeComplete, which
is why static `<link>` had no observable effect before. Mirrors Image.
HttpClient.Request.ResourceType gains a `.stylesheet` variant so CDP Network
events report the right type; cdp.fetch.zig switches updated.
Refs #2343
Reserves the CLI flag and LP.configureLoading externalStylesheets field
so drivers can adopt the API before the fetch implementation lands in a
follow-up that depends on #2303.
The bool is intentionally unread in this PR. Mirrors the existing
--disable-subframes / --disable-workers plumbing; the CDP field extends
LP.configureLoading alongside subFrame and worker without breaking
existing callers.
Refs #2343
Replace `grep '"id":N' | jq -e ...` with `jq -ec 'select(.id == N) | ...'`.
The grep form also matched `"id":10`, `"id":11`, ... and any tool description
containing that substring; numeric `select` is type-correct. `jq -e` still
fails the job when `select` produces no output (exit 4), so the smoke
semantics are preserved.
Also add `jq --version` up front so the job fails fast and loud if the
`ubuntu-latest` image ever stops shipping jq.
- Depth counter when recursing
- Better comment support
- Small perf tweak (e.g. lowercase once into stack buffer before multiple
compares)
- Few more test cases
- Download rustup to a file then execute, so a failed curl is not
masked by sh's exit code under /bin/sh (no pipefail).
- Add --no-install-recommends and apt-list cleanup to both apt stages
(stage 0 drops from 156 to 116 packages, 1144 MB to 605 MB).
- Add --retry 3 --retry-delay 2 to all 4 external downloads.
- Use git clone --depth 1 (28 MB to 9.6 MB working tree).
- Drop -v from tar for minisign and zig extractions (log noise only).
Final shipped image is unchanged; the wins live in the builder stage
and build-cache footprint.
Sends initialize + notifications/initialized + tools/list over stdin
and asserts the JSON-RPC responses with jq. Catches regressions in
the agentic surface (./lightpanda mcp) without needing a node client.
Reuses the existing lightpanda-build-release artifact, so the new
job costs about a minute on top of zig-build-release.
Without -Dprebuilt_v8_path, the build/test targets rebuild V8 from
source (10+ minutes per invocation). Contributors who already have a
cached archive can now short-circuit by exporting V8_PATH:
V8_PATH=v8/libc_v8.a mise exec -- make test
When V8_PATH is empty (default), behavior is unchanged.
Address review feedback on PR #2478:
- MediaQuery.zig: strip CSS `/* ... */` comments before tokenization so
`screen and /*x*/ (min-width: 1px)` evaluates the same as without the
comment.
- MediaQuery.zig: bound-check `em` / `rem` multiplication via
`std.math.mul` so a u32-overflowing length (e.g. `268435456em`) fails
closed instead of panicking in debug or wrapping in release.
- StyleManager.zig: prelude brace search skips `/* ... */` comments, so
`@media /* { fake */ screen { ... }` splits at the real opening brace
rather than the one inside the comment.
- Tests: unit tests for stripped comments, em/rem overflow, and
unimplemented units (cm/mm/pt/in/vw). HTML fixtures cover commented
preludes/queries and the `replaceSync` cascade path.
Inline `@media` rules were parsed but never applied to the cascade, and
`window.matchMedia(q).matches` always returned false. Add a Media Queries
Level 4 subset evaluator (`width`/`height`/`orientation`, lengths in
`px`/`em`/`rem`, comma OR, `and`, `not`, `only`) wired into both surfaces.
External `<link rel="stylesheet">` fetch remains out of scope; the
evaluator reads the same 1920x1080 viewport already exposed by
`Window.innerWidth` / `innerHeight`.
Closes#2477
V8's `JSON::Stringify` finishes by calling `Object::ToString` on whatever
`i::JsonStringify` returns. For values that `JSON.stringify` treats as
non-serializable at the top level (`undefined`, functions, symbols),
`i::JsonStringify` yields the undefined sentinel and `ToString` coerces
it to the JS string `"undefined"`. `Value.jsonStringify` then wrote those
9 bytes raw via `writer.writeAll`, embedding a bare `undefined` token in
the JSON stream — invalid per RFC 8259 and rejected by any strict-JSON
CDP client. Detect the sentinel and emit JSON `null` instead, matching
what `JSON.stringify` produces when the same value sits in an array slot
(`JSON.stringify([undefined])` → `"[null]"`).
Closes#2473
CDP target IDs (`FID-{d:0>10}`) must stay unique for the lifetime of
the CDP connection -- Playwright's `CRBrowser._onAttachedToTarget`
asserts on duplicates and the assertion is fatal (the connection is
unusable afterwards).
Before this fix, `Session.frame_id_gen` reset to 0 in two places:
1. `tearDownActivePage` explicitly reset to 0 after every page
teardown (likely intended to mimic pre-pending-page numbering
within a single Session, but invisible there because the
immediately-following `installNewActivePage` typically reuses
the old frame's explicit `frame_id`, see `replaceRootImmediate`).
2. Fresh Sessions started from the field default of 0. Each
`Target.createBrowserContext` calls `Browser.newSession`, which
deinits the old Session and constructs a new one -- so even
without (1), the next BrowserContext's first page would still
get `FID-0000000001`.
(2) is what trips Playwright on the second `browser.newContext()`
on a connection: the second context's first frame re-issues
`FID-0000000001`, identical to the first context's frame, and
Playwright's `CRBrowser._onAttachedToTarget` raises
`Duplicate target FID-0000000001`.
Move `frame_id_gen` (and `nextFrameId`) from `Session` to `Browser`,
which is per-CDP-connection. Existing callers (`Session.createPage`,
`Frame.zig:1327`, `Frame.zig:1437`, `Worker.zig:74`) still go through
`Session.nextFrameId` -- it's now a thin pass-through to
`browser.nextFrameId()` -- so no call sites change. Removed the
explicit reset in `tearDownActivePage`; it was redundant within a
Session (root navigation reuses the old frame_id) and harmful across
Sessions.
`loader_id_gen` stays on Session: Loader IDs (`LID-...`) are scoped
per-frame in CDP and Playwright doesn't track them in the target
registry, so the per-Session reset is correct there.
Repro (`playwright-core@1.58.2`):
for (let i = 1; i <= 3; i++) {
const ctx = await browser.newContext();
await ctx.newPage();
await ctx.close();
}
Before: cycle 2 throws `Duplicate target FID-0000000001`.
After: 5/5 cycles complete cleanly.
Tests: 653/653 pass. Added regression coverage in
`cdp.target: createTarget assigns unique IDs across BrowserContexts
(issue #2472)` -- verified to fail against the original source
(reverted Browser.zig and Session.zig, kept the test, ran zig build
test: only the new test fails).
`cli.zig` is now aware of `help` command at all situations and creates it by itself. Instead of using errors, it initializes `Command` union where `help` branch is active.