feat(e2e): add conftest with Streamlit helpers, browser fixtures, console filter
This commit is contained in:
parent
0cdd97f1c0
commit
a55e09d30b
2 changed files with 241 additions and 0 deletions
171
tests/e2e/conftest.py
Normal file
171
tests/e2e/conftest.py
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue