Skip to content

Commit 203030f

Browse files
authored
Merge pull request #296 from GTBitsOfGood/claude/sweet-cray-mavobx
Fix inbox double dot, dashboard notifications card, and tenant INVALID_ORIGIN; add run skill
2 parents 14be433 + 82146ec commit 203030f

12 files changed

Lines changed: 1008 additions & 189 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/* global process, fetch */
2+
// Headless-Chromium driver for a running ServiceStart dev server.
3+
// Reads one command per line from stdin; see SKILL.md for the command list.
4+
// node .claude/skills/run-servicestart/driver.mjs <<'EOF'
5+
// login member1@example.com
6+
// shot home
7+
// EOF
8+
import fs from "node:fs";
9+
import path from "node:path";
10+
import readline from "node:readline";
11+
import { chromium } from "@playwright/test";
12+
13+
const SHOTS = process.env.SHOTS_DIR ?? "/tmp/servicestart/shots";
14+
const DEFAULT_ORIGIN = "http://localhost:3000";
15+
const PASSWORD = "password123"; // every seeded account
16+
fs.mkdirSync(SHOTS, { recursive: true });
17+
18+
const browser = await chromium.launch({
19+
executablePath: fs.existsSync("/opt/pw-browsers/chromium")
20+
? "/opt/pw-browsers/chromium"
21+
: undefined,
22+
args: [
23+
"--no-sandbox",
24+
// Keeps navigator.webdriver false. useActiveOrganization skips setting the
25+
// active org for webdriver sessions on localhost (an e2e escape hatch).
26+
"--disable-blink-features=AutomationControlled",
27+
"--host-resolver-rules=MAP *.lvh.me 127.0.0.1",
28+
// The sandbox's HTTPS proxy otherwise intercepts *.lvh.me traffic.
29+
"--no-proxy-server",
30+
],
31+
});
32+
33+
let context;
34+
let page;
35+
let origin = DEFAULT_ORIGIN;
36+
let problems = [];
37+
let shotCount = 0;
38+
let failed = false;
39+
const vars = {};
40+
const print = (line) => process.stdout.write(`${line}\n`);
41+
42+
async function newPage(nextOrigin) {
43+
await context?.close();
44+
origin = nextOrigin ?? DEFAULT_ORIGIN;
45+
context = await browser.newContext({
46+
viewport: { width: 1440, height: 900 },
47+
});
48+
page = await context.newPage();
49+
page.setDefaultTimeout(30_000);
50+
problems = [];
51+
page.on("console", (m) => {
52+
if (m.type() === "error")
53+
problems.push(`console: ${m.text().slice(0, 200)}`);
54+
});
55+
page.on("pageerror", (e) =>
56+
problems.push(`pageerror: ${e.message.slice(0, 200)}`),
57+
);
58+
page.on("response", (r) => {
59+
if (r.status() >= 500) {
60+
problems.push(`${r.status()} ${r.request().method()} ${r.url()}`);
61+
}
62+
});
63+
}
64+
65+
const settle = () =>
66+
page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
67+
const toUrl = (target) =>
68+
/^https?:/.test(target) ? target : `${origin}${target}`;
69+
70+
// Splits off the first argument; single or double quotes group a selector.
71+
function splitFirst(text) {
72+
const s = text.trimStart();
73+
const quote = s[0];
74+
if (quote === "'" || quote === '"') {
75+
const end = s.indexOf(quote, 1);
76+
return [s.slice(1, end), s.slice(end + 1).trim()];
77+
}
78+
const space = s.search(/\s/);
79+
return space === -1
80+
? [s, ""]
81+
: [s.slice(0, space), s.slice(space + 1).trim()];
82+
}
83+
84+
// A single selector argument, optionally quoted.
85+
const selector = (rest) => page.locator(splitFirst(rest)[0]).first();
86+
87+
async function screenshot(name, full = false) {
88+
const file = path.join(
89+
SHOTS,
90+
`${String(++shotCount).padStart(2, "0")}-${name}.png`,
91+
);
92+
await page.screenshot({ path: file, fullPage: full });
93+
return file;
94+
}
95+
96+
const commands = {
97+
async login(rest) {
98+
const [email, loginOrigin] = splitFirst(rest);
99+
await newPage(loginOrigin || undefined);
100+
await page.goto(toUrl("/login"));
101+
await page.fill('input[name="email"]', email);
102+
await page.fill('input[name="password"]', PASSWORD);
103+
await page.locator('role=button[name="Login"]').click();
104+
await page.waitForURL((u) => !u.pathname.startsWith("/login"), {
105+
timeout: 60_000,
106+
});
107+
// After sign-in the app sets the active org and router.push("/")es; a
108+
// navigation started before that finishes gets interrupted.
109+
await settle();
110+
await page.waitForTimeout(2500);
111+
await settle();
112+
return page.url();
113+
},
114+
async anon(rest) {
115+
await newPage(rest || undefined);
116+
return `signed out, origin ${origin}`;
117+
},
118+
async viewport(rest) {
119+
const [width, height] = rest.split(/\s+/).map(Number);
120+
await page.setViewportSize({ width, height });
121+
return `${width}x${height}`;
122+
},
123+
async nav(rest) {
124+
await page.goto(toUrl(rest));
125+
await settle();
126+
return page.url();
127+
},
128+
async click(rest) {
129+
await selector(rest).click();
130+
await settle();
131+
},
132+
async hover(rest) {
133+
await selector(rest).hover();
134+
},
135+
async fill(rest) {
136+
const [target, value] = splitFirst(rest);
137+
await page.locator(target).first().fill(value);
138+
},
139+
async press(rest) {
140+
await page.keyboard.press(rest);
141+
},
142+
async "wait-for"(rest) {
143+
await selector(rest).waitFor();
144+
},
145+
async wait(rest) {
146+
await page.waitForTimeout(Number(rest));
147+
},
148+
async text(rest) {
149+
return (await selector(rest).innerText()).trim().slice(0, 500);
150+
},
151+
async count(rest) {
152+
return String(await page.locator(splitFirst(rest)[0]).count());
153+
},
154+
async url() {
155+
return page.url();
156+
},
157+
async shot(rest) {
158+
const [name, flag] = splitFirst(rest);
159+
return screenshot(name || "shot", flag === "full");
160+
},
161+
async eval(rest) {
162+
return JSON.stringify(await page.evaluate(rest));
163+
},
164+
async set(rest) {
165+
const [name, expression] = splitFirst(rest);
166+
vars[name] = await page.evaluate(expression);
167+
return `${name}=${JSON.stringify(vars[name])}`;
168+
},
169+
async api(rest) {
170+
const [method, afterMethod] = splitFirst(rest);
171+
const [apiPath, body] = splitFirst(afterMethod);
172+
const result = await page.evaluate(
173+
async ({ method, apiPath, body }) => {
174+
const res = await fetch(apiPath, {
175+
method,
176+
headers: body ? { "content-type": "application/json" } : {},
177+
body: body || undefined,
178+
});
179+
return `${res.status} ${await res.text()}`;
180+
},
181+
{ method: method.toUpperCase(), apiPath, body },
182+
);
183+
return result.slice(0, 600);
184+
},
185+
async errors() {
186+
const out = problems.length ? problems.join("\n") : "(none)";
187+
problems = [];
188+
return out;
189+
},
190+
};
191+
192+
const rl = readline.createInterface({ input: process.stdin });
193+
for await (const raw of rl) {
194+
const line = raw
195+
.trim()
196+
.replace(/\{\{(\w+)\}\}/g, (_, name) => vars[name] ?? "");
197+
if (!line || line.startsWith("#")) continue;
198+
if (line === "quit") break;
199+
const [name, rest] = splitFirst(line);
200+
print(`> ${line}`);
201+
const command = commands[name];
202+
if (!page && name !== "login" && name !== "anon") await newPage();
203+
try {
204+
if (!command) throw new Error(`unknown command "${name}"`);
205+
const result = await command(rest);
206+
if (result) print(` ${String(result).replace(/\n/g, "\n ")}`);
207+
} catch (error) {
208+
// Stop at the first failure: later steps depend on earlier ones.
209+
failed = true;
210+
print(` ERR ${error.message.split("\n")[0]}`);
211+
const file = await screenshot("error").catch(() => null);
212+
if (file) print(` (screenshot: ${file}; stopping)`);
213+
break;
214+
}
215+
}
216+
217+
await browser.close();
218+
process.exit(failed ? 1 : 0);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* global process */
2+
// Stand-in for Juno on :8888. Answers every request with success and appends
3+
// the request to a log, so email sends (POST /email/send) can be inspected.
4+
import fs from "node:fs";
5+
import http from "node:http";
6+
7+
const logFile = process.argv[2] ?? "/tmp/servicestart/juno-requests.log";
8+
9+
http
10+
.createServer((req, res) => {
11+
let body = "";
12+
req.on("data", (chunk) => (body += chunk));
13+
req.on("end", () => {
14+
fs.appendFileSync(logFile, `${req.method} ${req.url}\n${body}\n\n`);
15+
res.writeHead(200, { "content-type": "application/json" });
16+
res.end(JSON.stringify({ success: true, statusCode: 200 }));
17+
});
18+
})
19+
.listen(8888, () =>
20+
process.stdout.write(`juno stub on :8888, logging to ${logFile}\n`),
21+
);

0 commit comments

Comments
 (0)