Make Browser viewer CDP-live by default

Default the WebUI Browser surface back to live CDP screencast while keeping snapshot/state transport as a fallback. Carry viewer transport through the WebSocket API and Browser store, use screencast metadata to avoid extra frame dimension decode, and include frame-chain action metadata in Browser content results. Update Browser prompts, skill guidance, DOX, and focused regressions so agent actions prefer DOM/CDP refs while the visible Browser remains responsive.
This commit is contained in:
Alessandro 2026-06-17 16:15:07 +02:00
parent b9153f718e
commit d398656d33
7 changed files with 211 additions and 20 deletions

View file

@ -16,12 +16,16 @@
## Local Contracts
- Keep browser actions safe around external pages, credentials, and user data.
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility.
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
- Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
- Do not hardcode user-specific browser paths or secrets.
## Work Guidance
- Coordinate tool, helper, and panel changes so browser state shown in the UI matches tool behavior.
- Do not depend on nested Electron `<webview>` support or launcher-specific preload bridges unless the launcher exposes that bridge as an explicit contract.
- Keep `prompts/agent.system.tool.browser.md` as a compact callable contract; move detailed browser workflows into `skills/browser-automation/SKILL.md`.
- Keep `skills/browser-automation/SKILL.md` frontmatter triggers current with rendered browsing, host-browser, screenshot, and web-interaction user phrasing so relevant-skill recall can surface the skill before the full browser workflow is needed.
- Keep fragile form guidance progressively disclosed through `skills/browser-form-workflows/SKILL.md`, linked from the browser prompt through `browser-automation`.

View file

@ -14,7 +14,11 @@ from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
FRAME_IDLE_POLL_SECONDS = 0.05
FRAME_RETRY_DELAY_SECONDS = 0.5
FRAME_STATE_REFRESH_SECONDS = 0.75
SNAPSHOT_STATE_POLL_SECONDS = 0.75
SCREENCAST_QUALITY = 92
VIEWER_TRANSPORT_SCREENCAST = "screencast"
VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
class WsBrowser(WsHandler):
@ -92,11 +96,14 @@ class WsBrowser(WsHandler):
if existing:
existing.cancel()
viewer_id = str(data.get("viewer_id") or "")
viewer_transport = self._viewer_transport(data)
snapshot = None
if runtime:
self._streams[stream_key] = asyncio.create_task(
self._stream_frames(sid, context_id, active_id, viewer_id)
)
if viewer_transport == VIEWER_TRANSPORT_SCREENCAST:
stream_task = self._stream_frames(sid, context_id, active_id, viewer_id)
else:
stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
self._streams[stream_key] = asyncio.create_task(stream_task)
snapshot = await self._snapshot_for_browser(runtime, active_id)
return {
@ -107,6 +114,7 @@ class WsBrowser(WsHandler):
"browsers": await self._all_browser_tabs(),
"all_browsers": True,
"viewer_id": viewer_id,
"viewer_transport": viewer_transport,
}
def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@ -215,6 +223,7 @@ class WsBrowser(WsHandler):
"browsers": all_browsers,
"all_browsers": True,
"last_interacted_browser_id": last_interacted_browser_id,
"viewer_transport": self._viewer_transport(data),
},
correlation_id=data.get("correlationId"),
)
@ -228,6 +237,7 @@ class WsBrowser(WsHandler):
"command": command,
"browser_id": browser_id,
"viewer_id": viewer_id,
"viewer_transport": self._viewer_transport(data),
}
async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@ -374,7 +384,12 @@ class WsBrowser(WsHandler):
try:
runtime = await get_runtime(context_id, create=False)
if not runtime:
await self._emit_empty_frame(sid, context_id, viewer_id=viewer_id)
await self._emit_empty_frame(
sid,
context_id,
viewer_id=viewer_id,
frame_source=VIEWER_TRANSPORT_SCREENCAST,
)
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
continue
@ -382,7 +397,13 @@ class WsBrowser(WsHandler):
browsers = listing.get("browsers") or []
active_id = self._active_browser_id(listing, browser_id)
if not active_id:
await self._emit_empty_frame(sid, context_id, browsers=browsers, viewer_id=viewer_id)
await self._emit_empty_frame(
sid,
context_id,
browsers=browsers,
viewer_id=viewer_id,
frame_source=VIEWER_TRANSPORT_SCREENCAST,
)
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
continue
@ -407,6 +428,7 @@ class WsBrowser(WsHandler):
"mime": "",
"state": state,
"frame_source": "state",
"viewer_transport": VIEWER_TRANSPORT_SCREENCAST,
},
)
@ -435,7 +457,8 @@ class WsBrowser(WsHandler):
frame["browser_id"] = active_id
frame["browsers"] = browsers
frame["state"] = state
frame["frame_source"] = "screencast"
frame["frame_source"] = VIEWER_TRANSPORT_SCREENCAST
frame["viewer_transport"] = VIEWER_TRANSPORT_SCREENCAST
await self.emit_to(sid, "browser_viewer_frame", frame)
except asyncio.CancelledError:
raise
@ -447,6 +470,70 @@ class WsBrowser(WsHandler):
await runtime.call("stop_screencast", stream_id)
stream_id = None
async def _stream_state(
self,
sid: str,
context_id: str,
browser_id: int | str | None,
viewer_id: str = "",
) -> None:
last_signature = None
while True:
try:
runtime = await get_runtime(context_id, create=False)
if not runtime:
signature = (None, ())
if signature != last_signature:
await self._emit_empty_frame(
sid,
context_id,
viewer_id=viewer_id,
frame_source=VIEWER_TRANSPORT_SNAPSHOT,
)
last_signature = signature
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
continue
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
active_id = self._active_browser_id(listing, browser_id)
state = self._state_for_browser(browsers, active_id, None) if active_id else None
signature = (
str(active_id or ""),
tuple(
(
str(browser.get("context_id") or context_id),
str(browser.get("id") or ""),
str(browser.get("currentUrl") or ""),
str(browser.get("title") or ""),
bool(browser.get("loading")),
)
for browser in browsers
),
)
if signature != last_signature:
await self.emit_to(
sid,
"browser_viewer_frame",
{
"context_id": context_id,
"viewer_id": viewer_id,
"browser_id": active_id,
"browsers": browsers,
"image": "",
"mime": "",
"state": state,
"frame_source": VIEWER_TRANSPORT_SNAPSHOT,
"viewer_transport": VIEWER_TRANSPORT_SNAPSHOT,
},
)
last_signature = signature
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
except asyncio.CancelledError:
raise
except Exception:
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
@staticmethod
def _active_browser_id(
listing: dict[str, Any],
@ -484,6 +571,7 @@ class WsBrowser(WsHandler):
*,
browsers: list[dict[str, Any]] | None = None,
viewer_id: str = "",
frame_source: str = "",
) -> None:
await self.emit_to(
sid,
@ -496,9 +584,28 @@ class WsBrowser(WsHandler):
"image": "",
"mime": "",
"state": None,
"frame_source": frame_source,
"viewer_transport": frame_source or VIEWER_TRANSPORT_SNAPSHOT,
},
)
@staticmethod
def _viewer_transport(data: dict[str, Any]) -> str:
raw = (
data.get("viewer_transport")
or data.get("viewerTransport")
or data.get("surface_transport")
or data.get("surfaceTransport")
or data.get("transport")
or ""
)
normalized = str(raw or "").strip().lower().replace("-", "_")
if normalized == "live":
normalized = VIEWER_TRANSPORT_SCREENCAST
if normalized in VIEWER_TRANSPORTS:
return normalized
return VIEWER_TRANSPORT_SNAPSHOT
@staticmethod
def _viewport_from_data(data: dict[str, Any]) -> dict[str, int] | None:
try:

View file

@ -1,7 +1,7 @@
(() => {
const GLOBAL_KEY = "__spaceBrowserPageContent__";
const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
const VERSION = "12";
const VERSION = "13";
const REQUIRED_API_NAMES = Object.freeze([
"annotate",
"boundingBoxFor",
@ -3103,8 +3103,12 @@
function buildActionResult(entry, extra = {}) {
return {
actionStrategy: entry.helperBacked ? "frame_chain_ref" : "dom_ref",
captureId: state.captureId,
descriptorTags: Array.isArray(entry?.descriptorTags) ? entry.descriptorTags.slice() : [],
frameChain: Array.isArray(entry?.frameChain) ? entry.frameChain.slice() : [],
frameId: entry.frameId || "",
nodeId: entry.nodeId || "",
referenceId: entry.referenceId,
semanticTags: Array.isArray(entry?.semanticTags) ? entry.semanticTags.slice() : [],
state: entry.state || collectElementStateMetadata(entry.element, state.captureOptions),
@ -3116,6 +3120,7 @@
function buildHelperBackedActionResult(entry, helperResult, extra = {}) {
return {
actionStrategy: "frame_chain_ref",
captureId: state.captureId,
descriptorTags: Array.isArray(helperResult?.descriptorTags) ? helperResult.descriptorTags : (entry.descriptorTags || []),
frameChain: entry.frameChain.slice(),

View file

@ -11,7 +11,7 @@ Actions: tabs `open`, `list`, `state`, `set_active`, `navigate`, `back`, `forwar
Rules:
- If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id`.
- Use refs from the latest `content`; same-page controls may use `selector`.
- Prefer DOM/CDP actions: use refs from the latest `content`, including frame-chain refs, or same-page `selector`; use coordinates only when refs/selectors are unavailable or the user explicitly asks for visual manual control.
- Screenshots are explicit only; the browser does not automatically load screenshots. Call `vision_load` with the returned `vision_load.tool_args.paths` value before reasoning visually.
- Keep the tab set small; close pages after extracting what you need.
- `multi` is only a browser action: use `tool_name: "browser"` with `tool_args.action: "multi"`. Never use `tool_name: "multi"`.

View file

@ -30,7 +30,7 @@ For fragile forms, load `browser-form-workflows` with `skills_tool:load` before
1. `open` creates a browser tab and returns a `browser_id`.
2. `content` returns readable markdown plus typed refs like `[link 3]`, `[button 6]`, `[input text 8]`.
3. Interact with refs using `click`, `type`, `submit`, `scroll`, etc.
3. Interact with refs using `click`, `type`, `submit`, `scroll`, etc.; iframe/shadow targets may return frame-chain metadata in action results.
4. Use `navigate` on an existing `browser_id` for serial browsing.
5. Keep only a small working tab set; close pages when finished.
6. If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id` instead of opening a new tab.
@ -63,8 +63,9 @@ Screenshot args include `quality`, `full_page`, and optional `path`. Without `pa
## Pointer And Keyboard
- `hover`, `double_click`, `right_click`, and `drag` accept refs or viewport coordinates.
- Coordinates are Chromium viewport CSS pixels and match screenshots.
- Prefer refs/selectors and DOM/CDP actions over viewport coordinates.
- `hover`, `double_click`, `right_click`, and `drag` accept refs or viewport coordinates when no reliable ref exists.
- Coordinates are Chromium viewport CSS pixels and match screenshots; treat them as visual fallback, not the default interaction path.
- `key_chord` presses keys in order and releases in reverse.
- `clipboard` actions are copy, cut, or paste.
- `set_viewport` resizes the page viewport.

View file

@ -20,6 +20,8 @@ const EXTENSIONS_ROOT = "/a0/usr/_browser/extensions";
const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
const BROWSER_CONFIG_REFRESH_MS = 15000;
const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";
const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";
const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
const CANVAS_VIEWPORT_SETTLE_MS = 520;
@ -123,6 +125,8 @@ const model = {
address: "",
frameSrc: "",
frameState: null,
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
liveScreencastEnabled: true,
annotating: false,
annotationComments: [],
annotationDraft: null,
@ -885,12 +889,42 @@ const model = {
if (!this.isCurrentSurfaceOpen(sequence)) {
return;
}
await this.syncViewport(true, { restartStream: this._mode === "canvas" });
await this.syncViewport(true, {
restartStream: this._mode === "canvas" && this.usesScreencastTransport(),
});
if (this._mode !== "canvas") return;
this.scheduleViewportSyncForSurface(sequence, 240);
this.scheduleViewportSyncForSurface(sequence, 520);
},
requestedViewerTransport() {
return this.liveScreencastEnabled
? BROWSER_VIEWER_TRANSPORT_SCREENCAST
: BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
},
normalizeViewerTransport(value = "") {
const normalized = String(value || "").trim().toLowerCase().replace("-", "_");
if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) {
return BROWSER_VIEWER_TRANSPORT_SCREENCAST;
}
return BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
},
usesScreencastTransport() {
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
},
frameDimensionsFromMetadata(metadata = null) {
if (!metadata || typeof metadata !== "object") return null;
const width = Number(metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth || 0);
const height = Number(metadata.expectedHeight || metadata.deviceHeight || metadata.jpegHeight || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return null;
}
return { width, height };
},
scheduleViewportSyncForSurface(sequence, delayMs = 0) {
globalThis.setTimeout?.(() => {
if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== "canvas") {
@ -951,6 +985,7 @@ const model = {
browser_id: requestedBrowserId,
viewer_id: viewerToken,
create_browser: Boolean(options.createBrowser || options.create_browser),
viewer_transport: this.requestedViewerTransport(),
viewport_width: initialViewport?.width,
viewport_height: initialViewport?.height,
},
@ -973,6 +1008,7 @@ const model = {
}
const data = firstOk(response);
this.applyBrowserListing(data.browsers || [], contextId, { replaceAll: Boolean(data.all_browsers) });
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
this.setActiveBrowserId(
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
data.active_browser_context_id || contextId,
@ -987,6 +1023,9 @@ const model = {
const frameHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
this.applyBrowserListing(data.browsers || [], incomingContextId, { replaceContext: true });
@ -1011,6 +1050,7 @@ const model = {
this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
browserId: frameBrowserId,
contextId: incomingContextId,
dimensions: this.frameDimensionsFromMetadata(data.metadata),
onAccepted: () => {
if (
this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
@ -1041,6 +1081,9 @@ const model = {
const stateHandler = ({ data }) => {
if (data?.context_id !== this.contextId) return;
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
if (data?.viewer_transport) {
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
}
const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
this.applyBrowserListing(data.browsers || [], commandContextId, { replaceAll: Boolean(data.all_browsers) });
const command = String(data.command || "").toLowerCase();
@ -1109,7 +1152,7 @@ const model = {
}
return;
}
const dimensions = await loadFrameDimensions(frameSrc);
const dimensions = options?.dimensions || await loadFrameDimensions(frameSrc);
if (sequence !== this._frameRenderSequence || surfaceSequence !== this._surfaceOpenSequence) {
return;
}
@ -1254,12 +1297,14 @@ const model = {
context_id: targetContextId,
browser_id: targetBrowserId,
viewer_id: this._viewerToken,
viewer_transport: this.requestedViewerTransport(),
command,
},
{ timeoutMs: 20000 },
);
const data = firstOk(response);
this.applyBrowserListing(data.browsers || [], targetContextId, { replaceAll: Boolean(data.all_browsers) });
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
const result = data.result || {};
const resultContextId = this.normalizeContextId(
result.context_id
@ -1317,6 +1362,9 @@ const model = {
},
async restartCanvasStreamAfterPageChange() {
if (!this.usesScreencastTransport()) {
return;
}
const surfaceSequence = this._surfaceOpenSequence;
if (this._mode !== "canvas" || !this.isCurrentSurfaceOpen(surfaceSequence) || !this.activeBrowserId) {
return;
@ -2340,7 +2388,7 @@ const model = {
input_type: "viewport",
width: viewport.width,
height: viewport.height,
restart_stream: restartStream,
restart_stream: restartStream && this.usesScreencastTransport(),
});
this._lastViewportKey = key;
this._lastViewport = {
@ -2516,6 +2564,7 @@ const model = {
this._connectSequence += 1;
this._viewerToken = "";
this.switchingBrowserId = null;
this.viewerTransport = this.requestedViewerTransport();
this._surfaceMounted = false;
this._surfaceSwitching = false;
this.commandInFlight = false;

View file

@ -688,6 +688,7 @@ def test_browser_canvas_restarts_stream_after_page_navigation():
)
assert "async restartCanvasStreamAfterPageChange()" in js
assert "if (!this.usesScreencastTransport())" in js
assert '["navigate", "back", "forward", "reload"].includes(commandName)' in js
assert "await this.restartCanvasStreamAfterPageChange();" in js
assert "await this.waitForSurfaceViewport({ sequence: surfaceSequence });" in js
@ -1089,7 +1090,7 @@ def test_browser_tabs_close_without_confirmation_or_busy_lock():
assert "_commandInFlightCount" in browser_store
def test_browser_viewer_uses_cdp_screencast_transport():
def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
ws_browser = (PROJECT_ROOT / "plugins" / "_browser" / "api" / "ws_browser.py").read_text(
encoding="utf-8"
)
@ -1107,12 +1108,20 @@ def test_browser_viewer_uses_cdp_screencast_transport():
).read_text(encoding="utf-8")
assert 'runtime.call("screenshot"' in ws_browser
assert 'VIEWER_TRANSPORT_SNAPSHOT = "snapshot"' in ws_browser
assert 'VIEWER_TRANSPORT_SCREENCAST = "screencast"' in ws_browser
assert "def _viewer_transport(data: dict[str, Any])" in ws_browser
assert "return VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
assert "self._stream_state" in ws_browser
assert "SCREENCAST_QUALITY = 92" in ws_browser
assert "initial_viewport = self._viewport_from_data(data)" in ws_browser
assert '"set_viewport"' in ws_browser
assert "start_screencast" in ws_browser
assert "pop_screencast_frame" in ws_browser
assert "stop_screencast" in ws_browser
assert "viewer_transport == VIEWER_TRANSPORT_SCREENCAST" in ws_browser
assert '"viewer_transport": viewer_transport' in ws_browser
assert '"viewer_transport": VIEWER_TRANSPORT_SNAPSHOT' in ws_browser
assert '"Page.startScreencast"' in runtime
assert '"Page.screencastFrame"' in runtime
assert '"Page.screencastFrameAck"' in runtime
@ -1123,9 +1132,21 @@ def test_browser_viewer_uses_cdp_screencast_transport():
assert "await self._stop_screencasts_for_browser(resolved_id)" in runtime
assert "queueFrameRender" in browser_store
assert "requestAnimationFrame" in browser_store
assert 'const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";' in browser_store
assert 'const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";' in browser_store
assert "viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST" in browser_store
assert "liveScreencastEnabled: true" in browser_store
assert "requestedViewerTransport()" in browser_store
assert "normalizeViewerTransport(value = \"\")" in browser_store
assert "usesScreencastTransport()" in browser_store
assert "frameDimensionsFromMetadata(metadata = null)" in browser_store
assert "metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth" in browser_store
assert "dimensions: this.frameDimensionsFromMetadata(data.metadata)" in browser_store
assert "const dimensions = options?.dimensions || await loadFrameDimensions(frameSrc)" in browser_store
assert "viewer_transport: this.requestedViewerTransport()" in browser_store
assert "viewport_width: initialViewport?.width" in browser_store
assert "viewport_height: initialViewport?.height" in browser_store
assert "restart_stream: restartStream" in browser_store
assert "restart_stream: restartStream && this.usesScreencastTransport()" in browser_store
assert 'restart_screencast=bool(data.get("restart_stream"))' in ws_browser
assert "restart_screencast: bool = False" in runtime
assert "should_remount_viewport = changed or restart_screencast" in runtime
@ -1135,7 +1156,7 @@ def test_browser_viewer_uses_cdp_screencast_transport():
assert "await self._remount_viewport(page, viewport)" in runtime
assert "await asyncio.sleep(VIEWPORT_REMOUNT_PAUSE_SECONDS)" in runtime
assert "def _nudged_viewport(viewport: dict[str, int])" in runtime
assert 'await this.syncViewport(true, { restartStream: this._mode === "canvas" });' in browser_store
assert 'restartStream: this._mode === "canvas" && this.usesScreencastTransport()' in browser_store
assert "this.frameState = data.state || null" not in browser_store
assert "function loadFrameDimensions(src)" in browser_store
assert "frameMatchesViewport(dimensions = null, viewport = null)" in browser_store
@ -1155,7 +1176,8 @@ def test_browser_viewer_uses_cdp_screencast_transport():
assert "startBrowserScreenshotPreview(button, image, resolveBrowserPayload)" in browser_tool_handler
assert "FRAME_FALLBACK_SCREENSHOT_SECONDS" not in ws_browser
assert '"frame_source": "state"' in ws_browser
assert '"frame_source"] = "screencast"' in ws_browser
assert '"frame_source"] = VIEWER_TRANSPORT_SCREENCAST' in ws_browser
assert '"viewer_transport"] = VIEWER_TRANSPORT_SCREENCAST' in ws_browser
assert "fallback_screenshot" not in ws_browser
assert "canvas_wheel_screenshot" not in ws_browser
assert "surface_mode: this._mode" not in browser_store
@ -1279,7 +1301,7 @@ def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
PROJECT_ROOT / "plugins" / "_browser" / "assets" / "browser-page-content.js"
).read_text(encoding="utf-8")
assert 'const VERSION = "12"' in helper
assert 'const VERSION = "13"' in helper
assert "function patchOpenShadowDom" in helper
assert "Element.prototype.attachShadow = patched" in helper
assert "const REQUIRED_API_NAMES = Object.freeze([" in helper
@ -1294,6 +1316,9 @@ def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
assert "function isGlobalOrDelegatedEventBinding" in helper
assert 'parts.includes("window")' in helper
assert 'parts.includes("outside")' in helper
assert 'actionStrategy: entry.helperBacked ? "frame_chain_ref" : "dom_ref"' in helper
assert 'actionStrategy: "frame_chain_ref"' in helper
assert "frameChain: Array.isArray(entry?.frameChain)" in helper
def test_browser_panel_exposes_agent_friendly_address_input():