diff --git a/.gitignore b/.gitignore index e640caf..b5d3068 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules/ # Runtime data, databases, logs, generated artifacts data/ outputs/ +artifacts/ *.sqlite3 *.sqlite3-journal *.log diff --git a/requirements-dev.txt b/requirements-dev.txt index 780ff7e..0ce3b7e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,8 @@ pytest==8.4.2 pytest-cov==6.0.0 pytest-timeout==2.4.0 +# Operator-GUI browser (E2E) tests. The Chromium binary is NOT pip-installed: +# on the build/staging host run `playwright install chromium`, then bundle the +# browser cache (PLAYWRIGHT_BROWSERS_PATH) into the offline artifact for the +# air-gapped target. Browser tests skip cleanly when Chromium is absent. +playwright==1.49.1 diff --git a/tests/operator_gui/__init__.py b/tests/operator_gui/__init__.py new file mode 100644 index 0000000..f5fbd98 --- /dev/null +++ b/tests/operator_gui/__init__.py @@ -0,0 +1 @@ +"""Operator-GUI test package (enables relative imports of shared page objects).""" diff --git a/tests/operator_gui/conftest.py b/tests/operator_gui/conftest.py new file mode 100644 index 0000000..fc4dccf --- /dev/null +++ b/tests/operator_gui/conftest.py @@ -0,0 +1,105 @@ +"""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() diff --git a/tests/operator_gui/pages/__init__.py b/tests/operator_gui/pages/__init__.py new file mode 100644 index 0000000..8603cd3 --- /dev/null +++ b/tests/operator_gui/pages/__init__.py @@ -0,0 +1,5 @@ +"""Page objects for operator-GUI browser (Playwright) tests.""" + +from .workbench_page import OperatorWorkbench + +__all__ = ["OperatorWorkbench"] diff --git a/tests/operator_gui/pages/workbench_page.py b/tests/operator_gui/pages/workbench_page.py new file mode 100644 index 0000000..4fc5f48 --- /dev/null +++ b/tests/operator_gui/pages/workbench_page.py @@ -0,0 +1,151 @@ +"""Page Object Model for the operator workbench single-page app. + +Encapsulates the operator-GUI selectors and interactions so browser specs read +as user intent ("select case", "use suggested query") instead of raw locators. +Mirrors the existing app structure in ``web/operator-gui/index.html``. + +All locator accessors return Playwright ``Locator`` objects, which auto-wait; +prefer them over arbitrary timeouts (see the e2e-testing skill guidance). +""" + +from __future__ import annotations + +import json +from typing import Any + + +# Status string the app shows after a suggested query is loaded but not yet run. +SUGGESTED_QUERY_LOADED_STATUS = "추천 쿼리를 입력했습니다. 실행 버튼을 눌러 검색하세요." + +# Client-side guard message when a reject/correct decision is missing its memo. +MEMO_REQUIRED_ERROR = "반려 또는 보정 결정에는 메모가 필요합니다." + + +class OperatorWorkbench: + """Drives the operator GUI for browser-level (E2E) assertions.""" + + def __init__(self, page: Any, base_url: str) -> None: + self.page = page + self.base_url = base_url.rstrip("/") + self.console_errors: list[str] = [] + page.on("console", lambda message: self.console_errors.append(f"console:{message.type}:{message.text}")) + page.on("pageerror", lambda error: self.console_errors.append(f"pageerror:{error}")) + + # -- Locators --------------------------------------------------------- + @property + def queue_body(self) -> Any: + return self.page.locator("#queue-body") + + @property + def workbench_view(self) -> Any: + return self.page.locator("#workbench-view") + + @property + def case_title(self) -> Any: + return self.page.locator("#case-title") + + @property + def manual_query(self) -> Any: + return self.page.locator("#manual-query") + + @property + def manual_query_status(self) -> Any: + return self.page.locator("#manual-query-status") + + @property + def submission_image_name(self) -> Any: + return self.page.locator("#submission-image-name") + + @property + def submission_import_status(self) -> Any: + return self.page.locator("#submission-import-status") + + @property + def recommendation_box(self) -> Any: + """Selected-case summary; includes the "현재 운영 결정: