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 <noreply@anthropic.com>
This commit is contained in:
simandClaude Sonnet 5.5 committed 2026-11-05 21:56:03 +00:00
1 parent c4aa7f00c2
commit b7eb2bb6d0
17 files changed
+717 -9

No files matched your search

+20 -6
View File
@@ -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/**`.
+4
View File
@@ -0,0 +1,4 @@
# Native dev stack (dev/devenv.sh): pids, logs, tool binaries; backend data dir
.devenv/
backend/data/
+2 -1
View File
@@ -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:
+16
View File
@@ -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: []
+147
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
test-results/
playwright-report/
screenshots/
+38
View File
@@ -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<Fixtures>({
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 };
+12
View File
@@ -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;
}
+139
View File
@@ -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<string, string> }} [options]
* @returns {Promise<any>}
*/
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 } });
}
+91
View File
@@ -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"
}
}
}
+16
View File
@@ -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"
}
}
+23
View File
@@ -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"] } },
],
});
+85
View File
@@ -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();
+51
View File
@@ -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);
});
+46
View File
@@ -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([]);
});
+14
View File
@@ -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"]
}
+9 -2
View File
@@ -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,
},
});