mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 08:28:00 +00:00
* feat(browser): add agentic browser control
* fix(frontend): format browser view changes
* fix(browser): keep browser optional and isolate sidecar layout
* fix(browser): address PR review security and IME findings
- Nginx: add a browser-stream WebSocket location before the generic
/api/threads regex so Live upgrades instead of downgrading to HTTP
(both nginx.conf and nginx.local.conf).
- Ownership: require an existing owned thread for the WS stream and REST
navigate, and tear down the browser session on thread deletion so a
later caller cannot reuse a retained page/cookies by guessing the id.
- SSRF: enforce the URL policy at the browser request boundary via a
context-level route guard covering redirects, popups, iframes, and
subresources (skipped for CDP-attached Chrome).
- IME: skip key forwarding while a composition is active so confirming a
CJK candidate with Enter no longer submits the remote page form.
Adds regression tests for the request guard, session teardown on delete,
and the composing-Enter key decision.
* fix(frontend): smooth streaming in long tool threads
* Revert "fix(frontend): smooth streaming in long tool threads"
This reverts commit f0462516eabe77f138d4027ea1c714fb226683cf.
* fix(browser): address review security and lifecycle findings
- Reject cross-origin WebSocket upgrades on the live browser stream
(Origin allow-list reuse of CORS/same-origin helpers) to close a
WS-CSRF hole, and fail closed when the ownership store is absent.
- Warn when a CDP-attached session runs with the SSRF request guard
off, and drop the unreachable CDP screencast teardown dead code.
- Read browser session launch config from a single canonical source
(browser_navigate) so it is deterministic regardless of call order.
- Bound per-thread Chromium accumulation with idle-timeout eviction
and an LRU max-sessions cap.
- Reset the Live reconnect counter on a successful open so the stream
can't permanently stall after the cumulative attempt cap.
* fix(frontend): reduce long tool thread render stalls
Reuse stable historical message groups during streaming, defer heavy Markdown and browser previews, and lazy-decode message images.
* fix(browser): keep live control responsive during continuous input
Why: Manual browser control felt laggy — a physical click ran the remote
Playwright click three times and each non-move input synchronously awaited a
JPEG screenshot, so events queued behind capture (queue wait up to ~237ms).
The first async attempt used a trailing-edge debounce, which froze the visible
page until a wheel/keyboard gesture stopped ("scroll finishes, then it jumps").
What:
- Frontend forwards one `click` per physical click instead of also emitting
`down`/`up`, so the remote page is not clicked twice per gesture.
- Backend detaches live-frame capture from input dispatch: non-move actions
start a rate-limited background refresh loop (leading frame + bounded cadence)
that keeps emitting frames while input continues and never blocks dispatch.
- Add regression tests: input dispatch no longer awaits the screenshot, rapid
inputs coalesce, and continuous input keeps refreshing before it stops.
Scenarios: Verified in the live Browser panel — a single click completes in
~57ms (was blocked behind a 171ms capture), and a 1.14s sustained wheel gesture
renders ~7 frames throughout the scroll instead of one frame after it ends.
* fix(browser): harden worker and session lifecycle
* fix(browser): address latest review feedback
* fix(frontend): preserve optimistic new-chat message
* test(e2e): preserve mocked message run ids
* fix(browser): address capability review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1067 lines
40 KiB
Python
1067 lines
40 KiB
Python
"""Tests for the agentic browser automation tools and session manager.
|
|
|
|
The tool tests mock the browser session so they run without Playwright. A small
|
|
integration test at the end exercises a real headless Chromium session and is
|
|
skipped automatically when Playwright (or its browser binary) is unavailable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.community.browser_automation import session as session_mod
|
|
from deerflow.community.browser_automation import tools
|
|
from deerflow.community.browser_automation.session import (
|
|
_LIVE_FRAME_JPEG_QUALITY,
|
|
BrowserLiveViewerError,
|
|
BrowserSession,
|
|
BrowserSessionCapacityError,
|
|
BrowserSessionManager,
|
|
PageSnapshot,
|
|
SnapshotElement,
|
|
)
|
|
|
|
PlaywrightTimeoutError = type(
|
|
"TimeoutError",
|
|
(TimeoutError,),
|
|
{"__module__": "playwright.async_api"},
|
|
)
|
|
|
|
|
|
def _runtime(thread_id: str | None = "thread-1", outputs_path: str | None = None):
|
|
state = {"thread_data": {"outputs_path": outputs_path}} if outputs_path is not None else {"thread_data": {}}
|
|
return SimpleNamespace(context={"thread_id": thread_id}, state=state)
|
|
|
|
|
|
def _snapshot() -> PageSnapshot:
|
|
return PageSnapshot(
|
|
url="https://example.com/",
|
|
title="Example",
|
|
elements=[
|
|
SnapshotElement(ref=1, tag="a", role="", type="", name="More info"),
|
|
SnapshotElement(ref=2, tag="input", role="", type="text", name="Search"),
|
|
],
|
|
)
|
|
|
|
|
|
class TestSnapshotRendering:
|
|
def test_snapshot_lists_elements_by_ref(self):
|
|
rendered = _snapshot().render()
|
|
assert "URL: https://example.com/" in rendered
|
|
assert "Title: Example" in rendered
|
|
assert "[1] a: More info" in rendered
|
|
assert "[2] input type=text: Search" in rendered
|
|
|
|
def test_empty_snapshot_says_no_elements(self):
|
|
snap = PageSnapshot(url="https://x.test/", title="X", elements=[])
|
|
assert "No interactive elements detected." in snap.render()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestBrowserTools:
|
|
async def _patch_session(self, session):
|
|
manager = MagicMock()
|
|
manager.get_session.return_value = session
|
|
return patch.object(tools, "get_browser_session_manager", return_value=manager), manager
|
|
|
|
async def test_navigate_returns_snapshot(self):
|
|
session = MagicMock()
|
|
session.navigate = AsyncMock(return_value=_snapshot())
|
|
ctx, manager = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_navigate_tool.coroutine(
|
|
runtime=_runtime(),
|
|
url="https://example.com",
|
|
tool_call_id="t1",
|
|
)
|
|
content = result.update["messages"][0].content
|
|
assert "Navigated to https://example.com." in content
|
|
assert "[1] a: More info" in content
|
|
session.navigate.assert_awaited_once_with("https://example.com")
|
|
manager.get_session.assert_called_once()
|
|
|
|
async def test_navigate_emits_screenshot_artifact_and_browser_view(self, tmp_path):
|
|
outputs = tmp_path / "outputs"
|
|
outputs.mkdir()
|
|
session = MagicMock()
|
|
session.navigate = AsyncMock(return_value=_snapshot())
|
|
session.screenshot_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nshot")
|
|
session.schedule_live_frames = MagicMock()
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_navigate_tool.coroutine(
|
|
runtime=_runtime(outputs_path=str(outputs)),
|
|
url="https://example.com",
|
|
tool_call_id="t1",
|
|
)
|
|
# Screenshot is captured, saved, exposed as an artifact + inline browser_view.
|
|
session.screenshot_bytes.assert_awaited_once()
|
|
session.schedule_live_frames.assert_called_once()
|
|
artifact = result.update["artifacts"][0]
|
|
assert artifact.startswith("/mnt/user-data/outputs/.browser-frames/browser-navigate-")
|
|
assert artifact.endswith(".png")
|
|
saved = list((outputs / ".browser-frames").glob("browser-navigate-*.png"))
|
|
assert saved and saved[0].read_bytes() == b"\x89PNG\r\n\x1a\nshot"
|
|
meta = result.update["messages"][0].additional_kwargs["browser_view"]
|
|
assert meta["screenshot"] == artifact
|
|
assert meta["url"] == "https://example.com/"
|
|
assert meta["title"] == "Example"
|
|
|
|
async def test_navigate_screenshot_failure_does_not_break_action(self, tmp_path):
|
|
outputs = tmp_path / "outputs"
|
|
outputs.mkdir()
|
|
session = MagicMock()
|
|
session.navigate = AsyncMock(return_value=_snapshot())
|
|
session.screenshot_bytes = AsyncMock(side_effect=RuntimeError("headless crashed"))
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_navigate_tool.coroutine(
|
|
runtime=_runtime(outputs_path=str(outputs)),
|
|
url="https://example.com",
|
|
tool_call_id="t1",
|
|
)
|
|
# Navigation result still returned; no artifact, no browser_view, no crash.
|
|
assert "Navigated to https://example.com." in result.update["messages"][0].content
|
|
assert "artifacts" not in result.update
|
|
assert result.update["messages"][0].additional_kwargs == {}
|
|
|
|
async def test_navigate_blocks_private_url(self):
|
|
session = MagicMock()
|
|
session.navigate = AsyncMock()
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_navigate_tool.coroutine(
|
|
runtime=_runtime(),
|
|
url="http://169.254.169.254/latest/meta-data/",
|
|
tool_call_id="t1",
|
|
)
|
|
assert "private, loopback, or metadata" in result.update["messages"][0].content
|
|
session.navigate.assert_not_awaited()
|
|
|
|
async def test_navigate_rejects_non_http_scheme(self):
|
|
session = MagicMock()
|
|
session.navigate = AsyncMock()
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_navigate_tool.coroutine(
|
|
runtime=_runtime(),
|
|
url="file:///etc/passwd",
|
|
tool_call_id="t1",
|
|
)
|
|
assert "Error" in result.update["messages"][0].content
|
|
session.navigate.assert_not_awaited()
|
|
|
|
async def test_click_returns_updated_snapshot(self):
|
|
session = MagicMock()
|
|
session.click = AsyncMock(return_value=_snapshot())
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_click_tool.coroutine(runtime=_runtime(), ref=1, tool_call_id="t1")
|
|
assert "Clicked element [1]." in result.update["messages"][0].content
|
|
session.click.assert_awaited_once_with(1)
|
|
|
|
async def test_click_error_is_recoverable_message(self):
|
|
session = MagicMock()
|
|
session.click = AsyncMock(side_effect=RuntimeError("no such element"))
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_click_tool.coroutine(runtime=_runtime(), ref=9, tool_call_id="t1")
|
|
assert result.update["messages"][0].content.startswith("Error: could not click element [9]")
|
|
|
|
async def test_type_with_submit(self):
|
|
session = MagicMock()
|
|
session.type_text = AsyncMock(return_value=_snapshot())
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_type_tool.coroutine(
|
|
runtime=_runtime(),
|
|
ref=2,
|
|
text="hello",
|
|
tool_call_id="t1",
|
|
submit=True,
|
|
)
|
|
assert "Typed into element [2] and submitted." in result.update["messages"][0].content
|
|
session.type_text.assert_awaited_once_with(2, "hello", submit=True)
|
|
|
|
async def test_get_text_truncates_via_config(self):
|
|
session = MagicMock()
|
|
session.get_text = AsyncMock(return_value="body text")
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={"max_chars": 1234}):
|
|
result = await tools.browser_get_text_tool.coroutine(runtime=_runtime(), tool_call_id="t1")
|
|
assert result.update["messages"][0].content == "body text"
|
|
session.get_text.assert_awaited_once_with(max_chars=1234)
|
|
|
|
async def test_screenshot_writes_artifact(self, tmp_path):
|
|
outputs = tmp_path / "outputs"
|
|
outputs.mkdir()
|
|
session = MagicMock()
|
|
session.screenshot_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\npng-bytes")
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_screenshot_tool.coroutine(
|
|
runtime=_runtime(outputs_path=str(outputs)),
|
|
tool_call_id="t1",
|
|
filename="Login Page.png",
|
|
)
|
|
artifact = result.update["artifacts"][0]
|
|
assert artifact == "/mnt/user-data/outputs/Login_Page.png"
|
|
assert (outputs / "Login_Page.png").read_bytes() == b"\x89PNG\r\n\x1a\npng-bytes"
|
|
|
|
async def test_screenshot_errors_without_outputs_path(self):
|
|
session = MagicMock()
|
|
session.screenshot_bytes = AsyncMock(return_value=b"x")
|
|
ctx, _ = await self._patch_session(session)
|
|
with ctx, patch.object(tools, "_get_tool_config", return_value={}):
|
|
result = await tools.browser_screenshot_tool.coroutine(runtime=_runtime(), tool_call_id="t1")
|
|
assert "outputs path is not available" in result.update["messages"][0].content
|
|
session.screenshot_bytes.assert_not_awaited()
|
|
|
|
async def test_close_reports_when_no_session(self):
|
|
manager = MagicMock()
|
|
manager.close_session = AsyncMock(return_value=False)
|
|
with patch.object(tools, "get_browser_session_manager", return_value=manager):
|
|
result = await tools.browser_close_tool.coroutine(runtime=_runtime(), tool_call_id="t1")
|
|
assert "No active browser session" in result.update["messages"][0].content
|
|
manager.close_session.assert_awaited_once_with("thread-1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wheel_input_scrolls_target_container_at_pointer_location():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.mouse.move = AsyncMock()
|
|
page.mouse.wheel = AsyncMock()
|
|
page.evaluate = AsyncMock(return_value=True)
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
await session._dispatch_input(
|
|
{"type": "wheel", "nx": 0.25, "ny": 0.75, "dx": 0, "dy": 240},
|
|
)
|
|
|
|
page.mouse.move.assert_awaited_once_with(250.0, 375.0)
|
|
page.evaluate.assert_awaited_once()
|
|
page.mouse.wheel.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wheel_input_scrolls_viewport_center_without_pointer_location():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.mouse.move = AsyncMock()
|
|
page.mouse.wheel = AsyncMock()
|
|
page.evaluate = AsyncMock(return_value=True)
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
await session._dispatch_input({"type": "wheel", "dx": 0, "dy": 240})
|
|
|
|
page.mouse.move.assert_not_awaited()
|
|
page.evaluate.assert_awaited_once()
|
|
_, payload = page.evaluate.await_args.args
|
|
assert payload == {"x": 500.0, "y": 250.0, "dx": 0.0, "dy": 240.0}
|
|
page.mouse.wheel.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wheel_input_falls_back_to_native_wheel_when_js_scroll_fails():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.mouse.move = AsyncMock()
|
|
page.mouse.wheel = AsyncMock()
|
|
page.evaluate = AsyncMock(return_value=False)
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
await session._dispatch_input(
|
|
{"type": "wheel", "nx": 0.25, "ny": 0.75, "dx": 0, "dy": 240},
|
|
)
|
|
|
|
page.mouse.move.assert_awaited_once_with(250.0, 375.0)
|
|
page.evaluate.assert_awaited_once()
|
|
page.mouse.wheel.assert_awaited_once_with(0.0, 240.0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_frame_returns_base64_jpeg_screenshot():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.screenshot = AsyncMock(return_value=b"\xff\xd8jpeg-bytes")
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
frame = await session._live_frame()
|
|
|
|
assert frame == "/9hq cGVnLWJ5dGVz".replace(" ", "")
|
|
page.screenshot.assert_awaited_once_with(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_input_dispatch_does_not_wait_for_live_frame():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.mouse.click = AsyncMock()
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
refresh_started = asyncio.Event()
|
|
release_refresh = asyncio.Event()
|
|
|
|
async def slow_live_frame() -> None:
|
|
refresh_started.set()
|
|
await release_refresh.wait()
|
|
|
|
session._on_frame = MagicMock()
|
|
session._emit_live_frame = slow_live_frame
|
|
session._schedule_settle_live_frames = MagicMock()
|
|
dispatch_task = asyncio.create_task(
|
|
session._dispatch_input({"type": "click", "nx": 0.25, "ny": 0.75}),
|
|
)
|
|
|
|
await asyncio.wait_for(refresh_started.wait(), timeout=0.2)
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(dispatch_task), timeout=0.05)
|
|
finally:
|
|
release_refresh.set()
|
|
await dispatch_task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rapid_inputs_coalesce_live_frame_refresh(monkeypatch):
|
|
monkeypatch.setattr(session_mod, "_LIVE_FRAME_INPUT_INTERVAL_S", 0.01)
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.keyboard.press = AsyncMock()
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
session._on_frame = MagicMock()
|
|
session._emit_live_frame = AsyncMock()
|
|
session._schedule_settle_live_frames = MagicMock()
|
|
|
|
await session._dispatch_input({"type": "key", "key": "ArrowDown"})
|
|
await session._dispatch_input({"type": "key", "key": "ArrowDown"})
|
|
await session._dispatch_input({"type": "key", "key": "ArrowDown"})
|
|
await asyncio.sleep(0.03)
|
|
|
|
assert page.keyboard.press.await_count == 3
|
|
session._emit_live_frame.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_continuous_inputs_refresh_before_input_stops(monkeypatch):
|
|
monkeypatch.setattr(session_mod, "_LIVE_FRAME_INPUT_INTERVAL_S", 0.01)
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
page.keyboard.press = AsyncMock()
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
session._on_frame = MagicMock()
|
|
session._live_frame = AsyncMock(return_value="frame")
|
|
session._schedule_settle_live_frames = MagicMock()
|
|
|
|
stop = asyncio.Event()
|
|
|
|
async def send_continuously() -> None:
|
|
while not stop.is_set():
|
|
await session._dispatch_input({"type": "key", "key": "ArrowDown"})
|
|
await asyncio.sleep(0.001)
|
|
|
|
producer = asyncio.create_task(send_continuously())
|
|
try:
|
|
await asyncio.sleep(0.06)
|
|
assert session._on_frame.call_count >= 2
|
|
finally:
|
|
stop.set()
|
|
await producer
|
|
await asyncio.sleep(0.02)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_fast_fails_on_stale_ref_without_blocking():
|
|
"""A ref missing after a re-render fails immediately with a re-snapshot hint.
|
|
|
|
Previously ``page.click`` on a missing selector blocked for the 30s session
|
|
default; here the locator reports count 0 so we raise straight away and never
|
|
attempt the click, letting the model re-snapshot instead of stalling the loop.
|
|
"""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=30000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
locator = MagicMock()
|
|
locator.count = AsyncMock(return_value=0)
|
|
page = MagicMock()
|
|
page.locator = MagicMock(return_value=locator)
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
with pytest.raises(RuntimeError, match="no longer on the page"):
|
|
await session._click(7)
|
|
|
|
page.locator.assert_called_once_with('[data-df-ref="7"]')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_click_tolerates_spa_navigation_without_load_event():
|
|
"""A client-side click that never fires a load event still returns a snapshot.
|
|
|
|
The post-click settle wait is best-effort: a Playwright timeout on
|
|
``wait_for_load_state`` must be swallowed so SPA navigations (which never emit
|
|
a fresh load event) still yield the updated snapshot instead of raising.
|
|
"""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=30000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
first = MagicMock()
|
|
first.scroll_into_view_if_needed = AsyncMock()
|
|
first.click = AsyncMock()
|
|
locator = MagicMock()
|
|
locator.count = AsyncMock(return_value=1)
|
|
locator.first = first
|
|
page = MagicMock()
|
|
page.locator = MagicMock(return_value=locator)
|
|
page.wait_for_load_state = AsyncMock(side_effect=PlaywrightTimeoutError("no load"))
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
session._snapshot_impl = AsyncMock(return_value=_snapshot())
|
|
|
|
result = await session._click(1)
|
|
|
|
assert result.url == "https://example.com/"
|
|
first.click.assert_awaited_once()
|
|
page.wait_for_load_state.assert_awaited_once()
|
|
session._snapshot_impl.assert_awaited_once_with(page)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ensure_page_serializes_concurrent_rebuilds():
|
|
"""Concurrent tool/Live callers must share one rebuilt browser page."""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
launch_started = asyncio.Event()
|
|
release_launch = asyncio.Event()
|
|
page = MagicMock()
|
|
page.is_closed.return_value = False
|
|
context = MagicMock()
|
|
context.new_page = AsyncMock(return_value=page)
|
|
context.set_default_timeout = MagicMock()
|
|
context.on = MagicMock()
|
|
browser = MagicMock()
|
|
browser.is_connected.return_value = True
|
|
browser.new_context = AsyncMock(return_value=context)
|
|
|
|
async def launch(*, headless):
|
|
launch_started.set()
|
|
await release_launch.wait()
|
|
return browser
|
|
|
|
chromium = MagicMock()
|
|
chromium.launch = AsyncMock(side_effect=launch)
|
|
session._playwright = SimpleNamespace(chromium=chromium)
|
|
|
|
# ``_ensure_page`` imports Playwright lazily even when a fake runtime is
|
|
# already installed. Keep this unit test independent of the optional extra.
|
|
fake_async_api = ModuleType("playwright.async_api")
|
|
fake_async_api.async_playwright = MagicMock()
|
|
fake_playwright = ModuleType("playwright")
|
|
fake_playwright.async_api = fake_async_api
|
|
with patch.dict(sys.modules, {"playwright": fake_playwright, "playwright.async_api": fake_async_api}):
|
|
first = asyncio.create_task(session._ensure_page())
|
|
await asyncio.wait_for(launch_started.wait(), timeout=1.0)
|
|
second = asyncio.create_task(session._ensure_page())
|
|
await asyncio.sleep(0)
|
|
release_launch.set()
|
|
|
|
assert await asyncio.wait_for(asyncio.gather(first, second), timeout=1.0) == [page, page]
|
|
chromium.launch.assert_awaited_once_with(headless=True)
|
|
browser.new_context.assert_awaited_once()
|
|
context.new_page.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tabs_report_active_page():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page_a = MagicMock()
|
|
page_a.is_closed.return_value = False
|
|
page_a.title = AsyncMock(return_value="A")
|
|
page_a.url = "https://a.example/"
|
|
page_b = MagicMock()
|
|
page_b.is_closed.return_value = False
|
|
page_b.title = AsyncMock(return_value="B")
|
|
page_b.url = "https://b.example/"
|
|
session._context = SimpleNamespace(pages=[page_a, page_b])
|
|
session._page = page_b
|
|
session._ensure_page = AsyncMock(return_value=page_b)
|
|
|
|
tabs = await session._tabs()
|
|
|
|
assert [(tab.index, tab.title, tab.url, tab.active) for tab in tabs] == [
|
|
(0, "A", "https://a.example/", False),
|
|
(1, "B", "https://b.example/", True),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_activate_tab_switches_active_page():
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page_a = MagicMock()
|
|
page_a.is_closed.return_value = False
|
|
page_b = MagicMock()
|
|
page_b.is_closed.return_value = False
|
|
page_b.bring_to_front = AsyncMock()
|
|
session._context = SimpleNamespace(pages=[page_a, page_b])
|
|
session._page = page_a
|
|
session._ensure_page = AsyncMock(return_value=page_a)
|
|
|
|
await session._activate_tab(1)
|
|
|
|
assert session._page is page_b
|
|
page_b.bring_to_front.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_active_page_schedules_rebind_when_screencast_running():
|
|
"""Switching the active page while streaming must rebind the screencast.
|
|
|
|
The frame source follows ``self._page``; the CDP repaint signal is bound to a
|
|
specific page, so a page switch has to reschedule the bind or new-page
|
|
repaints stop driving frames (the exact drift that froze Live on the old
|
|
page). No rebind should be scheduled when the page is unchanged or idle.
|
|
"""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
old_page = MagicMock()
|
|
new_page = MagicMock()
|
|
session._screencast_page = old_page
|
|
rebind = AsyncMock()
|
|
session._rebind_screencast_safe = rebind
|
|
|
|
# Not streaming yet: no rebind.
|
|
session._set_active_page(new_page)
|
|
assert session._page is new_page
|
|
rebind.assert_not_called()
|
|
|
|
# Streaming and the page actually changed: schedule exactly one rebind.
|
|
session._on_frame = lambda _data: None
|
|
another_page = MagicMock()
|
|
session._set_active_page(another_page)
|
|
await asyncio.sleep(0) # let the scheduled task run
|
|
rebind.assert_awaited_once()
|
|
|
|
# Same page again: no extra rebind.
|
|
session._screencast_page = another_page
|
|
rebind.reset_mock()
|
|
session._set_active_page(another_page)
|
|
await asyncio.sleep(0)
|
|
rebind.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_frame_screenshots_current_active_page_after_switch():
|
|
"""A live frame captures ``self._page``, not a stale handle.
|
|
|
|
``_live_frame`` and the screencast both screenshot the active page, so after
|
|
the active page is reassigned (tab/popup handoff) the next frame reflects the
|
|
new page — keeping Live aligned with the address bar and snapshot, which also
|
|
track ``self._page``.
|
|
"""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
new_page = MagicMock()
|
|
new_page.screenshot = AsyncMock(return_value=b"\xff\xd8new-page")
|
|
session._ensure_page = AsyncMock(return_value=new_page)
|
|
|
|
frame = await session._live_frame()
|
|
|
|
new_page.screenshot.assert_awaited_once_with(type="jpeg", quality=_LIVE_FRAME_JPEG_QUALITY)
|
|
assert frame # base64 payload of the new page
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_screencast_ignores_stale_connection_callback():
|
|
"""A closing old WebSocket must not clear the newer Live frame callback."""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
|
|
def old_frame(_data: str) -> None:
|
|
pass
|
|
|
|
def new_frame(_data: str) -> None:
|
|
pass
|
|
|
|
session._on_frame = new_frame
|
|
|
|
await session._stop_screencast(old_frame)
|
|
|
|
assert session._on_frame is new_frame
|
|
|
|
await session._stop_screencast(new_frame)
|
|
|
|
assert session._on_frame is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_screencast_rejects_second_live_viewer():
|
|
"""A second Live connection must not silently freeze the first viewer."""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
page = MagicMock()
|
|
session._ensure_page = AsyncMock(return_value=page)
|
|
|
|
def old_frame(_data: str) -> None:
|
|
pass
|
|
|
|
def new_frame(_data: str) -> None:
|
|
pass
|
|
|
|
session._on_frame = old_frame
|
|
session._screencast_page = page
|
|
|
|
with pytest.raises(BrowserLiveViewerError):
|
|
await session._start_screencast(new_frame)
|
|
|
|
assert session._on_frame is old_frame
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestSessionManager:
|
|
async def test_get_session_is_per_thread_and_cached(self):
|
|
manager = BrowserSessionManager()
|
|
fake_loop = MagicMock()
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
a1 = manager.get_session("thread-a")
|
|
a2 = manager.get_session("thread-a")
|
|
b1 = manager.get_session("thread-b")
|
|
assert a1 is a2
|
|
assert a1 is not b1
|
|
|
|
async def test_close_session_removes_and_closes(self):
|
|
manager = BrowserSessionManager()
|
|
fake_loop = MagicMock()
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
session = manager.get_session("thread-a")
|
|
session.close = AsyncMock()
|
|
assert await manager.close_session("thread-a") is True
|
|
session.close.assert_awaited_once()
|
|
# Second close is a no-op because the session was dropped.
|
|
assert await manager.close_session("thread-a") is False
|
|
|
|
async def test_idle_sessions_are_evicted_on_next_get(self):
|
|
"""A session unused past the idle timeout is dropped + scheduled to close.
|
|
|
|
The active (just-requested) thread is always kept; only the stale one is
|
|
evicted, so a long-running gateway can't accumulate one Chromium per
|
|
thread that ever touched the tools.
|
|
"""
|
|
manager = BrowserSessionManager(idle_timeout_s=100.0, max_sessions=0)
|
|
fake_loop = MagicMock()
|
|
manager._loop = fake_loop
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
with patch.object(session_mod.time, "monotonic", return_value=0.0):
|
|
stale = manager.get_session("thread-stale")
|
|
# Avoid an un-awaited coroutine warning: the fake loop.submit does
|
|
# not consume the coroutine _schedule_close would build.
|
|
stale._close = MagicMock()
|
|
with patch.object(session_mod.time, "monotonic", return_value=1000.0):
|
|
fresh = manager.get_session("thread-fresh")
|
|
assert "thread-stale" not in manager._sessions
|
|
assert "thread-fresh" in manager._sessions
|
|
# The evicted session is closed on the private loop (fire-and-forget).
|
|
fake_loop.submit.assert_called_once()
|
|
assert fresh is manager._sessions["thread-fresh"]
|
|
del stale
|
|
|
|
async def test_max_sessions_cap_evicts_least_recently_used(self):
|
|
manager = BrowserSessionManager(idle_timeout_s=0, max_sessions=2)
|
|
fake_loop = MagicMock()
|
|
manager._loop = fake_loop
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
with patch.object(session_mod.time, "monotonic", return_value=1.0):
|
|
manager.get_session("a")
|
|
with patch.object(session_mod.time, "monotonic", return_value=2.0):
|
|
evicted = manager.get_session("b")
|
|
evicted._close = MagicMock()
|
|
# Re-touch "a" so "b" becomes the least-recently-used.
|
|
with patch.object(session_mod.time, "monotonic", return_value=3.0):
|
|
manager.get_session("a")
|
|
with patch.object(session_mod.time, "monotonic", return_value=4.0):
|
|
manager.get_session("c")
|
|
assert set(manager._sessions) == {"a", "c"}
|
|
assert "b" not in manager._sessions
|
|
fake_loop.submit.assert_called_once()
|
|
|
|
async def test_pinned_session_survives_lru_eviction(self):
|
|
manager = BrowserSessionManager(idle_timeout_s=0, max_sessions=2)
|
|
fake_loop = MagicMock()
|
|
manager._loop = fake_loop
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
with patch.object(session_mod.time, "monotonic", return_value=0.0):
|
|
pinned = manager.get_session("pinned", pin=True)
|
|
pinned._close = MagicMock()
|
|
with patch.object(session_mod.time, "monotonic", return_value=1.0):
|
|
lru = manager.get_session("lru")
|
|
lru._close = MagicMock()
|
|
|
|
with patch.object(session_mod.time, "monotonic", return_value=2.0):
|
|
current = manager.get_session("current")
|
|
current._close = MagicMock()
|
|
|
|
# The pinned stream is the oldest session, but the LRU cap must evict
|
|
# the oldest unpinned session instead.
|
|
assert "pinned" in manager._sessions
|
|
assert "lru" not in manager._sessions
|
|
assert current is manager._sessions["current"]
|
|
|
|
async def test_pinned_session_makes_capacity_admission_fail_until_release(self):
|
|
manager = BrowserSessionManager(idle_timeout_s=0, max_sessions=1)
|
|
fake_loop = MagicMock()
|
|
manager._loop = fake_loop
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
with patch.object(session_mod.time, "monotonic", return_value=0.0):
|
|
pinned = manager.get_session("pinned", pin=True)
|
|
pinned._close = MagicMock()
|
|
with patch.object(session_mod.time, "monotonic", return_value=1.0):
|
|
with pytest.raises(BrowserSessionCapacityError):
|
|
manager.get_session("current")
|
|
|
|
assert set(manager._sessions) == {"pinned"}
|
|
assert fake_loop.submit.call_count == 0
|
|
|
|
manager.release_session("pinned", pinned)
|
|
assert set(manager._sessions) == {"pinned"}
|
|
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
with patch.object(session_mod.time, "monotonic", return_value=2.0):
|
|
manager.get_session("current")
|
|
|
|
assert set(manager._sessions) == {"current"}
|
|
fake_loop.submit.assert_called_once()
|
|
|
|
async def test_get_session_rejects_runtime_multi_worker_browser_use(self, monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_WORKERS", "2")
|
|
manager = BrowserSessionManager()
|
|
|
|
with pytest.raises(RuntimeError, match="process-local"):
|
|
manager.get_session("thread-a")
|
|
|
|
async def test_cdp_requires_explicit_unguarded_trust_opt_in(self):
|
|
manager = BrowserSessionManager()
|
|
|
|
with pytest.raises(RuntimeError, match="allow_unguarded_cdp"):
|
|
manager.get_session("thread-a", cdp_url="http://127.0.0.1:9222")
|
|
|
|
async def test_cdp_allows_explicit_unguarded_trust_opt_in(self):
|
|
manager = BrowserSessionManager()
|
|
fake_loop = MagicMock()
|
|
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
session = manager.get_session(
|
|
"thread-a",
|
|
cdp_url="http://127.0.0.1:9222",
|
|
allow_unguarded_cdp=True,
|
|
)
|
|
|
|
assert session._cdp_url == "http://127.0.0.1:9222"
|
|
|
|
async def test_acquire_session_releases_pin_after_scope(self):
|
|
manager = BrowserSessionManager()
|
|
fake_loop = MagicMock()
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
with manager.acquire_session("thread-a") as session:
|
|
assert session.active_refs == 1
|
|
assert manager._sessions["thread-a"] is session
|
|
assert session.active_refs == 0
|
|
|
|
async def test_in_flight_browser_operation_is_not_evicted(self):
|
|
manager = BrowserSessionManager(idle_timeout_s=100.0, max_sessions=0)
|
|
fake_loop = MagicMock()
|
|
manager._loop = fake_loop
|
|
with patch.object(manager, "_ensure_loop", return_value=fake_loop):
|
|
session = manager.get_session("thread-a")
|
|
session._close = MagicMock()
|
|
page = MagicMock()
|
|
page.is_closed.return_value = False
|
|
page.url = "https://example.com/"
|
|
session._page = page
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
async def run(coro):
|
|
started.set()
|
|
await release.wait()
|
|
return await coro
|
|
|
|
fake_loop.run = run
|
|
operation = asyncio.create_task(session.current_url())
|
|
await started.wait()
|
|
|
|
with patch.object(session_mod.time, "monotonic", return_value=1000.0):
|
|
manager.get_session("thread-b")
|
|
assert "thread-a" in manager._sessions
|
|
assert session.active_refs == 1
|
|
|
|
release.set()
|
|
assert await operation == "https://example.com/"
|
|
assert session.active_refs == 0
|
|
|
|
|
|
def test_resolve_session_always_reads_browser_navigate_config():
|
|
"""Launch config is deterministic regardless of which tool runs first.
|
|
|
|
``get_session`` caches per thread and ignores launch params for later
|
|
callers, so keying config off the calling tool made it "first tool to run
|
|
wins" — a ``headless: false`` set only on ``browser_navigate`` was silently
|
|
dropped if e.g. ``browser_snapshot`` created the session first. We now always
|
|
read ``browser_navigate``'s config as the single canonical source.
|
|
"""
|
|
configs = {
|
|
"browser_navigate": {
|
|
"headless": False,
|
|
"viewport_width": 1920,
|
|
"viewport_height": 1080,
|
|
"timeout_ms": 12345,
|
|
"cdp_url": "http://127.0.0.1:9222",
|
|
"allow_unguarded_cdp": True,
|
|
},
|
|
"browser_snapshot": {"headless": True},
|
|
}
|
|
captured: dict[str, object] = {}
|
|
|
|
class _FakeManager:
|
|
def get_session(self, thread_id, **kwargs):
|
|
captured.update(kwargs)
|
|
captured["thread_id"] = thread_id
|
|
return MagicMock()
|
|
|
|
with (
|
|
patch.object(tools, "_get_tool_config", lambda name: configs.get(name, {})),
|
|
patch.object(tools, "get_browser_session_manager", return_value=_FakeManager()),
|
|
):
|
|
# Even when a NON-navigate tool resolves the session, launch config comes
|
|
# from browser_navigate.
|
|
tools._resolve_session(_runtime(), "browser_snapshot")
|
|
|
|
assert captured["headless"] is False
|
|
assert captured["timeout_ms"] == 12345
|
|
assert captured["viewport"] == {"width": 1920, "height": 1080}
|
|
assert captured["cdp_url"] == "http://127.0.0.1:9222"
|
|
assert captured["allow_unguarded_cdp"] is True
|
|
|
|
|
|
def test_reset_manager_singleton():
|
|
first = session_mod.get_browser_session_manager()
|
|
session_mod.reset_browser_session_manager()
|
|
second = session_mod.get_browser_session_manager()
|
|
assert first is not second
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_playwright_navigate_click_type():
|
|
"""End-to-end check against real headless Chromium (skipped if unavailable)."""
|
|
pytest.importorskip("playwright.async_api")
|
|
|
|
session_mod.reset_browser_session_manager()
|
|
manager = session_mod.get_browser_session_manager()
|
|
session = manager.get_session("it-thread", headless=True, timeout_ms=15000)
|
|
|
|
html = "data:text/html,<h1>Hi</h1><input id='q' name='q' placeholder='Search'><button onclick=\"document.getElementById('out').innerText='clicked'\">Go</button><div id='out'></div>"
|
|
try:
|
|
snap = await session.navigate(html)
|
|
assert any(el.tag == "input" for el in snap.elements)
|
|
button = next(el for el in snap.elements if "Go" in el.name)
|
|
after_click = await session.click(button.ref)
|
|
assert after_click.url # still a valid snapshot
|
|
|
|
input_ref = next(el for el in snap.elements if el.tag == "input").ref
|
|
typed = await session.type_text(input_ref, "hello", submit=False)
|
|
assert typed.url
|
|
|
|
text = await session.get_text()
|
|
assert "clicked" in text
|
|
|
|
shot = await session.screenshot_bytes()
|
|
assert shot[:4] == b"\x89PNG"
|
|
finally:
|
|
await manager.close_session("it-thread")
|
|
session_mod.reset_browser_session_manager()
|
|
|
|
|
|
class _FakeRoute:
|
|
def __init__(self, url: str):
|
|
self.request = SimpleNamespace(url=url)
|
|
self.aborted_with: str | None = None
|
|
self.continued = False
|
|
|
|
async def abort(self, error_code: str = "failed") -> None:
|
|
self.aborted_with = error_code
|
|
|
|
async def continue_(self) -> None:
|
|
self.continued = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_guard_aborts_blocked_redirect_target():
|
|
"""The context request guard aborts any URL the SSRF policy rejects.
|
|
|
|
A public initial URL can 30x-redirect to a metadata/private host, and
|
|
Playwright follows it automatically; the per-request guard catches those
|
|
hops (and subresources/popups) that the one-time initial-URL check misses.
|
|
"""
|
|
captured: dict[str, object] = {}
|
|
|
|
class _FakeContext:
|
|
async def route(self, pattern, handler):
|
|
captured["pattern"] = pattern
|
|
captured["handler"] = handler
|
|
|
|
def guard(url: str) -> str | None:
|
|
return "Error: blocked" if "169.254.169.254" in url else None
|
|
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
url_guard=guard,
|
|
)
|
|
session._context = _FakeContext()
|
|
|
|
await session._install_request_guard()
|
|
assert session._request_guard_bound is True
|
|
assert captured["pattern"] == "**/*"
|
|
handler = captured["handler"]
|
|
|
|
blocked = _FakeRoute("http://169.254.169.254/latest/meta-data/")
|
|
await handler(blocked)
|
|
assert blocked.aborted_with == "blockedbyclient"
|
|
assert blocked.continued is False
|
|
|
|
allowed = _FakeRoute("https://example.com/page")
|
|
await handler(allowed)
|
|
assert allowed.continued is True
|
|
assert allowed.aborted_with is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_continues_when_browser_driver_is_already_disconnected():
|
|
"""Shutdown must clear every handle even if the driver died first."""
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
)
|
|
session._stop_screencast = AsyncMock()
|
|
context = MagicMock()
|
|
context.close = AsyncMock(side_effect=RuntimeError("driver disconnected"))
|
|
browser = MagicMock()
|
|
browser.close = AsyncMock(side_effect=RuntimeError("connection closed"))
|
|
playwright = MagicMock()
|
|
playwright.stop = AsyncMock()
|
|
session._context = context
|
|
session._browser = browser
|
|
session._playwright = playwright
|
|
|
|
await session._close()
|
|
|
|
context.close.assert_awaited_once()
|
|
browser.close.assert_awaited_once()
|
|
playwright.stop.assert_awaited_once()
|
|
assert session._context is None
|
|
assert session._browser is None
|
|
assert session._playwright is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_guard_not_installed_for_cdp_sessions():
|
|
"""CDP-attached real Chrome owns its own context, so we don't route it."""
|
|
|
|
class _FakeContext:
|
|
def __init__(self):
|
|
self.routed = False
|
|
|
|
async def route(self, pattern, handler):
|
|
self.routed = True
|
|
|
|
session = BrowserSession(
|
|
MagicMock(),
|
|
headless=True,
|
|
timeout_ms=1000,
|
|
viewport={"width": 1000, "height": 500},
|
|
cdp_url="http://127.0.0.1:9222",
|
|
url_guard=lambda _url: "Error: blocked",
|
|
)
|
|
context = _FakeContext()
|
|
session._context = context
|
|
|
|
await session._install_request_guard()
|
|
assert context.routed is False
|
|
assert session._request_guard_bound is False
|