mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 07:23:51 +00:00
Reduce browser startup and rendering overhead
Avoid starting Chromium for empty tab listings while preserving saved-tab restoration. Honor navigation readiness without redundant load waits or fixed pauses, and let WebUI navigation return at document commit. Skip automatic history screenshots for inspection, evaluation, and batches containing only those actions. Read page metadata in one evaluation and report the actual loading state. Reuse the native viewer display size when no resize is requested, preventing page zoom from enlarging and clipping frames during navigation. Update browser documentation and regression coverage. Validated 194 browser regression/output tests and 29 host-browser tests in separate runs, plus live WebUI navigation, pointer input, screenshots, and repeated reloads. Local test-page medians improved from 474 to 83 ms for navigation and from 82 to 19 ms for content reads.
This commit is contained in:
parent
45b74ab7cf
commit
50db781a4d
7 changed files with 137 additions and 58 deletions
|
|
@ -90,6 +90,10 @@ Many Browser steps also keep a small history screenshot. That means an older
|
|||
chat can show the page as it looked when the agent worked on it, not just the
|
||||
latest page frame.
|
||||
|
||||
Reading page content, listing tabs, inspecting state or elements, and running
|
||||
JavaScript skip automatic history screenshots to keep these tasks quick.
|
||||
Ask for a screenshot when you need a visual record.
|
||||
|
||||
## Annotate Pages
|
||||
|
||||
Annotate mode lets you mark a page element or region and send a targeted comment back into the chat. This is useful for UI review: you can point at the exact thing that needs to change instead of describing it from memory.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
- Persist open-tab ownership and URLs through the shared KVP store; automatically restore the current chat when its Browser surface opens in per-chat mode and every saved chat in shared mode, then hide Chromium's redundant crash-restore advisory.
|
||||
- When no Browser tab manifest exists yet, use Chromium's last session once to migrate open tabs into the owned manifest.
|
||||
- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops.
|
||||
- Reuse the private display's native dimensions when interactive-viewer requests omit a size. Page CSS viewport dimensions change with zoom and must not resize the display during navigation or tab switching.
|
||||
- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
|
||||
- Complete tab switching when the viewer subscription succeeds and reuses the interactive viewer URL; the shared iframe does not reload for each tab. New viewer URLs and fallback frames retain their load/render completion path.
|
||||
- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
|
||||
|
|
@ -40,6 +41,8 @@
|
|||
- On first shared-profile use after an upgrade, adopt the first requesting chat's legacy Browser profile when one exists.
|
||||
- Empty viewer subscriptions and closing the last tab clear the address, page state, viewer, and switching flags. Context changes invalidate pending surface, subscription, and command results before awaiting session refresh; stale completions must not restore another chat or clear the current startup state. Structured subscription errors release switching state just like transport failures.
|
||||
- Show an accessible in-panel startup state while the on-demand shared Browser runtime is cold-starting; keep that one runtime warm until Browser configuration changes or Agent Zero shuts down.
|
||||
- Listing tabs must not cold-start Chromium when there are no saved tabs to restore. Preserve per-chat/shared and legacy-session restoration when saved tabs exist.
|
||||
- Navigation waits only for the requested readiness: agent tools default to `domcontentloaded`, while WebUI navigation/new tabs and restored tabs use `commit`. Do not follow navigation with another load wait or fixed settle delay; retain settling for interaction actions. Read title, back-navigation availability, and loading state in one page evaluation.
|
||||
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
|
||||
- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic host selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`.
|
||||
- Browser Settings must refresh connected host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
|
||||
|
|
@ -50,6 +53,7 @@
|
|||
- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. After the first Browser tool call, use the selected preset for subsequent model turns in that monologue and clear it at monologue end.
|
||||
- Open-browser runtime state (tab listing, last interacted browser, page content) is per-loop dynamic context: it must flow through the `message_loop_prompts_after` extension into `loop_data.extras_temporary` (rendered as message extras), never into the system prompt, so the system prompt stays cache-stable across turns. The extension must check the profile Browser tool policy first and inject nothing when the tool is blocked.
|
||||
- Browser tool results serialize minified JSON for agent history; any JSON beautification for display stays plugin-local in the WebUI tool handler (`browser-tool-handler.js`), never in shared `helpers/tool.py` where it would apply to every tool.
|
||||
- History screenshot capture skips `list`, `state`, `content`, `detail`, `evaluate`, closing actions, and batches containing only those actions. Preserve history previews for other actions and reuse explicit screenshot results without capturing again.
|
||||
- `evaluate` requires a non-empty `script` string in single and multi calls. Reject missing input instead of evaluating `undefined`; preserve legitimate JavaScript null/undefined results.
|
||||
- `evaluate_timeout_seconds` is a Browser setting (default 30, finite range 0.1–60 seconds, invalid values rejected), resolved for the requesting chat/project; never accept a model-facing timeout override. It bounds user evaluation and result-state collection. Serialize evaluates per page and interrupt with CDP `Runtime.terminateExecution`. Preserve the document when the original request settles within a 250 ms response grace; reload to commit only for unresolved execution (such as awaited promises). Report any reload or fallback closure in the timeout error. Recovery, fallback page closure, and protocol detachment each have a 5-second bound; report recovery failure rather than success. Never reset the shared browser for an evaluate timeout. Caller cancellation must reach the Browser worker and await its cleanup without stopping the shared thread.
|
||||
- Host evaluate requires `evaluate_timeout_v1`. Core supplies the settings deadline, dispatches multi calls through the same per-browser grouping as Internal Browser, and waits for `cancel_evaluate` acknowledgement on caller/transport cancellation. The companion connector owns browser-side interruption/recovery and rejects Safari evaluate. A lost acknowledgement is an unconfirmed cancellation error, never proof that JavaScript stopped.
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class WsBrowser(WsHandler):
|
|||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
if runtime and not browsers and create_browser:
|
||||
opened = await runtime.call("open", "")
|
||||
opened = await runtime.call("open", "", wait_until="commit")
|
||||
listing = await runtime.call("list")
|
||||
browsers = listing.get("browsers") or []
|
||||
if opened.get("id"):
|
||||
|
|
@ -264,7 +264,7 @@ class WsBrowser(WsHandler):
|
|||
|
||||
try:
|
||||
if command == "open":
|
||||
result = await runtime.call("open", data.get("url") or "")
|
||||
result = await runtime.call("open", data.get("url") or "", wait_until="commit")
|
||||
elif command == "navigate":
|
||||
result = await runtime.call(
|
||||
"navigate",
|
||||
|
|
|
|||
|
|
@ -1302,7 +1302,9 @@ class _BrowserRuntimeCore:
|
|||
except OSError as exc:
|
||||
PrintStyle.warning(f"Could not force-stop orphaned Chromium process {pid}: {exc}")
|
||||
|
||||
async def open(self, url: str = "") -> dict[str, Any]:
|
||||
async def open(
|
||||
self, url: str = "", *, wait_until: str = "domcontentloaded"
|
||||
) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
self._ensure_can_open_page()
|
||||
context_id = self.current_context_id
|
||||
|
|
@ -1314,9 +1316,7 @@ class _BrowserRuntimeCore:
|
|||
self.last_interacted_browser_id = browser_page.id
|
||||
target_url = self._initial_url(url)
|
||||
if target_url and target_url != "about:blank":
|
||||
await self._goto(page, normalize_url(target_url))
|
||||
else:
|
||||
await self._settle(page)
|
||||
await self._goto(page, normalize_url(target_url), wait_until=wait_until)
|
||||
self._persist_browser_tabs()
|
||||
return {"id": browser_page.id, "state": await self._state(browser_page.id)}
|
||||
|
||||
|
|
@ -1346,7 +1346,8 @@ class _BrowserRuntimeCore:
|
|||
raise self._tab_limit_error()
|
||||
|
||||
async def list(self, include_content: bool = False) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
if self.context or has_restorable_browser_tabs(self.current_context_id):
|
||||
await self.ensure_started()
|
||||
ids = self._context_browser_ids()
|
||||
if not ids:
|
||||
return {
|
||||
|
|
@ -1377,7 +1378,8 @@ class _BrowserRuntimeCore:
|
|||
}
|
||||
|
||||
async def list_all(self) -> dict[str, Any]:
|
||||
await self.ensure_started()
|
||||
if self.context or has_restorable_browser_tabs(self.current_context_id):
|
||||
await self.ensure_started()
|
||||
browser_ids = sorted(self.pages)
|
||||
return {
|
||||
"browsers": await asyncio.gather(
|
||||
|
|
@ -1644,7 +1646,6 @@ class _BrowserRuntimeCore:
|
|||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.go_back(wait_until=wait_until, timeout=10000)
|
||||
await self._settle(page, short=wait_until == "commit")
|
||||
self._maybe_promote(resolved_id)
|
||||
self._persist_browser_tabs()
|
||||
return await self._state(resolved_id)
|
||||
|
|
@ -1659,7 +1660,6 @@ class _BrowserRuntimeCore:
|
|||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.go_forward(wait_until=wait_until, timeout=10000)
|
||||
await self._settle(page, short=wait_until == "commit")
|
||||
self._maybe_promote(resolved_id)
|
||||
self._persist_browser_tabs()
|
||||
return await self._state(resolved_id)
|
||||
|
|
@ -1674,7 +1674,6 @@ class _BrowserRuntimeCore:
|
|||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
await page.reload(wait_until=wait_until, timeout=15000)
|
||||
await self._settle(page, short=wait_until == "commit")
|
||||
self._maybe_promote(resolved_id)
|
||||
self._persist_browser_tabs()
|
||||
return await self._state(resolved_id)
|
||||
|
|
@ -2241,11 +2240,7 @@ class _BrowserRuntimeCore:
|
|||
await self.ensure_started()
|
||||
resolved_id = self._resolve_browser_id(browser_id)
|
||||
page = self._page(resolved_id)
|
||||
current_viewport = await self._page_viewport(page)
|
||||
viewer = self.interactive_view.ensure_viewer(
|
||||
width or int(current_viewport.get("width") or DEFAULT_VIEWPORT["width"]),
|
||||
height or int(current_viewport.get("height") or DEFAULT_VIEWPORT["height"]),
|
||||
)
|
||||
viewer = self.interactive_view.ensure_viewer(width, height)
|
||||
if not viewer.get("available"):
|
||||
return viewer
|
||||
|
||||
|
|
@ -2825,7 +2820,6 @@ class _BrowserRuntimeCore:
|
|||
PrintStyle.warning(f"Browser navigation timed out waiting for {wait_until}: {url}")
|
||||
except PlaywrightError as exc:
|
||||
PrintStyle.warning(f"Browser navigation showed a native error page for {url}: {exc}")
|
||||
await self._settle(page, short=wait_until == "commit")
|
||||
|
||||
async def _settle(self, page: Any, short: bool = False) -> None:
|
||||
from patchright.async_api import Error as PlaywrightError
|
||||
|
|
@ -2846,24 +2840,21 @@ class _BrowserRuntimeCore:
|
|||
raise KeyError(f"Browser {browser_id} is not open.")
|
||||
page = browser_page.page
|
||||
try:
|
||||
title = await page.title()
|
||||
except Exception:
|
||||
title = ""
|
||||
try:
|
||||
history_length = await page.evaluate(
|
||||
"() => globalThis.history?.length || 0",
|
||||
state = await page.evaluate(
|
||||
"() => ({title: document.title, canGoBack: history.length > 1, "
|
||||
"loading: document.readyState !== 'complete'})",
|
||||
isolated_context=False,
|
||||
)
|
||||
except Exception:
|
||||
history_length = 0
|
||||
state = {}
|
||||
return {
|
||||
"id": browser_page.id,
|
||||
"context_id": self._page_context_id(browser_page),
|
||||
"currentUrl": page.url,
|
||||
"title": title,
|
||||
"canGoBack": bool(history_length and int(history_length) > 1),
|
||||
"title": state.get("title", ""),
|
||||
"canGoBack": bool(state.get("canGoBack")),
|
||||
"canGoForward": False,
|
||||
"loading": False,
|
||||
"loading": bool(state.get("loading")),
|
||||
}
|
||||
|
||||
def _register_page_locked(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ from plugins._browser.helpers.selector import get_tool_runtime
|
|||
|
||||
|
||||
HISTORY_SCREENSHOT_QUALITY = 62
|
||||
HISTORY_SCREENSHOT_ACTION_DENYLIST = {"close", "close_all"}
|
||||
HISTORY_SCREENSHOT_ACTION_DENYLIST = {
|
||||
"close", "close_all", "list", "state", "content", "detail", "evaluate",
|
||||
}
|
||||
|
||||
|
||||
async def get_runtime(context_id: str, create: bool = True, agent: Any | None = None):
|
||||
|
|
@ -315,7 +317,7 @@ class Browser(Tool):
|
|||
message=f"Unknown browser action: {action}",
|
||||
break_loop=False,
|
||||
)
|
||||
await self._record_history_screenshot(runtime, action, result, browser_id)
|
||||
await self._record_history_screenshot(runtime, action, result, browser_id, calls=calls)
|
||||
except Exception as exc:
|
||||
return Response(message=f"Browser {action} failed: {exc}", break_loop=False)
|
||||
|
||||
|
|
@ -429,11 +431,19 @@ class Browser(Tool):
|
|||
action: str,
|
||||
result: Any,
|
||||
requested_browser_id: int | str | None = None,
|
||||
*,
|
||||
calls: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
if not getattr(self, "log", None):
|
||||
return
|
||||
if action in HISTORY_SCREENSHOT_ACTION_DENYLIST:
|
||||
return
|
||||
if action == "multi" and calls and all(
|
||||
str(call.get("action") or "").strip().lower().replace("-", "_")
|
||||
in HISTORY_SCREENSHOT_ACTION_DENYLIST
|
||||
for call in calls
|
||||
):
|
||||
return
|
||||
|
||||
screenshot = result if action == "screenshot" and isinstance(result, dict) else None
|
||||
if not self._screenshot_has_reference(screenshot):
|
||||
|
|
|
|||
|
|
@ -2106,7 +2106,7 @@ def test_browser_navigation_errors_stay_inside_native_browser_page():
|
|||
assert "Error as PlaywrightError" in runtime
|
||||
assert "except PlaywrightError as exc:" in runtime
|
||||
assert "Browser navigation showed a native error page" in runtime
|
||||
assert "await self._settle(page)" in runtime
|
||||
assert "await page.goto(url, wait_until=wait_until, timeout=30000)" in runtime
|
||||
assert "except (PlaywrightError, PlaywrightTimeoutError):" in runtime
|
||||
|
||||
|
||||
|
|
@ -2930,9 +2930,10 @@ def test_browser_interactive_views_use_isolated_loopback_sessions(monkeypatch, t
|
|||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_browser_interactive_viewer_reuses_the_automated_page():
|
||||
@pytest.mark.parametrize("width,height", [(1200, 700), (0, 0)])
|
||||
async def test_browser_interactive_viewer_reuses_the_automated_page(width, height):
|
||||
class FakePage:
|
||||
viewport_size = {"width": 1024, "height": 768}
|
||||
viewport_size = {"width": 1800, "height": 1050}
|
||||
|
||||
class FakeInteractiveView:
|
||||
def __init__(self):
|
||||
|
|
@ -2944,8 +2945,8 @@ async def test_browser_interactive_viewer_reuses_the_automated_page():
|
|||
"available": True,
|
||||
"token": "browser-token",
|
||||
"url": "/desktop/session/browser-token/",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"width": width or 1200,
|
||||
"height": height or 700,
|
||||
}
|
||||
|
||||
page = FakePage()
|
||||
|
|
@ -2971,10 +2972,10 @@ async def test_browser_interactive_viewer_reuses_the_automated_page():
|
|||
core._stop_screencasts_for_browser = stop_screencasts
|
||||
core.set_viewport = set_viewport
|
||||
|
||||
result = await core.interactive_viewer(7, width=1200, height=700)
|
||||
result = await core.interactive_viewer(7, width=width, height=height)
|
||||
|
||||
assert core.pages[7].page is page
|
||||
assert interactive_view.ensure_calls == [(1200, 700)]
|
||||
assert interactive_view.ensure_calls == [(width, height)]
|
||||
assert stopped == [7]
|
||||
assert viewport_calls == [(7, 1200, 700, {"resize_interactive": True})]
|
||||
assert core.last_interacted_browser_id == 7
|
||||
|
|
@ -3718,7 +3719,7 @@ async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monk
|
|||
def __init__(self) -> None:
|
||||
self.opened = False
|
||||
|
||||
async def call(self, method, *args):
|
||||
async def call(self, method, *args, **kwargs):
|
||||
if method == "list":
|
||||
if self.opened:
|
||||
return {
|
||||
|
|
@ -3727,6 +3728,7 @@ async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monk
|
|||
}
|
||||
return {"browsers": [], "last_interacted_browser_id": None}
|
||||
if method == "open":
|
||||
assert kwargs == {"wait_until": "commit"}
|
||||
self.opened = True
|
||||
return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
|
||||
raise AssertionError(method)
|
||||
|
|
@ -4071,11 +4073,8 @@ async def test_shared_browser_runtime_keeps_tabs_context_scoped():
|
|||
self.url = url
|
||||
self.closed = False
|
||||
|
||||
async def title(self):
|
||||
return self.url
|
||||
|
||||
async def evaluate(self, script, **kwargs):
|
||||
return 1
|
||||
return {"title": self.url, "canGoBack": False, "loading": False}
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
|
@ -4679,11 +4678,8 @@ async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(
|
|||
Path(kwargs["path"]).write_bytes(b"image-bytes")
|
||||
return b"image-bytes"
|
||||
|
||||
async def title(self):
|
||||
return "Blank"
|
||||
|
||||
async def evaluate(self, script, payload=None, **kwargs):
|
||||
return 1
|
||||
return {"title": "Blank", "canGoBack": False, "loading": False}
|
||||
|
||||
core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
|
||||
core.context = object()
|
||||
|
|
@ -4821,10 +4817,7 @@ async def test_browser_runtime_ref_point_resolution_applies_offsets():
|
|||
"rect": {"x": 10, "y": 20, "width": 100, "height": 40},
|
||||
"selector": "#target",
|
||||
}
|
||||
return 1
|
||||
|
||||
async def title(self):
|
||||
return "Blank"
|
||||
return {"title": "Blank", "canGoBack": False, "loading": False}
|
||||
|
||||
core = _BrowserRuntimeCore("ctx")
|
||||
core.context = object()
|
||||
|
|
@ -4892,10 +4885,7 @@ async def test_browser_runtime_clipboard_paste_uses_dom_bridge():
|
|||
"changed": True,
|
||||
"default_prevented": False,
|
||||
}
|
||||
return 1
|
||||
|
||||
async def title(self):
|
||||
return "Blank"
|
||||
return {"title": "Blank", "canGoBack": False, "loading": False}
|
||||
|
||||
page = FakePage()
|
||||
core = _BrowserRuntimeCore("ctx")
|
||||
|
|
@ -4941,10 +4931,7 @@ async def test_browser_runtime_clipboard_paste_falls_back_to_keyboard_insert_tex
|
|||
"changed": False,
|
||||
"default_prevented": False,
|
||||
}
|
||||
return 1
|
||||
|
||||
async def title(self):
|
||||
return "Blank"
|
||||
return {"title": "Blank", "canGoBack": False, "loading": False}
|
||||
|
||||
page = FakePage()
|
||||
core = _BrowserRuntimeCore("ctx")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import json
|
|||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -16,6 +16,89 @@ from plugins._browser.tools.browser import Browser
|
|||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method", ["list", "list_all"])
|
||||
@pytest.mark.parametrize("restorable", [False, True])
|
||||
async def test_listing_only_starts_cold_browser_when_tabs_need_restoring(monkeypatch, method, restorable):
|
||||
core = _BrowserRuntimeCore("listing-test")
|
||||
started = AsyncMock()
|
||||
monkeypatch.setattr(core, "ensure_started", started)
|
||||
monkeypatch.setattr(runtime_module, "has_restorable_browser_tabs", lambda context_id: restorable)
|
||||
|
||||
assert (await getattr(core, method)())["browsers"] == []
|
||||
assert started.await_count == int(restorable)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("wait_until", ["commit", "domcontentloaded"])
|
||||
async def test_navigation_uses_requested_readiness_without_extra_settling(monkeypatch, wait_until):
|
||||
core = _BrowserRuntimeCore("navigation-test")
|
||||
page = SimpleNamespace(
|
||||
goto=AsyncMock(), go_back=AsyncMock(), go_forward=AsyncMock(), reload=AsyncMock(),
|
||||
)
|
||||
core.pages[1] = BrowserPage(1, page, "navigation-test")
|
||||
core.context = SimpleNamespace(new_page=AsyncMock(return_value=page))
|
||||
monkeypatch.setattr(core, "ensure_started", AsyncMock())
|
||||
monkeypatch.setattr(core, "_state", AsyncMock(return_value={"id": 1}))
|
||||
monkeypatch.setattr(core, "_register_page", AsyncMock(return_value=core.pages[1]))
|
||||
monkeypatch.setattr(core, "_persist_browser_tabs", lambda: None)
|
||||
monkeypatch.setattr(core, "_settle", AsyncMock(side_effect=AssertionError("Redundant wait")))
|
||||
monkeypatch.setattr(runtime_module, "get_browser_config", lambda: {})
|
||||
|
||||
await core.open("https://example.com", wait_until=wait_until)
|
||||
await core.navigate(1, "https://example.com", wait_until=wait_until)
|
||||
await core.back(1, wait_until=wait_until)
|
||||
await core.forward(1, wait_until=wait_until)
|
||||
await core.reload(1, wait_until=wait_until)
|
||||
for method in (page.goto, page.go_back, page.go_forward, page.reload):
|
||||
assert all(call.kwargs["wait_until"] == wait_until for call in method.await_args_list)
|
||||
core._settle.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_state_reads_metadata_once_and_reports_loading(monkeypatch):
|
||||
core = _BrowserRuntimeCore("state-test")
|
||||
page = SimpleNamespace(url="https://example.com", evaluate=AsyncMock(return_value={
|
||||
"title": "Example", "canGoBack": True, "loading": True,
|
||||
}))
|
||||
core.pages[1] = BrowserPage(1, page, "state-test")
|
||||
state = await core._state(1)
|
||||
assert state == {
|
||||
"id": 1, "context_id": "state-test", "currentUrl": page.url,
|
||||
"title": "Example", "canGoBack": True, "canGoForward": False, "loading": True,
|
||||
}
|
||||
assert page.evaluate.await_count == 1
|
||||
page.evaluate.side_effect = RuntimeError("Execution context destroyed during navigation")
|
||||
assert (await core._state(1))["currentUrl"] == page.url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("action,calls,capture", [
|
||||
*[(action, None, False) for action in ("list", "state", "content", "detail", "evaluate", "close", "close_all")],
|
||||
("multi", [{"action": "content"}, {"action": "evaluate", "script": "document.title"}], False),
|
||||
("multi", [{"action": "content"}, {"action": "click", "ref": 1}], True),
|
||||
("screenshot", None, True),
|
||||
])
|
||||
async def test_browser_inspection_skips_history_capture_but_preserves_visual_actions(monkeypatch, action, calls, capture):
|
||||
from plugins._browser.tools import browser as tool_module
|
||||
|
||||
screenshot = {"browser_id": 1, "ephemeral_ref": "a0-ephemeral-image://test"}
|
||||
runtime = SimpleNamespace(call=AsyncMock(side_effect=lambda method, *a, **kw:
|
||||
screenshot if method == "screenshot_file" else {}))
|
||||
monkeypatch.setattr(tool_module, "get_runtime", AsyncMock(return_value=runtime))
|
||||
monkeypatch.setattr(tool_module, "activate_browser_model", lambda agent: None)
|
||||
tool = object.__new__(Browser)
|
||||
tool.agent = SimpleNamespace(context=SimpleNamespace(id="test"))
|
||||
tool.method = ""
|
||||
tool.log = SimpleNamespace(update=Mock())
|
||||
|
||||
response = await tool.execute(action=action, browser_id=1, ref=1, script="document.title", calls=calls)
|
||||
|
||||
assert not response.message.startswith("Browser "), response.message
|
||||
assert sum(call.args[0] == "screenshot_file" for call in runtime.call.await_args_list) == int(capture)
|
||||
assert tool.log.update.call_count == int(capture)
|
||||
|
||||
|
||||
def test_format_result_minifies_tool_json() -> None:
|
||||
result = {"browsers": [{"id": "b1", "title": "Example"}]}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue