POSA_Copyrighter/tests/operator_gui/conftest.py
changukyu 0bfa30d7f5 test: add Playwright E2E harness for operator GUI with decision-flow spec
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.
2026-06-21 21:10:22 +09:00

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()