Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions backend/druks/browser/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@
class BrowserSession:
"""A named browser login the extension's runs borrow.

Declared on the Extension class — ``x = BrowserSession(site="x.com")`` — the
attribute name and the extension's name become the session's identity
(``x_me.x``). The operator signs in once through the login window; a
workflow then borrows the logged-in browser::
Declared on the Extension class — ``acme = BrowserSession(site="acme.example")``
— the attribute name and the extension's name become the session's identity
(``night_watch.acme``). The operator signs in once through the login window;
a workflow then borrows the logged-in browser::

async with XMe.x.playwright() as browser:
async with NightWatch.acme.playwright() as browser:
page = await browser.new_page()
await page.goto("https://x.com/home")
await page.goto("https://acme.example/home")
"""

site: str
Expand Down
86 changes: 45 additions & 41 deletions backend/tests/test_browser_borrow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@


@pytest.fixture
def x_me(browser_session_declarations):
class XMe:
name = "x_me"
x = BrowserSession(site="x.com", persist=True)
def night_watch(browser_session_declarations):
class NightWatch:
name = "night_watch"
acme = BrowserSession(site="acme.example", persist=True)
docs = BrowserSession(site="docs.example")

return XMe
return NightWatch


class FakeListener:
Expand Down Expand Up @@ -115,16 +115,16 @@ def stored_session(
return row


def test_declaration_carries_the_extension_namespace(x_me):
assert x_me.x.name == "x_me.x"
assert x_me.docs.name == "x_me.docs"
def test_declaration_carries_the_extension_namespace(night_watch):
assert night_watch.acme.name == "night_watch.acme"
assert night_watch.docs.name == "night_watch.docs"


async def test_borrow_yields_a_tunneled_cdp_url(borrow, x_me):
async def test_borrow_yields_a_tunneled_cdp_url(borrow, night_watch):
browser, redis = borrow
stored_session(x_me.docs)
stored_session(night_watch.docs)

async with x_me.docs.cdp() as cdp_url:
async with night_watch.docs.cdp() as cdp_url:
assert cdp_url == "http://127.0.0.1:43987"

assert browser.forwarded_port == 9222
Expand All @@ -138,14 +138,14 @@ async def test_borrow_yields_a_tunneled_cdp_url(borrow, x_me):
launch_script = browser.commands[0][2]
assert "session-launch --headed" in launch_script
assert not redis.values
assert StoredBrowserSession.get_for_name(x_me.docs.name).last_used_at
assert StoredBrowserSession.get_for_name(night_watch.docs.name).last_used_at


async def test_headless_declaration_launches_headless(borrow):
browser, _ = borrow
quiet = BrowserSession(site="docs.example")
quiet.headless = True
quiet.name = "x_me.quiet"
quiet.name = "night_watch.quiet"
stored_session(quiet)

async with quiet.cdp():
Expand All @@ -154,92 +154,96 @@ async def test_headless_declaration_launches_headless(borrow):
assert "session-launch --headless" in browser.commands[0][2]


async def test_persisting_borrow_locks_exports_and_stores(borrow, x_me):
async def test_persisting_borrow_locks_exports_and_stores(borrow, night_watch):
browser, redis = borrow
row = stored_session(x_me.x)
row = stored_session(night_watch.acme)

async with x_me.x.cdp():
async with night_watch.acme.cdp():
assert redis.values

assert not redis.values
assert browser.commands[-1] == ["session-export"]
db_session().expire_all()
stored = StoredBrowserSession.get_for_name(x_me.x.name)
stored = StoredBrowserSession.get_for_name(night_watch.acme.name)
assert stored.payload.decrypt() == b"exported-profile"
assert stored.payload_format == BrowserSessionPayloadFormat.PROFILE_DIR.value
assert stored.id == row.id


async def test_persisting_borrow_refuses_a_second_writer(borrow, x_me):
async def test_persisting_borrow_refuses_a_second_writer(borrow, night_watch):
browser, redis = borrow
stored_session(x_me.x)
redis.values[f"browser_session:{StoredBrowserSession.get_for_name(x_me.x.name).id}"] = "other"
stored_session(night_watch.acme)
redis.values[
f"browser_session:{StoredBrowserSession.get_for_name(night_watch.acme.name).id}"
] = "other"

with pytest.raises(BrowserSessionWriterLockedError):
async with x_me.x.cdp():
async with night_watch.acme.cdp():
pass

assert browser.commands == []


async def test_first_borrow_writes_the_declared_session_and_asks_for_a_login(
borrow, x_me, druks_db
borrow, night_watch, druks_db
):
"""The first borrow materializes the row and refuses to open a browser:
the session is declared, but nobody has signed into it yet."""
assert not StoredBrowserSession.get_for_name(x_me.docs.name)
assert not StoredBrowserSession.get_for_name(night_watch.docs.name)

with pytest.raises(BrowserSessionNotReadyError):
async with x_me.docs.cdp():
async with night_watch.docs.cdp():
pass

row = StoredBrowserSession.get_for_name(x_me.docs.name)
row = StoredBrowserSession.get_for_name(night_watch.docs.name)
assert row.status == BrowserSessionStatus.NEEDS_LOGIN.value
assert row.site == x_me.docs.site
assert row.site == night_watch.docs.site

with pytest.raises(BrowserSessionNotReadyError):
async with x_me.docs.cdp():
async with night_watch.docs.cdp():
pass

assert StoredBrowserSession.list_all() == [row]


async def test_launch_failure_raises_and_releases_the_lock(borrow, x_me):
async def test_launch_failure_raises_and_releases_the_lock(borrow, night_watch):
browser, redis = borrow
stored_session(x_me.x)
stored_session(night_watch.acme)
browser.launch_exit = 1

with pytest.raises(BrowserLaunchError, match="launch stderr"):
async with x_me.x.cdp():
async with night_watch.acme.cdp():
pass

assert not redis.values


async def test_signed_out_borrow_stamps_the_session_and_stores_nothing(borrow, x_me):
async def test_signed_out_borrow_stamps_the_session_and_stores_nothing(borrow, night_watch):
"""The extension raises through the borrow when the site bounced the login:
the door stamps which session bounced, and the dead state is never stored."""
browser, redis = borrow
stored_session(x_me.x, payload=b"live-state")
stored_session(night_watch.acme, payload=b"live-state")

with pytest.raises(BrowserSessionSignedOutError) as caught:
async with x_me.x.cdp():
async with night_watch.acme.cdp():
raise BrowserSessionSignedOutError("the site bounced the login")

assert caught.value.session_name == "x_me.x"
assert caught.value.session_name == "night_watch.acme"
db_session().expire_all()
assert StoredBrowserSession.get_for_name(x_me.x.name).payload.decrypt() == b"live-state"
assert (
StoredBrowserSession.get_for_name(night_watch.acme.name).payload.decrypt() == b"live-state"
)
assert ["session-export"] not in browser.commands
assert not redis.values # the writer lock released on the way out


async def test_playwright_yields_the_logged_in_context(borrow, x_me, monkeypatch):
async def test_playwright_yields_the_logged_in_context(borrow, night_watch, monkeypatch):
import sys
import types
from contextlib import asynccontextmanager as acm

browser, _ = borrow
stored_session(x_me.docs)
stored_session(night_watch.docs)
seen = {}
logged_in_context = object()

Expand All @@ -263,19 +267,19 @@ async def fake_playwright():
monkeypatch.setitem(sys.modules, "playwright", types.ModuleType("playwright"))
monkeypatch.setitem(sys.modules, "playwright.async_api", playwright_module)

async with x_me.docs.playwright() as context:
async with night_watch.docs.playwright() as context:
assert context is logged_in_context

assert seen == {"url": "http://127.0.0.1:43987", "closed": True}


async def test_playwright_without_the_dependency_names_the_fix(borrow, x_me, monkeypatch):
async def test_playwright_without_the_dependency_names_the_fix(borrow, night_watch, monkeypatch):
import sys

stored_session(x_me.docs)
stored_session(night_watch.docs)
monkeypatch.setitem(sys.modules, "playwright", None)
monkeypatch.setitem(sys.modules, "playwright.async_api", None)

with pytest.raises(BrowserClientMissingError, match="add playwright"):
async with x_me.docs.playwright():
async with night_watch.docs.playwright():
pass
6 changes: 3 additions & 3 deletions backend/tests/test_browser_session_login_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ def window_runtime(tmp_path, monkeypatch):
return client


def create_session(name: str = "x-main") -> StoredBrowserSession:
def create_session(name: str = "acme-main") -> StoredBrowserSession:
return StoredBrowserSession.get_or_create(
name=name,
payload_format=BrowserSessionPayloadFormat.STORAGE_STATE,
site="x.com",
site="acme.example",
)


Expand All @@ -106,7 +106,7 @@ async def test_login_launch_opens_on_the_session_site(window_runtime):
await LoginWindow.open(create_session())

command = client.browsers[0].launch_command or ""
assert "DRUKS_BROWSER_URL=https://x.com" in command
assert "DRUKS_BROWSER_URL=https://acme.example" in command


def _runtime_with_sandbox(tmp_path, monkeypatch, **sandbox) -> FakeSandboxClient:
Expand Down
Loading