Skyvern/tests/unit/test_browser_launcher_profile.py

318 lines
12 KiB
Python

"""Tests for Chrome profile utilities in browser_launcher.py."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from skyvern.cli.core.browser_launcher import (
_PROFILE_COPY_IGNORE,
clone_local_chrome_profile,
get_local_chrome_profile_dir,
is_chrome_running,
)
class TestGetLocalChromeProfileDir:
def test_darwin(self) -> None:
with patch("skyvern.cli.core.browser_launcher.platform.system", return_value="Darwin"):
result = get_local_chrome_profile_dir()
assert result == Path.home() / "Library/Application Support/Google/Chrome"
def test_windows(self) -> None:
with patch("skyvern.cli.core.browser_launcher.platform.system", return_value="Windows"):
result = get_local_chrome_profile_dir()
assert result == Path.home() / "AppData" / "Local" / "Google" / "Chrome" / "User Data"
def test_linux(self) -> None:
with patch("skyvern.cli.core.browser_launcher.platform.system", return_value="Linux"):
result = get_local_chrome_profile_dir()
assert result == Path.home() / ".config" / "google-chrome"
def _create_mock_chrome_dir(base: Path, profile_name: str = "Default") -> Path:
"""Create a minimal mock Chrome user data directory with cache dirs."""
chrome_dir = base / "chrome_data"
profile = chrome_dir / profile_name
profile.mkdir(parents=True)
# Local State at the chrome data dir level
(chrome_dir / "Local State").write_text('{"os_crypt": {}}')
# Lock files
(chrome_dir / "SingletonLock").write_text("")
(chrome_dir / "SingletonSocket").write_text("")
(chrome_dir / "SingletonCookie").write_text("")
# Profile-level files
(profile / "Preferences").write_text('{"profile": {}}')
(profile / "Cookies").write_bytes(b"fake-sqlite-data")
# Subdirectory
local_storage = profile / "Local Storage"
local_storage.mkdir()
(local_storage / "data.txt").write_text("stored_value")
# Cache directories (should be skipped in selective mode)
for cache_name in [
"Cache",
"Code Cache",
"GPUCache",
"Service Worker",
"blob_storage",
"Extensions",
"IndexedDB",
"File System",
"Session Storage",
]:
cache_dir = profile / cache_name
cache_dir.mkdir()
(cache_dir / "data").write_bytes(b"cache_data")
return chrome_dir
def _patch_for_clone(chrome_dir: Path, tmp_path: Path):
"""Return a combined context manager that patches both get_local_chrome_profile_dir and SKYVERN_DATA_DIR."""
return _MultiPatch(chrome_dir, tmp_path)
class _MultiPatch:
"""Patches get_local_chrome_profile_dir and SKYVERN_DATA_DIR together."""
def __init__(self, chrome_dir: Path, skyvern_data: Path) -> None:
self._p1 = patch("skyvern.cli.core.browser_launcher.get_local_chrome_profile_dir", return_value=chrome_dir)
self._p2 = patch("skyvern.cli.core.browser_launcher.SKYVERN_DATA_DIR", skyvern_data)
def __enter__(self):
self._p1.__enter__()
self._p2.__enter__()
return self
def __exit__(self, *args):
self._p2.__exit__(*args)
self._p1.__exit__(*args)
class TestCloneLocalChromeProfileSelective:
"""Tests for selective (default) copy mode."""
def test_copies_profile_and_local_state(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest)
assert (dest / "Local State").exists()
assert (dest / "Local State").read_text() == '{"os_crypt": {}}'
assert (dest / "Default" / "Preferences").exists()
assert (dest / "Default" / "Cookies").exists()
assert (dest / "Default" / "Local Storage" / "data.txt").exists()
assert (dest / "Default" / "Local Storage" / "data.txt").read_text() == "stored_value"
def test_skips_cache_directories(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest)
for cache_name in _PROFILE_COPY_IGNORE:
assert not (dest / "Default" / cache_name).exists(), f"Cache dir {cache_name} should have been skipped"
def test_clears_lock_files(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest)
assert not (dest / "SingletonLock").exists()
assert not (dest / "SingletonSocket").exists()
assert not (dest / "SingletonCookie").exists()
def test_removes_existing_dest_before_copy(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
dest.mkdir(parents=True)
(dest / "stale_file.txt").write_text("old")
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest)
assert not (dest / "stale_file.txt").exists()
assert (dest / "Default" / "Preferences").exists()
def test_copies_profile_with_spaces_in_name(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path, profile_name="Profile 1")
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Profile 1", dest)
assert (dest / "Profile 1" / "Preferences").exists()
assert (dest / "Profile 1" / "Cookies").exists()
assert (dest / "Local State").exists()
def test_works_without_local_state(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
(chrome_dir / "Local State").unlink()
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest)
assert not (dest / "Local State").exists()
assert (dest / "Default" / "Preferences").exists()
class TestCloneLocalChromeProfileFull:
"""Tests for full copy mode."""
def test_copies_full_user_data_dir(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest, full=True)
assert (dest / "Local State").exists()
assert (dest / "Default" / "Preferences").exists()
assert (dest / "Default" / "Cookies").exists()
assert (dest / "Default" / "Local Storage" / "data.txt").exists()
assert (dest / "Default" / "Local Storage" / "data.txt").read_text() == "stored_value"
def test_full_includes_cache_dirs(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest, full=True)
assert (dest / "Default" / "Cache" / "data").exists()
assert (dest / "Default" / "Code Cache" / "data").exists()
def test_full_clears_lock_files(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest, full=True)
assert not (dest / "SingletonLock").exists()
assert not (dest / "SingletonSocket").exists()
assert not (dest / "SingletonCookie").exists()
def test_full_removes_existing_dest_before_copy(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
dest = tmp_path / "dest_data"
dest.mkdir(parents=True)
(dest / "stale_file.txt").write_text("old")
with _patch_for_clone(chrome_dir, tmp_path):
clone_local_chrome_profile("Default", dest, full=True)
assert not (dest / "stale_file.txt").exists()
assert (dest / "Default" / "Preferences").exists()
class TestCloneLocalChromeProfileErrors:
def test_raises_when_profile_not_found(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
with _patch_for_clone(chrome_dir, tmp_path):
with pytest.raises(FileNotFoundError, match="NoSuchProfile"):
clone_local_chrome_profile("NoSuchProfile", tmp_path / "dest")
def test_lists_available_profiles_in_error(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
(chrome_dir / "Profile 1").mkdir()
with _patch_for_clone(chrome_dir, tmp_path):
with pytest.raises(FileNotFoundError, match="Default"):
clone_local_chrome_profile("BadName", tmp_path / "dest")
def test_rejects_dest_outside_skyvern_data_dir(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
skyvern_dir = tmp_path / "skyvern_data"
skyvern_dir.mkdir()
outside_dest = tmp_path / "somewhere_else"
with _patch_for_clone(chrome_dir, skyvern_dir):
with pytest.raises(ValueError, match="outside Skyvern's data directory"):
clone_local_chrome_profile("Default", outside_dest)
def test_raises_when_chrome_not_installed(self, tmp_path: Path) -> None:
nonexistent = tmp_path / "no_chrome_here"
with _patch_for_clone(nonexistent, tmp_path):
with pytest.raises(FileNotFoundError, match="Is Google Chrome installed"):
clone_local_chrome_profile("Default", tmp_path / "dest")
def test_rejects_path_traversal_in_profile_name(self, tmp_path: Path) -> None:
chrome_dir = _create_mock_chrome_dir(tmp_path)
with _patch_for_clone(chrome_dir, tmp_path):
with pytest.raises(ValueError, match="resolves outside"):
clone_local_chrome_profile("../../etc", tmp_path / "dest")
def _make_proc(name: str) -> MagicMock:
proc = MagicMock()
proc.info = {"name": name}
return proc
class TestIsChromeRunning:
def test_detects_chrome_main_process(self) -> None:
procs = [_make_proc("Google Chrome")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is True
def test_detects_lowercase_chrome(self) -> None:
procs = [_make_proc("chrome")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is True
def test_detects_google_chrome_stable(self) -> None:
procs = [_make_proc("google-chrome-stable")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is True
def test_detects_chromium(self) -> None:
procs = [_make_proc("chromium")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is True
def test_ignores_crashpad_handler(self) -> None:
procs = [_make_proc("chrome_crashpad_handler")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is False
def test_ignores_chromedriver(self) -> None:
procs = [_make_proc("chromedriver")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is False
def test_ignores_chrome_helper(self) -> None:
procs = [_make_proc("Google Chrome Helper")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is False
def test_no_false_positive_on_substring(self) -> None:
"""Processes with 'chrome' as a substring should NOT match."""
procs = [_make_proc("chrome_renderer"), _make_proc("chrome-sandbox")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is False
def test_returns_false_with_no_processes(self) -> None:
with patch("psutil.process_iter", return_value=[]):
assert is_chrome_running() is False
def test_returns_false_with_unrelated_processes(self) -> None:
procs = [_make_proc("firefox"), _make_proc("code"), _make_proc("python")]
with patch("psutil.process_iter", return_value=procs):
assert is_chrome_running() is False