diff --git a/.gitignore b/.gitignore index 82dbf878..c76dcd6c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ backend/public/ backend/Cold-Friendly-Feud backend/famf.db test-results/ +backend/.cache/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fb347b41..41af86df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,7 @@ WORKDIR /src RUN npm install FROM base AS dev +RUN apk add curl COPY --from=builder /src/node_modules/ /src/node_modules/ COPY . /src/ WORKDIR /src diff --git a/backend/Dockerfile b/backend/Dockerfile index a6226f00..732d9a51 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,11 +4,14 @@ WORKDIR /src FROM base as dev ENV CGO_ENABLED 1 -RUN apk add gcc musl-dev sqlite +ENV GOCACHE=/src/.cache/go-build +ENV GOMODCACHE=/src/.cache/go-mod +RUN apk add gcc musl-dev sqlite curl RUN go install github.com/air-verse/air@latest COPY --from=games . /src/games/ COPY . . -RUN go mod download +RUN mkdir -p .cache/go-build .cache/go-mod && \ + go mod download CMD ["air", "--build.cmd", "go build .", "--build.bin", "/src/Cold-Friendly-Feud"] FROM base AS builder diff --git a/backend/api/health.go b/backend/api/health.go new file mode 100644 index 00000000..731b4a8f --- /dev/null +++ b/backend/api/health.go @@ -0,0 +1,94 @@ +package api + +import ( + "fmt" + "time" + + "github.com/gorilla/websocket" +) + +type HealthStatus struct { + Status string `json:"status"` + Details HealthDetails `json:"details"` +} + +type HealthDetails struct { + WebSocket ComponentStatus `json:"websocket"` + Database ComponentStatus `json:"database"` +} + +type ComponentStatus struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp,omitempty"` + Error string `json:"error,omitempty"` +} + +func HealthTest(port string) (HealthStatus, error) { + status := HealthStatus{ + Status: "up", + Details: HealthDetails{ + WebSocket: checkWebSocket(port), + Database: checkDatabase(), + }, + } + + if status.Details.WebSocket.Status == "down" || status.Details.Database.Status == "down" { + status.Status = "down" + return status, fmt.Errorf("one or more components are down") + } + + return status, nil +} + +func checkWebSocket(port string) ComponentStatus { + status := ComponentStatus{ + Status: "up", + Timestamp: time.Now().UTC(), + } + + url := fmt.Sprintf("ws://localhost%s/api/ws", port) + + // Set a shorter timeout for health checks + dialer := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + + // Attempt connection + conn, _, err := dialer.Dial(url, nil) + if err != nil { + status.Status = "down" + status.Error = fmt.Sprintf("websocket connection failed: %v", err) + return status + } + defer conn.Close() + + // Ping and wait for pong to verify connection is working + err = conn.WriteMessage(websocket.PingMessage, []byte{}) + if err != nil { + status.Status = "down" + status.Error = fmt.Sprintf("failed to send ping: %v", err) + } + + return status +} + +func checkDatabase() ComponentStatus { + status := ComponentStatus{ + Status: "up", + Timestamp: time.Now().UTC(), + } + + if store == nil { + status.Status = "down" + status.Error = "store not initialized" + return status + } + + rooms := store.currentRooms() + if rooms == nil { + status.Status = "down" + status.Error = "failed to query rooms" + } + + return status +} diff --git a/backend/main.go b/backend/main.go index 826da707..1bdd5a63 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "flag" "log" "net/http" @@ -39,9 +40,18 @@ func main() { api.ServeWs(httpWriter, httpRequest) }) - http.HandleFunc("/api/healthcheckz", func(httpWriter http.ResponseWriter, httpRequest *http.Request) { - httpWriter.Write([]byte("ok")) - httpWriter.WriteHeader(200) + http.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + status, err := api.HealthTest(cfg.addr) + + if err != nil { + w.WriteHeader(503) + } else { + w.WriteHeader(200) + } + + json.NewEncoder(w).Encode(status) }) http.HandleFunc("/api/rooms/{roomCode}/logo", func(httpWriter http.ResponseWriter, httpRequest *http.Request) { diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index a065e656..e28687c1 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -84,11 +84,11 @@ spec: {{- end }} livenessProbe: httpGet: - path: /api/healthcheckz + path: /api/health port: bhttp readinessProbe: httpGet: - path: /api/healthcheckz + path: /api/health port: bhttp {{- with .Values.backend.volumeMounts }} volumeMounts: diff --git a/docker/docker-compose-dev-wsl.yaml b/docker/docker-compose-dev-wsl.yaml index f8203ab7..a03abcc5 100644 --- a/docker/docker-compose-dev-wsl.yaml +++ b/docker/docker-compose-dev-wsl.yaml @@ -1,23 +1,46 @@ services: frontend: image: ${docker_registry}/famf-web:dev - network_mode: "host" + network_mode: 'host' volumes: - ../:/src environment: - HOST=0.0.0.0 + healthcheck: + # Verifies that the frontend dev server is responding on port 3000 + # Start period is longer due to npm install + test: ['CMD', 'curl', '-f', 'http://localhost:3000'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 60s backend: image: ${docker_registry}/famf-server:dev - network_mode: "host" + network_mode: 'host' environment: - HOST=0.0.0.0 - GAME_STORE=${game_store} volumes: - ../backend:/src - ../games/:/src/games/ + # Verifies dedicated backend health endpoint returns 200 + # Start period is longer due to go build + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 60s proxy: image: nginx:1.27-alpine - network_mode: "host" + network_mode: 'host' volumes: - ./nginx/nginx.wsl.conf:/etc/nginx/nginx.conf - ../dev/cert/:/etc/nginx/cert/ + healthcheck: + # -k flag allows self-signed certificates in development + test: ['CMD', 'curl', '-f', '-k', 'https://localhost:443'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 10s diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index f886d0d9..47189e4b 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -5,6 +5,14 @@ services: - ../:/src ports: - 3000:3000 + healthcheck: + # Verifies that the frontend dev server is responding on port 3000 + # Start period is longer due to npm install + test: ['CMD', 'curl', '-f', 'http://localhost:3000'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 60s backend: image: ${docker_registry}/famf-server:dev ports: @@ -15,6 +23,14 @@ services: volumes: - ../backend:/src - ../games/:/src/games/ + # Verifies dedicated backend health endpoint returns 200 + # Start period is longer due to go build + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 60s proxy: image: nginx:1.27-alpine ports: @@ -22,3 +38,10 @@ services: volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ../dev/cert/:/etc/nginx/cert/ + healthcheck: + # -k flag allows self-signed certificates in development + test: ['CMD', 'curl', '-f', '-k', 'https://localhost:443'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 10s diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 3c602b1f..1612e0c0 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -1,9 +1,39 @@ services: frontend: - image: ${docker_registry}/famf-web:latest + image: ${docker_registry}/famf-web:dev ports: - 3000:3000 + healthcheck: + # Verifies that the frontend dev server is responding on port 3000 + # Start period is longer due to npm install + test: ['CMD', 'curl', '-f', 'http://localhost:3000'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 60s backend: - image: ${docker_registry}/famf-server:latest + image: ${docker_registry}/famf-server:dev + ports: + - 8080:8080 environment: + # One of memory, sqlite GAME_STORE: ${game_store} + # Verifies dedicated backend health endpoint returns 200 + # Start period is longer due to go build + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:8080/api/health'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 60s + proxy: + image: nginx:1.27-alpine + ports: + - 443:443 + healthcheck: + # -k flag allows self-signed certificates in development + test: ['CMD', 'curl', '-f', '-k', 'https://localhost:443'] + interval: 5s + timeout: 3s + retries: 3 + start_period: 10s diff --git a/e2e/playwright-dev.config.js b/e2e/playwright-dev.config.js index 25dbec53..6d6428bb 100644 --- a/e2e/playwright-dev.config.js +++ b/e2e/playwright-dev.config.js @@ -48,15 +48,15 @@ module.exports = defineConfig({ use: { ...devices['Desktop Chrome'] }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, /* Test against mobile viewports. */ // { diff --git a/e2e/playwright-prod.config.js b/e2e/playwright-prod.config.js index ca1739dd..0bca91b2 100644 --- a/e2e/playwright-prod.config.js +++ b/e2e/playwright-prod.config.js @@ -48,15 +48,15 @@ module.exports = defineConfig({ use: { ...devices['Desktop Chrome'] }, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, /* Test against mobile viewports. */ // { diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js index 943735c8..4a1b9704 100644 --- a/e2e/playwright.config.js +++ b/e2e/playwright.config.js @@ -12,6 +12,8 @@ const { defineConfig, devices } = require('@playwright/test'); */ module.exports = defineConfig({ testDir: './tests', + /* 60s timeout (initial build takes 60s) */ + timeout: 60000, /* Run tests in files in parallel */ fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -44,19 +46,26 @@ module.exports = defineConfig({ /* Configure projects for major browsers */ projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + name: 'docker', + testMatch: /global\.setup\.ts/, }, - { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + dependencies: ['docker'], }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // dependencies: ['docker'], + // }, + // + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // dependencies: ['docker'], + // }, /* Test against mobile viewports. */ // { diff --git a/e2e/tests/admin.spec.js b/e2e/tests/admin.spec.js index 1eae5272..7ce626c8 100644 --- a/e2e/tests/admin.spec.js +++ b/e2e/tests/admin.spec.js @@ -297,8 +297,8 @@ test('can hide game board from player', async ({ browser }) => { const adminPage = new AdminPage(host.page); await adminPage.gameSelector.selectOption({ index: 1 }); - await adminPage.startRoundOneButton.click(); - expect(buzzerPage1.playerBlindFoldedText).not.toBeVisible(); - await adminPage.player0Team1HideGameButton.click(); - expect(buzzerPage1.playerBlindFoldedText).toBeVisible(); + await adminPage.startRoundOneButton.click({ timeout: 2000 }); + await expect(buzzerPage1.playerBlindFoldedText).not.toBeVisible({ timeout: 2000 }); + await adminPage.player0Team1HideGameButton.click({ timeout: 2000 }); + await expect(buzzerPage1.playerBlindFoldedText).toBeVisible({ timeout: 2000 }); }); diff --git a/e2e/tests/global.setup.ts b/e2e/tests/global.setup.ts new file mode 100644 index 00000000..f9388e4a --- /dev/null +++ b/e2e/tests/global.setup.ts @@ -0,0 +1,62 @@ +import { test as setup } from '@playwright/test'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +setup('docker', async () => { + const maxRetries = 60; + const waitSeconds = 1; + + for (let i = 0; i < maxRetries; i++) { + try { + // Get status of all running containers + const { stdout } = await execAsync('docker ps --format "{{.Names}} {{.Status}}"'); + + // Regex patterns to extract container name and health status + // "famf-backend-1 Up 4 minutes (healthy)" + const nameRegex = /^[^\s]+/; // "famf-backend-1" + const statusRegex = /(healthy|unhealthy|starting)/i; // "healthy" + + // Boolean filter to exclude empty strings + const containers = stdout.split('\n').filter(Boolean).map((container => { + const nameMatch = container.match(nameRegex); + const statusMatch = container.match(statusRegex); + + if (!nameMatch?.[0] || !statusMatch?.[1]) { + throw new Error('Failed to parse container info'); + } + + const name = nameMatch[0]; + const status = statusMatch[1]; + + return { + name, status + } + })) + + + // Check if all required containers are healthy + // These container names must be substrings of the actual container names + // e.g. 'frontend' will match 'famf-frontend-1' + const requiredContainers = ['backend', 'frontend', 'proxy']; + const containerStatuses = requiredContainers.map(name => ({ + name, + isHealthy: containers.find(c => c.name.includes(name))?.status === 'healthy' ?? false + })); + + const allHealthy = containerStatuses.every(c => c.isHealthy); + if (allHealthy) { + return; + } + + console.log(`Waiting for containers to be healthy ${i + 1}/${maxRetries}`); + await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); + } catch (err) { + console.log(`Error checking container health ${i + 1}/${maxRetries}:`, err); + await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); + } + } + + throw new Error('containers failed to become healthy'); +});