mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 11:40:32 +00:00
318 lines
12 KiB
Python
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
|