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 (thePORTconstant) 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 usersalice_test/bob_testlogged in via the login form.bobgets 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 usewait_until="domcontentloaded", never the default"load". CDN scripts and avatar images causeloadto timeout. - Every
page.wait_for_url()must also usewait_until="domcontentloaded"for the same reason. - Prefer
page.locator(...).wait_for(state="visible")over barewait_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_contextis session-scoped (one per test session, shared by all tests in all files).- Cookies are cleared per test via
browser_context.clear_cookies()in thepagefixture. - Each test gets a fresh
pagefrom the shared context. bobfixture creates its own context from the sessionbrowser- 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_testare seeded once at session level via HTTP POST to/auth/signup.alicefixture logs in alice_test via the login form.bobfixture logs in bob_test in a separate Playwright context.- Use
alicefor single-user tests. It returns(page, user_dict).
Required test patterns (summary)
- Every
page.goto(...)andpage.wait_for_url(...)MUST passwait_until="domcontentloaded". CDN libs and avatars cause the default"load"to time out. - Prefer
page.locator(...).wait_for(state="visible")overwait_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 thepytest_runtest_makereporthook inconftest.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- seetests/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'sapp_serverseedsrate_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 theRATE_LIMITconstant - the constant is now only the fallback default passed toget_int_setting. - Service enabled/disabled state is global state too - restore it. Stopping a service through
POST /admin/services/{name}/stopsets itsservice_{name}_enabledsetting to"0"for the rest of the session. A test that stops a service (e.g.test_gateway_disabled_returns_503stopsopenaito assert the gateway returns 503) MUST restart it in atry/finally(POST /admin/services/{name}/start), and anyfinallythat ends instopshould end instartinstead - the default, expected state is enabled. Leaving the gateway stopped makes a later file's request hitis_enabled()first and get a503where it expected a401/200. Theopenaigateway'shandle()checksis_enabled()(503) BEFOREauthorize()(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.pyseedsrate_limit_per_minutewith a raw DB insert (noclear_settings_cache()), so for the first ~60s of server uptime the server still uses the60/min default. A high-volume request loop (e.g.test_auth_matrix) run in the FIRST minute can therefore trip a spurious429; in the full suite that test runs well past the window so it never does. If you reproduce a429only 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.pyplus thetests/api/admin/settings.pyfamily; 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 (
-xflag 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") |