From b7eb2bb6d025ccb12e551a734903f0eaa77d5ca0 Mon Sep 17 00:00:00 2001 From: Simon Gruber Date: Thu, 5 Nov 2026 21:56:03 +0000 Subject: [PATCH] Add native dev stack and Playwright e2e/API tests - src/dev/devenv.sh: run backend, Vite and Mailpit without Docker - CONFIG_PATH env override for the backend config; optional Vite /api proxy - src/e2e: Playwright API + UI tests (real email sign-in via Mailpit) and shoot.mjs screenshot CLI - Document the workflow in CLAUDE.md Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 26 +++- src/.gitignore | 4 + src/backend/app/services/config_service.py | 3 +- src/dev/config.yaml | 16 +++ src/dev/devenv.sh | 147 +++++++++++++++++++++ src/e2e/.gitignore | 4 + src/e2e/fixtures.ts | 38 ++++++ src/e2e/global-setup.ts | 12 ++ src/e2e/lib.mjs | 139 +++++++++++++++++++ src/e2e/package-lock.json | 91 +++++++++++++ src/e2e/package.json | 16 +++ src/e2e/playwright.config.ts | 23 ++++ src/e2e/shoot.mjs | 85 ++++++++++++ src/e2e/tests/orders.api.spec.ts | 51 +++++++ src/e2e/tests/orders.ui.spec.ts | 46 +++++++ src/e2e/tsconfig.json | 14 ++ src/frontend/vite.config.ts | 11 +- 17 files changed, 717 insertions(+), 9 deletions(-) create mode 100644 src/dev/config.yaml create mode 100755 src/dev/devenv.sh create mode 100644 src/e2e/.gitignore create mode 100644 src/e2e/fixtures.ts create mode 100644 src/e2e/global-setup.ts create mode 100644 src/e2e/lib.mjs create mode 100644 src/e2e/package-lock.json create mode 100644 src/e2e/package.json create mode 100644 src/e2e/playwright.config.ts create mode 100644 src/e2e/shoot.mjs create mode 100644 src/e2e/tests/orders.api.spec.ts create mode 100644 src/e2e/tests/orders.ui.spec.ts create mode 100644 src/e2e/tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md index d87af79..2ff3a42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -Lunchtime is a self-hosted group lunch ordering app: FastAPI + SQLite backend (Python 3.12), React 18 + TypeScript + Vite + Ant Design frontend, nginx in front. All app code lives under `src/`. There are no tests and no linter configured. +Lunchtime is a self-hosted group lunch ordering app: FastAPI + SQLite backend (Python 3.12), React 18 + TypeScript + Vite + Ant Design frontend, nginx in front. All app code lives under `src/`. No linter is configured; end-to-end/API tests live in `src/e2e/` (Playwright, see below). ## Workflow @@ -18,15 +18,29 @@ Full dev stack (backend with `--reload`, Vite HMR, nginx, Mailpit); also what th cd src && docker compose up -d # app at http://localhost:8020, Mailpit at http://localhost:8025 ``` -Without Docker: +Without Docker (native stack: uvicorn `--reload` on :5000, Vite HMR on **:8020** proxying `/api` to the backend, Mailpit on :8025 / SMTP :1025): ```bash -cd src/backend && pip install -r requirements.txt && DB_PATH=./.data uvicorn app.main:app --reload --port 5000 -cd src/frontend && npm install && npm run dev # Vite on :3000 -cd src/frontend && npm run build # tsc is not run by build; use `npx tsc --noEmit` for type checking +src/dev/devenv.sh setup # backend venv (src/backend/.venv), npm ci for frontend + e2e, Mailpit binary into src/.devenv/bin +src/dev/devenv.sh start # idempotent; waits until everything answers. Also: stop | restart | status | logs [service] | reset (wipes DB + uploads) +cd src/frontend && npx tsc --noEmit # type checking (vite build does not run tsc) ``` -`nginx.conf` (repo root) expects `backend:5000` and `frontend:8000`, so running outside Compose needs your own proxy for `/api`. +The native stack uses `src/dev/config.yaml` (via the `CONFIG_PATH` env var) instead of the root `config.yaml`, stores data in `src/backend/data/`, sets `ADMIN_EMAILS=admin@example.com`, and keeps pids/logs in `src/.devenv/`. `API_PROXY_TARGET` turns on Vite's `/api` proxy; it's unset under Compose, where nginx does the routing. + +### Testing (requires a running stack; both Compose and native serve the app on :8020) + +```bash +cd src/e2e +npx playwright test # everything; --project=api or --project=ui to narrow down +node shoot.mjs / /create "{order}" "{admin}" /stats [--as=user|admin|anon] [--dark] [--mobile] [--full] +``` + +- Logging in uses the real flow: `lib.mjs` `signIn(email?)` registers (or migrates, for an existing email), reads the confirmation mail from Mailpit's API and confirms it, giving back a user ID. In the browser, a user is "logged in" by setting localStorage `lunchtime_token` before load (`loginAs` in `fixtures.ts`). Fixtures: `user` (fresh per test), `admin` (the ADMIN_EMAILS account), `userPage`. +- `lib.mjs` also has `api()` (fetch with `X-User-Id`), `createOrder()` (with `SAMPLE_MENU`) and `submit()`. Use them to seed data instead of clicking through the UI. +- `shoot.mjs` prints PNG paths (default dir `src/e2e/screenshots/`, gitignored) plus console errors and failed requests. `{order}`/`{admin}` seed an order that has a menu and two submissions. Use it to view the UI after visual changes. +- Ant Design Select: its `role=option` nodes are hidden a11y helpers, so click `.ant-select-item-option` instead. +- Playwright is pinned to 1.65.0 to match the Chromium build in `PLAYWRIGHT_BROWSERS_PATH`. If they diverge, `npx playwright install chromium`. CI (`.gitea/workflows/build-and-push-images.yaml`) builds and pushes backend/frontend images with Kaniko on changes to `src/**`. diff --git a/src/.gitignore b/src/.gitignore index e69de29..b54ecb1 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -0,0 +1,4 @@ + +# Native dev stack (dev/devenv.sh): pids, logs, tool binaries; backend data dir +.devenv/ +backend/data/ diff --git a/src/backend/app/services/config_service.py b/src/backend/app/services/config_service.py index 66325eb..0861e3c 100644 --- a/src/backend/app/services/config_service.py +++ b/src/backend/app/services/config_service.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Any @@ -5,7 +6,7 @@ import yaml from fastapi import HTTPException BASE_DIR = Path(__file__).resolve().parents[2] -CONFIG_PATH = BASE_DIR / "config.yaml" +CONFIG_PATH = Path(os.getenv("CONFIG_PATH") or BASE_DIR / "config.yaml") def normalize_announcement(item: Any) -> dict[str, str] | None: diff --git a/src/dev/config.yaml b/src/dev/config.yaml new file mode 100644 index 0000000..9f38a8d --- /dev/null +++ b/src/dev/config.yaml @@ -0,0 +1,16 @@ +# App config for the native (no Docker) dev stack started by src/dev/devenv.sh. +# Same as ../../config.yaml, but SMTP points at the locally running Mailpit. +app: + public_base_url: "http://localhost:8020/" + +email: + enabled: true + smtp_host: "127.0.0.1" + smtp_port: 1025 + smtp_username: "" + smtp_password: "" + from_address: "lunchtime@example.com" + use_tls: false + use_ssl: false + +announcements: [] diff --git a/src/dev/devenv.sh b/src/dev/devenv.sh new file mode 100755 index 0000000..418a8fc --- /dev/null +++ b/src/dev/devenv.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# Native dev stack (no Docker): backend (uvicorn --reload), frontend (Vite HMR, proxies /api), Mailpit. +# App: http://localhost:8020 API: http://localhost:8020/api (or :5000 directly) Mailpit: http://localhost:8025 +# +# Usage: src/dev/devenv.sh setup|start|stop|restart|status|logs [service]|reset +set -euo pipefail + +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +STATE_DIR="$SRC_DIR/.devenv" # pids, logs, tool binaries (gitignored) +BACKEND_DIR="$SRC_DIR/backend" +FRONTEND_DIR="$SRC_DIR/frontend" +E2E_DIR="$SRC_DIR/e2e" +DATA_DIR="$BACKEND_DIR/data" # SQLite + per-order files (order_file_service writes here regardless of DB_PATH) +MAILPIT_VERSION="v1.31.6" + +APP_PORT="${APP_PORT:-8020}" +BACKEND_PORT="${BACKEND_PORT:-5000}" +MAILPIT_UI_PORT="${MAILPIT_UI_PORT:-8025}" +MAILPIT_SMTP_PORT="${MAILPIT_SMTP_PORT:-1025}" +# Users registered with this email are site admins (see ADMIN_EMAILS in compose.yml). +DEV_ADMIN_EMAILS="${ADMIN_EMAILS:-admin@example.com}" + +SERVICES=(mailpit backend frontend) + +log() { printf '\033[1;34m[devenv]\033[0m %s\n' "$*"; } + +pid_file() { echo "$STATE_DIR/$1.pid"; } +log_file() { echo "$STATE_DIR/$1.log"; } + +is_running() { + local f; f="$(pid_file "$1")" + [[ -f "$f" ]] && kill -0 "$(cat "$f")" 2>/dev/null +} + +wait_for_url() { + local name="$1" url="$2" + for _ in $(seq 1 60); do + if curl -fsS -o /dev/null "$url" 2>/dev/null; then log "$name ready ($url)"; return 0; fi + sleep 0.5 + done + log "$name did not become ready at $url; last log lines:"; tail -n 20 "$(log_file "$name")" || true + return 1 +} + +start_service() { + local name="$1"; shift + if is_running "$name"; then log "$name already running (pid $(cat "$(pid_file "$name")"))"; return; fi + # setsid: own process group, so stop can kill uvicorn's/Vite's children too. + setsid nohup "$@" >"$(log_file "$name")" 2>&1 < /dev/null & + echo $! >"$(pid_file "$name")" +} + +stop_service() { + local name="$1" f; f="$(pid_file "$name")" + if is_running "$name"; then + local pid; pid="$(cat "$f")" + kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 20); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done + kill -KILL -- "-$pid" 2>/dev/null || true + log "$name stopped" + fi + rm -f "$f" +} + +cmd_setup() { + mkdir -p "$STATE_DIR/bin" + log "backend: venv + requirements" + [[ -x "$BACKEND_DIR/.venv/bin/python" ]] || python3 -m venv "$BACKEND_DIR/.venv" + "$BACKEND_DIR/.venv/bin/pip" install -q -r "$BACKEND_DIR/requirements.txt" + + log "frontend: npm ci" + npm --prefix "$FRONTEND_DIR" ci --no-audit --no-fund + + log "e2e: npm ci" + npm --prefix "$E2E_DIR" ci --no-audit --no-fund + if [[ -z "${PLAYWRIGHT_BROWSERS_PATH:-}" ]] || ! ls "$PLAYWRIGHT_BROWSERS_PATH" | grep -q '^chromium'; then + log "e2e: installing Playwright Chromium" + (cd "$E2E_DIR" && npx playwright install chromium) + fi + + if [[ ! -x "$STATE_DIR/bin/mailpit" ]]; then + log "mailpit: downloading $MAILPIT_VERSION" + local arch; case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; *) arch="$(uname -m)" ;; esac + curl -fsSL "https://github.com/axllent/mailpit/releases/download/$MAILPIT_VERSION/mailpit-linux-$arch.tar.gz" \ + | tar -xz -C "$STATE_DIR/bin" mailpit + fi + log "setup done. Next: $0 start" +} + +cmd_start() { + mkdir -p "$STATE_DIR" "$DATA_DIR" + [[ -x "$BACKEND_DIR/.venv/bin/uvicorn" && -d "$FRONTEND_DIR/node_modules" && -x "$STATE_DIR/bin/mailpit" ]] \ + || { log "dependencies missing, running setup first"; cmd_setup; } + + start_service mailpit "$STATE_DIR/bin/mailpit" \ + --listen "127.0.0.1:$MAILPIT_UI_PORT" --smtp "127.0.0.1:$MAILPIT_SMTP_PORT" + + start_service backend env \ + DB_PATH="$DATA_DIR" CONFIG_PATH="$SRC_DIR/dev/config.yaml" ADMIN_EMAILS="$DEV_ADMIN_EMAILS" \ + APP_ENV=development LOG_LEVEL=info \ + bash -c "cd '$BACKEND_DIR' && exec .venv/bin/uvicorn app.main:app --host 127.0.0.1 --port $BACKEND_PORT --reload --reload-dir app" + + start_service frontend env API_PROXY_TARGET="http://127.0.0.1:$BACKEND_PORT" \ + bash -c "cd '$FRONTEND_DIR' && exec npx vite --host 127.0.0.1 --port $APP_PORT --strictPort" + + wait_for_url mailpit "http://127.0.0.1:$MAILPIT_UI_PORT/api/v1/info" + wait_for_url backend "http://127.0.0.1:$BACKEND_PORT/api/config" + wait_for_url frontend "http://127.0.0.1:$APP_PORT/" + wait_for_url "frontend /api proxy" "http://127.0.0.1:$APP_PORT/api/config" + log "app: http://localhost:$APP_PORT mailpit: http://localhost:$MAILPIT_UI_PORT admin emails: $DEV_ADMIN_EMAILS" +} + +cmd_stop() { + for ((i=${#SERVICES[@]}-1; i>=0; i--)); do stop_service "${SERVICES[$i]}"; done +} + +cmd_status() { + for s in "${SERVICES[@]}"; do + if is_running "$s"; then echo "$s: running (pid $(cat "$(pid_file "$s")"))"; else echo "$s: stopped"; fi + done +} + +cmd_logs() { + if [[ -n "${1:-}" ]]; then tail -n 100 "$(log_file "$1")"; else + for s in "${SERVICES[@]}"; do echo "== $s =="; tail -n 30 "$(log_file "$s")" 2>/dev/null || true; done + fi +} + +# Wipes the dev database, uploaded files and captured mail. The stack is restarted if it was running. +cmd_reset() { + local was_running=0; is_running backend && was_running=1 + cmd_stop + rm -rf "$DATA_DIR" + log "dev data wiped ($DATA_DIR)" + if ((was_running)); then cmd_start; fi +} + +case "${1:-}" in + setup) cmd_setup ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + status) cmd_status ;; + logs) cmd_logs "${2:-}" ;; + reset) cmd_reset ;; + *) echo "Usage: $0 setup|start|stop|restart|status|logs [mailpit|backend|frontend]|reset" >&2; exit 2 ;; +esac diff --git a/src/e2e/.gitignore b/src/e2e/.gitignore new file mode 100644 index 0000000..62b5f9f --- /dev/null +++ b/src/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +screenshots/ diff --git a/src/e2e/fixtures.ts b/src/e2e/fixtures.ts new file mode 100644 index 0000000..151cf74 --- /dev/null +++ b/src/e2e/fixtures.ts @@ -0,0 +1,38 @@ +import { test as base, expect, type Page } from "@playwright/test"; +import { ADMIN_EMAIL, USER_ID_STORAGE_KEY, signIn } from "./lib.mjs"; + +export type TestUser = { userId: string; email: string }; + +/** Makes `page` act as `user` from the first navigation on (the app reads its user ID from localStorage). */ +export async function loginAs(page: Page, user: TestUser) { + await page.addInitScript( + ([key, value]) => window.localStorage.setItem(key, value), + [USER_ID_STORAGE_KEY, user.userId] as const, + ); +} + +type Fixtures = { + /** A fresh, email-confirmed user per test. */ + user: TestUser; + /** The site admin (ADMIN_EMAILS), signed in once in global-setup. */ + admin: TestUser; + /** `page` already logged in as `user`. */ + userPage: Page; +}; + +export const test = base.extend({ + user: async ({}, use) => { + await use(await signIn()); + }, + admin: async ({}, use) => { + const userId = process.env.E2E_ADMIN_USER_ID; + if (!userId) throw new Error("E2E_ADMIN_USER_ID missing; global-setup did not run"); + await use({ userId, email: ADMIN_EMAIL }); + }, + userPage: async ({ page, user }, use) => { + await loginAs(page, user); + await use(page); + }, +}); + +export { expect }; diff --git a/src/e2e/global-setup.ts b/src/e2e/global-setup.ts new file mode 100644 index 0000000..06008c9 --- /dev/null +++ b/src/e2e/global-setup.ts @@ -0,0 +1,12 @@ +import { ADMIN_EMAIL, BASE_URL, MAILPIT_URL, signIn } from "./lib.mjs"; + +// Fail fast with a useful message when the stack isn't up, then sign in the site admin once +// (parallel workers re-running the email flow for the same address would race on its mails). +export default async function globalSetup() { + for (const [name, url] of [["app", `${BASE_URL}/api/config`], ["Mailpit", `${MAILPIT_URL}/api/v1/info`]]) { + const ok = await fetch(url).then((r) => r.ok).catch(() => false); + if (!ok) throw new Error(`${name} not reachable at ${url}. Start the stack: src/dev/devenv.sh start`); + } + const admin = await signIn(ADMIN_EMAIL); + process.env.E2E_ADMIN_USER_ID = admin.userId; +} diff --git a/src/e2e/lib.mjs b/src/e2e/lib.mjs new file mode 100644 index 0000000..fb61cf7 --- /dev/null +++ b/src/e2e/lib.mjs @@ -0,0 +1,139 @@ +// Shared helpers for Playwright tests and shoot.mjs. Plain JS so the CLI can run it with node directly. +// Talks to the running dev stack (src/dev/devenv.sh start); nothing here starts servers. + +export const BASE_URL = (process.env.BASE_URL || "http://localhost:8020").replace(/\/$/, ""); +export const MAILPIT_URL = (process.env.MAILPIT_URL || "http://localhost:8025").replace(/\/$/, ""); +/** Must be listed in the backend's ADMIN_EMAILS (devenv.sh defaults it to this). */ +export const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || "admin@example.com"; +/** localStorage key holding the user ID (frontend/src/lib/constants.ts USER_TOKEN_KEY). */ +export const USER_ID_STORAGE_KEY = "lunchtime_token"; + +export function uniqueEmail(prefix = "e2e") { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + +/** + * Minimal API client. `userId` is sent as X-User-Id (the app's only auth). + * Throws on non-2xx with the backend's `detail` in the message. + * @param {string} path + * @param {{ userId?: string, method?: string, body?: unknown, headers?: Record }} [options] + * @returns {Promise} + */ +export async function api(path, { userId, method = "GET", body, headers = {} } = {}) { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: { + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(userId ? { "X-User-Id": userId } : {}), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + const data = text ? safeJson(text) : null; + if (!response.ok) { + const detail = data && typeof data === "object" ? JSON.stringify(data.detail ?? data) : text; + throw new Error(`${method} ${path} -> ${response.status}: ${detail}`); + } + return data; +} + +function safeJson(text) { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +// --- Mailpit ------------------------------------------------------------------------------- + +async function mailpit(path, init) { + const response = await fetch(`${MAILPIT_URL}/api/v1${path}`, init); + if (!response.ok) throw new Error(`Mailpit ${path} -> ${response.status}`); + return response.status === 200 && response.headers.get("content-type")?.includes("json") ? response.json() : null; +} + +export async function clearMailFor(email) { + await mailpit(`/search?query=${encodeURIComponent(`to:"${email}"`)}`, { method: "DELETE" }); +} + +/** Waits for the newest mail to `email` and returns its plain-text body. */ +export async function waitForMail(email, { timeoutMs = 10_000 } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await mailpit(`/search?query=${encodeURIComponent(`to:"${email}"`)}&limit=1`); + const latest = result?.messages?.[0]; + if (latest) { + const message = await mailpit(`/message/${latest.ID}`); + return { subject: message.Subject, text: message.Text }; + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`No mail for ${email} within ${timeoutMs}ms (is Mailpit running?)`); +} + +export function extractToken(text) { + const match = text.match(/[?&]token=([^\s&]+)/); + if (!match) throw new Error(`No confirmation token in mail:\n${text}`); + return decodeURIComponent(match[1]); +} + +// --- Accounts ------------------------------------------------------------------------------ + +/** + * Returns a confirmed user ID for `email`, going through the real email flow: + * new email -> register; existing email -> account migration ("log in on a new device"). + */ +export async function signIn(email = uniqueEmail()) { + const { exists } = await api(`/api/account/lookup?email=${encodeURIComponent(email)}`); + await clearMailFor(email); + if (exists) { + await api("/api/account/migrate/request", { method: "POST", body: { email } }); + } else { + await api("/api/account/register", { method: "POST", body: { email } }); + } + const mail = await waitForMail(email); + const confirmed = await api(`/api/account/confirm?token=${encodeURIComponent(extractToken(mail.text))}`); + return { userId: confirmed.user_id, email }; +} + +// --- Orders -------------------------------------------------------------------------------- + +/** Sample menu used by tests and screenshots; shape matches OrderConfig in backend/app/main.py. */ +export const SAMPLE_MENU = { + categories: [ + { + id: "burger", + label: "Burger", + required: true, + multiple: false, + items: [ + { name: "Classic", price: 9.5 }, + { name: "Cheeseburger", price: 10.5 }, + { name: "Veggie", price: 10 }, + ], + }, + { + id: "sides", + label: "Sides", + required: false, + multiple: true, + items: [ + { name: "Fries", price: 3.5 }, + { name: "Side salad", price: 4 }, + ], + }, + ], +}; + +export async function createOrder(userId, { title = "E2E Burger Friday", description = "Order by **11:30**.", menu = SAMPLE_MENU } = {}) { + const order = await api("/api/orders", { userId, method: "POST", body: { title, description } }); + if (menu) await api(`/api/orders/${order.id}/admin/config`, { userId, method: "PUT", body: menu }); + return order; +} + +/** Places the caller's submission; `choices` maps category id -> item name (array for multiple). */ +export async function submit(userId, orderId, choices) { + return api(`/api/orders/${orderId}/submissions/me`, { userId, method: "POST", body: { choices } }); +} diff --git a/src/e2e/package-lock.json b/src/e2e/package-lock.json new file mode 100644 index 0000000..e9b428b --- /dev/null +++ b/src/e2e/package-lock.json @@ -0,0 +1,91 @@ +{ + "name": "lunchtime-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lunchtime-e2e", + "devDependencies": { + "@playwright/test": "1.65.0", + "@types/node": "^22.0.0", + "typescript": "^5.8.3" + } + }, + "node_modules/@playwright/test": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/playwright": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.65.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/src/e2e/package.json b/src/e2e/package.json new file mode 100644 index 0000000..ce6fcd5 --- /dev/null +++ b/src/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "lunchtime-e2e", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test", + "test:api": "playwright test --project=api", + "test:ui": "playwright test --project=ui", + "shoot": "node shoot.mjs" + }, + "devDependencies": { + "@playwright/test": "1.65.0", + "@types/node": "^22.0.0", + "typescript": "^5.8.3" + } +} diff --git a/src/e2e/playwright.config.ts b/src/e2e/playwright.config.ts new file mode 100644 index 0000000..07d69cf --- /dev/null +++ b/src/e2e/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Runs against the already-running dev stack (src/dev/devenv.sh start). Override with BASE_URL. +const baseURL = process.env.BASE_URL || "http://localhost:8020"; + +export default defineConfig({ + testDir: "./tests", + outputDir: "./test-results", + globalSetup: "./global-setup.ts", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]], + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { name: "api", testMatch: /.*\.api\.spec\.ts/ }, + { name: "ui", testMatch: /.*\.ui\.spec\.ts/, use: { ...devices["Desktop Chrome"] } }, + ], +}); diff --git a/src/e2e/shoot.mjs b/src/e2e/shoot.mjs new file mode 100644 index 0000000..2c9aaa6 --- /dev/null +++ b/src/e2e/shoot.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Screenshot CLI for eyeballing UI changes (used by Claude to "look at" the app). +// +// node shoot.mjs [path...] [options] +// +// path App path(s), e.g. / /create /stats. Placeholders: {order} -> a seeded order's +// participant page, {admin} -> its admin page. Default: / +// --as=user|admin|anon Who is logged in (default: user = fresh account; admin = ADMIN_EMAILS account) +// --seed Create a sample order (with menu + a submission) first; implied by {order}/{admin} +// --dark Dark theme (sets the app's theme preference) +// --mobile Phone viewport (Pixel 7); default is 1440x900 desktop +// --full Full-page screenshot instead of viewport only +// --wait=ms Extra settle time after network idle (default 300) +// --out=dir Output directory (default: screenshots/) +// +// Prints one PNG path per line. Also reports console errors and failed requests, which are worth checking. +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { chromium, devices } from "@playwright/test"; +import { ADMIN_EMAIL, BASE_URL, USER_ID_STORAGE_KEY, createOrder, signIn, submit } from "./lib.mjs"; + +const args = process.argv.slice(2); +const flag = (name, fallback) => { + const hit = args.find((a) => a === `--${name}` || a.startsWith(`--${name}=`)); + if (!hit) return fallback; + return hit.includes("=") ? hit.split("=").slice(1).join("=") : true; +}; +const paths = args.filter((a) => !a.startsWith("--")); +if (!paths.length) paths.push("/"); + +const as = flag("as", "user"); +const dark = !!flag("dark", false); +const mobile = !!flag("mobile", false); +const fullPage = !!flag("full", false); +const settleMs = Number(flag("wait", 300)); +const outDir = resolve(flag("out", "screenshots")); +const needsOrder = !!flag("seed", false) || paths.some((p) => p.includes("{order}") || p.includes("{admin}")); +mkdirSync(outDir, { recursive: true }); + +const account = as === "anon" ? null : await signIn(as === "admin" ? ADMIN_EMAIL : undefined); + +let order = null; +if (needsOrder) { + if (!account) throw new Error("--seed/{order} needs a logged-in user (not --as=anon)"); + order = await createOrder(account.userId); + const guest = await signIn(); + await submit(guest.userId, order.id, { burger: "Cheeseburger", sides: ["Fries", "Side salad"] }); + await submit(account.userId, order.id, { burger: "Veggie", sides: [] }); +} + +const browser = await chromium.launch(); +const context = await browser.newContext({ + ...(mobile ? devices["Pixel 7"] : { viewport: { width: 1440, height: 900 } }), + colorScheme: dark ? "dark" : "light", + baseURL: BASE_URL, +}); +await context.addInitScript( + ([key, userId, theme]) => { + if (userId) localStorage.setItem(key, userId); + localStorage.setItem("lunchtime_theme_mode", theme); + }, + [USER_ID_STORAGE_KEY, account?.userId ?? "", dark ? "dark" : "light"], +); + +const page = await context.newPage(); +const problems = []; +page.on("console", (m) => m.type() === "error" && problems.push(`console: ${m.text()}`)); +page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); +page.on("response", (r) => r.status() >= 400 && problems.push(`HTTP ${r.status()} ${r.url()}`)); + +const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); +for (const raw of paths) { + const path = raw.replace("{order}", `/order/${order?.id}`).replace("{admin}", `/order/${order?.id}/admin`).replace(/^\/\//, "/"); + await page.goto(path, { waitUntil: "networkidle" }); + await page.waitForTimeout(settleMs); + const slug = (raw.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, "") || "home") + (dark ? "-dark" : "") + (mobile ? "-mobile" : ""); + const file = resolve(outDir, `${stamp}-${slug}.png`); + await page.screenshot({ path: file, fullPage }); + console.log(file); +} + +if (account) console.error(`logged in as ${account.email} (${account.userId})`); +if (order) console.error(`seeded order ${order.id}`); +for (const p of problems) console.error(`WARN ${p}`); +await browser.close(); diff --git a/src/e2e/tests/orders.api.spec.ts b/src/e2e/tests/orders.api.spec.ts new file mode 100644 index 0000000..7f5c0aa --- /dev/null +++ b/src/e2e/tests/orders.api.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from "../fixtures"; +import { api, createOrder, signIn, submit } from "../lib.mjs"; + +test("config is public", async () => { + const config = await api("/api/config"); + expect(config).toHaveProperty("announcements"); +}); + +test("unknown user ID is rejected", async () => { + await expect(api("/api/me/profile", { userId: "does-not-exist" })).rejects.toThrow(/401/); +}); + +test("order lifecycle: create, submit, accept, close", async ({ user }) => { + const order = await createOrder(user.userId, { title: "API lifecycle" }); + + const participant = await signIn(); + const submission = await submit(participant.userId, order.id, { burger: "Classic", sides: ["Fries"] }); + expect(submission.choices).toEqual({ burger: "Classic", sides: ["Fries"] }); + + const adminView = await api(`/api/orders/${order.id}/admin`, { userId: user.userId }); + expect(adminView.submissions).toHaveLength(1); + + await api(`/api/orders/${order.id}/admin/submissions/${submission.id}/status`, { + userId: user.userId, + method: "PUT", + body: { accepted: true, paid: true }, + }); + await api(`/api/orders/${order.id}/admin/status`, { userId: user.userId, method: "PUT", body: { closed: true } }); + + const mine = await api(`/api/orders/${order.id}/submissions/me`, { userId: participant.userId }); + expect(mine).toMatchObject({ accepted: true, paid: true }); + await expect(submit(participant.userId, order.id, { burger: "Veggie" })).rejects.toThrow(/4\d\d/); +}); + +test("participants cannot use admin routes", async ({ user }) => { + const order = await createOrder(user.userId); + const other = await signIn(); + // 404 on purpose: the backend doesn't reveal that the order exists to non-owners. + await expect(api(`/api/orders/${order.id}/admin`, { userId: other.userId })).rejects.toThrow(/404/); +}); + +test("required menu categories are enforced", async ({ user }) => { + const order = await createOrder(user.userId); + await expect(submit(user.userId, order.id, { sides: ["Fries"] })).rejects.toThrow(/4\d\d/); +}); + +test("site admin routes need an ADMIN_EMAILS account", async ({ user, admin }) => { + await expect(api("/api/admin/users", { userId: user.userId })).rejects.toThrow(/403/); + const result = await api("/api/admin/users", { userId: admin.userId }); + expect(result.users.length).toBeGreaterThan(0); +}); diff --git a/src/e2e/tests/orders.ui.spec.ts b/src/e2e/tests/orders.ui.spec.ts new file mode 100644 index 0000000..a8598e6 --- /dev/null +++ b/src/e2e/tests/orders.ui.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, loginAs } from "../fixtures"; +import { api, createOrder, signIn } from "../lib.mjs"; + +test("home lists the user's orders", async ({ userPage, user }) => { + await createOrder(user.userId, { title: "Listed on home" }); + await userPage.goto("/"); + await expect(userPage.getByText("Your Orders")).toBeVisible(); + await expect(userPage.getByText("Listed on home")).toBeVisible(); +}); + +test("participant submits an order through the form", async ({ page, user }) => { + const order = await createOrder(user.userId, { title: "UI submission" }); + const participant = await signIn(); + await loginAs(page, participant); + + await page.goto(`/order/${order.id}`); + await expect(page.getByText("UI submission")).toBeVisible(); + + await page.getByRole("combobox", { name: "Burger" }).click(); + // antd Select: role=option elements are hidden a11y helpers; click the rendered dropdown items. + await page.locator(".ant-select-item-option", { hasText: "Cheeseburger" }).click(); + await page.getByRole("combobox", { name: "Sides" }).click(); + await page.locator(".ant-select-item-option", { hasText: "Fries" }).click(); + await page.keyboard.press("Escape"); + + await page.getByRole("button", { name: /Submit order/ }).click(); + await expect(page.getByText("Order submitted")).toBeVisible(); + + const saved = await api(`/api/orders/${order.id}/submissions/me`, { userId: participant.userId }); + expect(saved.choices).toEqual({ burger: "Cheeseburger", sides: ["Fries"] }); +}); + +test("owner sees the admin view", async ({ userPage, user }) => { + const order = await createOrder(user.userId, { title: "Owner view" }); + await userPage.goto(`/order/${order.id}/admin`); + await expect(userPage.getByText("Control center")).toBeVisible(); + await expect(userPage.getByRole("button", { name: /Close order/ })).toBeVisible(); +}); + +test("first visit without an account shows onboarding, not a crash", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto("/"); + await expect(page.getByText("Lunchtime").first()).toBeVisible(); + expect(errors).toEqual([]); +}); diff --git a/src/e2e/tsconfig.json b/src/e2e/tsconfig.json new file mode 100644 index 0000000..23fdc6f --- /dev/null +++ b/src/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "allowJs": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.mjs"], + "exclude": ["node_modules", "test-results", "playwright-report"] +} diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index d03e54a..7325c73 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -1,8 +1,15 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +// Set by src/dev/devenv.sh when running without Docker/nginx: Vite then proxies /api itself. +// Unset under Compose, where nginx routes /api to the backend. +const apiProxyTarget = process.env.API_PROXY_TARGET; + export default defineConfig({ plugins: [react()], - // Bind mounts from Windows/macOS hosts don't deliver file events; poll instead - server: { watch: { usePolling: true, interval: 300 } }, + server: { + // Bind mounts from Windows/macOS hosts don't deliver file events; poll instead + watch: { usePolling: true, interval: 300 }, + proxy: apiProxyTarget ? { "/api": { target: apiProxyTarget, changeOrigin: true } } : undefined, + }, });