This commit is contained in:
2025-12-26 20:54:20 +01:00
parent 904feb41b8
commit 6389be4f0c
26 changed files with 745 additions and 195 deletions

View File

@@ -1,45 +1,84 @@
# ========================================== # ==========================================
# GridPilot Development Environment # GridPilot Development Environment
# ========================================== # ==========================================
# Used by `docker-compose.dev.yml` via `env_file: .env.development`.
# Node Environment # ------------------------------------------
# Runtime
# ------------------------------------------
NODE_ENV=development NODE_ENV=development
NEXT_TELEMETRY_DISABLED=1
# ------------------------------------------
# API (NestJS)
# ------------------------------------------
# API persistence is inferred from DATABASE_URL by default.
# GRIDPILOT_API_PERSISTENCE=postgres
# ==========================================
# Database (PostgreSQL)
# ==========================================
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev
# Postgres container vars (used by `docker-compose.dev.yml` -> `db`)
POSTGRES_DB=gridpilot_dev POSTGRES_DB=gridpilot_dev
POSTGRES_USER=gridpilot_user POSTGRES_USER=gridpilot_user
POSTGRES_PASSWORD=gridpilot_dev_pass POSTGRES_PASSWORD=gridpilot_dev_pass
# ========================================== # ------------------------------------------
# API Configuration # Website (Next.js) - public (exposed to browser)
# ========================================== # ------------------------------------------
API_PORT=3000
API_HOST=0.0.0.0
# ==========================================
# Website Configuration
# ==========================================
NEXT_PUBLIC_GRIDPILOT_MODE=alpha NEXT_PUBLIC_GRIDPILOT_MODE=alpha
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
NEXT_TELEMETRY_DISABLED=1
# ========================================== # Browser → API base URL (host port 3001 -> container port 3000)
# Vercel KV (Optional in Development) NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
# ==========================================
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
NEXT_PUBLIC_X_URL=https://x.com/your-handle
# Optional site/legal metadata (defaults used when unset)
# NEXT_PUBLIC_SITE_NAME=GridPilot
# NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@example.com
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=
# NEXT_PUBLIC_LEGAL_VAT_ID=
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=
# ------------------------------------------
# Vercel KV (optional in dev)
# ------------------------------------------
# If unset, the Website uses an in-memory fallback with a warning.
# KV_REST_API_URL= # KV_REST_API_URL=
# KV_REST_API_TOKEN= # KV_REST_API_TOKEN=
# ========================================== # ------------------------------------------
# Automation Mode # Automation / Companion (advanced)
# ========================================== # ------------------------------------------
AUTOMATION_MODE=dev # Prefer using NODE_ENV=development/test. `AUTOMATION_MODE` is legacy & deprecated.
# AUTOMATION_MODE=dev
CHROME_DEBUG_PORT=9222 CHROME_DEBUG_PORT=9222
# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/<id>
# Native automation tuning (optional)
# IRACING_WINDOW_TITLE=iRacing
# TEMPLATE_PATH=./resources/templates/iracing
# OCR_CONFIDENCE=0.9
# NUTJS_MOUSE_SPEED=1000
# NUTJS_KEYBOARD_DELAY=50
# AUTOMATION_MAX_RETRIES=3
# AUTOMATION_BASE_DELAY_MS=500
# AUTOMATION_MAX_DELAY_MS=5000
# AUTOMATION_BACKOFF_MULTIPLIER=2
# AUTOMATION_PAGE_LOAD_WAIT_MS=5000
# AUTOMATION_INTER_ACTION_DELAY_MS=200
# AUTOMATION_POST_CLICK_DELAY_MS=300
# AUTOMATION_PRE_STEP_DELAY_MS=100
AUTOMATION_TIMEOUT=30000 AUTOMATION_TIMEOUT=30000
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
SCREENSHOT_ON_ERROR=true SCREENSHOT_ON_ERROR=true
# Logging (automation/adapters)
# LOG_LEVEL=debug
# LOG_FILE_PATH=./logs/gridpilot
# LOG_MAX_FILES=7
# LOG_MAX_SIZE=10m

View File

@@ -1,18 +1,89 @@
# Development Environment Configuration # ==========================================
# Copy this file to .env.development and adjust values as needed # GridPilot Development Environment Example
# ==========================================
# Copy to `.env.development` and adjust values as needed.
#
# This file is consumed by `docker-compose.dev.yml` (API, Website, Postgres).
# Automation mode: 'dev' | 'production' | 'mock' # ------------------------------------------
AUTOMATION_MODE=dev # Runtime
# ------------------------------------------
NODE_ENV=development
NEXT_TELEMETRY_DISABLED=1
# Chrome DevTools settings (for dev mode) # ------------------------------------------
# API (NestJS)
# ------------------------------------------
# API persistence is inferred from DATABASE_URL by default.
# GRIDPILOT_API_PERSISTENCE=postgres
DATABASE_URL=postgres://gridpilot_user:gridpilot_dev_pass@db:5432/gridpilot_dev
# Postgres container vars (used by docker `db` service)
POSTGRES_DB=gridpilot_dev
POSTGRES_USER=gridpilot_user
POSTGRES_PASSWORD=gridpilot_dev_pass
# ------------------------------------------
# Website (Next.js) - public (exposed to browser)
# ------------------------------------------
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Browser → API base URL (host port 3001 -> container port 3000)
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
# Optional links / metadata
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
NEXT_PUBLIC_X_URL=https://x.com/your-handle
# NEXT_PUBLIC_SITE_NAME=GridPilot
# NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@example.com
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=
# NEXT_PUBLIC_LEGAL_VAT_ID=
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=
# ------------------------------------------
# Vercel KV (optional in dev)
# ------------------------------------------
# If unset, the Website uses an in-memory fallback with a warning.
# KV_REST_API_URL=
# KV_REST_API_TOKEN=
# ------------------------------------------
# Automation / Companion (advanced)
# ------------------------------------------
# Prefer using NODE_ENV=development/test. `AUTOMATION_MODE` is legacy & deprecated.
# AUTOMATION_MODE=dev
CHROME_DEBUG_PORT=9222 CHROME_DEBUG_PORT=9222
# CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/<id> # CHROME_WS_ENDPOINT=ws://127.0.0.1:9222/devtools/browser/<id>
# Shared automation settings # Native automation tuning (optional)
# IRACING_WINDOW_TITLE=iRacing
# TEMPLATE_PATH=./resources/templates/iracing
# OCR_CONFIDENCE=0.9
# NUTJS_MOUSE_SPEED=1000
# NUTJS_KEYBOARD_DELAY=50
# AUTOMATION_MAX_RETRIES=3
# AUTOMATION_BASE_DELAY_MS=500
# AUTOMATION_MAX_DELAY_MS=5000
# AUTOMATION_BACKOFF_MULTIPLIER=2
# AUTOMATION_PAGE_LOAD_WAIT_MS=5000
# AUTOMATION_INTER_ACTION_DELAY_MS=200
# AUTOMATION_POST_CLICK_DELAY_MS=300
# AUTOMATION_PRE_STEP_DELAY_MS=100
AUTOMATION_TIMEOUT=30000 AUTOMATION_TIMEOUT=30000
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
SCREENSHOT_ON_ERROR=true SCREENSHOT_ON_ERROR=true
# Logging (automation/adapters)
# LOG_LEVEL=debug
# LOG_FILE_PATH=./logs/gridpilot
# LOG_MAX_FILES=7
# LOG_MAX_SIZE=10m
# Start Chrome with debugging enabled: # Start Chrome with debugging enabled:
# /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug # /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
# Or use: npm run chrome:debug # Or use: npm run chrome:debug

View File

@@ -1,55 +1,78 @@
# ========================================== # ==========================================
# GridPilot Production Environment # GridPilot Production Environment
# ========================================== # ==========================================
# Used by `docker-compose.prod.yml` via `env_file: .env.production`.
#
# Security:
# - Do NOT commit real secrets (use a secret manager / CI vars).
# - Replace all `CHANGE_ME_...` values before deploying.
# Node Environment # ------------------------------------------
# Runtime
# ------------------------------------------
NODE_ENV=production NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1
# ========================================== # ------------------------------------------
# Database (PostgreSQL) # API (NestJS)
# ========================================== # ------------------------------------------
# IMPORTANT: Change these credentials in production! # Controls whether the API uses Postgres or runs in-memory.
# Values: `postgres` | `inmemory`
GRIDPILOT_API_PERSISTENCE=postgres
# Optional: bootstrap demo data (default: true)
# GRIDPILOT_API_BOOTSTRAP=true
# Database connection for the API (preferred: single URL)
DATABASE_URL=postgres://gridpilot_user:CHANGE_ME_IN_PRODUCTION@db:5432/gridpilot_prod DATABASE_URL=postgres://gridpilot_user:CHANGE_ME_IN_PRODUCTION@db:5432/gridpilot_prod
# Postgres container vars (used by `docker-compose.prod.yml` -> `db` service)
POSTGRES_DB=gridpilot_prod POSTGRES_DB=gridpilot_prod
POSTGRES_USER=gridpilot_user POSTGRES_USER=gridpilot_user
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
# ========================================== # ------------------------------------------
# Redis Cache # Redis container vars (used by `docker-compose.prod.yml` -> `redis` service)
# ========================================== # ------------------------------------------
# IMPORTANT: Change password in production!
REDIS_URL=redis://:CHANGE_ME_IN_PRODUCTION@redis:6379
REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION REDIS_PASSWORD=CHANGE_ME_IN_PRODUCTION
REDIS_HOST=redis
REDIS_PORT=6379
# ========================================== # ------------------------------------------
# API Configuration # Website (Next.js) - public (exposed to browser)
# ========================================== # ------------------------------------------
API_PORT=3000
API_HOST=0.0.0.0
# ==========================================
# Website Configuration
# ==========================================
NEXT_PUBLIC_GRIDPILOT_MODE=alpha NEXT_PUBLIC_GRIDPILOT_MODE=alpha
NEXT_PUBLIC_SITE_URL=http://localhost:80 NEXT_PUBLIC_SITE_URL=https://your-domain.com
NEXT_PUBLIC_API_URL=http://localhost:80/api
# Browser → API base URL.
# If nginx proxies `/api` to the API service, this is typically:
# https://your-domain.com/api
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com/api
# Optional links / metadata (defaults used when unset)
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
NEXT_TELEMETRY_DISABLED=1 NEXT_PUBLIC_X_URL=https://x.com/your-handle
# NEXT_PUBLIC_SITE_NAME=GridPilot
# NEXT_PUBLIC_SUPPORT_EMAIL=support@your-domain.com
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@your-domain.com
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=Your Company GmbH
# NEXT_PUBLIC_LEGAL_VAT_ID=DE123456789
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=DE
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=Street 1, 12345 City
# ========================================== # ------------------------------------------
# Vercel KV (REQUIRED in Production) # Website (Next.js) - server (NOT exposed to browser)
# ========================================== # ------------------------------------------
# For local testing, these can be left as placeholders # SSR/server-to-server base URL inside Docker network
# In production, get these from: https://vercel.com/dashboard -> Storage -> KV API_BASE_URL=http://api:3000
KV_REST_API_URL=https://placeholder-kv.vercel-storage.com
KV_REST_API_TOKEN=placeholder_kv_token
# ========================================== # Vercel KV (required for email signup storage/rate limit in production)
# Automation Mode KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com
# ========================================== KV_REST_API_TOKEN=CHANGE_ME_IN_PRODUCTION
AUTOMATION_MODE=production
# ------------------------------------------
# Automation / Companion (advanced)
# ------------------------------------------
# NOTE: `AUTOMATION_MODE` is deprecated (see `getAutomationMode()` in adapters).
# AUTOMATION_MODE=production
AUTOMATION_TIMEOUT=30000 AUTOMATION_TIMEOUT=30000
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
SCREENSHOT_ON_ERROR=false SCREENSHOT_ON_ERROR=false

View File

@@ -1,62 +1,70 @@
# ========================================== # ==========================================
# GridPilot Production Environment Example # GridPilot Production Environment Example
# ========================================== # ==========================================
# Copy this file to .env.production and update with real credentials # Copy to `.env.production` and replace placeholders with real values.
#
# Do NOT commit real secrets.
# Node Environment # ------------------------------------------
# Runtime
# ------------------------------------------
NODE_ENV=production NODE_ENV=production
# ==========================================
# Database (PostgreSQL)
# ==========================================
# Update these with your production database credentials
DATABASE_URL=postgres://gridpilot_user:YOUR_SECURE_PASSWORD@db:5432/gridpilot_prod
POSTGRES_DB=gridpilot_prod
POSTGRES_USER=gridpilot_user
POSTGRES_PASSWORD=YOUR_SECURE_PASSWORD
# ==========================================
# Redis Cache
# ==========================================
# Update with your production Redis password
REDIS_URL=redis://:YOUR_REDIS_PASSWORD@redis:6379
REDIS_PASSWORD=YOUR_REDIS_PASSWORD
REDIS_HOST=redis
REDIS_PORT=6379
# ==========================================
# API Configuration
# ==========================================
API_PORT=3000
API_HOST=0.0.0.0
# ==========================================
# Website Configuration
# ==========================================
# Update with your actual domain
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
NEXT_PUBLIC_SITE_URL=https://your-domain.com
NEXT_PUBLIC_API_URL=https://api.your-domain.com
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
# ========================================== # ------------------------------------------
# Vercel KV (REQUIRED in Production) # API (NestJS)
# ========================================== # ------------------------------------------
# Get these from: https://vercel.com/dashboard -> Storage -> KV GRIDPILOT_API_PERSISTENCE=postgres
KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com # GRIDPILOT_API_BOOTSTRAP=true
KV_REST_API_TOKEN=your_kv_rest_api_token_here
# ========================================== # Prefer a single connection URL (Docker: host `db`)
# Automation Mode DATABASE_URL=postgres://gridpilot_user:CHANGE_ME@db:5432/gridpilot_prod
# ==========================================
AUTOMATION_MODE=production # Postgres container vars (used by `docker-compose.prod.yml` -> `db`)
POSTGRES_DB=gridpilot_prod
POSTGRES_USER=gridpilot_user
POSTGRES_PASSWORD=CHANGE_ME
# Redis container vars (used by `docker-compose.prod.yml` -> `redis`)
REDIS_PASSWORD=CHANGE_ME
# ------------------------------------------
# Website (Next.js) - public (exposed to browser)
# ------------------------------------------
NEXT_PUBLIC_GRIDPILOT_MODE=alpha
NEXT_PUBLIC_SITE_URL=https://your-domain.com
# Browser → API base URL.
# If nginx proxies `/api` to the API service, this is typically:
# https://your-domain.com/api
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com/api
NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code
NEXT_PUBLIC_X_URL=https://x.com/your-handle
# Optional site/legal metadata (defaults used when unset)
# NEXT_PUBLIC_SITE_NAME=GridPilot
# NEXT_PUBLIC_SUPPORT_EMAIL=support@your-domain.com
# NEXT_PUBLIC_SPONSOR_EMAIL=sponsors@your-domain.com
# NEXT_PUBLIC_LEGAL_COMPANY_NAME=Your Company GmbH
# NEXT_PUBLIC_LEGAL_VAT_ID=DE123456789
# NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY=DE
# NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS=Street 1, 12345 City
# ------------------------------------------
# Website (Next.js) - server (NOT exposed to browser)
# ------------------------------------------
API_BASE_URL=http://api:3000
# Vercel KV (required in production)
KV_REST_API_URL=https://your-kv-rest-api-url.vercel-storage.com
KV_REST_API_TOKEN=CHANGE_ME
# ------------------------------------------
# Automation / Companion (advanced)
# ------------------------------------------
# NOTE: `AUTOMATION_MODE` is deprecated.
# AUTOMATION_MODE=production
AUTOMATION_TIMEOUT=30000 AUTOMATION_TIMEOUT=30000
RETRY_ATTEMPTS=3 RETRY_ATTEMPTS=3
SCREENSHOT_ON_ERROR=false SCREENSHOT_ON_ERROR=false
# ==========================================
# Security & Performance
# ==========================================
# Add any additional production-specific variables here
# Example: API keys, webhook URLs, etc.

View File

@@ -1,10 +1,28 @@
# Test Environment Configuration # Test Environment Configuration (automation stack)
# Copy this file to .env.test and adjust values as needed # Copy this file to `.env.test` and adjust values as needed.
#
# Scope:
# - This file is for the hosted-session automation stack (companion/automation).
# - It is NOT used by the Website ↔ API Docker smoke stack (that stack is driven by env vars in
# [`docker-compose.test.yml`](docker-compose.test.yml:1) and scripts in [`package.json`](package.json:92)).
#
# Note on legacy config:
# - `AUTOMATION_MODE` is deprecated in favor of `NODE_ENV` mapping in [`getAutomationMode()`](adapters/automation/config/AutomationConfig.ts:104).
# - Keep `AUTOMATION_MODE` only if you need to force legacy behavior.
# Use mock adapter for testing (no real browser automation) # Prefer this:
NODE_ENV=test
# Optional legacy override (deprecated): `dev` | `mock` | `production`
AUTOMATION_MODE=mock AUTOMATION_MODE=mock
# Test timeouts (can be shorter for faster tests) # Shared automation settings
AUTOMATION_TIMEOUT=5000 AUTOMATION_TIMEOUT=5000
RETRY_ATTEMPTS=1 RETRY_ATTEMPTS=1
SCREENSHOT_ON_ERROR=false SCREENSHOT_ON_ERROR=false
# Logging (automation/adapters)
# LOG_LEVEL=warn
# LOG_FILE_PATH=./logs/gridpilot
# LOG_MAX_FILES=7
# LOG_MAX_SIZE=10m

View File

@@ -115,8 +115,8 @@ Before deploying to production:
- Update `DATABASE_URL` with production database - Update `DATABASE_URL` with production database
4. **Redis Password**: 4. **Redis Password**:
- Update `REDIS_PASSWORD` with strong password - Update `REDIS_PASSWORD` with a strong password
- Update `REDIS_URL` accordingly - No `REDIS_URL` is required (the Redis container is configured via `REDIS_PASSWORD` in `docker-compose.prod.yml`)
5. **Vercel KV** (if using): 5. **Vercel KV** (if using):
- Get credentials from Vercel dashboard - Get credentials from Vercel dashboard
@@ -124,7 +124,7 @@ Before deploying to production:
6. **Domain Configuration**: 6. **Domain Configuration**:
- Update `NEXT_PUBLIC_SITE_URL` with your domain - Update `NEXT_PUBLIC_SITE_URL` with your domain
- Update `NEXT_PUBLIC_API_URL` with your API domain - Update `NEXT_PUBLIC_API_BASE_URL` with your public API base (often `https://your-domain.com/api` when nginx proxies `/api`)
7. **Build & Deploy**: 7. **Build & Deploy**:
```bash ```bash

View File

@@ -13,13 +13,13 @@ npm run docker:dev:build
This will: This will:
- Start PostgreSQL database on port 5432 - Start PostgreSQL database on port 5432
- Start API on port 3000 (with debugger on 9229) - Start API on port 3001 (container port 3000, debugger 9229)
- Start Website on port 3001 - Start Website on port 3000
- Enable hot-reloading for both apps - Enable hot-reloading for both apps
Access: Access:
- Website: http://localhost:3001 - Website: http://localhost:3000
- API: http://localhost:3000 - API: http://localhost:3001
- Database: localhost:5432 - Database: localhost:5432
### Production ### Production
@@ -54,16 +54,93 @@ Access:
- `npm run docker:prod:logs` - View logs - `npm run docker:prod:logs` - View logs
- `npm run docker:prod:clean` - Stop and remove volumes - `npm run docker:prod:clean` - Stop and remove volumes
### Testing (Docker)
The goal of Docker-backed tests is to catch *wiring* issues between Website ↔ API (wrong hostnames/ports/env vars, missing CORS for credentialed requests, etc.) in a deterministic environment.
- `npm run test:docker:website` - Start a dedicated test stack (ports `3100/3101`) and run Playwright smoke tests against it.
- Uses [`docker-compose.test.yml`](docker-compose.test.yml:1).
- Runs the Website in Docker and talks to an **API mock** container.
- Validates that pages render and that core API fetches succeed (hostname + CORS + routing).
Supporting scripts:
- `npm run docker:test:deps` - Install monorepo deps inside a reusable Docker volume (one-shot).
- `npm run docker:test:up` - Bring up the test stack.
- `npm run docker:test:wait` - Wait for `http://localhost:3100` and `http://localhost:3101/health` to be ready.
- `npm run docker:test:down` - Tear the test stack down (including volumes).
## Environment Variables ## Environment Variables
### “Mock vs Real” (Website & API)
There is **no** `AUTOMATION_MODE` equivalent for the Website/API runtime.
- **Website “mock vs real”** is controlled purely by *which API base URL you point it at* via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6):
- Browser calls use `NEXT_PUBLIC_API_BASE_URL`
- Server/Next.js calls use `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL`
- **API “mock vs real”** is controlled by API runtime env:
- Persistence: `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` in [`AppModule`](apps/api/src/app.module.ts:25)
- Optional bootstrapping: `GRIDPILOT_API_BOOTSTRAP=0|1` in [`AppModule`](apps/api/src/app.module.ts:35)
Practical presets:
- **Website + real API (Docker dev)**: `npm run docker:dev:build` (Website `3000`, API `3001`, Postgres required).
- Website browser → API: `NEXT_PUBLIC_API_BASE_URL=http://localhost:3001`
- Website container → API container: `API_BASE_URL=http://api:3000`
- **Website + mock API (Docker smoke)**: `npm run test:docker:website` (Website `3100`, API mock `3101`).
- API mock is defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24)
- Website browser → API mock: `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101`
- Website container → API mock container: `API_BASE_URL=http://api:3000`
### Website ↔ API Connection
The website talks to the API via `fetch()` in [`BaseApiClient`](apps/website/lib/api/base/BaseApiClient.ts:11), and it always includes cookies (`credentials: 'include'`). That means:
- The **browser** must be pointed at a host-accessible API URL via `NEXT_PUBLIC_API_BASE_URL`
- The **server** (Next.js / Node) must be pointed at a container-network API URL via `API_BASE_URL` (when running in Docker)
The single source of truth for “what base URL should I use?” is [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6):
- Browser: reads `NEXT_PUBLIC_API_BASE_URL`
- Server: reads `API_BASE_URL ?? NEXT_PUBLIC_API_BASE_URL`
- In Docker/CI/test: throws if missing (no silent localhost fallback)
#### Dev Docker defaults (docker-compose.dev.yml)
- Website: `http://localhost:3000`
- API: `http://localhost:3001` (maps to container `api:3000`)
- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3001` (browser → host port)
- `API_BASE_URL=http://api:3000` (website container → api container)
#### Test Docker defaults (docker-compose.test.yml)
This stack is intended for deterministic smoke tests and uses different host ports to avoid colliding with `docker:dev`:
- Website: `http://localhost:3100`
- API mock: `http://localhost:3101` (maps to container `api:3000`)
- `NEXT_PUBLIC_API_BASE_URL=http://localhost:3101` (browser → host port)
- `API_BASE_URL=http://api:3000` (website container → api container)
Important: the test stacks API is a mock server defined inline in [`docker-compose.test.yml`](docker-compose.test.yml:24). It exists to validate Website ↔ API wiring, not domain correctness.
#### Troubleshooting
- If `docker:dev` is running, use `npm run docker:dev:down` before `npm run test:docker:website` to avoid port conflicts.
- If Docker volumes get stuck, run `npm run docker:test:down` (it uses `--remove-orphans` + `rm -f`).
### API “Real vs In-Memory” Mode
The API can now be run either:
- **postgres**: loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) (requires Postgres)
- **inmemory**: does not load `DatabaseModule` (no Postgres required)
Control it with:
- `GRIDPILOT_API_PERSISTENCE=postgres|inmemory` (defaults to `postgres` if `DATABASE_URL` is set, otherwise `inmemory`)
- Optional: `GRIDPILOT_API_BOOTSTRAP=0` to skip [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:1)
### Development (.env.development) ### Development (.env.development)
Copy and customize as needed. Default values work out of the box. Copy and customize as needed. Default values work out of the box.
### Production (.env.production) ### Production (.env.production)
**IMPORTANT**: Update these before deploying: **IMPORTANT**: Update these before deploying:
- Database credentials (POSTGRES_PASSWORD, DATABASE_URL) - Database credentials (`POSTGRES_PASSWORD`, `DATABASE_URL`)
- API URLs (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL) - Website/API URLs (`NEXT_PUBLIC_API_BASE_URL`, `NEXT_PUBLIC_SITE_URL`)
- Vercel KV credentials (required for production) - Vercel KV credentials (`KV_REST_API_URL`, `KV_REST_API_TOKEN`) (required for production email signups/rate limit)
## Architecture ## Architecture

44
adapters/env.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV?: 'development' | 'production' | 'test';
// Automation (legacy + current)
AUTOMATION_MODE?: 'dev' | 'production' | 'mock';
AUTOMATION_TIMEOUT?: string;
RETRY_ATTEMPTS?: string;
SCREENSHOT_ON_ERROR?: string;
// Chrome / DevTools
CHROME_DEBUG_PORT?: string;
CHROME_WS_ENDPOINT?: string;
// Native automation tuning
NUTJS_MOUSE_SPEED?: string;
NUTJS_KEYBOARD_DELAY?: string;
IRACING_WINDOW_TITLE?: string;
TEMPLATE_PATH?: string;
OCR_CONFIDENCE?: string;
// Retry tuning
AUTOMATION_MAX_RETRIES?: string;
AUTOMATION_BASE_DELAY_MS?: string;
AUTOMATION_MAX_DELAY_MS?: string;
AUTOMATION_BACKOFF_MULTIPLIER?: string;
// Timing tuning
AUTOMATION_PAGE_LOAD_WAIT_MS?: string;
AUTOMATION_INTER_ACTION_DELAY_MS?: string;
AUTOMATION_POST_CLICK_DELAY_MS?: string;
AUTOMATION_PRE_STEP_DELAY_MS?: string;
// Logging
LOG_LEVEL?: 'debug' | 'info' | 'warn' | 'error' | 'fatal';
LOG_FILE_PATH?: string;
LOG_MAX_FILES?: string;
LOG_MAX_SIZE?: string;
}
}
}
export {};

View File

@@ -7,6 +7,6 @@
"sourceMap": true, "sourceMap": true,
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"include": ["**/*.ts", "../core/**/*.ts"], "include": ["**/*.ts", "**/*.d.ts", "../core/**/*.ts", "../core/**/*.d.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@@ -17,12 +17,20 @@ import { RaceModule } from './domain/race/RaceModule';
import { SponsorModule } from './domain/sponsor/SponsorModule'; import { SponsorModule } from './domain/sponsor/SponsorModule';
import { TeamModule } from './domain/team/TeamModule'; import { TeamModule } from './domain/team/TeamModule';
import { getApiPersistence, getEnableBootstrap } from './env';
const API_PERSISTENCE = getApiPersistence();
const USE_DATABASE = API_PERSISTENCE === 'postgres';
// Keep bootstrap on by default; tests can disable explicitly.
const ENABLE_BOOTSTRAP = getEnableBootstrap();
@Module({ @Module({
imports: [ imports: [
HelloModule, HelloModule,
DatabaseModule, ...(USE_DATABASE ? [DatabaseModule] : []),
LoggingModule, LoggingModule,
BootstrapModule, ...(ENABLE_BOOTSTRAP ? [BootstrapModule] : []),
AnalyticsModule, AnalyticsModule,
AuthModule, AuthModule,
DashboardModule, DashboardModule,

34
apps/api/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV?: 'development' | 'production' | 'test';
// API runtime toggles
GRIDPILOT_API_PERSISTENCE?: 'postgres' | 'inmemory';
GRIDPILOT_API_BOOTSTRAP?: string;
GENERATE_OPENAPI?: string;
// Database (TypeORM)
DATABASE_URL?: string;
DATABASE_HOST?: string;
DATABASE_PORT?: string;
DATABASE_USER?: string;
DATABASE_PASSWORD?: string;
DATABASE_NAME?: string;
// Policy / operational mode
GRIDPILOT_POLICY_CACHE_MS?: string;
GRIDPILOT_POLICY_PATH?: string;
GRIDPILOT_OPERATIONAL_MODE?: string;
GRIDPILOT_FEATURES_JSON?: string;
GRIDPILOT_MAINTENANCE_ALLOW_VIEW?: string;
GRIDPILOT_MAINTENANCE_ALLOW_MUTATE?: string;
// Authorization
GRIDPILOT_AUTHZ_CACHE_MS?: string;
GRIDPILOT_USER_ROLES_JSON?: string;
}
}
}
export {};

62
apps/api/src/env.ts Normal file
View File

@@ -0,0 +1,62 @@
export type ApiPersistence = 'postgres' | 'inmemory';
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return value !== '0' && value.toLowerCase() !== 'false';
}
function isSet(value: string | undefined): boolean {
return value !== undefined;
}
function readLower(name: string): string | undefined {
const raw = process.env[name];
if (raw === undefined) return undefined;
return raw.toLowerCase();
}
function requireOneOf<T extends string>(name: string, value: string, allowed: readonly T[]): T {
if ((allowed as readonly string[]).includes(value)) {
return value as T;
}
const valid = allowed.join(', ');
throw new Error(`Invalid ${name}: "${value}". Must be one of: ${valid}`);
}
/**
* Controls whether the API uses Postgres or runs in-memory.
*
* If `GRIDPILOT_API_PERSISTENCE` is set, it must be `postgres|inmemory`.
* Otherwise, it falls back to: `postgres` when `DATABASE_URL` exists, else `inmemory`.
*/
export function getApiPersistence(): ApiPersistence {
const configured = readLower('GRIDPILOT_API_PERSISTENCE');
if (configured) {
return requireOneOf('GRIDPILOT_API_PERSISTENCE', configured, ['postgres', 'inmemory'] as const);
}
return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
}
/**
* Keep bootstrap on by default; tests can disable explicitly.
*
* `GRIDPILOT_API_BOOTSTRAP` uses "truthy" parsing:
* - false when unset / "0" / "false"
* - true otherwise
*/
export function getEnableBootstrap(): boolean {
const raw = process.env.GRIDPILOT_API_BOOTSTRAP;
if (raw === undefined) return true;
return isTruthyEnv(raw);
}
/**
* When set, the API will generate `openapi.json` and optionally reduce logging noise.
*
* Matches previous behavior: any value (even "0") counts as enabled if the var is present.
*/
export function getGenerateOpenapi(): boolean {
return isSet(process.env.GENERATE_OPENAPI);
}

View File

@@ -11,8 +11,11 @@ import { AuthenticationGuard } from './domain/auth/AuthenticationGuard';
import { AuthorizationGuard } from './domain/auth/AuthorizationGuard'; import { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard'; import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
import { getGenerateOpenapi } from './env';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined); const generateOpenapi = getGenerateOpenapi();
const app = await NestFactory.create(AppModule, generateOpenapi ? { logger: false } : undefined);
// Website runs on a different origin in dev/docker (e.g. http://localhost:3000 -> http://localhost:3001), // Website runs on a different origin in dev/docker (e.g. http://localhost:3000 -> http://localhost:3001),
// and our website HTTP client uses `credentials: 'include'`, so we must support CORS with credentials. // and our website HTTP client uses `credentials: 'include'`, so we must support CORS with credentials.
@@ -64,7 +67,7 @@ async function bootstrap() {
SwaggerModule.setup('api/docs', app as any, document); SwaggerModule.setup('api/docs', app as any, document);
// Export OpenAPI spec as JSON file when GENERATE_OPENAPI env var is set // Export OpenAPI spec as JSON file when GENERATE_OPENAPI env var is set
if (process.env.GENERATE_OPENAPI) { if (generateOpenapi) {
const outputPath = join(__dirname, '../openapi.json'); const outputPath = join(__dirname, '../openapi.json');
writeFileSync(outputPath, JSON.stringify(document, null, 2)); writeFileSync(outputPath, JSON.stringify(document, null, 2));
console.log(`✅ OpenAPI spec generated at: ${outputPath}`); console.log(`✅ OpenAPI spec generated at: ${outputPath}`);

View File

@@ -50,6 +50,7 @@
], ],
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"include": [ "include": [
"src/**/*" "src/**/*",
"src/**/*.d.ts"
] ]
} }

25
apps/website/env.d.ts vendored
View File

@@ -47,8 +47,33 @@ declare module 'react/compiler-runtime' {
declare global { declare global {
namespace NodeJS { namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
NODE_ENV?: 'development' | 'production' | 'test';
// Website (public, exposed to browser)
NEXT_PUBLIC_GRIDPILOT_MODE?: 'pre-launch' | 'alpha'; NEXT_PUBLIC_GRIDPILOT_MODE?: 'pre-launch' | 'alpha';
NEXT_PUBLIC_SITE_URL?: string;
NEXT_PUBLIC_API_BASE_URL?: string;
NEXT_PUBLIC_SITE_NAME?: string;
NEXT_PUBLIC_SUPPORT_EMAIL?: string;
NEXT_PUBLIC_SPONSOR_EMAIL?: string;
NEXT_PUBLIC_LEGAL_COMPANY_NAME?: string;
NEXT_PUBLIC_LEGAL_VAT_ID?: string;
NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY?: string;
NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS?: string;
NEXT_PUBLIC_DISCORD_URL?: string;
NEXT_PUBLIC_X_URL?: string; NEXT_PUBLIC_X_URL?: string;
NEXT_TELEMETRY_DISABLED?: string;
// Website (server-only)
API_BASE_URL?: string;
// Vercel KV (server-only)
KV_REST_API_URL?: string;
KV_REST_API_TOKEN?: string;
// Build/CI toggles (server-only)
CI?: string;
DOCKER?: string;
} }
} }
} }

View File

@@ -0,0 +1,96 @@
import { z } from 'zod';
const urlOptional = z.string().url().optional();
const stringOptional = z.string().optional();
const publicEnvSchema = z.object({
NEXT_PUBLIC_GRIDPILOT_MODE: z.enum(['pre-launch', 'alpha']).optional(),
NEXT_PUBLIC_SITE_URL: urlOptional,
NEXT_PUBLIC_API_BASE_URL: urlOptional,
NEXT_PUBLIC_SITE_NAME: stringOptional,
NEXT_PUBLIC_SUPPORT_EMAIL: stringOptional,
NEXT_PUBLIC_SPONSOR_EMAIL: stringOptional,
NEXT_PUBLIC_LEGAL_COMPANY_NAME: stringOptional,
NEXT_PUBLIC_LEGAL_VAT_ID: stringOptional,
NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY: stringOptional,
NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS: stringOptional,
NEXT_PUBLIC_DISCORD_URL: stringOptional,
NEXT_PUBLIC_X_URL: stringOptional,
});
const serverEnvSchema = z.object({
API_BASE_URL: urlOptional,
KV_REST_API_URL: urlOptional,
KV_REST_API_TOKEN: stringOptional,
CI: stringOptional,
DOCKER: stringOptional,
NODE_ENV: z.enum(['development', 'production', 'test']).optional(),
});
export type WebsitePublicEnv = z.infer<typeof publicEnvSchema>;
export type WebsiteServerEnv = z.infer<typeof serverEnvSchema>;
function formatZodIssues(issues: z.ZodIssue[]): string {
return issues
.map((issue) => {
const path = issue.path.join('.') || '(root)';
return `${path}: ${issue.message}`;
})
.join('; ');
}
/**
* Parses Website env vars (server-side safe).
* Only validates the variables we explicitly support.
*/
export function getWebsiteServerEnv(): WebsiteServerEnv {
const result = serverEnvSchema.safeParse(process.env);
if (!result.success) {
throw new Error(`Invalid website server env: ${formatZodIssues(result.error.issues)}`);
}
return result.data;
}
/**
* Parses Website public env vars (safe on both server + client).
* Note: on the client, only `NEXT_PUBLIC_*` vars are actually present.
*/
export function getWebsitePublicEnv(): WebsitePublicEnv {
const result = publicEnvSchema.safeParse(process.env);
if (!result.success) {
throw new Error(`Invalid website public env: ${formatZodIssues(result.error.issues)}`);
}
return result.data;
}
export function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return value !== '0' && value.toLowerCase() !== 'false';
}
/**
* Matches the semantics used in [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6).
*/
export function isTestLikeEnvironment(): boolean {
const { NODE_ENV, CI, DOCKER } = getWebsiteServerEnv();
return NODE_ENV === 'test' || CI === 'true' || DOCKER === 'true';
}
export function isProductionEnvironment(): boolean {
return getWebsiteServerEnv().NODE_ENV === 'production';
}
export function isKvConfigured(): boolean {
const { KV_REST_API_URL, KV_REST_API_TOKEN } = getWebsiteServerEnv();
return Boolean(KV_REST_API_URL && KV_REST_API_TOKEN);
}
export function assertKvConfiguredInProduction(): void {
if (isProductionEnvironment() && !isKvConfigured()) {
throw new Error('Missing KV_REST_API_URL/KV_REST_API_TOKEN in production environment');
}
}

View File

@@ -1,8 +1,11 @@
import { assertKvConfiguredInProduction, isKvConfigured, isProductionEnvironment } from './config/env';
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour in milliseconds
const MAX_REQUESTS_PER_WINDOW = 5; const MAX_REQUESTS_PER_WINDOW = 5;
const RATE_LIMIT_PREFIX = 'ratelimit:signup:'; const RATE_LIMIT_PREFIX = 'ratelimit:signup:';
const isDev = !process.env.KV_REST_API_URL; // Dev fallback: only allowed outside production.
const isDev = !isProductionEnvironment() && !isKvConfigured();
// In-memory fallback for development // In-memory fallback for development
const devRateLimits = new Map<string, { count: number; resetAt: number }>(); const devRateLimits = new Map<string, { count: number; resetAt: number }>();
@@ -49,6 +52,8 @@ export async function checkRateLimit(identifier: string): Promise<{
} }
// Production: Use Vercel KV // Production: Use Vercel KV
assertKvConfiguredInProduction();
const { kv } = await import('@vercel/kv'); const { kv } = await import('@vercel/kv');
const key = `${RATE_LIMIT_PREFIX}${identifier}`; const key = `${RATE_LIMIT_PREFIX}${identifier}`;

View File

@@ -1,9 +1,20 @@
import { apiClient } from '@/lib/apiClient'; import { ApiClient } from '@/lib/api';
import type { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; import type { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel'; import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel';
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import type { LeagueMembership } from '@/lib/types/LeagueMembership'; import type { LeagueMembership } from '@/lib/types/LeagueMembership';
let cachedLeaguesApiClient: LeaguesApiClient | undefined;
function getDefaultLeaguesApiClient(): LeaguesApiClient {
if (cachedLeaguesApiClient) return cachedLeaguesApiClient;
const api = new ApiClient(getWebsiteApiBaseUrl());
cachedLeaguesApiClient = (api as any).leagues as LeaguesApiClient;
return cachedLeaguesApiClient;
}
export class LeagueMembershipService { export class LeagueMembershipService {
// In-memory cache for memberships (populated via API calls) // In-memory cache for memberships (populated via API calls)
private static leagueMemberships = new Map<string, LeagueMembership[]>(); private static leagueMemberships = new Map<string, LeagueMembership[]>();
@@ -11,7 +22,7 @@ export class LeagueMembershipService {
constructor(private readonly leaguesApiClient?: LeaguesApiClient) {} constructor(private readonly leaguesApiClient?: LeaguesApiClient) {}
private getClient(): LeaguesApiClient { private getClient(): LeaguesApiClient {
return (this.leaguesApiClient ?? (apiClient as any).leagues) as LeaguesApiClient; return this.leaguesApiClient ?? getDefaultLeaguesApiClient();
} }
async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> { async getLeagueMemberships(leagueId: string, currentUserId: string): Promise<LeagueMemberViewModel[]> {
@@ -45,7 +56,7 @@ export class LeagueMembershipService {
*/ */
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> { static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
try { try {
const result = await apiClient.leagues.getMemberships(leagueId); const result = await getDefaultLeaguesApiClient().getMemberships(leagueId);
const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({ const memberships: LeagueMembership[] = ((result as any)?.members ?? []).map((member: any) => ({
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
leagueId, leagueId,

View File

@@ -73,6 +73,7 @@
"hooks/", "hooks/",
"lib/", "lib/",
"next-env.d.ts", "next-env.d.ts",
"env.d.ts",
"types/", "types/",
"utilities/", "utilities/",
".next/types/**/*.ts" ".next/types/**/*.ts"

View File

@@ -17,7 +17,7 @@ services:
[ [
"sh", "sh",
"-lc", "-lc",
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_dev\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing api+website deps (slow first time)\"; npm install --no-package-lock --workspace=./apps/api --workspace=./apps/website --include-workspace-root --no-audit --fund=false --prefer-offline; node -e \"require.resolve('ts-node-dev')\"; node -e \"require.resolve('next')\"; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_dev\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps (slow first time)\"; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; node -e \"require.resolve('ts-node-dev')\"; node -e \"require.resolve('next')\"; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
] ]
networks: networks:
- gridpilot-network - gridpilot-network

View File

@@ -15,7 +15,7 @@ services:
[ [
"sh", "sh",
"-lc", "-lc",
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing api+website deps\"; npm install --no-package-lock --workspace=./apps/api --workspace=./apps/website --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps\"; rm -rf apps/api/node_modules apps/website/node_modules; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
] ]
networks: networks:
- gridpilot-test-network - gridpilot-test-network
@@ -26,12 +26,12 @@ services:
environment: environment:
- NODE_ENV=test - NODE_ENV=test
ports: ports:
- "3001:3000" - "3101:3000"
command: command:
[ [
"sh", "sh",
"-lc", "-lc",
"node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; const server=http.createServer((req,res)=>{ const origin=req.headers.origin||'http://localhost:3000'; res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary','Origin'); for(const [k,v] of Object.entries(baseCors)){ res.setHeader(k,v); } if(req.method==='OPTIONS'){ res.statusCode=204; return res.end(); } const url=new URL(req.url,'http://localhost'); const send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"", "node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; const server=http.createServer((req,res)=>{ const origin=req.headers.origin||'http://localhost:3100'; res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary','Origin'); for(const [k,v] of Object.entries(baseCors)){ res.setHeader(k,v); } if(req.method==='OPTIONS'){ res.statusCode=204; return res.end(); } const url=new URL(req.url,'http://localhost'); const send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"",
] ]
networks: networks:
- gridpilot-test-network - gridpilot-test-network
@@ -59,9 +59,9 @@ services:
- DOCKER=true - DOCKER=true
- NEXT_PUBLIC_GRIDPILOT_MODE=alpha - NEXT_PUBLIC_GRIDPILOT_MODE=alpha
- API_BASE_URL=http://api:3000 - API_BASE_URL=http://api:3000
- NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 - NEXT_PUBLIC_API_BASE_URL=http://localhost:3101
ports: ports:
- "3000:3000" - "3100:3000"
volumes: volumes:
- ./:/app - ./:/app
- test_node_modules:/app/node_modules - test_node_modules:/app/node_modules

View File

@@ -273,60 +273,49 @@ This pattern applies equally to integration tests (with real database operations
--- ---
## Docker E2E Setup ## Docker-Based Tests (Website ↔ API Wiring)
### Architecture This repo uses Docker in two different ways:
E2E tests run against a full stack orchestrated by `docker-compose.test.yml`: 1) **Website ↔ API smoke (wiring validation)**
- Orchestrated by [`docker-compose.test.yml`](docker-compose.test.yml:1) at the repo root.
- Runs:
- Website in Docker (Next.js dev server)
- An API mock server in Docker (Node HTTP server)
- Goal: catch misconfigured hostnames/ports/env vars and CORS issues that only show up in Dockerized setups.
```yaml 2) **Hosted-session automation E2E (fixture-driven automation)**
services: - Orchestrated by `docker/docker-compose.e2e.yml` (separate stack; documented later in this file).
postgres: - Goal: validate Playwright-driven automation against HTML fixtures.
image: postgres:16
environment:
POSTGRES_DB: gridpilot_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
redis: ### Website ↔ API smoke: how to run
image: redis:7-alpine
web-api: Run:
build: ./src/apps/web-api - `npm run test:docker:website` (see [`package.json`](package.json:92))
depends_on:
- postgres
- redis
environment:
DATABASE_URL: postgres://test:test@postgres:5432/gridpilot_test
REDIS_URL: redis://redis:6379
ports:
- "3000:3000"
```
### Execution Flow What it does (in order):
- Installs deps in a dedicated Docker volume (`npm run docker:test:deps`)
- Starts the test stack (`npm run docker:test:up`)
- Waits for readiness (`npm run docker:test:wait`)
- Runs Playwright smoke tests (`npm run smoke:website:docker`)
1. **Start Services:** `docker compose -f docker-compose.test.yml up -d` Ports used:
2. **Run Migrations:** `npm run migrate:test` (seeds database) - Website: `http://localhost:3100`
3. **Execute Tests:** Playwright targets `http://localhost:3000` - API mock: `http://localhost:3101`
4. **Teardown:** `docker compose -f docker-compose.test.yml down -v`
### Environment Setup Key contract:
- Website must resolve the API base URL via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6).
- The websites HTTP client uses `credentials: 'include'`, so the API must support CORS-with-credentials (implemented for the real API in [`bootstrap()`](apps/api/src/main.ts:14)).
```bash ### “Mock vs Real” (Website & API)
# tests/e2e/setup.ts
export async function globalSetup() {
// Wait for web-api to be ready
await waitForService('http://localhost:3000/health');
// Run database migrations - The Website does **not** have a runtime flag like `AUTOMATION_MODE`.
await runMigrations(); - “Mock vs real” for the Website is **only** which API base URL it uses:
} - Browser: `NEXT_PUBLIC_API_BASE_URL`
- Server: `API_BASE_URL` (preferred in Docker) or `NEXT_PUBLIC_API_BASE_URL` fallback
export async function globalTeardown() { In the Docker smoke stack, “mock API” means the Node HTTP server in [`docker-compose.test.yml`](docker-compose.test.yml:24).
// Stop Docker Compose services In Docker dev/prod, “real API” means the NestJS app started from [`bootstrap()`](apps/api/src/main.ts:14), and “real vs in-memory” persistence is controlled by `GRIDPILOT_API_PERSISTENCE` in [`AppModule`](apps/api/src/app.module.ts:25).
await exec('docker compose -f docker-compose.test.yml down -v');
}
```
--- ---

View File

@@ -91,8 +91,8 @@
"docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f", "docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f",
"docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps", "docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps",
"docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d --build api website", "docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d --build api website",
"docker:test:down": "docker-compose -p gridpilot-test -f docker-compose.test.yml down -v", "docker:test:down": "sh -lc \"docker-compose -p gridpilot-test -f docker-compose.test.yml down -v --remove-orphans || true; docker-compose -p gridpilot-test -f docker-compose.test.yml rm -fsv || true\"",
"docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3001/health','api'); await wait('http://localhost:3000','website');})();\"", "docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3101/health','api'); await wait('http://localhost:3100','website');})();\"",
"smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts", "smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts",
"test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; npm run smoke:website:docker\"", "test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; npm run smoke:website:docker\"",
"dom:process": "npx tsx scripts/dom-export/processWorkflows.ts", "dom:process": "npx tsx scripts/dom-export/processWorkflows.ts",

View File

@@ -29,7 +29,7 @@ export default defineConfig({
// Base URL for the website // Base URL for the website
use: { use: {
baseURL: 'http://localhost:3000', baseURL: process.env.DOCKER_SMOKE ? 'http://localhost:3100' : 'http://localhost:3000',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
video: 'retain-on-failure', video: 'retain-on-failure',
trace: 'retain-on-failure', trace: 'retain-on-failure',

34
tests/env.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV?: 'development' | 'production' | 'test';
// CI providers / generic CI
CI?: string;
CONTINUOUS_INTEGRATION?: string;
GITHUB_ACTIONS?: string;
GITLAB_CI?: string;
CIRCLECI?: string;
TRAVIS?: string;
JENKINS_URL?: string;
BUILDKITE?: string;
TF_BUILD?: string;
// Playwright / smoke
DOCKER_SMOKE?: string;
// E2E toggles
HOSTED_REAL_E2E?: '0' | '1';
COMPANION_FIXTURE_HOSTED?: string;
// Electron (smoke harness)
ELECTRON_EXECUTABLE_PATH?: string;
// Headless heuristics
HEADLESS?: string;
DISPLAY?: string;
}
}
}
export {};

View File

@@ -1,6 +1,7 @@
{ {
"extends": "./tsconfig.base.json", "extends": "./tsconfig.base.json",
"include": [ "include": [
"tests/env.d.ts",
"tests/**/*.ts", "tests/**/*.ts",
"tests/**/*.tsx" "tests/**/*.tsx"
], ],