detachFromTarget and setAutoAttach(false) both null bc.session_id
without notifying the client. Per the CDP spec, detaching a session
must fire a Target.detachedFromTarget event so the driver stops
sending messages on the stale session ID.
Capture the session_id before nulling it and fire the event in both
code paths. Add tests covering the event emission and the no-session
edge case.
Fixes#1819
This contribution was developed with AI assistance (Claude Code + Codex).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Small tweaks to https://github.com/lightpanda-io/browser/pull/1896
Improve the wait ergonomics with an Option with default parameter. Revert
page pointer logic to original (don't think that change was necessary).
Add `--wait_until` and `--wait_ms` CLI arguments to configure session wait behavior. Updates `Session.wait` to evaluate specific page load states (`load`, `domcontentloaded`, `networkidle`, `fixed`) before completing the wait loop.
Playwright calls Emulation.setUserAgentOverride when creating a
browser context with a custom user agent. Without this handler,
Lightpanda returns UnknownMethod which crashes the Playwright
driver.
Add a noop handler matching the existing pattern for other
Emulation methods (setDeviceMetricsOverride, setEmulatedMedia, etc.)
so the CDP handshake can proceed.
Fixes#1436
Signed-off-by: JiangNan <1394485448@qq.com>
processMessage no longer returns Zig errors when dispatchCommand fails —
it sends a CDP error response and continues. Update all expectError calls
to use processMessage + expectSentError instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a CDP command with an unrecognized domain (e.g. `NonExistent.method`)
was sent, the error response was correctly returned but the connection
died immediately after. This happened because dispatch() re-returned the
error after sending the error response, which propagated up through
processMessage() → handleMessage() where `catch return false` closed
the WebSocket connection.
Now the error is only propagated if sendError itself fails (e.g. broken
pipe). Otherwise dispatch() returns normally and the read loop continues.
Fixes#1843
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The CDP Accessibility spec defines AXValue.value as always being a
string, but integer values were serialized as JSON numbers. This
breaks CDP clients with strict deserialization (e.g., Rust serde).
Fixes#1822
Puppeteer's page.setCookie() internally calls Network.deleteCookies twice
before setting a cookie. The second call includes a partitionKey field for
CHIPS (partitioned cookies), which caused Lightpanda to return NotImplemented.
Since Lightpanda doesn't support partitioned cookies, we now silently ignore
the partitionKey parameter and proceed with the cookie operation based on
name/domain/path matching.
This change affects:
- Network.deleteCookies: no longer rejects requests with partitionKey
- Network.setCookie (via setCdpCookie): no longer rejects cookies with partitionKey
Fixes#1818
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 - When Target.setAutoAttach is called, send the `Target.attachedToTarget`
event before sending the response. This matches Chrome's behavior and
it stops playwright from thinking there's no target and making extra calls,
e.g. to Target.attachedToTarget.
2 - Use the same dummy frameId for all startup messages. I'm not sure why we
have STARTUP-P and STARTUP-B. Using the same frame (a) makes more sense to
me (b) doesn't break any existing integration tests, and (c) improves this
scenario: https://github.com/lightpanda-io/browser/issues/1800
In https://github.com/lightpanda-io/browser/pull/1774 we started to track Ranges
in the page in order to correctly make them "live". But, without correct
lifetime, they would continue to be "live" even if out of scope in JS.
This commit adds finalizers to Range via reference counting similar to Events.
It _is_ possible for a Range to outlive its page, so we can't just remove the
range from the Page's _live_range list - the page might not be valid. This
commit gives every page an unique id and the ability to try and get the page
by id from the session. By capturing the page_id at creation-time, a Range
can defensively remove itself from the page's list. If the page is already
gone, then there's no need to do anything.
When calculating accessible names for elements without explicit labels, multiple descendant text nodes were previously concatenated directly together. This adds a space between distinct text node contents to prevent words from sticking together.
The introduction of frames means that data is no longer tied to a specific Page
or Context. 255b9a91cc introduced Origins for
v8 values shared across frames of the same origin. The commit highlighted the
lifetime mismatched that we now have with data that can outlive 1 frame. A
specific issue with that commit was the finalizers were still Context-owned.
But like any other piece of data, that isn't right; aside from modules, nothing
should be context-owned.
This commit continues where the last left off and moves finalizers from Context
to Origin. This is done in a separate commit because it introduces significant
changes. Currently, finalizers take a *Page, but that's no longer correct. A
value created in one Page, can outlive the Page. We need another container. I
original thought to use Origin, but that isn't know to CDP/MCP. Instead, I
decide to enhance the Session.
Session is now the owner of the page.arena, the page.factory and the
page.arena_pool. Finalizers are given a *Session which they can use to release
their arena.
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/153
In some ways this is an extension of
https://github.com/lightpanda-io/browser/pull/1635 but it has more implications
with respect to correctness.
A js.Context wraps a v8::Context. One of the important thing it adds is the
identity_map so that, given a Zig instance we always return the same v8::Object.
But imagine code running in a frame. This frame has its own Context, and thus
its own identity_map. What happens when that frame does:
```js
window.top.frame_loaded = true;
```
From Zig's point of view, `Window.getTop` will return the correct Zig instance.
It will return the *Window references by the "root" page. When that instance is
passed to the bridge, we'll look for the v8::Object in the Context's
`identity_map` but wont' find it. The mapping exists in the root context
`identity_map`, but not within this frame. So we create a new v8::Object and now
our 1 zig instance has N v8::Objects for every page/frame that tries to access
it.
This breaks cross-frame scripting which should work, at least to some degree,
even when frames are on the same origin.
This commit adds a `js.Origin` which contains the `identity_map`, along with our
other `v8::Global` storage. The `Env` now contains a `*js.Origin` lookup,
mapping an origin string (e.g. lightpanda.io:443) to an *Origin. When a Page's
URL is changed, we call `self.js.setOrigin(new_url)` which will then either get
or create an origin from the Env's origin lookup map.
js.Origin is reference counted so that it remains valid so long as at least 1
frame references them.
There's some special handling for null-origins (i.e. about:blank). At the root,
null origins get a distinct/isolated Origin. For a frame, the parent's origin
is used.
Above, we talked about `identity_map`, but a `js.Context` has 8 other fields
to track v8 values, e.g. `global_objects`, `global_functions`,
`global_values_temp`, etc. These all must be shared by frames on the same
origin. So all of these have also been moved to js.Origin. They've also been
merged so that we now have 3 fields: `identity_map`, `globals` and `temps`.
Finally, when the origin of a context is changed, we set the v8::Context's
SecurityToken (to that origin). This is a key part of how v8 allows cross-
context access.