Files

522 lines
17 KiB
Python
Raw Permalink Normal View History

import os
import sys
import tempfile
import subprocess
import time
import asyncio
import threading
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parent.parent
SCREENSHOT_DIR = Path("/tmp/devplace_test_screenshots")
PORT = int(os.environ.get("DEVPLACE_TEST_PORT", "10501"))
BASE_URL = f"http://127.0.0.1:{PORT}"
# Older than any user a fixture can create, so the seeded admin stays primary administrator.
PRIMARY_ADMIN_CREATED_AT = "2000-01-01T00:00:00"
# The cross-worker cache-version read is 1s; give the server a beat to see the new admin set.
CACHE_VERSION_PROPAGATION_SECONDS = 1.5
_TEST_DB = tempfile.NamedTemporaryFile(suffix=f"_{PORT}.db", delete=False)
_TEST_DB.close()
_TEST_DATA_DIR = Path(tempfile.mkdtemp(suffix=f"_data_{PORT}"))
os.environ["DEVPLACE_DATABASE_URL"] = f"sqlite:///{_TEST_DB.name}"
os.environ["DEVPLACE_DATA_DIR"] = str(_TEST_DATA_DIR)
os.environ["SECRET_KEY"] = "test-secret-key"
os.environ["DEVPLACE_DISABLE_SERVICES"] = "1"
os.environ["DEVPLACE_RATE_LIMIT"] = "1000000"
# Pin a single web worker so the per-worker rate-limit divisor is 1 regardless of
# any DEVPLACE_WEB_WORKERS the host (or a server .env) exports.
os.environ["DEVPLACE_WEB_WORKERS"] = "1"
# Disable per-IP rate limiting outright for the suite: the middleware short-circuits
# when this is set, so request-dense tests (e.g. test_auth_matrix) are never throttled
# regardless of host environment. test_ratelimit.py re-enables it per test to exercise
# the limiter directly.
os.environ["DEVPLACE_DISABLE_RATE_LIMIT"] = "1"
os.environ["DEVPLACE_SITEMAP_TTL"] = "0"
os.environ["DEVPLACE_HOME_CACHE_TTL"] = "0"
os.environ["DEVPLACE_RANKING_TTL"] = "0"
os.environ["DEVPLACE_MARKET_SATURATION_TTL"] = "0"
2026-08-16 05:00:52 +02:00
_ASYNC_LOOP = None
_ASYNC_LOOP_LOCK = threading.Lock()
def _async_loop():
# One shared background loop thread for the whole session, not one thread
# per run_async() call: dataset's NullPool caches one SQLite connection per
# OS thread ID forever (see the root CLAUDE.md NullPool note), so a fresh
# throwaway thread per call - this helper is invoked 270+ times across the
# suite - permanently claimed one file descriptor apiece for the rest of
# the session and could exhaust the process's open-file limit on a long run.
global _ASYNC_LOOP
with _ASYNC_LOOP_LOCK:
if _ASYNC_LOOP is None:
loop = asyncio.new_event_loop()
threading.Thread(
target=loop.run_forever, name="run-async-loop", daemon=True
).start()
_ASYNC_LOOP = loop
return _ASYNC_LOOP
def run_async(coro):
2026-08-16 05:00:52 +02:00
"""Run a coroutine from sync test code on the shared background loop."""
async def _wrapped():
from devplacepy.database import refresh_snapshot
try:
2026-08-16 05:00:52 +02:00
return await coro
finally:
refresh_snapshot()
2026-08-16 05:00:52 +02:00
future = asyncio.run_coroutine_threadsafe(_wrapped(), _async_loop())
try:
return future.result(timeout=30)
except TimeoutError:
raise TimeoutError(
"run_async timed out waiting for coroutine to complete"
) from None
finally:
from devplacepy.database import refresh_snapshot
2026-08-16 05:00:52 +02:00
refresh_snapshot()
@pytest.fixture(autouse=True)
def _fresh_db_snapshot():
from devplacepy.database import refresh_snapshot
refresh_snapshot()
yield
def save_failure_screenshot(page, test_name):
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
try:
if page and not page.is_closed():
path = str(SCREENSHOT_DIR / f"{test_name}.png")
page.screenshot(path=path, full_page=True)
except Exception:
pass
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
for name in ("page", "alice", "bob"):
if name in item.funcargs:
try:
obj = item.funcargs[name]
if name in ("alice", "bob"):
obj = obj[0]
save_failure_screenshot(
obj, item.nodeid.replace("::", "_").replace("/", "_")
)
except Exception:
pass
@pytest.fixture(scope="session")
def test_db_path():
yield _TEST_DB.name
try:
os.unlink(_TEST_DB.name)
except OSError:
pass
import shutil
shutil.rmtree(_TEST_DATA_DIR, ignore_errors=True)
def _port_in_use(port):
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(0.5)
return sock.connect_ex(("127.0.0.1", port)) == 0
# Set once app_server has successfully started, so pytest_runtest_setup can
# detect a mid-session crash (e.g. resource exhaustion on a constrained CI
# runner) and fail every subsequent HTTP-dependent test with one clear
# diagnostic instead of a wall of opaque connection-refused errors.
_APP_SERVER_STATE = {"proc": None, "log_path": None}
@pytest.fixture(scope="session")
def app_server(test_db_path):
if _port_in_use(PORT):
raise RuntimeError(
f"Port {PORT} is already in use before app_server starts. A stale test "
f"server is running with a different database; kill it (the readiness "
f"check would otherwise bind tests to it and produce phantom failures)."
)
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
log_file = tempfile.NamedTemporaryFile(suffix=f"_server_{PORT}.log", delete=False)
proc = subprocess.Popen(
[
sys.executable,
"-m",
"uvicorn",
"devplacepy.main:app",
"--host",
"127.0.0.1",
"--port",
str(PORT),
"--log-level",
"warning",
],
cwd=str(PROJECT_ROOT),
env=env,
stdout=log_file,
stderr=subprocess.STDOUT,
)
def server_log():
try:
with open(log_file.name, "r", errors="replace") as f:
return f.read()[-5000:]
except OSError:
return ""
deadline = time.time() + 30
while time.time() < deadline:
try:
import urllib.request
urllib.request.urlopen(f"{BASE_URL}/", timeout=2)
break
except Exception:
poll = proc.poll()
if poll is not None:
proc.wait()
raise RuntimeError(
f"Server process died (exit code {poll}). Log:\n{server_log()}"
)
time.sleep(0.5)
else:
proc.terminate()
proc.wait(timeout=5)
raise RuntimeError(f"Server did not start after 30s. Log:\n{server_log()}")
from devplacepy.database import set_setting as _set_setting
# Use set_setting (upsert + cache-version bump) rather than a raw insert: the
# server process caches settings and only reloads when the version bumps, so a
# bare insert can leave the worker on a stale/default rate limit and throttle
# request-dense tests (e.g. test_auth_matrix) with spurious 429s. Pin the limit
# high regardless of any pre-existing row or the host's DEVPLACE_* environment.
_set_setting("rate_limit_per_minute", "1000000")
_set_setting("rate_limit_window_seconds", "60")
_APP_SERVER_STATE["proc"] = proc
_APP_SERVER_STATE["log_path"] = log_file.name
yield proc
_APP_SERVER_STATE["proc"] = None
try:
proc.terminate()
proc.wait(timeout=10)
except Exception:
proc.kill()
proc.wait(timeout=5)
finally:
try:
log_file.close()
os.unlink(log_file.name)
except OSError:
pass
def pytest_runtest_setup(item):
proc = _APP_SERVER_STATE["proc"]
if proc is None or "tests/unit/" in item.nodeid:
return
exit_code = proc.poll()
if exit_code is None:
return
log_path = _APP_SERVER_STATE["log_path"]
try:
with open(log_path, "r", errors="replace") as f:
log_tail = f.read()[-5000:]
except OSError:
log_tail = "(log unavailable)"
pytest.fail(
f"app_server died mid-session (exit code {exit_code}). It was healthy "
f"earlier in this run and has since crashed - this is a shared-server "
f"failure, not a bug in {item.nodeid}. Server log tail:\n{log_tail}",
pytrace=False,
)
@pytest.fixture(scope="session")
def playwright_instance():
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
yield p
@pytest.fixture(scope="session")
def browser(playwright_instance):
headless = os.environ.get("PLAYWRIGHT_HEADLESS", "1") == "1"
b = playwright_instance.chromium.launch(
headless=headless,
slow_mo=int(os.environ.get("PLAYWRIGHT_SLOW_MO", "0")),
args=["--window-size=1400,900"],
)
yield b
b.close()
@pytest.fixture(scope="module")
def browser_context(browser):
ctx = browser.new_context(
viewport={"width": 1400, "height": 900},
permissions=["clipboard-read", "clipboard-write"],
)
yield ctx
ctx.close()
@pytest.fixture
def page(browser_context):
browser_context.clear_cookies()
p = browser_context.new_page()
p.set_default_timeout(15000)
p.bring_to_front()
yield p
p.close()
def signup_user(page, user):
page.bring_to_front()
page.goto(f"{BASE_URL}/auth/signup", wait_until="domcontentloaded")
page.fill("#username", user["username"])
page.fill("#email", user["email"])
page.fill("#password", user["password"])
page.fill("#confirm_password", user["password"])
page.fill("#birth_date", "1990-01-01")
page.check("#accept_terms")
page.click("button:has-text('Create account')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def login_user(page, user):
page.bring_to_front()
page.goto(f"{BASE_URL}/auth/login", wait_until="domcontentloaded")
page.fill("#email", user["email"])
page.fill("#password", user["password"])
page.click("button:has-text('Sign in')")
page.wait_for_url("**/feed", timeout=10000, wait_until="domcontentloaded")
def paste_image(page, selector, name="pasted.png"):
import base64
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (4, 4), (0, 128, 255)).save(buf, "PNG")
page.eval_on_selector(
selector,
"""(target, [data, filename]) => {
const bytes = Uint8Array.from(atob(data), (ch) => ch.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], filename, { type: "image/png" }));
target.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData: transfer,
bubbles: true,
cancelable: true,
})
);
}""",
[base64.b64encode(buf.getvalue()).decode(), name],
)
def create_post_with_files(page, content, files, expected_count=1):
from playwright.sync_api import expect
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
page.locator(".feed-fab").first.click()
page.fill("#post-content", content)
page.locator("#create-post-modal dp-upload .dp-upload-input").first.set_input_files(
files
)
expect(
page.locator("#create-post-modal dp-upload .dp-upload-count").first
).to_have_text(f"({expected_count})", timeout=15000)
page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
def assert_share_copies(page, expected_fragment):
from playwright.sync_api import expect
share = page.locator("button[data-share]").first
share.scroll_into_view_if_needed()
assert expected_fragment in (share.get_attribute("data-share") or ""), (
f"data-share={share.get_attribute('data-share')!r}"
)
share.click()
expect(share).to_have_text("Copied!", timeout=3000)
expect(share).not_to_have_text("Copied!", timeout=3000)
try:
clip = page.evaluate("navigator.clipboard.readText()")
except Exception:
clip = None
if clip:
assert clip.startswith("http") and expected_fragment in clip, (
f"clipboard={clip!r}"
)
2026-08-09 11:39:53 +02:00
def assert_no_horizontal_overflow(page, layout):
report = page.evaluate(
"""(selector) => {
const root = document.documentElement;
const container = document.querySelector(selector);
const widest = [];
document.querySelectorAll("body *").forEach((el) => {
if (el.getBoundingClientRect().right <= root.clientWidth + 1) return;
widest.push(
el.tagName.toLowerCase() + "." + (el.className || "").toString().trim()
);
});
return {
page: root.scrollWidth - root.clientWidth,
layout: container ? container.scrollWidth - container.clientWidth : null,
widest: widest.slice(0, 5),
};
}""",
layout,
)
assert report["layout"] is not None, f"{layout} not found"
assert report["page"] <= 1, (
f"page scrolls horizontally by {report['page']}px: {report['widest']}"
)
assert report["layout"] <= 1, (
f"{layout} overflows its own width by {report['layout']}px: {report['widest']}"
)
@pytest.fixture(scope="session")
def seeded_db(app_server):
"""Create test users once at session level using requests directly."""
import urllib.request
import urllib.parse
users = [
("alice_test", "alice@test.devplace", "secret123"),
("bob_test", "bob@test.devplace", "secret456"),
]
for username, email, pw in users:
data = urllib.parse.urlencode(
{
"username": username,
"email": email,
"password": pw,
"confirm_password": pw,
"birth_date": "1990-01-01",
"accept_terms": "1",
}
).encode()
req = urllib.request.Request(
f"{BASE_URL}/auth/signup",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
urllib.request.urlopen(req)
from devplacepy.database import get_table as _get_table
users_table = _get_table("users")
for username, role in (("alice_test", "Admin"), ("bob_test", "Member")):
row = users_table.find_one(username=username)
if row and row.get("role") != role:
users_table.update({"uid": row["uid"], "role": role}, ["uid"])
# The primary administrator is the earliest-created Admin, and unit-tier fixtures
# create their own admins in this same database before the api tier seeds alice.
# Back-date her so she is unambiguously the platform's first administrator, which
# is what every primary-admin test (dbapi, backup download, container isolation)
# assumes. Without this, a combined unit+api run hands primary admin to a fixture
# user and those tests 403.
from devplacepy.database import invalidate_admins_cache as _invalidate_admins
alice_row = users_table.find_one(username="alice_test")
if alice_row:
users_table.update(
{"uid": alice_row["uid"], "created_at": PRIMARY_ADMIN_CREATED_AT}, ["uid"]
)
_invalidate_admins()
time.sleep(CACHE_VERSION_PROPAGATION_SECONDS)
return {
"alice": {
"username": "alice_test",
"email": "alice@test.devplace",
"password": "secret123",
},
"bob": {
"username": "bob_test",
"email": "bob@test.devplace",
"password": "secret456",
},
}
@pytest.fixture
def alice(page, seeded_db):
login_user(page, seeded_db["alice"])
return page, seeded_db["alice"]
@pytest.fixture
def bob(browser, seeded_db):
ctx = browser.new_context(
viewport={"width": 1400, "height": 900},
permissions=["clipboard-read", "clipboard-write"],
)
p = ctx.new_page()
p.set_default_timeout(15000)
p.bring_to_front()
login_user(p, seeded_db["bob"])
yield p, seeded_db["bob"]
p.close()
ctx.close()
@pytest.fixture
def mobile_page(browser, seeded_db):
ctx = browser.new_context(
viewport={"width": 412, "height": 840},
has_touch=True,
permissions=["clipboard-read", "clipboard-write"],
)
p = ctx.new_page()
p.set_default_timeout(15000)
p.bring_to_front()
login_user(p, seeded_db["alice"])
yield p, seeded_db["alice"]
p.close()
ctx.close()
@pytest.fixture(scope="session")
def local_db():
from devplacepy.database import init_db, db
init_db()
return db