Introduce a reusable browser-test harness under tests/operator_gui/: - conftest.py: shared fixtures (operator_server, browser with graceful skip, operator_page with screenshot-on-failure artifacts) - pages/: OperatorWorkbench Page Object encapsulating selectors and actions - refactor test_browser_smoke.py onto the fixtures + POM (drops duplicated server/browser plumbing) - test_decision_flow.py: approve/hold/reject E2E against the real server, covering the reject-requires-memo guard and decision persistence Pin playwright in requirements-dev.txt (Chromium bundled offline for the air-gapped target) and ignore the artifacts/ screenshot dir.
105 lines
3.4 KiB
Python
105 lines
3.4 KiB
Python
"""Shared fixtures for operator-GUI tests.
|
|
|
|
The browser fixtures lazily import Playwright (via ``importorskip``) and skip
|
|
when Chromium is unavailable, so the non-browser static tests in this package
|
|
still collect and run on a host without Playwright installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from threading import Thread
|
|
from typing import Any, Iterator
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT / "src") not in sys.path:
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
APP_DIR = ROOT / "web" / "operator-gui"
|
|
ARTIFACT_DIR = ROOT / "artifacts" / "operator-gui-e2e"
|
|
DEFAULT_VIEWPORT = {"width": 1280, "height": 900}
|
|
|
|
|
|
@dataclass
|
|
class OperatorServer:
|
|
"""A running operator server plus the paths a spec may need to assert on."""
|
|
|
|
base_url: str
|
|
image_root: Path
|
|
store: Any
|
|
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_makereport(item, call): # noqa: ANN001 - pytest hook signature
|
|
"""Stash each phase's report so fixtures can screenshot on failure."""
|
|
outcome = yield
|
|
setattr(item, f"rep_{outcome.get_result().when}", outcome.get_result())
|
|
|
|
|
|
@pytest.fixture
|
|
def operator_server(tmp_path: Path) -> Iterator[OperatorServer]:
|
|
"""Build and serve the operator app against a fresh, isolated SQLite store."""
|
|
from rights_filter.server.http_app import build_server
|
|
from rights_filter.server.image_store import LocalSubmissionImageStore
|
|
from rights_filter.server.sqlite_store import CopyrighterStore
|
|
|
|
image_root = tmp_path / "submissions"
|
|
image_root.mkdir()
|
|
store = CopyrighterStore(tmp_path / "copyrighter.sqlite3")
|
|
store.initialize()
|
|
server = build_server(
|
|
host="127.0.0.1",
|
|
port=0,
|
|
store=store,
|
|
image_store=LocalSubmissionImageStore(image_root),
|
|
static_dir=APP_DIR,
|
|
)
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield OperatorServer(
|
|
base_url=f"http://127.0.0.1:{server.server_port}",
|
|
image_root=image_root,
|
|
store=store,
|
|
)
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=5)
|
|
|
|
|
|
@pytest.fixture
|
|
def browser() -> Iterator[Any]:
|
|
"""Launch headless Chromium, skipping cleanly when it is unavailable."""
|
|
playwright_api = pytest.importorskip("playwright.sync_api")
|
|
with playwright_api.sync_playwright() as pw:
|
|
try:
|
|
launched = pw.chromium.launch(headless=True)
|
|
except Exception as exc: # missing browser binaries, sandbox, etc.
|
|
pytest.skip(f"Playwright Chromium is not available: {exc}")
|
|
try:
|
|
yield launched
|
|
finally:
|
|
launched.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def operator_page(browser: Any, request: pytest.FixtureRequest) -> Iterator[Any]:
|
|
"""A fresh page at the default viewport; screenshots to artifacts on failure."""
|
|
page = browser.new_page(viewport=DEFAULT_VIEWPORT)
|
|
try:
|
|
yield page
|
|
finally:
|
|
report = getattr(request.node, "rep_call", None)
|
|
if report is not None and report.failed:
|
|
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
|
|
safe_name = request.node.name.replace("/", "_").replace("::", "_")[:120]
|
|
try:
|
|
page.screenshot(path=str(ARTIFACT_DIR / f"{safe_name}.png"), full_page=True)
|
|
except Exception:
|
|
pass # screenshot is best-effort diagnostics, never fail teardown
|
|
page.close()
|