Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ tests/daemon/test_adr0011_*.py the one TCP endpoint: surfaces, Origi
tests/daemon/test_adr0012_isolation.py dev/prod isolation: own-port stale-detect, stop guard, plist carry-forward, activity verb
tests/daemon/test_adr0013_*.py recovery state, bounded recover, executor adoption/handoff
tests/skill/test_adr0013_diagnose_before_blame.py diagnose-before-blame errors, banned words, --reuse, attributed log
tests/daemon/test_extension_*.py extension upstream, title marker, version reload
tests/daemon/test_extension_*.py extension upstream, debugger timeouts, version reload
tests/daemon/test_launch_chrome.py Chrome launch/discovery (fake binaries; no real Chrome)
tests/daemon/test_doctor.py doctor schema + backend recommendation
tests/daemon/test_stale_daemon*.py stale/half-alive daemon recovery
Expand Down
555 changes: 39 additions & 516 deletions chrome-extension/background.js

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions docs/runtime-console.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Console capture on the extension backend

The extension CDP surface keeps Chrome's Runtime event subscription off by
default. Subscribing makes Chrome synchronously build console object previews;
large `console.table()` calls can slow down ordinary page execution even when
the agent never asks for console messages.

`ExtensionFacadeBridge` supplies Playwright with real execution-context IDs
through `extension_contexts.py`. It resolves native Document handles with
`DOM.getDocument` / `DOM.getFrameOwner` / `DOM.describeNode` / `DOM.resolveNode`,
validates the context component of Chrome's V8 object ID, and releases each
handle. This works for scriptless local frames without a page preload. An
unfamiliar object ID format fails explicitly; discovery never enables Runtime
events as a fallback. Explicit isolated-world creation supplies each utility
world's name and context ID.

Frame navigation and removal retire old contexts. Discovery retries provisional
documents and discards results from superseded navigations. Native Runtime
evaluation commands remain available, so `page.evaluate()` reads the page's
own globals as usual. Debugger stays disabled during ordinary automation:
even skipped pauses can make repeated `debugger` statements expensive.
The legacy Console domain supplies text messages without Runtime's object
previews. Startup text waits for its default realm to be published, so navigation
does not drop messages while context discovery is in flight. Translating those messages preserves Playwright's internal debug
marker used by `page.set_content()` to coordinate document lifecycle events.
Full console arguments and exception events require the capture opt-in below.

Set `BW_CAPTURE_CONSOLE=1` in the daemon environment before starting the daemon
to retain native Runtime events, including Playwright console and exception
events. An explicit CDP session's `Runtime.enable` also subscribes normally;
its `Runtime.disable` turns the subscription off again. Console capture carries
Chrome's normal serialization cost.

Repeatable verification: `uv run pytest
tests/daemon/e2e/test_runtime_console_cost.py -v`. Evidence is saved in
`tests/daemon/e2e/_artifacts/runtime-console-cost.json`: console timing with
capture off/on, main-world globals across navigation, and scriptless iframe
evaluation.

Extension-only verification without a Chrome debugging port: `uv run pytest
tests/daemon/e2e/test_debugger_pause_extension.py -v`. Its artifact
`tests/daemon/e2e/_artifacts/debugger-pause-extension.json` records native pause
events, repeated debugger-statement timing, renderer swaps, scriptless child
realms, startup console text and levels, and executor rebind.
3 changes: 3 additions & 0 deletions src/browserwright/_executor/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,9 @@ def _bind_live_surface(self, g: dict[str, Any]) -> None:
helper modules are seeded from the live surface too.
"""
from ..repl.markdown import make_read_markdown
from ..repl.screenshot_defaults import install_screenshot_defaults

install_screenshot_defaults(self._context)

g.pop("__bw_playwright_handle__", None)
g["page"] = self._page
Expand Down
81 changes: 81 additions & 0 deletions src/browserwright/daemon/server/extension_contexts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Discover extension execution contexts without Runtime or Debugger events.

Blink resolves a native Document node in its frame's main world. V8's remote
object handle contains that world's actual context ID, so no page scripts,
globals, console previews, or debugger machinery are needed for discovery.
"""
from __future__ import annotations

import re
from collections.abc import Awaitable, Callable

SendCommand = Callable[[str, dict], Awaitable[dict]]
_REMOTE_OBJECT_ID = re.compile(r"(-?[0-9]+)\.([0-9]+)\.([0-9]+)\Z")


class ContextDiscoveryError(ValueError):
"""Chrome could not expose a supported local document context."""


class UnsupportedContextHandleError(ContextDiscoveryError):
"""Chrome's remote object format cannot supply a validated context ID."""


def _context_id(object_id: object) -> int:
# V8 15.4 remote-object-id.cc serializes isolate.context.boundObject.
# CDP treats the string as opaque: reject unfamiliar formats explicitly
# rather than guessing an ID or enabling expensive Runtime events.
match = (_REMOTE_OBJECT_ID.fullmatch(object_id)
if isinstance(object_id, str) and len(object_id) <= 128 else None)
if match is None:
raise UnsupportedContextHandleError("unsupported Chrome remote-object ID format")
_, context_id, bound_id = map(int, match.groups())
if context_id <= 0 or bound_id <= 0:
raise UnsupportedContextHandleError("invalid Chrome remote-object ID")
return context_id


async def discover_default_context(
send: SendCommand, frame_id: str, *, main: bool,
) -> dict:
"""Return the real default realm for a native Chrome frame ID.

Child documents must belong to this CDP target; OOPIFs require their own
target routing. Internal discovery uses backend node IDs because requesting
the root document resets the DOM domain's frontend node ID bindings.
"""
if main:
document = (await send("DOM.getDocument", {"depth": 0}))["root"]
else:
owner = await send("DOM.getFrameOwner", {"frameId": frame_id})
described = await send("DOM.describeNode", {
"backendNodeId": owner["backendNodeId"], "depth": 0,
})
document = described["node"].get("contentDocument")
if not isinstance(document, dict):
raise ContextDiscoveryError("frame has no local document in this CDP target")
remote = (await send("DOM.resolveNode", {
"backendNodeId": document["backendNodeId"],
})).get("object", {})
object_id = remote.get("objectId")
try:
if remote.get("subtype") != "node":
raise ContextDiscoveryError("document did not resolve to a native node")
context_id = _context_id(object_id)
checked = await send("Runtime.evaluate", {
"expression": "void 0", "contextId": context_id,
"silent": True, "returnByValue": True, "disableBreaks": True,
"throwOnSideEffect": True,
})
if checked.get("exceptionDetails"):
raise ContextDiscoveryError("Chrome rejected the document execution context")
return {
"id": context_id, "origin": "", "name": "",
# Context numbers can repeat in a replacement renderer; the native
# isolate component distinguishes those realms during navigation.
"_native_identity": f"{int(object_id.split('.')[0])}.{context_id}",
"auxData": {"isDefault": True, "type": "default", "frameId": frame_id},
}
finally:
if isinstance(object_id, str):
await send("Runtime.releaseObject", {"objectId": object_id})
Loading
Loading