Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/skills/frontend-htmx/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ violations on every page, so an inline script or a style without the nonce shows
- On a failure, the report prints a trace and a screenshot path. `make ui-trace F=<path>` opens the trace
(DOM snapshot per step, network, console). Look at the screenshot before reading any HTML.
- A change to a controller, a template's interactive parts or an htmx attribute comes with a browser test in
the matching `e2e/browser/<area>_test.go`. Server-only behavior (statuses, headers, DB state) belongs in
the cheaper `e2e/` HTTP tests instead.
the matching `e2e/browser/<area>_test.go`. Assert what the user sees and the DB state, never htmx
internals (events, `hx-*` attributes, request headers), so the test survives an htmx upgrade and catches
one that breaks the page. Only server rules that don't need the page's JavaScript (access control,
guards, API, RSS, headers) belong in the cheaper `e2e/` HTTP tests, which don't imitate htmx.
- Locate by role, label and text (`page.GetByRole("button", …{Name: "Publish"})`). Where that's ambiguous,
add a `data-testid` to the template. That is fine in frontend work, but not in test waves, which don't
change templates.
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/wave-run/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ no logs. If you need a detail, ask with `SendMessage`, which keeps the subagent'

Variations by wave:

- **W6 (browser):** step 1 is `make test-ui RUN=<the task's tests>`. The mutation check breaks a Stimulus
- **W6 (browser):** step 1 is `make test-ui RUN=<the task's tests>`, then again with `COUNT=3` (a flaky
test is sent back, not accepted). The mutation check breaks a Stimulus
controller or an htmx attribute the task covers.
- **R-waves and RS (refactors):** replace step 1 with `make test-q PKG=<task packages>` plus
`git diff --stat -- e2e/`, which must be empty. Also check that the task shrank the `pkg/arch` allowlist
Expand Down
18 changes: 12 additions & 6 deletions docs/implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ The end state:
repository (`pkg/repo`), every business rule and authorization check in a
service (`pkg/service/<area>`), and handlers and CLI subcommands only
translate to and from service calls. An architecture test enforces it;
- a browser test suite, so frontend changes can be iterated on safely;
- a browser test suite that specifies every user flow, so frontend changes and
frontend dependency upgrades (htmx, Stimulus) are checked in one command;
- a docker-compose development stack (Postgres, and
[tommy](https://github.com/can3p/tommy) as the mail sink and S3-compatible
object store) with every build tool in a container;
Expand All @@ -47,10 +48,10 @@ Related documents:
| W0 | Test foundation | — (gogo `v0.0.2` released) | done | `test/w0-foundation` |
| W1 | Unit tests, no database | W0 | not started | `test/w1-unit` |
| W2 | Package tests against Postgres | W0 | not started | `test/w2-db` |
| W3 | End-to-end HTTP tests | W0 | not started | `test/w3-e2e` |
| W3 | End-to-end HTTP tests: server rules | W0 | not started | `test/w3-e2e` |
| W4 | Local stack (Postgres, tommy for mail and S3), dev tooling container, app in compose, seed | W0 | not started | `test/w4-local-stack` |
| W5 | Coverage ratchet | W1–W4 | not started | `test/w5-ratchet` |
| W6 | Browser tests (playwright-go) | W0 | not started | `test/w6-browser` |
| W6 | Browser tests (playwright-go): user flows | W0 | not started | `test/w6-browser` |
| WB | Bug-fix wave (#108–#117, #119–#122) | W1–W3 | not started | `fix/wb-survey-bugs` |
| R1 | Router decomposition (move handlers) | W3, W6, WB | planned | `refactor/r1-router` |
| RS | Repositories and services, thin handlers | R1 | planned | `refactor/rs-layers` |
Expand All @@ -63,9 +64,9 @@ Related documents:
```
┌── W1 (12 tasks) ──┐
├── W2 (9 tasks) ──┤
W0 ─────────┼── W3 (6 tasks) ──┼── W5 ── WB ── R1 ── RS ── R2 ── R4
W0 ─────────┼── W3 (4 tasks) ──┼── W5 ── WB ── R1 ── RS ── R2 ── R4
(1 session) ├── W4 (5 tasks) ──┘ │ │
└── W6 (7 tasks) ──────────────────┘ └─ R5 (also after R3)
└── W6 (8 tasks) ──────────────────┘ └─ R5 (also after R3)
R3: after W5 R6: any time
```

Expand All @@ -76,6 +77,11 @@ can run at the same time: about 40 tasks in total, each owning disjoint files.
Each of those waves branches from `test/w0-foundation` (or `master` once W0
has merged), not from each other.

**Start W6.B0 first.** User flows are tested in the browser, not over plain
HTTP: an HTTP test that imitates htmx keeps passing when an htmx upgrade
breaks the pages. So the browser suite is the main safety net for R1 and RS,
and W3 covers only the server rules that don't depend on the frontend.

**Layering is two waves on purpose.** R1 moves handlers verbatim, so its diff
is reviewable as a move. RS then extracts repositories and services area by
area, reusing R1's per-area files. R5 comes after RS, so the ORM swap touches
Expand Down Expand Up @@ -126,7 +132,7 @@ cost, and opens the PR.

| Tier | `model:` value for the Agent tool | Use for |
|---|---|---|
| strong | `opus` | W0; wave coordination; visibility/permission tests (W2.D2a, W3.E1); W6.B0 browser harness; R1 skeleton; RS contracts (step 0) and the visibility service (L1); R5 planning |
| strong | `opus` | W0; wave coordination; visibility/permission tests (W2.D2a, W3.E1, W3.E2); W6.B0 browser harness; R1 skeleton; RS contracts (step 0) and the visibility service (L1); R5 planning |
| mid | `sonnet` | business-logic tests (connections, forms, feed composition), E2E and browser scenarios, WB fixes, RS area extractions |
| cheap | `haiku` | pure-function unit tests, golden tests, factory-driven CRUD checks, docs, CI config |

Expand Down
6 changes: 3 additions & 3 deletions docs/plan/r2.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ Replace the switch with explicit settings, such as `--secure-cookies`,
tommy rejects.
- **The E2E harness** starts the tommy container (mail and S3, with the
bucket configured), and passes the endpoint, bucket and path-style settings to the
binary. It adds `app.S3Objects(t)`. The upload tests from W3 (E5
`upload_media`, E6 `PUT /api/v1/image`) gain assertions that the object
binary. It adds `app.S3Objects(t)`. The upload tests (W6.B5's upload
through the settings page, W3.E4's `PUT /api/v1/image`) gain assertions that the object
landed in the bucket with the right content type. They also check that
requesting a resized class stores the cached variant (the caching media
server writes it back to storage). The "upload, then GET the returned
`/user-media` URL" assertions that W3 already made pass unchanged; they
`/user-media` URL" assertions that W3 and W6 already made pass unchanged; they
prove the switch from local files to S3 kept serving intact.
- Unit tests keep using `fakestorage`.

Expand Down
53 changes: 35 additions & 18 deletions docs/plan/w3.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
## W3 — End-to-end HTTP tests
## W3 — End-to-end HTTP tests: server rules

**6 parallel tasks**, all on the W0 harness. Each owns one file,
`e2e/<area>_test.go`. They assert status codes, redirects, htmx headers, key
HTML (via goquery) and resulting DB state (via factory readers). Together they
are the specification R1 must preserve: **every route in `cmd/web/main.go`,
`actions.go` and `api.go` gets at least one test**. The coordinator checks this
against a route list extracted from the source.
**4 parallel tasks**, all on the W0 harness. Each owns one file,
`e2e/<area>_test.go`.

| Task | Routes | Tier |
**Scope.** W3 pins the rules the server enforces regardless of the frontend:
who may see what, which requests are refused, what the API and RSS return, and
which security headers are sent. User flows (submitting a form, clicking an
action button, what the page shows afterwards) are **not** W3's: they need the
page's JavaScript and belong to the browser suite (W6). An HTTP test that
imitates htmx keeps passing when an htmx upgrade breaks every page, so W3:

- sends plain requests (the harness client doesn't set `HX-Request`, and the
server never reads it);
- never asserts on `HX-*` response headers (the harness has no accessors for
them);
- asserts statuses, `Location` redirects, key HTML via goquery, and database
state via factory readers. For a refused mutation it also asserts that the
database is unchanged.

Together with W6, W3 is the specification R1 must preserve. **Every route in
`cmd/web/main.go`, `actions.go` and `api.go` is covered by W3 or W6**: GET
routes and every guard on mutating routes here, and every mutating route's
successful use in W6. The coordinator checks the union against a route list
extracted from the source.

| Task | Covers | Tier |
|---|---|---|
| E1 | Public and visibility: `/`, `/articles/:id` (valid, unknown, bad name), `/users/:username` and `/rss/public/:username` for each profile visibility, `/users/:u/user_styles` (referer enforcement, `@scope` wrapping for non-Firefox UAs), `/posts/:id` (the D2a matrix, at HTTP level, one case per row), `/shared/:id`, `/explore` anon vs logged-in, `/user-media/robots.txt`, `/user-media/favicon.ico`, `/static/*`, CSP and nosniff headers present | **strong** |
| E2 | Auth: `/login` (logged-in redirect, `return_url` signature kept or dropped), `POST /form/login` (CSRF required, bad credentials, success redirect, signed return), `POST /controls/action/logout`, `/signup` and `POST /form/signup` with registration open and closed, `/confirm_signup/:id`, `/invite/:id` and `POST /form/accept_invite/:id` (full flow, reused invite → 404), `/confirm_waiting_list/:id`, `POST /form/signup_waiting_list` (always 404 today), `EnforceAuth` redirects on every `/controls` and `/write` route. #109 as skipped tests. | mid |
| E3 | Posts: `/write` (with a `?prompt=`), `POST /controls/form/edit_post` (new draft, autosave, publish, make_draft, delete, someone else's post → 404), `/posts/:id/edit` (not the author → 403), `/posts/:id/md`, `/posts/:id/zip` (#110 skipped), `POST /controls/form/new_comment` (reply, permissions, emails queued), `/controls/action/delete_draft` | mid |
| E4 | Connections: whitelist form, `remove_from_whitelist`, `create_connection`, `drop_connection`, `request_mediation`, `revoke_mediation_request`, `sign_mediation`, `dismiss_mediation`, `accept_connection`, `reject_connection`, and `/controls` rendering each state. A full three-user story: A and B connected, B and C connected, A requests C, B signs, C accepts, and A sees C's direct-only post. | mid |
| E5 | Settings and misc: `/controls/settings`, save_settings, save_user_styles, change_password (then log in with the new one), generate_api_key, send_invite (email queued), prompt_post and dismiss_prompt, create_share and delete_share (then `/shared/:id` works and stops working), add_user_feed (feed at a test `httptest.Server`), remove_rss_subscription, `dissmiss_rss_item` (sic), upload_media (a PNG fixture; see "Uploads" below), `settings/export` and `settings/import` round-trip | cheap |
| E6 | API v1 and private RSS: missing, malformed and unknown bearer (400/403), `GET /api/v1/posts` pagination, `POST /api/v1/posts` (new, edit), `DELETE /api/v1/posts/:id` (own post → 200, foreign post → 404; fixed in #118), `PUT /api/v1/image` (see "Uploads"), `/rss/private/:key` (valid, and unknown → #115 skipped) | cheap |
| E1 | Visibility and read access: `/`, `/articles/:id` (valid, unknown, bad name), `/users/:username` and `/rss/public/:username` for each profile visibility, `/users/:u/user_styles` (referer enforcement, `@scope` wrapping for non-Firefox UAs), `/posts/:id` (the D2a matrix at HTTP level, one case per row), `/posts/:id/md`, `/posts/:id/zip` (#110 skipped), `/posts/:id/edit` (not the author → 403), `/shared/:id` (valid, deleted share), `/explore` anonymous vs logged in, `/user-media/robots.txt`, `/user-media/favicon.ico`, `/static/*`, and CSP and `nosniff` headers present | **strong** |
| E2 | Guards on every mutating route, driven by a table built from the route list: anonymous → the `EnforceAuth` redirect to `/login` on every `/controls` and `/write` route; missing and wrong CSRF token → 403 on every `/form` and `/controls` POST (header and `header_csrf` field); object-level authorization: acting on someone else's post, draft, comment, share, API key, feed subscription or connection is refused, with the database unchanged. Also `/login` while logged in, `return_url` signature kept or dropped, and bad credentials. #109 as skipped tests. | **strong** |
| E3 | One-shot links and account GETs: `/confirm_signup/:id` (valid, unknown, already confirmed), `/invite/:id` (valid, used → 404), `/confirm_waiting_list/:id`, `/signup` with registration open and closed, `POST /form/signup_waiting_list` (always 404 today) | cheap |
| E4 | API v1, private RSS and uploads through the API: missing, malformed and unknown bearer (400/403), `GET /api/v1/posts` pagination, `POST /api/v1/posts` (new, edit), `DELETE /api/v1/posts/:id` (own post → 200, foreign post → 404; fixed in #118), `PUT /api/v1/image` (see "Uploads"), `/rss/private/:key` (valid; unknown → #115 skipped) | cheap |

**Uploads are tested black-box, upload then serve,** so the tests survive R2
switching storage from local files to S3. After an upload, `GET` the returned
Expand All @@ -23,8 +38,10 @@ as an image. Then `GET` it with a resize class: 200, and the image is no
wider than that class. An unknown name returns 404. Before R2 the binary
writes to local storage inside the harness's temp working directory. From R2
on it writes to tommy's S3, and R2 adds bucket-level assertions next to these
without editing them.
without editing them. The upload through the settings page's file input is
W6.B5's.

**Done when:** every route is covered, `make cover` shows the `cmd/web`
statement coverage coming from the binary, and the E2E suite runs in under two
minutes.
**Done when:** every GET route and every guard is covered (the union with W6
covers every route), no test sets `HX-Request` or reads an `HX-*` header,
`make cover` shows the `cmd/web` statement coverage coming from the binary,
and the E2E suite runs in under two minutes.
Loading
Loading