131 lines
11 KiB
Markdown
131 lines
11 KiB
Markdown
|
|
# 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).
|
||
|
|
|
||
|
|
```python
|
||
|
|
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:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# 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:
|
||
|
|
|
||
|
|
```python
|
||
|
|
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") |
|