POSA_Copyrighter/tests/operator_gui/conftest.py
changukyu 2735ce1194 feat: 운영자 신원 기록 + 게시처 성격(언론·공식 / 무단 게시처) 표시
④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청
   범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 →
   X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이
   막힌다. 인증이 아니라 기록 — 권한은 공유 토큰.
⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에
   '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지.

verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
2026-09-26 14:46:20 +09:00

108 lines
3.7 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)
# The console refuses decisions until an operator name is set (audit "who").
# Specs drive decisions as a named operator, the way a real session would.
page.add_init_script("window.localStorage.setItem('copyrighter.operatorName', 'e2e 운영자')")
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()