feat: add multi-tab support to remote browser
This commit is contained in:
@@ -34,11 +34,21 @@ class BrowserSessionCreate(BaseModel):
|
|||||||
height: int = Field(default=720)
|
height: int = Field(default=720)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTabResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
class BrowserSessionResponse(BaseModel):
|
class BrowserSessionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
custom_page_id: int
|
custom_page_id: int
|
||||||
url: str
|
url: str
|
||||||
title: str
|
title: str
|
||||||
|
active_tab_id: Optional[str] = None
|
||||||
|
tabs: Optional[list[BrowserTabResponse]] = None
|
||||||
|
tab_revision: Optional[int] = 0
|
||||||
|
|
||||||
|
|
||||||
class BrowserSelectionResponse(BaseModel):
|
class BrowserSelectionResponse(BaseModel):
|
||||||
@@ -123,6 +133,22 @@ async def send_event(session_id: str, body: BrowserEvent, _=Depends(get_current_
|
|||||||
raise _error_from_browser(exc)
|
raise _error_from_browser(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/tabs/{tab_id}/activate", response_model=BrowserSessionResponse)
|
||||||
|
async def activate_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
return await browser_sessions.activate_tab(session_id, tab_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _error_from_browser(exc)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{session_id}/tabs/{tab_id}", response_model=BrowserSessionResponse)
|
||||||
|
async def close_tab(session_id: str, tab_id: str, _=Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
return await browser_sessions.close_tab(session_id, tab_id)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _error_from_browser(exc)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
|
@router.get("/{session_id}/selection", response_model=BrowserSelectionResponse)
|
||||||
async def get_selection(session_id: str, _=Depends(get_current_user)):
|
async def get_selection(session_id: str, _=Depends(get_current_user)):
|
||||||
try:
|
try:
|
||||||
@@ -314,6 +340,7 @@ async def session_ws(
|
|||||||
# Task: push screenshots
|
# Task: push screenshots
|
||||||
async def push_loop():
|
async def push_loop():
|
||||||
nonlocal last_frame_hash, unchanged_count
|
nonlocal last_frame_hash, unchanged_count
|
||||||
|
last_tab_revision = -1
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
@@ -327,6 +354,13 @@ async def session_ws(
|
|||||||
interval = _WS_IDLE_INTERVAL
|
interval = _WS_IDLE_INTERVAL
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Check for tab state changes
|
||||||
|
session_obj = browser_sessions.get_session(session_id)
|
||||||
|
if session_obj.tab_revision != last_tab_revision:
|
||||||
|
last_tab_revision = session_obj.tab_revision
|
||||||
|
state = await browser_sessions.state(session_id)
|
||||||
|
await websocket.send_json({"type": "state", "session": state})
|
||||||
|
|
||||||
frame = await browser_sessions.screenshot(session_id)
|
frame = await browser_sessions.screenshot(session_id)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
await websocket.send_json({"error": "session_not_found"})
|
await websocket.send_json({"error": "session_not_found"})
|
||||||
|
|||||||
@@ -24,18 +24,35 @@ class BrowserSessionError(RuntimeError):
|
|||||||
"""Raised when an existing browser session can no longer be used."""
|
"""Raised when an existing browser session can no longer be used."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BrowserTab:
|
||||||
|
id: str
|
||||||
|
page: Any
|
||||||
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BrowserSession:
|
class BrowserSession:
|
||||||
id: str
|
id: str
|
||||||
custom_page_id: int
|
custom_page_id: int
|
||||||
profile_key: str
|
profile_key: str
|
||||||
context: Any
|
context: Any
|
||||||
page: Any
|
tabs: dict[str, BrowserTab]
|
||||||
|
active_tab_id: str
|
||||||
lock: asyncio.Lock
|
lock: asyncio.Lock
|
||||||
|
tab_revision: int = 0
|
||||||
cdp_session: Any = None
|
cdp_session: Any = None
|
||||||
captured_headers: list[dict] = None # auth headers from CDP
|
captured_headers: list[dict] = None # auth headers from CDP
|
||||||
last_saved_state_at: float = 0.0
|
last_saved_state_at: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_tab(self) -> BrowserTab:
|
||||||
|
return self.tabs[self.active_tab_id]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page(self) -> Any:
|
||||||
|
return self.active_tab.page
|
||||||
|
|
||||||
|
|
||||||
class BrowserSessionService:
|
class BrowserSessionService:
|
||||||
# Idle TTL: close sessions that haven't had activity for this long
|
# Idle TTL: close sessions that haven't had activity for this long
|
||||||
@@ -110,17 +127,22 @@ class BrowserSessionService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("clipboard permission grant failed (non-fatal)")
|
logger.debug("clipboard permission grant failed (non-fatal)")
|
||||||
page = context.pages[0] if context.pages else await context.new_page()
|
page = context.pages[0] if context.pages else await context.new_page()
|
||||||
|
tab_id = uuid4().hex
|
||||||
|
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
|
||||||
session = BrowserSession(
|
session = BrowserSession(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
custom_page_id=custom_page_id,
|
custom_page_id=custom_page_id,
|
||||||
profile_key=profile_key,
|
profile_key=profile_key,
|
||||||
context=context,
|
context=context,
|
||||||
page=page,
|
tabs={tab_id: tab},
|
||||||
|
active_tab_id=tab_id,
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
)
|
)
|
||||||
self._sessions[session.id] = session
|
self._sessions[session.id] = session
|
||||||
self._profiles[profile_key] = session.id
|
self._profiles[profile_key] = session.id
|
||||||
self._touch(session.id)
|
self._touch(session.id)
|
||||||
|
# Register page capture for multi-tab support
|
||||||
|
context.on("page", lambda p: self._handle_new_page(session, p))
|
||||||
# Evict again after adding the new session so cap is enforced immediately
|
# Evict again after adding the new session so cap is enforced immediately
|
||||||
await self._evict_idle_sessions()
|
await self._evict_idle_sessions()
|
||||||
try:
|
try:
|
||||||
@@ -136,6 +158,33 @@ class BrowserSessionService:
|
|||||||
"""Mark a session as recently active (reset idle timer)."""
|
"""Mark a session as recently active (reset idle timer)."""
|
||||||
self._last_event_at[session_id] = asyncio.get_event_loop().time()
|
self._last_event_at[session_id] = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
def _handle_new_page(self, session: BrowserSession, page: Any) -> None:
|
||||||
|
"""Capture a new page opened by the remote browser (e.g. target="_blank")."""
|
||||||
|
tab_id = uuid4().hex
|
||||||
|
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
|
||||||
|
session.tabs[tab_id] = tab
|
||||||
|
session.active_tab_id = tab_id
|
||||||
|
session.tab_revision += 1
|
||||||
|
logger.info("session %s: captured new tab %s (total: %d)", session.id[:12], tab_id[:8], len(session.tabs))
|
||||||
|
# Best-effort: bring to front and reset zoom
|
||||||
|
asyncio.create_task(self._init_new_tab(session, tab))
|
||||||
|
|
||||||
|
async def _init_new_tab(self, session: BrowserSession, tab: BrowserTab) -> None:
|
||||||
|
try:
|
||||||
|
await tab.page.bring_to_front()
|
||||||
|
await self._reset_page_zoom(session)
|
||||||
|
# Grant clipboard permission for the new page's origin if possible
|
||||||
|
try:
|
||||||
|
url = tab.page.url
|
||||||
|
if url.startswith("http"):
|
||||||
|
parsed = urlparse(url)
|
||||||
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
await session.context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("new tab %s init failed: %s", tab.id[:8], exc)
|
||||||
|
|
||||||
async def screenshot(self, session_id: str) -> bytes:
|
async def screenshot(self, session_id: str) -> bytes:
|
||||||
session = self._get(session_id)
|
session = self._get(session_id)
|
||||||
self._touch(session_id)
|
self._touch(session_id)
|
||||||
@@ -309,12 +358,82 @@ class BrowserSessionService:
|
|||||||
self._ensure_open(session)
|
self._ensure_open(session)
|
||||||
return await self._session_state(session)
|
return await self._session_state(session)
|
||||||
|
|
||||||
|
async def activate_tab(self, session_id: str, tab_id: str) -> dict[str, Any]:
|
||||||
|
session = self._get(session_id)
|
||||||
|
self._touch(session_id)
|
||||||
|
async with session.lock:
|
||||||
|
self._ensure_open(session)
|
||||||
|
if tab_id not in session.tabs:
|
||||||
|
raise KeyError("tab not found")
|
||||||
|
session.active_tab_id = tab_id
|
||||||
|
session.tab_revision += 1
|
||||||
|
await session.page.bring_to_front()
|
||||||
|
return await self._session_state(session)
|
||||||
|
|
||||||
|
async def close_tab(self, session_id: str, tab_id: str) -> dict[str, Any]:
|
||||||
|
session = self._get(session_id)
|
||||||
|
self._touch(session_id)
|
||||||
|
async with session.lock:
|
||||||
|
self._ensure_open(session)
|
||||||
|
if tab_id not in session.tabs:
|
||||||
|
raise KeyError("tab not found")
|
||||||
|
if len(session.tabs) <= 1:
|
||||||
|
raise ValueError("cannot close the last tab")
|
||||||
|
|
||||||
|
tab = session.tabs.pop(tab_id)
|
||||||
|
try:
|
||||||
|
await tab.page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if session.active_tab_id == tab_id:
|
||||||
|
# Pick the latest remaining tab
|
||||||
|
latest = max(session.tabs.values(), key=lambda t: t.created_at)
|
||||||
|
session.active_tab_id = latest.id
|
||||||
|
await session.page.bring_to_front()
|
||||||
|
|
||||||
|
session.tab_revision += 1
|
||||||
|
return await self._session_state(session)
|
||||||
|
|
||||||
async def _session_state(self, session: BrowserSession) -> dict[str, Any]:
|
async def _session_state(self, session: BrowserSession) -> dict[str, Any]:
|
||||||
|
tabs = []
|
||||||
|
# We might need to prune closed pages during state generation too
|
||||||
|
closed_ids = []
|
||||||
|
for tid, tab in session.tabs.items():
|
||||||
|
if tab.page.is_closed():
|
||||||
|
closed_ids.append(tid)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
title = await tab.page.title()
|
||||||
|
url = tab.page.url
|
||||||
|
except Exception:
|
||||||
|
title, url = "Loading...", "about:blank"
|
||||||
|
tabs.append({
|
||||||
|
"id": tid,
|
||||||
|
"title": title,
|
||||||
|
"url": url,
|
||||||
|
"created_at": tab.created_at,
|
||||||
|
})
|
||||||
|
|
||||||
|
if closed_ids:
|
||||||
|
for cid in closed_ids:
|
||||||
|
session.tabs.pop(cid, None)
|
||||||
|
if not session.tabs:
|
||||||
|
raise BrowserSessionError("all browser pages are closed")
|
||||||
|
if session.active_tab_id in closed_ids:
|
||||||
|
latest = max(session.tabs.values(), key=lambda t: t.created_at)
|
||||||
|
session.active_tab_id = latest.id
|
||||||
|
session.tab_revision += 1
|
||||||
|
|
||||||
|
tabs.sort(key=lambda x: x["created_at"])
|
||||||
return {
|
return {
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"custom_page_id": session.custom_page_id,
|
"custom_page_id": session.custom_page_id,
|
||||||
"url": session.page.url,
|
"url": session.page.url,
|
||||||
"title": await session.page.title(),
|
"title": await session.page.title(),
|
||||||
|
"active_tab_id": session.active_tab_id,
|
||||||
|
"tabs": tabs,
|
||||||
|
"tab_revision": session.tab_revision,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _ensure_playwright(self) -> None:
|
async def _ensure_playwright(self) -> None:
|
||||||
@@ -470,9 +589,18 @@ class BrowserSessionService:
|
|||||||
_get = get_session # alias for internal use
|
_get = get_session # alias for internal use
|
||||||
|
|
||||||
def _ensure_open(self, session: BrowserSession) -> None:
|
def _ensure_open(self, session: BrowserSession) -> None:
|
||||||
if session.page.is_closed():
|
if session.active_tab.page.is_closed():
|
||||||
self._discard_session(session.id)
|
# Current tab closed? Try to cleanup and find another one
|
||||||
raise BrowserSessionError("browser page is closed")
|
session.tabs.pop(session.active_tab_id, None)
|
||||||
|
if session.tabs:
|
||||||
|
# Pick the latest created tab
|
||||||
|
latest = max(session.tabs.values(), key=lambda t: t.created_at)
|
||||||
|
session.active_tab_id = latest.id
|
||||||
|
session.tab_revision += 1
|
||||||
|
logger.info("active tab closed, switched to %s", latest.id[:8])
|
||||||
|
else:
|
||||||
|
self._discard_session(session.id)
|
||||||
|
raise BrowserSessionError("all browser pages are closed")
|
||||||
|
|
||||||
def _discard_session(self, session_id: str) -> BrowserSession | None:
|
def _discard_session(self, session_id: str) -> BrowserSession | None:
|
||||||
session = self._sessions.pop(session_id, None)
|
session = self._sessions.pop(session_id, None)
|
||||||
@@ -592,17 +720,22 @@ class BrowserSessionService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("clipboard permission grant failed (non-fatal)")
|
logger.debug("clipboard permission grant failed (non-fatal)")
|
||||||
page = context.pages[0] if context.pages else await context.new_page()
|
page = context.pages[0] if context.pages else await context.new_page()
|
||||||
|
tab_id = uuid4().hex
|
||||||
|
tab = BrowserTab(id=tab_id, page=page, created_at=asyncio.get_event_loop().time())
|
||||||
session = BrowserSession(
|
session = BrowserSession(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
custom_page_id=0,
|
custom_page_id=0,
|
||||||
profile_key=profile_key,
|
profile_key=profile_key,
|
||||||
context=context,
|
context=context,
|
||||||
page=page,
|
tabs={tab_id: tab},
|
||||||
|
active_tab_id=tab_id,
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
captured_headers=[],
|
captured_headers=[],
|
||||||
)
|
)
|
||||||
self._sessions[session.id] = session
|
self._sessions[session.id] = session
|
||||||
self._touch(session.id)
|
self._touch(session.id)
|
||||||
|
# Register page capture
|
||||||
|
context.on("page", lambda p: self._handle_new_page(session, p))
|
||||||
# Start CDP network capture BEFORE the initial page load,
|
# Start CDP network capture BEFORE the initial page load,
|
||||||
# so we capture login redirects and auth headers from the start.
|
# so we capture login redirects and auth headers from the start.
|
||||||
await self._start_cdp_capture(session)
|
await self._start_cdp_capture(session)
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ def test_screenshot_throttled_save():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.browser_session_service import BrowserSession
|
from app.services.browser_session_service import BrowserSession, BrowserTab
|
||||||
|
|
||||||
# Mock Context & Page
|
# Mock Context & Page
|
||||||
fake_context = AsyncMock()
|
fake_context = AsyncMock()
|
||||||
@@ -172,7 +172,8 @@ def test_screenshot_throttled_save():
|
|||||||
custom_page_id=1,
|
custom_page_id=1,
|
||||||
profile_key="page-1-test",
|
profile_key="page-1-test",
|
||||||
context=fake_context,
|
context=fake_context,
|
||||||
page=fake_page,
|
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
|
||||||
|
active_tab_id="main",
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
last_saved_state_at=0.0
|
last_saved_state_at=0.0
|
||||||
)
|
)
|
||||||
@@ -204,7 +205,8 @@ def test_screenshot_throttled_save():
|
|||||||
custom_page_id=0,
|
custom_page_id=0,
|
||||||
profile_key="auth-capture-xyz",
|
profile_key="auth-capture-xyz",
|
||||||
context=fake_context,
|
context=fake_context,
|
||||||
page=fake_page,
|
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
|
||||||
|
active_tab_id="main",
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
last_saved_state_at=0.0
|
last_saved_state_at=0.0
|
||||||
)
|
)
|
||||||
@@ -228,7 +230,7 @@ def test_close_saves_state_and_cleans_up():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.browser_session_service import BrowserSession
|
from app.services.browser_session_service import BrowserSession, BrowserTab
|
||||||
|
|
||||||
fake_context = AsyncMock()
|
fake_context = AsyncMock()
|
||||||
fake_page = MagicMock()
|
fake_page = MagicMock()
|
||||||
@@ -240,7 +242,8 @@ def test_close_saves_state_and_cleans_up():
|
|||||||
custom_page_id=2,
|
custom_page_id=2,
|
||||||
profile_key="page-2-test",
|
profile_key="page-2-test",
|
||||||
context=fake_context,
|
context=fake_context,
|
||||||
page=fake_page,
|
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
|
||||||
|
active_tab_id="main",
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
last_saved_state_at=time.monotonic() # 此时在限流内
|
last_saved_state_at=time.monotonic() # 此时在限流内
|
||||||
)
|
)
|
||||||
@@ -260,7 +263,8 @@ def test_close_saves_state_and_cleans_up():
|
|||||||
custom_page_id=0,
|
custom_page_id=0,
|
||||||
profile_key="auth-capture-abc",
|
profile_key="auth-capture-abc",
|
||||||
context=eph_context,
|
context=eph_context,
|
||||||
page=eph_page,
|
tabs={"main": BrowserTab(id="main", page=eph_page, created_at=0.0)},
|
||||||
|
active_tab_id="main",
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
last_saved_state_at=0.0
|
last_saved_state_at=0.0
|
||||||
)
|
)
|
||||||
@@ -382,7 +386,7 @@ def test_websocket_event_saves_state():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.browser_session_service import BrowserSession
|
from app.services.browser_session_service import BrowserSession, BrowserTab
|
||||||
|
|
||||||
fake_context = AsyncMock()
|
fake_context = AsyncMock()
|
||||||
fake_page = MagicMock()
|
fake_page = MagicMock()
|
||||||
@@ -395,7 +399,8 @@ def test_websocket_event_saves_state():
|
|||||||
custom_page_id=3,
|
custom_page_id=3,
|
||||||
profile_key="page-3-ws-test",
|
profile_key="page-3-ws-test",
|
||||||
context=fake_context,
|
context=fake_context,
|
||||||
page=fake_page,
|
tabs={"main": BrowserTab(id="main", page=fake_page, created_at=0.0)},
|
||||||
|
active_tab_id="main",
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
last_saved_state_at=0.0
|
last_saved_state_at=0.0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
from app.services.browser_session_service import BrowserSessionService, BrowserSession, BrowserTab, BrowserSessionError
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
return BrowserSessionService()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session(service):
|
||||||
|
fake_context = AsyncMock()
|
||||||
|
fake_page = AsyncMock()
|
||||||
|
fake_page.is_closed = MagicMock(return_value=False)
|
||||||
|
fake_page.url = "https://initial.test"
|
||||||
|
fake_page.title = AsyncMock(return_value="Initial Tab")
|
||||||
|
|
||||||
|
tab_id = "tab1"
|
||||||
|
tab = BrowserTab(id=tab_id, page=fake_page, created_at=100.0)
|
||||||
|
|
||||||
|
sess = BrowserSession(
|
||||||
|
id="sess123",
|
||||||
|
custom_page_id=1,
|
||||||
|
profile_key="profile1",
|
||||||
|
context=fake_context,
|
||||||
|
tabs={tab_id: tab},
|
||||||
|
active_tab_id=tab_id,
|
||||||
|
lock=asyncio.Lock(),
|
||||||
|
)
|
||||||
|
service._sessions[sess.id] = sess
|
||||||
|
return sess
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tab_capture(service, session):
|
||||||
|
new_page = AsyncMock()
|
||||||
|
new_page.is_closed = MagicMock(return_value=False)
|
||||||
|
new_page.url = "https://new.test"
|
||||||
|
new_page.title = AsyncMock(return_value="New Tab")
|
||||||
|
new_page.bring_to_front = AsyncMock()
|
||||||
|
|
||||||
|
# Simulate page capture
|
||||||
|
service._handle_new_page(session, new_page)
|
||||||
|
|
||||||
|
assert len(session.tabs) == 2
|
||||||
|
assert session.active_tab_id != "tab1"
|
||||||
|
new_tab_id = session.active_tab_id
|
||||||
|
assert session.tabs[new_tab_id].page == new_page
|
||||||
|
assert session.tab_revision == 1
|
||||||
|
|
||||||
|
# Wait a bit for the background task _init_new_tab to finish if possible,
|
||||||
|
# though it's mocked anyway.
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_activate_tab(service, session):
|
||||||
|
new_page = AsyncMock()
|
||||||
|
new_page.is_closed = MagicMock(return_value=False)
|
||||||
|
new_page.bring_to_front = AsyncMock()
|
||||||
|
tab2_id = "tab2"
|
||||||
|
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=new_page, created_at=200.0)
|
||||||
|
|
||||||
|
await service.activate_tab(session.id, tab2_id)
|
||||||
|
|
||||||
|
assert session.active_tab_id == tab2_id
|
||||||
|
assert new_page.bring_to_front.call_count == 1
|
||||||
|
assert session.tab_revision == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_tab_safety(service, session):
|
||||||
|
# Cannot close last tab
|
||||||
|
with pytest.raises(ValueError, match="cannot close the last tab"):
|
||||||
|
await service.close_tab(session.id, "tab1")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_active_tab_fallback(service, session):
|
||||||
|
# Setup tab2
|
||||||
|
page2 = AsyncMock()
|
||||||
|
page2.is_closed = MagicMock(return_value=False)
|
||||||
|
page2.bring_to_front = AsyncMock()
|
||||||
|
tab2_id = "tab2"
|
||||||
|
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
|
||||||
|
|
||||||
|
# Active is tab2
|
||||||
|
session.active_tab_id = tab2_id
|
||||||
|
|
||||||
|
# Close active tab2
|
||||||
|
await service.close_tab(session.id, tab2_id)
|
||||||
|
|
||||||
|
assert len(session.tabs) == 1
|
||||||
|
assert session.active_tab_id == "tab1"
|
||||||
|
assert tab2_id not in session.tabs
|
||||||
|
assert session.tabs["tab1"].page.bring_to_front.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_state_includes_tabs(service, session):
|
||||||
|
# Setup tab2
|
||||||
|
page2 = AsyncMock()
|
||||||
|
page2.is_closed = MagicMock(return_value=False)
|
||||||
|
page2.url = "https://tab2.test"
|
||||||
|
page2.title = AsyncMock(return_value="Tab 2")
|
||||||
|
tab2_id = "tab2"
|
||||||
|
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
|
||||||
|
|
||||||
|
state = await service.state(session.id)
|
||||||
|
|
||||||
|
assert state["id"] == session.id
|
||||||
|
assert state["active_tab_id"] == "tab1"
|
||||||
|
assert len(state["tabs"]) == 2
|
||||||
|
|
||||||
|
# Tabs should be sorted by created_at
|
||||||
|
assert state["tabs"][0]["id"] == "tab1"
|
||||||
|
assert state["tabs"][1]["id"] == "tab2"
|
||||||
|
assert state["tabs"][0]["title"] == "Initial Tab"
|
||||||
|
assert state["tabs"][1]["url"] == "https://tab2.test"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_open_prunes_closed_tab(service, session):
|
||||||
|
# Setup tab2 and make it active
|
||||||
|
page2 = AsyncMock()
|
||||||
|
page2.is_closed = MagicMock(return_value=True) # Page 2 is closed
|
||||||
|
tab2_id = "tab2"
|
||||||
|
session.tabs[tab2_id] = BrowserTab(id=tab2_id, page=page2, created_at=200.0)
|
||||||
|
session.active_tab_id = tab2_id
|
||||||
|
|
||||||
|
# Calling any interaction method should trigger _ensure_open
|
||||||
|
# and fallback to tab1
|
||||||
|
await service.screenshot(session.id)
|
||||||
|
|
||||||
|
assert session.active_tab_id == "tab1"
|
||||||
|
assert tab2_id not in session.tabs
|
||||||
|
assert session.tab_revision == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_open_discards_session_if_all_tabs_closed(service, session):
|
||||||
|
session.tabs["tab1"].page.is_closed = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with pytest.raises(BrowserSessionError, match="all browser pages are closed"):
|
||||||
|
await service.screenshot(session.id)
|
||||||
|
|
||||||
|
assert session.id not in service._sessions
|
||||||
@@ -397,11 +397,21 @@ export const customPagesApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ——— Remote browser sessions ———
|
// ——— Remote browser sessions ———
|
||||||
|
export interface BrowserTabData {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
created_at: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrowserSessionData {
|
export interface BrowserSessionData {
|
||||||
id: string
|
id: string
|
||||||
custom_page_id: number
|
custom_page_id: number
|
||||||
url: string
|
url: string
|
||||||
title: string
|
title: string
|
||||||
|
active_tab_id?: string
|
||||||
|
tabs?: BrowserTabData[]
|
||||||
|
tab_revision?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserEventPayload =
|
export type BrowserEventPayload =
|
||||||
@@ -418,6 +428,10 @@ export const browserSessionsApi = {
|
|||||||
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
|
get: (id: string) => api.get<BrowserSessionData>(`/api/browser-sessions/${id}`),
|
||||||
event: (id: string, data: BrowserEventPayload) =>
|
event: (id: string, data: BrowserEventPayload) =>
|
||||||
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/events`, data),
|
||||||
|
activateTab: (id: string, tabId: string) =>
|
||||||
|
api.post<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}/activate`),
|
||||||
|
closeTab: (id: string, tabId: string) =>
|
||||||
|
api.delete<BrowserSessionData>(`/api/browser-sessions/${id}/tabs/${tabId}`),
|
||||||
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
|
selection: (id: string) => api.get<{ text: string }>(`/api/browser-sessions/${id}/selection`),
|
||||||
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
|
clipboard: (id: string) => api.get<{ text?: string; error?: string }>(`/api/browser-sessions/${id}/clipboard`),
|
||||||
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
close: (id: string) => api.delete(`/api/browser-sessions/${id}`),
|
||||||
|
|||||||
@@ -64,6 +64,26 @@
|
|||||||
<div class="viewer-body">
|
<div class="viewer-body">
|
||||||
<div v-if="isRemoteBrowser" class="viewer-content viewer-content-remote">
|
<div v-if="isRemoteBrowser" class="viewer-content viewer-content-remote">
|
||||||
<div class="viewer-stage" :class="{ 'viewer-stage-error': showRemoteError }">
|
<div class="viewer-stage" :class="{ 'viewer-stage-error': showRemoteError }">
|
||||||
|
<!-- Multi-tab bar -->
|
||||||
|
<div v-if="!showRemoteError && remoteSession?.tabs?.length" class="remote-tabs">
|
||||||
|
<div
|
||||||
|
v-for="tab in remoteSession.tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
class="remote-tab"
|
||||||
|
:class="{ active: tab.id === remoteSession.active_tab_id }"
|
||||||
|
@click="switchRemoteTab(tab.id)"
|
||||||
|
>
|
||||||
|
<span class="tab-title" :title="tab.url">{{ tab.title || 'Loading...' }}</span>
|
||||||
|
<el-icon
|
||||||
|
v-if="remoteSession.tabs.length > 1"
|
||||||
|
class="tab-close"
|
||||||
|
@click.stop="closeRemoteTab(tab.id)"
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="!showRemoteError"
|
v-if="!showRemoteError"
|
||||||
ref="remoteFrameRef"
|
ref="remoteFrameRef"
|
||||||
@@ -153,7 +173,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen,
|
ArrowLeft, TopRight, Refresh, Loading, Back, Right, Warning, DocumentCopy, Delete, EditPen,
|
||||||
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
Link as LinkIcon, Monitor, SetUp, Reading, Cpu, DataLine,
|
||||||
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House,
|
Grid, Connection, Ticket, Wallet, Key, Tools, Star, House, Close,
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import { browserSessionsApi, customPagesApi, type BrowserEventPayload, type BrowserSessionData, type CustomPageData } from '@/api'
|
import { browserSessionsApi, customPagesApi, type BrowserEventPayload, type BrowserSessionData, type CustomPageData } from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -396,7 +416,7 @@ function connectRemoteWs() {
|
|||||||
// Text frame = JSON control message
|
// Text frame = JSON control message
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(evt.data as string)
|
const msg = JSON.parse(evt.data as string)
|
||||||
if (msg.type === 'init' && msg.session) {
|
if ((msg.type === 'init' || msg.type === 'state') && msg.session) {
|
||||||
remoteSession.value = msg.session as BrowserSessionData
|
remoteSession.value = msg.session as BrowserSessionData
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -477,6 +497,26 @@ function sendRemoteCommand(type: 'reload' | 'back' | 'forward') {
|
|||||||
sendRemoteEvent({ type })
|
sendRemoteEvent({ type })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchRemoteTab(tabId: string) {
|
||||||
|
if (!remoteSession.value || remoteSession.value.active_tab_id === tabId) return
|
||||||
|
try {
|
||||||
|
const res = await browserSessionsApi.activateTab(remoteSession.value.id, tabId)
|
||||||
|
remoteSession.value = res.data
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '切换标签页失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeRemoteTab(tabId: string) {
|
||||||
|
if (!remoteSession.value) return
|
||||||
|
try {
|
||||||
|
const res = await browserSessionsApi.closeTab(remoteSession.value.id, tabId)
|
||||||
|
remoteSession.value = res.data
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.detail || '关闭标签页失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyRemoteSelection() {
|
async function copyRemoteSelection() {
|
||||||
if (!remoteSession.value) return
|
if (!remoteSession.value) return
|
||||||
try {
|
try {
|
||||||
@@ -1138,6 +1178,62 @@ onBeforeUnmount(() => {
|
|||||||
var(--bg-surface);
|
var(--bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-bottom: 1px solid rgba(140, 119, 98, 0.15);
|
||||||
|
padding: 4px 6px 0;
|
||||||
|
gap: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.remote-tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.remote-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 180px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-tab:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-tab.active {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: rgba(140, 119, 98, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.tab-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
.page-iframe {
|
.page-iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"ignoreDeprecations": "5.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["src/*"] }
|
"paths": { "@/*": ["src/*"] }
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user