|
| 1 | +--- |
| 2 | +name: run-servicestart |
| 3 | +description: Start, seed, run, screenshot and click through the ServiceStart web app locally (Next.js + Postgres) without Docker, and run its unit tests, lint and typecheck. Use when asked to run or start the app, check a change in the real app, log in as a seeded user, drive pages in a headless browser, call the API as a logged-in user, or run the test suite. |
| 4 | +--- |
| 5 | + |
| 6 | +ServiceStart is a Next.js app backed by Postgres. In the Claude Code cloud container the Docker daemon is not running, so `setup.sh` runs Postgres natively, seeds it, stubs Juno (email/file infra) and starts `next dev`. You then drive the app by piping commands into `driver.mjs` (headless Chromium via Playwright). All paths are relative to the repo root. |
| 7 | + |
| 8 | +## Prerequisites |
| 9 | + |
| 10 | +Nothing to install in the cloud image: PostgreSQL 16 (`/usr/lib/postgresql/16`), Chromium (`/opt/pw-browsers/chromium`), Node 22 and pnpm 10.17.1 are already there. `setup.sh` runs as root (it uses `su postgres`). |
| 11 | + |
| 12 | +## Start the app |
| 13 | + |
| 14 | +```bash |
| 15 | +.claude/skills/run-servicestart/setup.sh # ~2 min cold, idempotent |
| 16 | +RESET_DB=1 .claude/skills/run-servicestart/setup.sh # also wipe + reseed the dev DB |
| 17 | +``` |
| 18 | + |
| 19 | +It starts Postgres clusters `main` (:5432, user `dev`/`root`, dev DB) and `test` (:5433, user `test`/`root`, for unit tests), writes `.env` if missing, runs `pnpm install`, migrates, seeds the DB if it has no notifications yet (the seed's last step, so an interrupted seed is retried), starts the Juno stub on :8888 and `next dev` on :3000, and waits until `/login` answers. `RESET_DB=1` resets the database `DB_URL` in `.env` points at, and refuses unless it's on localhost. Logs go to `/tmp/servicestart/` (`dev.log`, `seed.log`, …). Every request the app sends to "Juno" (for example emails) is appended to `/tmp/servicestart/juno-requests.log`. |
| 20 | + |
| 21 | +Seeded accounts (password `password123`) on http://localhost:3000, which is the `servicestart` org: `owner@example.com`, `admin@example.com` (admins), `member1@example.com`, `member2@example.com`, `joinrequest-pending@example.com`, `nonmember@example.com`. |
| 22 | + |
| 23 | +## Drive it (agent path) |
| 24 | + |
| 25 | +Pipe one command per line to the driver. It stops at the first failing command, saves `NN-error.png` and exits 1. Screenshots land in `/tmp/servicestart/shots/` (override with `SHOTS_DIR`); read them to check the page. |
| 26 | + |
| 27 | +Use an unquoted heredoc so `$(date …)` expands. Driver variables use `{{name}}`. |
| 28 | + |
| 29 | +```bash |
| 30 | +node .claude/skills/run-servicestart/driver.mjs <<EOF |
| 31 | +# Admin: create a draft event, publish it, add a shift through the API. |
| 32 | +login admin@example.com |
| 33 | +nav /events/create |
| 34 | +fill 'input[name="title"]' Park cleanup |
| 35 | +fill 'input[name="date"]' $(date -d '+30 days' +%F) |
| 36 | +fill 'input[name="start"]' 10:00 |
| 37 | +fill 'input[name="end"]' 12:00 |
| 38 | +fill '[name="description"]' Bring gloves. |
| 39 | +fill 'input[name="address"]' 123 Main St |
| 40 | +fill 'input[name="city"]' Atlanta |
| 41 | +fill 'input[name="state"]' GA |
| 42 | +fill 'input[name="zip"]' 30332 |
| 43 | +fill 'input[name="capacity"]' 2 |
| 44 | +click 'role=button[name="Save draft"]' |
| 45 | +wait-for 'role=link[name="Edit"]' |
| 46 | +set eventId location.pathname.split("/").pop() |
| 47 | +click 'role=button[name="Publish"]' |
| 48 | +wait-for 'role=button[name="Unpublish"]' |
| 49 | +shot published |
| 50 | +api POST /api/shifts {"eventId":"{{eventId}}","startTimestamp":"$(date -d '+30 days' +%F)T15:00:00Z","duration":"1 hour","rsvpLimit":1} |
| 51 | +# Member: register for the event, RSVP to the shift. |
| 52 | +login member1@example.com |
| 53 | +nav /events/{{eventId}} |
| 54 | +click 'role=button[name="Register"]' |
| 55 | +wait-for 'role=button[name="Unregister"]' |
| 56 | +shot registered |
| 57 | +set shiftId fetch("/api/events/{{eventId}}/shifts").then((r) => r.json()).then((j) => j.data[0].id) |
| 58 | +api POST /api/shifts/{{shiftId}}/rsvps |
| 59 | +login member2@example.com |
| 60 | +api POST /api/shifts/{{shiftId}}/rsvps |
| 61 | +errors |
| 62 | +EOF |
| 63 | +``` |
| 64 | + |
| 65 | +The last `api` call prints `409 {"error":"This shift has reached its capacity",…}`. |
| 66 | + |
| 67 | +Admin email through the stub, plus the bell count (admin has 13 unread seeded notifications, member1 has 7): |
| 68 | + |
| 69 | +```bash |
| 70 | +node .claude/skills/run-servicestart/driver.mjs <<'EOF' |
| 71 | +login admin@example.com |
| 72 | +text 'button[aria-label="Notifications"]' |
| 73 | +nav /members |
| 74 | +click 'table tbody [role="checkbox"] >> nth=2' |
| 75 | +click 'role=button[name="Email selected"]' |
| 76 | +fill '[placeholder="Message Headline"]' Park cleanup |
| 77 | +fill '[placeholder="Message Text"]' Meet at the main gate. |
| 78 | +click 'role=button[name="Send Email"]' |
| 79 | +wait-for 'text=Email sent.' |
| 80 | +shot email-sent |
| 81 | +EOF |
| 82 | +tail -3 /tmp/servicestart/juno-requests.log |
| 83 | +``` |
| 84 | + |
| 85 | +| command | what it does | |
| 86 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | |
| 87 | +| `login <email> [origin]` | Fresh browser context, signs in with `password123`, waits out the post-login redirect. Origin defaults to localhost:3000. | |
| 88 | +| `anon [origin]` | Fresh signed-out context. | |
| 89 | +| `viewport <width> <height>` | Resize the page, e.g. `viewport 390 844` for a phone. | |
| 90 | +| `nav <path or url>` | Go to a page (paths are relative to the current origin) and wait for network idle. | |
| 91 | +| `click` / `hover` / `wait-for` | Act on the first match of a Playwright selector. Quote selectors that contain spaces. | |
| 92 | +| `fill <selector> <text>` | Fill an input; the rest of the line is the value. | |
| 93 | +| `press <key>` / `wait <ms>` | Keyboard press / fixed pause. | |
| 94 | +| `text <sel>` / `count <sel>` | Print innerText / number of matches (`count` does not wait). | |
| 95 | +| `shot <name> [full]` | Screenshot (full page with `full`). | |
| 96 | +| `api <METHOD> <path> [json]` | `fetch` from the page with the user's cookies; prints status and body. Use this for the shift API, which has no UI yet. | |
| 97 | +| `eval <js>` / `set <name> <js>` | Evaluate an expression in the page (promises are awaited); `set` stores the result for `{{name}}` in later lines. | |
| 98 | +| `url` / `errors` / `quit` | Current URL / console errors and 5xx responses since the last `errors` or `login` / stop. | |
| 99 | + |
| 100 | +## Test |
| 101 | + |
| 102 | +```bash |
| 103 | +pnpm run test:config && pnpm exec vitest run # 51 files / 561 tests, ~2 min, uses the :5433 cluster |
| 104 | +pnpm lint |
| 105 | +pnpm format |
| 106 | +pnpm exec tsc --noEmit --ignoreDeprecations 6.0 |
| 107 | +pnpm run db:check |
| 108 | +``` |
| 109 | + |
| 110 | +`pnpm test` wraps the vitest run in `docker container start/stop servicestart-test-db`, which needs a Docker daemon, so call vitest directly. Plain `tsc --noEmit` stops on TS 6's `target: es5` deprecation before type-checking anything. |
| 111 | + |
| 112 | +## Stop |
| 113 | + |
| 114 | +```bash |
| 115 | +lsof -ti:3000,8888 -sTCP:LISTEN | xargs -r kill |
| 116 | +pg_ctlcluster 16 main stop; pg_ctlcluster 16 test stop |
| 117 | +``` |
| 118 | + |
| 119 | +## Human path |
| 120 | + |
| 121 | +With Docker Desktop, the README's `pnpm dev` (Juno + database containers + `next dev`) applies. It was not run here. |
| 122 | + |
| 123 | +## Gotchas |
| 124 | + |
| 125 | +- **Playwright's automation flag makes the app skip the active org.** `lib/hooks/useActiveOrganization.ts` skips `organization.setActive` when `navigator.webdriver` is true on localhost (an escape hatch for the e2e suite). Plain Playwright then gets `activeOrganizationId: null`, the bell shows nothing, and `/api/notifications/*` returns `400 No active organization`. The driver launches Chromium with `--disable-blink-features=AutomationControlled` so it behaves like a real browser. |
| 126 | +- **The sandbox's HTTPS proxy intercepts `*.lvh.me`.** The dev server's hot-reload websocket then gets 403, the page keeps reloading and filled forms reset. The driver passes `--no-proxy-server` and maps `*.lvh.me` to 127.0.0.1. External images (placehold.co covers in the seed) fail to load; ignore them. |
| 127 | +- **Other seeded orgs live on subdomains.** Use `login member1@example.com http://vertical-icon.lvh.me:3000` (also `horizontal-left`, `horizontal-center`). Each org has its own accounts under the same emails. `lib/auth.ts` trusts `*.<tenant domain>` and, outside production, `*.lvh.me`; without that, `set-active` fails with `403 INVALID_ORIGIN` and the session has no active org. |
| 128 | +- **Unknown-tenant hosts don't hydrate under `next dev`.** For a host with no org (e.g. `nosuchorg.lvh.me`), the hot-reload websocket upgrade hangs and the page stays as server-rendered HTML, so the "Organization not found" screen never appears. The e2e spec `tests/e2e/auth-tenant-origin.spec.ts` covers it against `pnpm run build && pnpm run start` (see `playwright.config.ts`). My one `pnpm build` attempt here ended with the container restarting (exit 137). |
| 129 | +- **Sign-in navigates twice.** After login the app calls `set-active` and then `router.push("/")`. A `goto` issued in that window fails with "interrupted by another navigation". The driver's `login` waits for it. |
| 130 | +- **`button:text-is("X")` never matches BoG buttons.** The label is in an inner `<div>` and `:text-is` targets the innermost element. Use `role=button[name="X"]`, which matches exactly ("Publish" does not match "Unpublish"). |
| 131 | +- **The event create form has its own Publish button.** After "Save draft", wait for `role=link[name="Edit"]` (only on the event page) before reading the event id from the URL. |
| 132 | +- **Seed data is dated.** Seeded events are all before September 2026, so the dashboard shows "No upcoming events". Create your own future event (the example uses `date -d '+30 days'`). Every seeded member also has a stray pending join request, so the Requests panel shows 9 pending. Each org has its own accounts under the same emails. |
| 133 | +- **Changing `.env` needs a dev-server restart.** For example, `lib/junoClient.ts` reads `JUNO_API_KEY` at module load. Kill :3000 and re-run `setup.sh`. |
| 134 | +- **Container restarts kill every background process.** The DB data under `/var/lib/postgresql` survives; re-run `setup.sh`. |
| 135 | +- **Expected console noise:** a 401 from `get-full-organization` on the login page, and 4xx responses from your own `api` calls. |
| 136 | + |
| 137 | +## Troubleshooting |
| 138 | + |
| 139 | +- **`failed to connect to the docker API at unix:///var/run/docker.sock`**: no Docker daemon in the container. Use `setup.sh` (native Postgres) instead of `pnpm run db:create` / `pnpm dev`. |
| 140 | +- **`400 No active organization` from `/api/notifications/unreadCount`**: the session has no active org, usually because the browser has `navigator.webdriver` set (see Gotchas). Use the driver. |
| 141 | +- **`WebSocket connection to 'ws://<tenant>.lvh.me:3000/_next/webpack-hmr' failed … 403`** and forms emptying themselves: Chromium is going through the proxy. Launch with `--no-proxy-server`. |
| 142 | +- **`500` on `POST /api/emails` with `JUNO_API_KEY is required` in `dev.log`**: `.env` has no Juno key. `setup.sh` writes `JUNO_API_KEY=local-stub-key` only when it creates `.env`, so add it by hand and restart the dev server. |
| 143 | +- **`pg_lsclusters` shows `down`** (e.g. after a container restart): re-run `setup.sh`. |
| 144 | +- **`count` prints 0 for an element you can see**: `count` doesn't wait. `wait-for` the element first. |
0 commit comments