"""End-to-end smoke: API checks + headless browser against the tabbed mobile-first UI. Set E2E_BASE_URL (default http://127.0.0.1:8012) to match your dev server. """ from __future__ import annotations import json import os import sys import time import urllib.error import urllib.parse import urllib.request from selenium.common.exceptions import TimeoutException from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait BASE_URL = os.environ.get("E2E_BASE_URL", "http://127.0.0.1:8012").rstrip("/") DOMINANT_ROLE_ID = "fetlife_kinktionary_dominant_wzwjm" def get_json(path: str, headers: dict[str, str] | None = None) -> dict: request = urllib.request.Request(f"{BASE_URL}{path}", headers=headers or {}) with urllib.request.urlopen(request, timeout=120) as response: return json.loads(response.read().decode("utf-8")) def post_json(path: str, payload: dict, headers: dict[str, str] | None = None) -> dict: request_headers = {"Content-Type": "text/plain;charset=UTF-8"} if headers: request_headers.update(headers) request = urllib.request.Request( f"{BASE_URL}{path}", data=json.dumps(payload).encode("utf-8"), headers=request_headers, method="POST", ) with urllib.request.urlopen(request, timeout=120) as response: return json.loads(response.read().decode("utf-8")) def assert_true(condition: bool, message: str) -> None: if not condition: raise AssertionError(message) def wait_text(driver: webdriver.Chrome, text: str, timeout: int = 15): return WebDriverWait(driver, timeout).until( EC.presence_of_element_located((By.XPATH, f"//*[contains(normalize-space(), {json.dumps(text)})]")) ) def run_browser_smoke() -> dict: options = Options() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--window-size=1440,1400") options.add_argument("--no-sandbox") driver = webdriver.Chrome(options=options) wait = WebDriverWait(driver, 25) long_wait = WebDriverWait(driver, 120) results: dict[str, str] = {} try: driver.get(BASE_URL) wait.until(EC.element_to_be_clickable((By.XPATH, "//button[normalize-space()='Create profile']"))).click() wait.until(EC.presence_of_element_located((By.XPATH, "//*[contains(., \"Set up your profile\")]"))) codes = wait.until(lambda d: d.find_elements(By.XPATH, "//div[contains(@class, 'creds')]//code")) assert_true(len(codes) >= 2, "create flow did not render profile credentials") profile_id = codes[0].text.strip() private_pass = codes[1].text.strip() results["profile_id"] = profile_id role_input = wait.until( EC.element_to_be_clickable((By.XPATH, "//input[@placeholder='Search roles...']")) ) role_input.clear() role_input.send_keys("dominant") try: add_btn = long_wait.until( EC.element_to_be_clickable( ( By.XPATH, "//div[contains(@class,'play-chip')]" "//span[contains(@class,'chip-name') and contains(., 'Dominant')]/following-sibling::button[normalize-space()='Add']", ) ) ) add_btn.click() except TimeoutException: post_json( f"/users/{profile_id}/roles", {"kink_id": DOMINANT_ROLE_ID, "selected": True}, headers={"x-private-token": private_pass}, ) wait.until(EC.element_to_be_clickable((By.XPATH, "//button[normalize-space()='Start starter deck']"))).click() long_wait.until( EC.presence_of_element_located( (By.XPATH, "//div[contains(@class,'top-bar-title') and normalize-space()='Starter deck']") ) ) for _ in range(12): long_wait.until(EC.element_to_be_clickable((By.XPATH, "//button[contains(normalize-space(),'Skip')]"))).click() wait.until(EC.presence_of_element_located((By.XPATH, "//nav[contains(@class,'tab-bar')]"))) wait_text(driver, "Discover", timeout=20) long_wait.until( EC.presence_of_element_located( (By.XPATH, "//div[contains(@class,'top-bar-title') and normalize-space()='Discover']") ) ) long_wait.until(EC.presence_of_element_located((By.XPATH, "//button[contains(normalize-space(),'Love')]"))) wait.until(EC.element_to_be_clickable((By.XPATH, "//button[contains(@class,'tab-btn') and contains(.,'Discover')]"))) wait.until( EC.element_to_be_clickable((By.XPATH, "//div[contains(@class,'top-bar-right')]//button[contains(normalize-space(),'Search')]")) ).click() kink_search = wait.until( EC.element_to_be_clickable((By.XPATH, "//div[contains(@class,'search-overlay')]//input[@placeholder='Search kinks...']")) ) kink_search.clear() kink_search.send_keys("bondage") long_wait.until( EC.element_to_be_clickable( ( By.XPATH, "//div[contains(@class,'search-overlay')]//div[contains(@class,'play-chip')][.//span[contains(.,'Bondage')]]", ) ) ).click() wait.until(EC.presence_of_element_located((By.XPATH, "//div[contains(@class,'top-bar-title') and normalize-space()='My Plays']"))) try: wait.until(EC.element_to_be_clickable((By.XPATH, "//button[contains(normalize-space(),'Show')]"))).click() wait.until(EC.presence_of_element_located((By.XPATH, "//div[contains(@class,'tinder-card')]//img"))) except TimeoutException: pass results["private_pass"] = private_pass return results finally: driver.quit() def run_api_smoke(credentials: dict[str, str]) -> None: headers = {"x-private-token": credentials["private_pass"]} user = get_json(f"/users/{credentials['profile_id']}", headers=headers) assert_true(isinstance(user.get("roles"), list), "user payload missing roles") search = get_json("/search?q=bondage&limit=1") top = search["items"][0]["kink"] assert_true(top["name"].strip().lower() == "bondage", "bondage search did not return exact bondage row") assert_true(bool(top["has_images"]), "canonical bondage result should report images") bondage_id = top["id"] roles = get_json("/roles/search?q=dominant&limit=3") assert_true(all(item["kink"]["content_kind"] in {"role", "dynamic"} for item in roles["items"]), "role search leaked non-role items") plays = get_json("/search?q=dominant&limit=3") assert_true(plays["items"] == [], "play search should not surface dynamic role terms by default") detail = get_json(f"/kinks/{bondage_id}") assert_true(detail["filtered_asset_count"] > 0, "bondage detail missing trusted assets") recs = get_json(f"/users/{credentials['profile_id']}/recommendations?limit=8", headers=headers) items = recs["items"] assert_true(len(items) >= 1, "recommendations returned no items") assert_true( all(item.get("discovery_source") in {"starter", "personalized", "explore", "partner_influenced"} for item in items), f"recommendations missing discovery source labels: {[item.get('discovery_source') for item in items]}", ) health = get_json("/admin/fetlife/data-health?limit=5") assert_true("starter_priority" in health["debt"], "data health missing prioritized crawl debt") assert_true("crawl" in health and "queue" in health, "data health missing crawl/queue readiness") assert_true("representative_image_kinks" in health["coverage"], "data health missing representative image coverage") debug = get_json(f"/admin/recommendation-debug/{credentials['profile_id']}?limit=5", headers=headers) assert_true("signals" in debug and "items" in debug, "recommendation debug payload invalid") partner = post_json("/users", {}) partner_headers = {"x-private-token": partner["private_token"]} post_json( f"/users/{credentials['profile_id']}/plays", {"kink_id": bondage_id, "interest_state": "love", "directions": ["to_me"]}, headers=headers, ) post_json( f"/users/{partner['id']}/plays", {"kink_id": bondage_id, "interest_state": "love", "directions": ["by_me"]}, headers=partner_headers, ) post_json( f"/users/{credentials['profile_id']}/partners", {"partner_id": partner["id"]}, headers=headers, ) post_json( f"/users/{partner['id']}/partner-requests/accept", {"from_user_id": credentials["profile_id"]}, headers=partner_headers, ) group = post_json( f"/users/{credentials['profile_id']}/partner-groups", {"name": "Smoke Group", "member_ids": [partner["id"]]}, headers=headers, ) groups = get_json(f"/users/{credentials['profile_id']}/partner-groups", headers=headers) assert_true(any(item["id"] == group["id"] for item in groups["items"]), "created partner group missing from list") overlap = get_json( f"/users/{credentials['profile_id']}/partner-groups/{group['id']}/overlap", headers=headers, ) assert_true(any(item["id"] == bondage_id for item in overlap["direct_matches"]), "group overlap missed compatible play") def main() -> int: try: health = get_json("/health") assert_true(bool(health.get("ok")), "health check failed") credentials = run_browser_smoke() run_api_smoke(credentials) except (AssertionError, urllib.error.URLError, TimeoutError) as exc: print(f"SMOKE FAILED: {exc}", file=sys.stderr) return 1 print("SMOKE OK") return 0 if __name__ == "__main__": raise SystemExit(main())