CLAUDE.md

This file documents detailed testing patterns, fixtures, and pitfalls for devplacepy/tests/ (Playwright + unit tests across api/e2e/unit tiers). Claude Code loads it automatically whenever a file under this directory is read or edited. Never run the test suite unless the user explicitly asks - validate with imports and manual checks instead.

Never run tests unless explicitly asked (critical guardrail)

NEVER run tests unless specifically asked by user. Not the full suite, not a single file - do not run any tests unless the user explicitly requests it. Validate each touched language manually instead: Python (compile + import), JS (parse / bracket matching), CSS (brace matching), HTML (tag matching). Zero tolerance. A clean python -c "from devplacepy.main import app" import is the baseline check.

Never run tests unless the user explicitly asks for it. Not the full suite, not a single file. Validate code with a clean import (python -c "from devplacepy.main import app") and per-language manual checks instead.

If you do need to run a single test on explicit request: python -m pytest tests/e2e/feed.py::test_name -v --tb=line -x.

Fixture stack

  • app_server (session-scoped): spawns uvicorn as a subprocess on port 10501 (the PORT constant) with a tempfile DB.
  • browser_context (session-scoped): one Playwright context shared by all tests.
  • page (function-scoped): clear_cookies() on the session context, then a fresh page.
  • alice / bob: seeded users alice_test / bob_test logged in via the login form. bob gets its own context for multi-user tests. Returns (page, user_dict).

Tests run on port 10501 with a tempfile SQLite DB and a dedicated DEVPLACE_DATA_DIR, and DEVPLACE_DISABLE_SERVICES=1 so background services don't start. make test runs serially, one test at a time, in a single process: one isolated DB + data dir + uvicorn subprocess + Chromium, shared across the whole session, so session fixtures (seeded_db) and intra-file ordering hold. Serial execution is enforced centrally in pyproject.toml ([tool.pytest.ini_options] addopts = "--tb=line -p no:xdist"); pytest-xdist is no longer a dependency and -n is rejected, so the suite can never run concurrently.

General

  • Around 1959 tests across tests/{unit,api,e2e}/. Playwright integration + HTTP + unit tests. All must pass before any merge.
  • Tests use -x (fail-fast). The suite stops at the first failure. Fix that test, then re-run.
  • Validate each touched language manually: Python (compile + import), JS (parse / bracket matching), CSS (brace matching), HTML (tag matching). Zero tolerance.
  • Playwright is used directly, NOT pytest-playwright (uninstall it if present - it conflicts).

Playwright navigation

  • Every page.goto() must use wait_until="domcontentloaded", never the default "load". CDN scripts and avatar images cause load to timeout.
  • Every page.wait_for_url() must also use wait_until="domcontentloaded" for the same reason.
  • Prefer page.locator(...).wait_for(state="visible") over bare wait_for_selector - it gives better error messages.
  • Default timeout is 15 seconds (increased from 10s for CDN script loading).
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
page.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)

Delete button locator scoping

When both a post Delete and comment Delete button exist, always scope to the comment:

# CORRECT - scoped to comment:
page.locator(".comment-action-btn:has-text('Delete')")

# WRONG - matches both post and comment Delete:
page.locator("button:has-text('Delete')")

Confirm dialogs

Delete buttons carry data-confirm, which ModalManager turns into a native confirm() dialog. Playwright auto-dismisses dialogs (treated as Cancel), so the form never submits. Accept the dialog before clicking, or the delete silently does nothing:

page.once("dialog", lambda d: d.accept())
page.locator(".comment-action-btn:has-text('Delete')").click()

Comment hash navigation

Opening a URL with a #comment-<uid> fragment scrolls the comment into view and adds the comment-highlight class (NotificationManager.initHashScroll). Tests assert on #comment-<uid>.comment-highlight after the page loads.

Browser context

  • browser_context is session-scoped (one per test session, shared by all tests in all files).
  • Cookies are cleared per test via browser_context.clear_cookies() in the page fixture.
  • Each test gets a fresh page from the shared context.
  • bob fixture creates its own context from the session browser - necessary for multi-user tests.
  • Never share a page between two logged-in users in the same test - use separate contexts.

Test users

  • alice_test / bob_test are seeded once at session level via HTTP POST to /auth/signup.
  • alice fixture logs in alice_test via the login form.
  • bob fixture logs in bob_test in a separate Playwright context.
  • Use alice for single-user tests. It returns (page, user_dict).

Required test patterns (summary)

  • Every page.goto(...) and page.wait_for_url(...) MUST pass wait_until="domcontentloaded". CDN libs and avatars cause the default "load" to time out.
  • Prefer page.locator(...).wait_for(state="visible") over wait_for_selector.
  • Scope ambiguous selectors: comment Delete is .comment-action-btn:has-text('Delete'); create-post Post button is #create-post-modal button.btn-primary:has-text('Post'); comment textarea is .comment-form textarea[name='content'].
  • Failure screenshots auto-save to /tmp/devplace_test_screenshots/ via the pytest_runtest_makereport hook in conftest.py.

Global site_settings tests

  • The uvicorn server is shared for the whole session. A test that flips a global setting (maintenance mode, registration, rate limit, session length) changes behavior for every later test, so restore the value in a try/finally - see tests/e2e/operational.py.
  • Saving through the admin form is the reliable mutation path, not a direct DB write: the form handler runs in the server process and calls clear_settings_cache(), so the change takes effect immediately (a direct DB write waits out the 60s server-side cache).
  • conftest.py's app_server seeds rate_limit_per_minute="1000000" so the suite is never throttled and the settings form round-trips a safe value instead of the template's "60" default.
  • Rate-limit unit tests patch m.get_int_setting, not the RATE_LIMIT constant - the constant is now only the fallback default passed to get_int_setting.
  • Service enabled/disabled state is global state too - restore it. Stopping a service through POST /admin/services/{name}/stop sets its service_{name}_enabled setting to "0" for the rest of the session. A test that stops a service (e.g. test_gateway_disabled_returns_503 stops openai to assert the gateway returns 503) MUST restart it in a try/finally (POST /admin/services/{name}/start), and any finally that ends in stop should end in start instead - the default, expected state is enabled. Leaving the gateway stopped makes a later file's request hit is_enabled() first and get a 503 where it expected a 401/200. The openai gateway's handle() checks is_enabled() (503) BEFORE authorize() (401), so a stopped gateway masks the auth result entirely.
  • The 1,000,000/min rate-limit seed only applies after the server's 60s settings cache expires. conftest.py seeds rate_limit_per_minute with a raw DB insert (no clear_settings_cache()), so for the first ~60s of server uptime the server still uses the 60/min default. A high-volume request loop (e.g. test_auth_matrix) run in the FIRST minute can therefore trip a spurious 429; in the full suite that test runs well past the window so it never does. If you reproduce a 429 only in a tiny isolated run, it is this warm-up artifact, not a real auth/rate-limit issue.
  • Operational tests live in tests/e2e/operational.py plus the tests/api/admin/settings.py family; rate-limit tests sit with the endpoint they probe (tests/api/root.py, tests/api/auth/login.py, tests/api/robotstxt.py).

Failure handling

  • Failure screenshots auto-save to /tmp/devplace_test_screenshots/.
  • Tests stop at first failure (-x flag in Makefile). No cascading failures.
  • If the server won't start, kill leftover processes: kill -9 $(pgrep -f "uvicorn")

Common pitfalls

Pitfall Fix
goto/wait_for_url times out Add wait_until="domcontentloaded"
CDN scripts block page load Use defer on all <script> tags
500 on dataset find() Use dict comparison syntax, not raw SQL
N+1 query slowness Use batch helpers: get_users_by_uids(), get_comment_counts_by_post_uids()
Modal not opening/closing Use style.display, not classList.add/remove
Dual Delete buttons match Scope to .comment-action-btn in tests
Dual Post buttons match (feed inline comment) Scope to #create-post-modal button.btn-primary:has-text('Post') in tests
Edit modal textarea conflicts with comment textarea Scope to .comment-form textarea[name='content'] for comments
Tests fail in sequence Session-scoped context + clear_cookies() per test
Double messages in chat Deduplicate by message UID with seen set
Avatar generation fails Falls back to initial-based SVG - check multiavatar import
Http.getJson returns HTML / JSON.parse throws The helper sends Accept: application/json; a content-negotiated route (respond(..., model=...)) needs it. Don't hand-roll fetch() without that header against a negotiated route
Page renders but data empty / 500 only after a CLI test ran earlier A partial insert created the table with a reduced schema. Ensure the full column set in init_db() (see "Dataset rules -> init_db() MUST create every queried column")
no such column: X 500 in the server log Same root cause: a queried/indexed column is missing because the table was created lazily by a partial insert. Add the column to the init_db() ensure-block
Action button click times out ("element is not visible") The button moved into the .project-actions-overflow menu. Open it first: click .project-actions-more, then .context-menu-item:has-text('<Label>') (see tests/e2e/projects/index.py)
ImportError: cannot import name X from devplacepy.utils in a test The util was renamed (e.g. badge_info -> get_badge). Update the test import to the current name; grep templating.py globals for the new name
Admin page heading assertion fails Admin pages use <h2> inside .admin-toolbar, not <h1> - assert text= or .admin-toolbar h2:has-text(...)
test_auth_matrix reports 422 instead of 401/403 A POST route's form params are undocumented in docs_api.py, so the matrix sends an empty body and FastAPI validation (422) fires before the auth guard. Document every required form param so auth is reached and tested
test_auth_matrix reports 503 on a gateway endpoint A prior test left the openai service stopped. Restore it in finally (see "Global site_settings tests -> Service enabled/disabled state")