feat: persist browser sessions and update admin workflows
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from pathlib import Path
|
||||
from app.config import get_settings
|
||||
from app.routers.auth_capture import _sanitize_candidate
|
||||
from app.services.browser_session_service import BrowserSessionService
|
||||
|
||||
@@ -127,3 +128,293 @@ def test_sanitize_candidate_strips_secret_fields_but_keeps_metadata():
|
||||
"cookie_name": "session",
|
||||
"domain": "example.test",
|
||||
}
|
||||
|
||||
|
||||
def test_cookies_path_mapping():
|
||||
import tempfile
|
||||
import shutil
|
||||
service = BrowserSessionService()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
original_dir = get_settings().browser_profiles_dir
|
||||
get_settings().browser_profiles_dir = temp_dir
|
||||
try:
|
||||
profile_key = "test-profile-123"
|
||||
expected_path = Path(service._cookies_path(profile_key))
|
||||
assert expected_path.name == "session-cookies.json"
|
||||
assert expected_path.parent == Path(service._profile_dir(profile_key))
|
||||
finally:
|
||||
get_settings().browser_profiles_dir = original_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_screenshot_throttled_save():
|
||||
import tempfile
|
||||
import shutil
|
||||
service = BrowserSessionService()
|
||||
|
||||
# 准备临时目录并 mock settings.browser_profiles_dir
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
original_dir = get_settings().browser_profiles_dir
|
||||
get_settings().browser_profiles_dir = temp_dir
|
||||
|
||||
try:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from app.services.browser_session_service import BrowserSession
|
||||
|
||||
# Mock Context & Page
|
||||
fake_context = AsyncMock()
|
||||
fake_page = MagicMock()
|
||||
fake_page.is_closed = MagicMock(return_value=False)
|
||||
fake_page.screenshot = AsyncMock(return_value=b"screenshot-bytes")
|
||||
|
||||
session = BrowserSession(
|
||||
id="session123",
|
||||
custom_page_id=1,
|
||||
profile_key="page-1-test",
|
||||
context=fake_context,
|
||||
page=fake_page,
|
||||
lock=asyncio.Lock(),
|
||||
last_saved_state_at=0.0
|
||||
)
|
||||
service._sessions[session.id] = session
|
||||
|
||||
# 第一次调用 screenshot: 触发存储
|
||||
res1 = run(service.screenshot(session.id))
|
||||
assert res1 == b"screenshot-bytes"
|
||||
assert fake_context.storage_state.call_count == 1
|
||||
|
||||
# 记录第一次保存后的时间戳
|
||||
first_save_time = session.last_saved_state_at
|
||||
assert first_save_time > 0
|
||||
|
||||
# 第二次立即调用 screenshot: 应该因为限流 10s 被跳过,不增加 call_count
|
||||
res2 = run(service.screenshot(session.id))
|
||||
assert res2 == b"screenshot-bytes"
|
||||
assert fake_context.storage_state.call_count == 1
|
||||
|
||||
# 模拟 11 秒后(防抖时间已过)再度截图
|
||||
session.last_saved_state_at = first_save_time - 11.0
|
||||
res3 = run(service.screenshot(session.id))
|
||||
assert res3 == b"screenshot-bytes"
|
||||
assert fake_context.storage_state.call_count == 2
|
||||
|
||||
# 测试临时 auth-capture 会话不触发任何 state 存储
|
||||
ephemeral_session = BrowserSession(
|
||||
id="session-eph",
|
||||
custom_page_id=0,
|
||||
profile_key="auth-capture-xyz",
|
||||
context=fake_context,
|
||||
page=fake_page,
|
||||
lock=asyncio.Lock(),
|
||||
last_saved_state_at=0.0
|
||||
)
|
||||
service._sessions[ephemeral_session.id] = ephemeral_session
|
||||
run(service.screenshot(ephemeral_session.id))
|
||||
# 它的 call_count 依然是 2,没有增加
|
||||
assert fake_context.storage_state.call_count == 2
|
||||
|
||||
finally:
|
||||
get_settings().browser_profiles_dir = original_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_close_saves_state_and_cleans_up():
|
||||
import tempfile
|
||||
import shutil
|
||||
service = BrowserSessionService()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
original_dir = get_settings().browser_profiles_dir
|
||||
get_settings().browser_profiles_dir = temp_dir
|
||||
|
||||
try:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from app.services.browser_session_service import BrowserSession
|
||||
|
||||
fake_context = AsyncMock()
|
||||
fake_page = MagicMock()
|
||||
fake_page.is_closed = MagicMock(return_value=False)
|
||||
|
||||
import time
|
||||
session = BrowserSession(
|
||||
id="session_close",
|
||||
custom_page_id=2,
|
||||
profile_key="page-2-test",
|
||||
context=fake_context,
|
||||
page=fake_page,
|
||||
lock=asyncio.Lock(),
|
||||
last_saved_state_at=time.monotonic() # 此时在限流内
|
||||
)
|
||||
service._sessions[session.id] = session
|
||||
|
||||
# 即使在限流时间内,close 也必须强制保存
|
||||
run(service.close(session.id))
|
||||
assert fake_context.storage_state.call_count == 1
|
||||
assert fake_context.close.call_count == 1
|
||||
|
||||
# 测试 ephemeral 会话在 close 时不应该保存 state,并且其 profile_dir 应当被清理,导致 cookies json 不复存在
|
||||
eph_context = AsyncMock()
|
||||
eph_page = MagicMock()
|
||||
eph_page.is_closed = MagicMock(return_value=False)
|
||||
eph_session = BrowserSession(
|
||||
id="session_eph_close",
|
||||
custom_page_id=0,
|
||||
profile_key="auth-capture-abc",
|
||||
context=eph_context,
|
||||
page=eph_page,
|
||||
lock=asyncio.Lock(),
|
||||
last_saved_state_at=0.0
|
||||
)
|
||||
service._sessions[eph_session.id] = eph_session
|
||||
|
||||
# 先手动创建一个假 session-cookies.json
|
||||
eph_cookies_path = service._cookies_path(eph_session.profile_key)
|
||||
eph_cookies_path.write_text("{}")
|
||||
assert eph_cookies_path.exists()
|
||||
|
||||
run(service.close(eph_session.id))
|
||||
# ephemeral close 时不保存,所以 call_count 依然是 0
|
||||
assert eph_context.storage_state.call_count == 0
|
||||
assert eph_context.close.call_count == 1
|
||||
# 但对应的 profile 目录已被删除,文件自然不复存在
|
||||
assert not eph_cookies_path.exists()
|
||||
|
||||
finally:
|
||||
get_settings().browser_profiles_dir = original_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_restore_session_state_decoding_and_inject():
|
||||
import json
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
service = BrowserSessionService()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
original_dir = get_settings().browser_profiles_dir
|
||||
get_settings().browser_profiles_dir = temp_dir
|
||||
|
||||
try:
|
||||
from unittest.mock import AsyncMock
|
||||
fake_context = AsyncMock()
|
||||
profile_key = "test-restore-profile"
|
||||
|
||||
# 准备假 cookies.json,包含 cookies 和 origins/localStorage
|
||||
cookies_path = service._cookies_path(profile_key)
|
||||
|
||||
now = time.time()
|
||||
fake_state = {
|
||||
"cookies": [
|
||||
{
|
||||
"name": "valid_persistent",
|
||||
"value": "123",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": now + 3600 # 未过期
|
||||
},
|
||||
{
|
||||
"name": "expired_cookie",
|
||||
"value": "456",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": now - 3600 # 已过期
|
||||
},
|
||||
{
|
||||
"name": "session_cookie",
|
||||
"value": "789",
|
||||
"domain": "example.test",
|
||||
"path": "/",
|
||||
"expires": -1 # session cookie,应被保留并剔除 expires 字段
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.test",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "theme",
|
||||
"value": "dark"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
with open(cookies_path, "w", encoding='utf-8') as f:
|
||||
json.dump(fake_state, f)
|
||||
|
||||
# 运行还原方法
|
||||
run(service._restore_session_state(fake_context, profile_key))
|
||||
|
||||
# 检查是否成功调用 add_cookies
|
||||
assert fake_context.add_cookies.call_count == 1
|
||||
|
||||
# 检查过滤后的 cookies 内容
|
||||
injected_cookies = fake_context.add_cookies.call_args[0][0]
|
||||
assert len(injected_cookies) == 2
|
||||
|
||||
names = [c["name"] for c in injected_cookies]
|
||||
assert "valid_persistent" in names
|
||||
assert "session_cookie" in names
|
||||
assert "expired_cookie" not in names
|
||||
|
||||
# 校验 session_cookie 是否成功移除了 expires
|
||||
session_c = next(c for c in injected_cookies if c["name"] == "session_cookie")
|
||||
assert "expires" not in session_c
|
||||
|
||||
# 检查是否成功调用了 add_init_script (用于还原 LocalStorage)
|
||||
assert fake_context.add_init_script.call_count == 1
|
||||
init_script = fake_context.add_init_script.call_args[0][0]
|
||||
assert "window.localStorage.setItem" in init_script
|
||||
assert "theme" in init_script
|
||||
assert "dark" in init_script
|
||||
|
||||
finally:
|
||||
get_settings().browser_profiles_dir = original_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def test_websocket_event_saves_state():
|
||||
import tempfile
|
||||
import shutil
|
||||
service = BrowserSessionService()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
original_dir = get_settings().browser_profiles_dir
|
||||
get_settings().browser_profiles_dir = temp_dir
|
||||
|
||||
try:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from app.services.browser_session_service import BrowserSession
|
||||
|
||||
fake_context = AsyncMock()
|
||||
fake_page = MagicMock()
|
||||
fake_page.is_closed = MagicMock(return_value=False)
|
||||
fake_page.mouse = MagicMock()
|
||||
fake_page.mouse.click = AsyncMock()
|
||||
|
||||
session = BrowserSession(
|
||||
id="session_ws",
|
||||
custom_page_id=3,
|
||||
profile_key="page-3-ws-test",
|
||||
context=fake_context,
|
||||
page=fake_page,
|
||||
lock=asyncio.Lock(),
|
||||
last_saved_state_at=0.0
|
||||
)
|
||||
service._sessions[session.id] = session
|
||||
|
||||
# 即使 include_state=False,也应当在 5 秒节流到期后保存状态
|
||||
run(service.event(
|
||||
session_id=session.id,
|
||||
event_type="click",
|
||||
payload={"x": 10.0, "y": 20.0},
|
||||
include_state=False
|
||||
))
|
||||
|
||||
# storage_state 应该被调用,说明保存成功触发了
|
||||
assert fake_context.storage_state.call_count == 1
|
||||
assert session.last_saved_state_at > 0
|
||||
|
||||
finally:
|
||||
get_settings().browser_profiles_dir = original_dir
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user