feat(e2e): add conftest with Streamlit helpers, browser fixtures, console filter

This commit is contained in:
pyr0ball 2026-03-16 23:14:24 -07:00
parent 0cdd97f1c0
commit a55e09d30b
2 changed files with 241 additions and 0 deletions

171
tests/e2e/conftest.py Normal file
View file

@ -0,0 +1,171 @@
"""
Peregrine E2E test harness shared fixtures and Streamlit helpers.
Run with: pytest tests/e2e/ --mode=demo|cloud|local|all
"""
from __future__ import annotations
import os
import logging
from pathlib import Path
import pytest
from dotenv import load_dotenv
from playwright.sync_api import Page, BrowserContext
from tests.e2e.models import ErrorRecord, ModeConfig, diff_errors
from tests.e2e.modes.demo import DEMO
from tests.e2e.modes.cloud import CLOUD
from tests.e2e.modes.local import LOCAL
load_dotenv(".env.e2e")
log = logging.getLogger(__name__)
_ALL_MODES = {"demo": DEMO, "cloud": CLOUD, "local": LOCAL}
_CONSOLE_NOISE = [
"WebSocket connection",
"WebSocket is closed",
"_stcore/stream",
"favicon.ico",
]
def pytest_addoption(parser):
parser.addoption(
"--mode",
action="store",
default="demo",
choices=["demo", "cloud", "local", "all"],
help="Which Peregrine instance(s) to test against",
)
def pytest_configure(config):
config.addinivalue_line("markers", "e2e: mark test as E2E (requires running Peregrine instance)")
def pytest_collection_modifyitems(config, items):
"""Skip E2E tests if --mode not explicitly passed (belt-and-suspenders isolation)."""
# Only skip if we're collecting from tests/e2e/ without explicit --mode
e2e_items = [i for i in items if "tests/e2e/" in str(i.fspath)]
if e2e_items and not any("--mode" in arg for arg in config.invocation_params.args):
skip = pytest.mark.skip(reason="E2E tests require explicit --mode flag")
for item in e2e_items:
item.add_marker(skip)
@pytest.fixture(scope="session")
def active_modes(pytestconfig) -> list[ModeConfig]:
mode_arg = pytestconfig.getoption("--mode")
if mode_arg == "all":
return list(_ALL_MODES.values())
return [_ALL_MODES[mode_arg]]
@pytest.fixture(scope="session", autouse=True)
def assert_instances_reachable(active_modes):
"""Fail fast with a clear message if any target instance is not running."""
import socket
from urllib.parse import urlparse
for mode in active_modes:
parsed = urlparse(mode.base_url)
host, port = parsed.hostname, parsed.port or 80
try:
with socket.create_connection((host, port), timeout=3):
pass
except OSError:
pytest.exit(
f"[{mode.name}] Instance not reachable at {mode.base_url}"
"start the instance before running E2E tests.",
returncode=1,
)
@pytest.fixture(scope="session")
def mode_contexts(active_modes, playwright) -> dict[str, BrowserContext]:
"""One browser context per active mode, with auth injected via route handler."""
from tests.e2e.modes.cloud import _get_jwt
headless = os.environ.get("E2E_HEADLESS", "true").lower() != "false"
slow_mo = int(os.environ.get("E2E_SLOW_MO", "0"))
browser = playwright.chromium.launch(headless=headless, slow_mo=slow_mo)
contexts = {}
for mode in active_modes:
ctx = browser.new_context(viewport={"width": 1280, "height": 900})
if mode.name == "cloud":
def _inject_jwt(route, request):
jwt = _get_jwt()
headers = {**request.headers, "x-cf-session": f"cf_session={jwt}"}
route.continue_(headers=headers)
ctx.route(f"{mode.base_url}/**", _inject_jwt)
else:
mode.auth_setup(ctx)
contexts[mode.name] = ctx
yield contexts
browser.close()
def wait_for_streamlit(page: Page, timeout: int = 10_000) -> None:
"""
Wait until Streamlit has finished rendering.
Uses 2000ms idle window NOT networkidle (Playwright's networkidle uses
500ms which is too short for Peregrine's 3s sidebar fragment poller).
"""
try:
page.wait_for_selector('[data-testid="stSpinner"]', state="hidden", timeout=timeout)
except Exception:
pass
try:
page.wait_for_function(
"() => !document.querySelector('[data-testid=\"stStatusWidget\"]')"
"?.textContent?.includes('running')",
timeout=5_000,
)
except Exception:
pass
page.wait_for_timeout(2_000)
def get_page_errors(page) -> list[ErrorRecord]:
"""Scan DOM for Streamlit error indicators."""
errors: list[ErrorRecord] = []
for el in page.query_selector_all('[data-testid="stException"]'):
errors.append(ErrorRecord(
type="exception",
message=el.inner_text()[:500],
element_html=el.inner_html()[:1000],
))
for el in page.query_selector_all('[data-testid="stAlert"]'):
# Streamlit 1.35+: st.error() renders child [data-testid="stAlertContentError"]
# kind is a React prop — NOT a DOM attribute. Child detection is authoritative.
if el.query_selector('[data-testid="stAlertContentError"]'):
errors.append(ErrorRecord(
type="alert",
message=el.inner_text()[:500],
element_html=el.inner_html()[:1000],
))
return errors
def get_console_errors(messages) -> list[str]:
"""Filter browser console messages to real errors, excluding Streamlit noise."""
result = []
for msg in messages:
if msg.type != "error":
continue
text = msg.text
if any(noise in text for noise in _CONSOLE_NOISE):
continue
result.append(text)
return result
def screenshot_on_fail(page: Page, mode_name: str, test_name: str) -> Path:
results_dir = Path(f"tests/e2e/results/{mode_name}/screenshots")
results_dir.mkdir(parents=True, exist_ok=True)
path = results_dir / f"{test_name}.png"
page.screenshot(path=str(path), full_page=True)
return path

View file

@ -109,3 +109,73 @@ def test_get_jwt_uses_cache(monkeypatch):
token = cloud_mod._get_jwt()
assert token == "cached.jwt"
mock_post.assert_not_called()
def test_get_page_errors_finds_exceptions(monkeypatch):
"""get_page_errors returns ErrorRecord for stException elements."""
from tests.e2e.conftest import get_page_errors
mock_el = MagicMock()
mock_el.get_attribute.return_value = None
mock_el.inner_text.return_value = "RuntimeError: boom"
mock_el.inner_html.return_value = "<div>RuntimeError: boom</div>"
mock_page = MagicMock()
mock_page.query_selector_all.side_effect = lambda sel: (
[mock_el] if "stException" in sel else []
)
errors = get_page_errors(mock_page)
assert len(errors) == 1
assert errors[0].type == "exception"
assert "boom" in errors[0].message
def test_get_page_errors_finds_alert_errors(monkeypatch):
"""get_page_errors returns ErrorRecord for stAlert with stAlertContentError child."""
from tests.e2e.conftest import get_page_errors
mock_child = MagicMock()
mock_el = MagicMock()
mock_el.query_selector.return_value = mock_child
mock_el.inner_text.return_value = "Something went wrong"
mock_el.inner_html.return_value = "<div>Something went wrong</div>"
mock_page = MagicMock()
mock_page.query_selector_all.side_effect = lambda sel: (
[] if "stException" in sel else [mock_el]
)
errors = get_page_errors(mock_page)
assert len(errors) == 1
assert errors[0].type == "alert"
def test_get_page_errors_ignores_non_error_alerts(monkeypatch):
"""get_page_errors does NOT flag st.warning() or st.info() alerts."""
from tests.e2e.conftest import get_page_errors
mock_el = MagicMock()
mock_el.query_selector.return_value = None
mock_el.inner_text.return_value = "Just a warning"
mock_page = MagicMock()
mock_page.query_selector_all.side_effect = lambda sel: (
[] if "stException" in sel else [mock_el]
)
errors = get_page_errors(mock_page)
assert errors == []
def test_get_console_errors_filters_noise():
"""get_console_errors filters benign Streamlit WebSocket reconnect messages."""
from tests.e2e.conftest import get_console_errors
messages = [
MagicMock(type="error", text="WebSocket connection closed"),
MagicMock(type="error", text="TypeError: cannot read property"),
MagicMock(type="log", text="irrelevant"),
]
errors = get_console_errors(messages)
assert errors == ["TypeError: cannot read property"]