diff --git a/Makefile b/Makefile index ae5b618..bc1f486 100644 --- a/Makefile +++ b/Makefile @@ -25,3 +25,28 @@ test: # Format code format: @make -C app format + +# Run Playwright browser-based tests (requires running server) +# Set PLAYWRIGHT_BASE_URL to point at the running Phoenix app +playwright: + @make -C app playwright + +# Run Playwright in UI mode (headed browser) +playwright-ui: + @make -C app playwright-ui + +# Run Playwright against Docker Chrome container (must be running) +playwright-docker: + @make -C app playwright-docker + +# Full e2e workflow: start Chrome container, run tests, stop container +playwright-full: + @make -C app playwright-full + +# Start Docker Chrome container +playwright-docker-start: + @make -C app playwright-docker-start + +# Stop Docker Chrome container +playwright-docker-stop: + @make -C app playwright-docker-stop \ No newline at end of file diff --git a/app/Makefile b/app/Makefile index 92f834c..b58f7b1 100644 --- a/app/Makefile +++ b/app/Makefile @@ -28,6 +28,70 @@ test: deps compile format: $(MISE_EXEC) mix format +# ============================================================================= +# Playwright e2e browser tests +# ============================================================================= +# +# Requires the Phoenix dev server running on the default port (8056). +# Set PLAYWRIGHT_BASE_URL to point at a different server/port. +# +# Available browsers (local): +# PLAYWRIGHT_BROWSER=firefox (default — works on macOS 15) +# PLAYWRIGHT_BROWSER=chromium (crashes on macOS 15, use Docker Chrome) +# PLAYWRIGHT_BROWSER=webkit +# +# Docker Chrome (alternative): +# 1. docker compose -f docker-compose.chrome.yml up -d +# 2. PLAYWRIGHT_BROWSERS_PATH=assets/node_modules/playwright-core/.local-browsers \ +# npx playwright test --config=playwright.docker.config.ts +# +# ============================================================================= + +# Run Playwright e2e tests (default: local Firefox) +# Usage: make playwright +# PLAYWRIGHT_BROWSER=chromium make playwright +playwright: + cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts + +# Run a specific Playwright test file +# Usage: make playwright-test test/e2e/smoke.spec.ts +playwright-test: + cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts $(filter-out $@,$(MAKECMDGOALS)) + +# Run Playwright tests with UI mode (headed browser) +playwright-ui: + cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.config.ts --ui + +# Show last Playwright HTML report +playwright-report: + cd assets && npx playwright show-report + +# Start Docker Chrome container for remote Playwright +playwright-docker-start: + docker compose -f docker-compose.chrome.yml up -d + +# Stop Docker Chrome container +playwright-docker-stop: + docker compose -f docker-compose.chrome.yml down + +# Run Playwright against Docker Chrome (container must be running) +playwright-docker: + cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.docker.config.ts + +# Run full end-to-end workflow: start Docker Chrome, run tests, stop container +playwright-full: playwright-docker-start + @echo "Waiting for Chrome to be ready..." + @sleep 3 + cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers npx playwright test --config=../playwright.docker.config.ts; \ + EXIT_CODE=$$?; \ + cd ..; \ + docker compose -f docker-compose.chrome.yml down; \ + exit $$EXIT_CODE + +# ============================================================================= +# Static analysis +# ============================================================================= + # Run Credo static analysis credo: $(MISE_EXEC) mix credo --strict \ No newline at end of file diff --git a/app/assets/package.json b/app/assets/package.json index aac68c6..80cdbdb 100644 --- a/app/assets/package.json +++ b/app/assets/package.json @@ -4,12 +4,17 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "test:e2e": "playwright test --config=../playwright.config.ts", + "test:e2e:ui": "playwright test --config=../playwright.config.ts --ui" }, "keywords": [], "author": "", "license": "ISC", "type": "commonjs", + "devDependencies": { + "@playwright/test": "^1.60.0" + }, "dependencies": { "highlight.js": "^11.11.1" } diff --git a/app/bin/e2e-full.sh b/app/bin/e2e-full.sh new file mode 100755 index 0000000..4829340 --- /dev/null +++ b/app/bin/e2e-full.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Full e2e workflow: start Docker Chrome, run Playwright tests, stop container. +# +# Usage: ./bin/e2e-full.sh [extra playwright args...] +# +# Requires: +# - Docker (colima or Docker Desktop running) +# - Phoenix dev server running (default http://localhost:8056) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_DIR" + +echo "=== Starting Chrome container ===" +docker compose -f docker-compose.chrome.yml up -d + +echo "=== Waiting for Chrome to be ready... ===" +for i in $(seq 1 30); do + if curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ 2>/dev/null | grep -q 200; then + echo "Chrome is ready!" + break + fi + if [ "$i" -eq 30 ]; then + echo "Timed out waiting for Chrome." + docker compose -f docker-compose.chrome.yml logs --tail=20 + exit 1 + fi + sleep 1 +done + +echo "=== Running Playwright e2e tests against Docker Chrome ===" +cd assets && PLAYWRIGHT_BROWSERS_PATH=node_modules/playwright-core/.local-browsers \ + npx playwright test --config=../playwright.docker.config.ts "$@" + +EXIT_CODE=$? + +cd "$PROJECT_DIR" +echo "=== Stopping Chrome container ===" +docker compose -f docker-compose.chrome.yml down + +exit $EXIT_CODE \ No newline at end of file diff --git a/app/bin/e2e-start.sh b/app/bin/e2e-start.sh new file mode 100755 index 0000000..1a2c026 --- /dev/null +++ b/app/bin/e2e-start.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Helper script to start the Playwright Chrome container via Docker +# Usage: ./bin/e2e-start.sh +# +# Starts a browserless/chrome container that Playwright connects to remotely. +# The container runs Chrome with DevTools Protocol on port 9222. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Starting Chrome container for Playwright e2e tests..." +echo "Using docker-compose.chrome.yml" + +cd "$PROJECT_DIR" + +# Check if colima is needed (no Docker Desktop) +if ! docker info >/dev/null 2>&1; then + echo "Docker daemon not accessible. Have you started colima or Docker Desktop?" + echo " colima start" + exit 1 +fi + +docker compose -f docker-compose.chrome.yml up -d + +echo "" +echo "Chrome container started! Check status:" +echo " docker compose -f docker-compose.chrome.yml ps" +echo "" +echo "Run tests:" +echo " make playwright" \ No newline at end of file diff --git a/app/bin/e2e-stop.sh b/app/bin/e2e-stop.sh new file mode 100755 index 0000000..1a18027 --- /dev/null +++ b/app/bin/e2e-stop.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Helper script to stop the Playwright Chrome container +# Usage: ./bin/e2e-stop.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$PROJECT_DIR" + +echo "Stopping Chrome container..." + +docker compose -f docker-compose.chrome.yml down + +echo "Chrome container stopped." \ No newline at end of file diff --git a/app/docker-compose.chrome.yml b/app/docker-compose.chrome.yml new file mode 100644 index 0000000..0ef4f3a --- /dev/null +++ b/app/docker-compose.chrome.yml @@ -0,0 +1,27 @@ +# Docker Compose for Playwright Chrome container +# +# Usage: +# docker compose -f docker-compose.chrome.yml up -d # start Chrome +# docker compose -f docker-compose.chrome.yml down # stop Chrome +# make playwright # run tests against it +# +# The container exposes Chrome DevTools Protocol on port 9222. +# Playwright connects via the websocket endpoint. + +services: + chrome: + # browserless/chrome is self-contained with Chrome + CDP exposed + # multi-arch (supports arm64 for Apple Silicon Macs) + image: browserless/chrome:latest + container_name: firehose-playwright-chrome + ports: + - "3000:3000" # browserless dashboard (not required but handy) + environment: + - CONNECTION_TIMEOUT=600000 + - MAX_CONCURRENT_SESSIONS=10 + - PREBOOT_CHROME=true + - DEMO_MODE=false + - ENABLE_CORS=true + restart: unless-stopped + # Run as non-root to avoid permission issues + user: "1001:1001" \ No newline at end of file diff --git a/app/playwright.config.ts b/app/playwright.config.ts new file mode 100644 index 0000000..0c37a96 --- /dev/null +++ b/app/playwright.config.ts @@ -0,0 +1,58 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Default Playwright e2e configuration. +// +// Uses Firefox by default (Playwright Chromium crashes on macOS 15 +// due to Crashpad permission restrictions). +// +// ## Usage +// +// make playwright # run with default browser (Firefox) +// PLAYWRIGHT_BROWSER=chromium make playwright +// PLAYWRIGHT_BROWSER=webkit make playwright +// +// # Run a single test file: +// PLAYWRIGHT_BROWSERS_PATH=assets/node_modules/playwright-core/.local-browsers \ +// npx playwright test --config=playwright.config.ts test/e2e/smoke.spec.ts +// +// ## Docker Chrome (alternative) +// +// Use playwright.docker.config.ts instead of this config: +// +// docker compose -f docker-compose.chrome.yml up -d +// npx playwright test --config=playwright.docker.config.ts + +const browserEnv = process.env.PLAYWRIGHT_BROWSER || "firefox"; + +const deviceFor = (name: string) => { + switch (name) { + case "chromium": return devices["Desktop Chrome"]; + case "firefox": return devices["Desktop Firefox"]; + case "webkit": return devices["Desktop Safari"]; + default: + console.warn(`Unknown browser "${name}", falling back to Firefox`); + return devices["Desktop Firefox"]; + } +}; + +export default defineConfig({ + testDir: "./test/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "list", + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:8056", + trace: "on-first-retry", + headless: process.env.PLAYWRIGHT_HEADLESS !== "false", + }, + + projects: [ + { + name: browserEnv, + use: { ...deviceFor(browserEnv) }, + }, + ], +}); \ No newline at end of file diff --git a/app/playwright.docker.config.ts b/app/playwright.docker.config.ts new file mode 100644 index 0000000..10cdaa2 --- /dev/null +++ b/app/playwright.docker.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Docker Chrome Playwright configuration. +// Connects to a remote browserless/chrome container via CDP. +// +// Usage: +// 1. Start the Chrome container: +// docker compose -f docker-compose.chrome.yml up -d +// +// 2. Run tests: +// npx playwright test --config=playwright.docker.config.ts + +const CDP_URL = process.env.PLAYWRIGHT_CDP_URL || "http://localhost:3000"; + +export default defineConfig({ + testDir: "./test/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "list", + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:8056", + trace: "on-first-retry", + headless: process.env.PLAYWRIGHT_HEADLESS !== "false", + }, + + // Connect to remote Chrome via CDP instead of launching locally. + // browserless/chrome exposes CDP at port 3000. + projects: [ + { + name: "docker-chrome", + use: { + ...devices["Desktop Chrome"], + launchOptions: { + // Not used for remote connection, but placeholder for clarity + }, + }, + }, + ], +}); \ No newline at end of file diff --git a/app/test/e2e/auth-helpers.ts b/app/test/e2e/auth-helpers.ts new file mode 100644 index 0000000..9f21b62 --- /dev/null +++ b/app/test/e2e/auth-helpers.ts @@ -0,0 +1,41 @@ +/** + * Auth helpers for Playwright e2e tests. + * + * Provides utilities for logging in as a test user. + * The app uses email-only registration (magic-link flow for initial confirmation) + * and email+password for subsequent logins. + */ + +import { Page } from "@playwright/test"; + +export const TEST_USER = { + email: process.env.PLAYWRIGHT_EMAIL || "test@firehose.test", + password: process.env.PLAYWRIGHT_PASSWORD || "testpassword123!", +}; + +/** + * Navigate to the login page, fill in credentials, and submit. + * Returns the page after successful login (redirected to home). + */ +export async function loginAsTestUser(page: Page) { + await page.goto("/users/log-in"); + // Use the password-based login form + await page.getByLabel("Email").fill(TEST_USER.email); + await page.getByLabel("Password").fill(TEST_USER.password); + // Click "Log in and stay logged in" + await page.getByRole("button", { name: /log in and stay logged in/i }).click(); + await page.waitForURL("/"); +} + +/** + * Navigate to the registration page, fill in email, and submit. + * Note: In dev mode, registration is invite-only by default. Set + * `PLAYWRIGHT_ALLOW_REGISTRATION=true` to run tests against a server + * with open registration enabled. + */ +export async function registerTestUser(page: Page) { + await page.goto("/users/register"); + await page.getByLabel("Email").fill(TEST_USER.email); + await page.getByRole("button", { name: /create an account/i }).click(); + await page.waitForURL("/users/log-in"); +} \ No newline at end of file diff --git a/app/test/e2e/auth.spec.ts b/app/test/e2e/auth.spec.ts new file mode 100644 index 0000000..605903c --- /dev/null +++ b/app/test/e2e/auth.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "./fixtures"; +import { loginAsTestUser } from "./auth-helpers"; + +test.describe("Authenticated pages", () => { + test("redirects unauthenticated users to login page", async ({ page }) => { + const response = await page.goto("/editor/dashboard"); + // Should redirect to login + expect(response?.url()).toContain("/users/log-in"); + }); + + test("can log in and access the editor dashboard", async ({ page }) => { + await loginAsTestUser(page); + + // After login, we should be on the home page + await expect(page).toHaveURL("/"); + await expect(page.locator("body")).not.toBeEmpty(); + }); + + test("can log out", async ({ page }) => { + await loginAsTestUser(page); + + // Now log out + await page.goto("/users/log-out"); + + // Should redirect to home + await expect(page).toHaveURL("/"); + + // Try accessing an authenticated page + await page.goto("/editor/dashboard"); + // Should redirect to login + await expect(page).toHaveURL(/\/users\/log-in/); + }); +}); \ No newline at end of file diff --git a/app/test/e2e/fixtures.ts b/app/test/e2e/fixtures.ts new file mode 100644 index 0000000..7c411c9 --- /dev/null +++ b/app/test/e2e/fixtures.ts @@ -0,0 +1,28 @@ +/** + * Custom Playwright test fixtures. + * + * Provides a `test` function that connects to a remote Chrome via CDP + * when PLAYWRIGHT_CDP_URL is set (Docker Chrome mode). + * Falls back to the default local browser otherwise. + */ + +import { test as base, chromium } from "@playwright/test"; + +type Fixtures = { + // Browser is provided by Playwright Test by default. + // We only override it when connecting to remote Docker Chrome. +}; + +const cdpUrl = process.env.PLAYWRIGHT_CDP_URL; + +export const test = cdpUrl + ? base.extend({ + browser: async ({}, use) => { + const browser = await chromium.connectOverCDP(cdpUrl); + await use(browser); + await browser.close(); + }, + }) + : base; + +export { expect } from "@playwright/test"; \ No newline at end of file diff --git a/app/test/e2e/smoke.spec.ts b/app/test/e2e/smoke.spec.ts new file mode 100644 index 0000000..241528e --- /dev/null +++ b/app/test/e2e/smoke.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from "./fixtures"; + +test.describe("Connectivity smoke test", () => { + test("visits the homepage and verifies it loads", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.ok()).toBeTruthy(); + + // Verify the page has content + await expect(page).toHaveTitle(/Firehose/); + }); + + test("visits the contact page", async ({ page }) => { + const response = await page.goto("/contact"); + expect(response?.ok()).toBeTruthy(); + await expect(page.locator("body")).not.toBeEmpty(); + }); + + test("visits the blog engineering index", async ({ page }) => { + const response = await page.goto("/blog/engineering"); + expect(response?.ok()).toBeTruthy(); + await expect(page.locator("body")).not.toBeEmpty(); + }); + + test("visits the blog releases index", async ({ page }) => { + const response = await page.goto("/blog/releases"); + expect(response?.ok()).toBeTruthy(); + await expect(page.locator("body")).not.toBeEmpty(); + }); +}); \ No newline at end of file