"""Browser end-to-end review journeys against the real server + static frontend. Covers keyboard inventory review, fuzzy-decision confirmation, stale-version conflict handling, and decision persistence across reload. The server runs as a real child process; the browser talks to it over HTTP like production. """ import shutil import socket import subprocess import sys import time from pathlib import Path from types import SimpleNamespace import httpx import numpy as np import pytest from PIL import Image from playwright.sync_api import expect from tests.conftest import session_client REPO = Path(__file__).resolve().parents[2] def _structured(path, seed, size=(256, 192)): path.parent.mkdir(parents=True, exist_ok=True) rng = np.random.default_rng(seed) w, h = size base = np.zeros((h, w, 3), dtype=np.uint8) for _ in range(6): x0, y0 = int(rng.integers(0, w - 60)), int(rng.integers(0, h - 60)) base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3) grad = np.linspace(0, 120, w, dtype=np.uint8) base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255) Image.fromarray(base).save(path, quality=95) return path def _resized(src, dst, scale=0.5): with Image.open(src) as image: image.resize( (int(image.width * scale), int(image.height * scale)), Image.LANCZOS ).save(dst, quality=95) def _free_port(): with socket.socket() as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] def _seed(data_dir, lib): # In-process seed, then dispose so the server process owns the database freely. from photo_pipeline.config import Config from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations from photo_pipeline.services.duplicates import DuplicateService from photo_pipeline.services.inventory import InventoryService a = _structured(lib / "a.jpg", 1) shutil.copy2(a, lib / "a_copy.jpg") # exact pair b = _structured(lib / "b.jpg", 2) _resized(b, lib / "b_small.jpg") # perceptual pair config = Config.from_env( {"PHOTO_PIPELINE_DATA_DIR": str(data_dir), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib)} ) run_migrations(config.database_url) engine = create_db_engine(config.database_url) sf = create_session_factory(engine) InventoryService(sf).scan(lib) DuplicateService(sf).detect() engine.dispose() @pytest.fixture def server(tmp_path): data = tmp_path / "data" data.mkdir() lib = tmp_path / "lib" lib.mkdir() _seed(data, lib) port = _free_port() env = { "PATH": __import__("os").environ.get("PATH", ""), "PHOTO_PIPELINE_DATA_DIR": str(data), "PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib), "PHOTO_PIPELINE_HOST": "127.0.0.1", "PHOTO_PIPELINE_PORT": str(port), } proc = subprocess.Popen( [sys.executable, "-m", "photo_pipeline", "serve"], cwd=str(REPO), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) base = f"http://127.0.0.1:{port}" deadline = time.monotonic() + 30 ready = False while time.monotonic() < deadline: if proc.poll() is not None: out, err = proc.communicate() pytest.fail(f"server exited: {err.decode(errors='replace')}") try: if httpx.get(f"{base}/api/v1/health/ready", timeout=1).status_code == 200: ready = True break except httpx.HTTPError: time.sleep(0.2) if not ready: proc.terminate() pytest.fail("server never became ready") client = session_client(base, timeout=5) def cluster_by_method(method): clusters = client.get("/api/v1/duplicates/clusters").json()["items"] return next(c for c in clusters if c["method"] == method) try: yield SimpleNamespace(base=base, client=client, cluster_by_method=cluster_by_method) finally: client.close() proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() def test_inventory_keyboard_review(page, server): page.goto(f"{server.base}/app/#/inventory") rows = page.get_by_test_id("asset-row") rows.first.wait_for() assert rows.count() == 4 # First row auto-selected; arrow-down moves the selection. assert rows.nth(0).get_attribute("aria-selected") == "true" page.keyboard.press("ArrowDown") assert rows.nth(1).get_attribute("aria-selected") == "true" assert rows.nth(0).get_attribute("aria-selected") == "false" def test_fuzzy_decision_requires_confirmation(page, server): cluster = server.cluster_by_method("perceptual") page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}") page.get_by_test_id("not-duplicate").click() # A confirmation appears before anything is persisted. page.get_by_test_id("confirm").wait_for() assert "open" in page.get_by_test_id("cluster-state").inner_text() page.get_by_test_id("confirm-yes").click() # A locator assertion, not wait_for_function: the app's CSP forbids eval, and # Playwright's polling predicate is evaluated as a string in the page (US07-02). expect(page.get_by_test_id("cluster-state")).to_contain_text("dismissed") def test_stale_version_shows_conflict(page, server): cluster = server.cluster_by_method("perceptual") page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}") # Open the confirmation for a fuzzy decision (this refetches the current version). page.get_by_test_id("defer").click() page.get_by_test_id("confirm").wait_for() # While the dialog is open, another actor decides — bumping the version. resp = server.client.post( f"/api/v1/duplicates/clusters/{cluster['id']}/decision", json={"decision": "deferred", "expected_version": cluster["version"]}, ) assert resp.status_code == 200 # Confirming now carries the stale version; the server refuses it. page.get_by_test_id("confirm-yes").click() page.get_by_role("alert").wait_for() assert "changed" in page.get_by_role("alert").inner_text() def test_decision_persists_after_reload(page, server): cluster = server.cluster_by_method("perceptual") page.goto(f"{server.base}/app/#/duplicates/{cluster['id']}") page.get_by_test_id("not-duplicate").click() page.get_by_test_id("confirm-yes").click() # A locator assertion, not wait_for_function: the app's CSP forbids eval, and # Playwright's polling predicate is evaluated as a string in the page (US07-02). expect(page.get_by_test_id("cluster-state")).to_contain_text("dismissed") page.reload() page.get_by_test_id("cluster-state").wait_for() assert "dismissed" in page.get_by_test_id("cluster-state").inner_text()