④ 감사 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
108 lines
3.7 KiB
Python
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()
|