diff --git a/app/Main.hs b/app/Main.hs index 0842fb9..6585710 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -1,10 +1,12 @@ {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -Wno-unused-imports -Wno-missing-export-lists -Wno-name-shadowing #-} module Main where +import Control.Exception (IOException, catch) import Data.ByteString qualified as BS import Data.ByteString.Char8 qualified as C8 import Data.ByteString.Lazy qualified as BL @@ -13,8 +15,10 @@ import Effectful import Network.HTTP.Types qualified as HTTP import Network.Wai qualified as Wai import Network.Wai.Handler.Warp qualified as Warp -import System.Directory (createDirectoryIfMissing, doesFileExist) +import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile) +import System.Environment (getArgs, lookupEnv) import System.FilePath (takeDirectory) +import System.IO.Error (isDoesNotExistError) import Sis.Database import Sis.Page.Activity @@ -43,13 +47,29 @@ mimeType fp main :: IO () main = do - let dbPath = "data/sis.db" + args <- getArgs + let dbPath = case args of + ("--db" : p : _) -> p + _ -> "data/sis.db" + + -- Use PORT env var or --port arg or default 8080 + mPortEnv <- lookupEnv "PORT" + mPortArg <- lookupEnv "SIS_PORT" + let port = case (mPortEnv, mPortArg, args) of + (Just p, _, _) -> read p + (_, Just p, _) -> read p + (_, _, "--port" : p : _) -> read p + _ -> 8080 + -- For fresh test databases, remove the file so tables are recreated + rmDB <- lookupEnv "SIS_FRESH_DB" + case rmDB of + Just _ -> removeFile dbPath `catch` (\(_ :: IOException) -> pure ()) + Nothing -> pure () createDirectoryIfMissing True (takeDirectory dbPath) putStrLn "[sis] opening database..." conn <- openDatabase dbPath - let port = 8080 putStrLn $ "[sis] listening on 0.0.0.0:" <> show port let hyperboleApp = diff --git a/package-lock.json b/package-lock.json index 8375833..1f15944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,25 @@ "packages": { "": { "dependencies": { + "@playwright/test": "^1.61.1", "playwright": "^1.61.1" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/package.json b/package.json index 2fe1cd5..794b355 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "@playwright/test": "^1.61.1", "playwright": "^1.61.1" } } diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..d733ab8 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,12 @@ +// @ts-check +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './test/e2e', + timeout: 30000, + retries: 0, + use: { + baseURL: process.env.SIS_URL || 'http://localhost:8080', + headless: true, + }, +}); diff --git a/scripts/test-e2e b/scripts/test-e2e new file mode 100755 index 0000000..44036af --- /dev/null +++ b/scripts/test-e2e @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Run Playwright end-to-end tests. +# +# Usage: ./scripts/test-e2e +# +# Starts the sis-server with a fresh database, runs the smoke test, +# then shuts down. Set SIS_URL to test against a running instance: +# SIS_URL=http://localhost:8080 ./scripts/test-e2e +set -euo pipefail + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_DIR" + +# Build the server if needed +BIN="./.stack-work/dist/x86_64-linux/ghc-9.10.3/build/sis-server/sis-server" +if [ ! -f "$BIN" ]; then + echo "[test-e2e] building server..." + ./hs stack build --fast +fi + +echo "[test-e2e] running Playwright tests..." + +if [ -n "${SIS_URL:-}" ]; then + npx playwright test --config=playwright.config.js "$@" +else + DB_PATH="/tmp/sis-test-$$.db" + PORT=8081 + + # Kill anything already on our test port + if lsof -ti:"$PORT" > /dev/null 2>&1; then + echo "[test-e2e] killing existing process on port $PORT..." + kill "$(lsof -ti:"$PORT")" 2>/dev/null || true + sleep 1 + fi + + # Remove stale db + rm -f "$DB_PATH" + + # Clean up on exit + cleanup() { + rm -f "$DB_PATH" + if [ -n "${SERVER_PID:-}" ]; then + kill "$SERVER_PID" 2>/dev/null + wait "$SERVER_PID" 2>/dev/null || true + fi + } + trap cleanup EXIT + + echo "[test-e2e] starting server on port $PORT with fresh db..." + SIS_FRESH_DB=1 SIS_PORT="$PORT" "$BIN" --db "$DB_PATH" & + SERVER_PID=$! + + # Wait for server to be ready + echo "[test-e2e] waiting for server..." + for i in $(seq 1 30); do + if curl -sf http://localhost:$PORT/signup > /dev/null 2>&1; then + echo "[test-e2e] server ready" + break + fi + sleep 0.5 + done + + # Run tests + SIS_URL="http://localhost:$PORT" npx playwright test --config=playwright.config.js "$@" +fi diff --git a/test/e2e/smoke.spec.js b/test/e2e/smoke.spec.js new file mode 100644 index 0000000..8fafede --- /dev/null +++ b/test/e2e/smoke.spec.js @@ -0,0 +1,97 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const BASE_URL = process.env.SIS_URL || 'http://localhost:8080'; + +// Hyperbole lowercases constructor names for URLs: RDashboard -> /rdashboard +const URLS = { + signup: `${BASE_URL}/rsignup`, + login: `${BASE_URL}/rlogin`, + dashboard: `${BASE_URL}/rdashboard`, + chores: `${BASE_URL}/rchores`, + household: `${BASE_URL}/rhousehold`, + activity: `${BASE_URL}/ractivity`, +}; + +test.describe('Sis app smoke test', () => { + + test('full flow: signup → login → create household → create chore', async ({ page }) => { + const unique = Date.now(); + const email = `test-${unique}@example.com`; + const displayName = 'James Brechtel'; + const password = 'password123'; + const householdName = 'Test Casa'; + const choreName = 'Take out the trash'; + + // ── 1. Sign up ────────────────────────────────────────────── + await page.goto(URLS.signup); + await page.waitForSelector('input[name="sfDisplayName"]', { timeout: 5000 }); + + // Fill out the signup form + await page.fill('input[name="sfDisplayName"]', displayName); + await page.fill('input[name="sfEmail"]', email); + await page.fill('input[name="sfPassword"]', password); + await page.fill('input[name="sfConfirm"]', password); + await page.check('input[name="sfAgree"]'); + + // Submit (Hyperbole replaces content in-place, no navigation) + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/rsignup') && resp.status() === 200), + page.click('button[type="submit"]'), + ]); + + // Signup success page has a "Go to Dashboard" link + await expect(page.getByText('Account Created!')).toBeVisible({ timeout: 10000 }); + await page.click('a:has-text("Go to Dashboard")'); + + // Now on dashboard or household page (no household yet) + await expect(page.locator('.nb-navbar-brand')).toBeVisible({ timeout: 10000 }); + + // ── 2. Create household ───────────────────────────────────── + const householdLink = page.locator('.nb-navbar-link', { hasText: 'Household' }); + await householdLink.click(); + + // Should see the "Create Your Household" form + await expect(page.getByText('Create Your Household')).toBeVisible({ timeout: 10000 }); + + await page.fill('input[name="hfdName"]', householdName); + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/rhousehold') && resp.status() === 200), + page.click('button[type="submit"]'), + ]); + + // Should see the household view with member count + await expect(page.getByText('1 members')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(householdName)).toBeVisible(); + + // ── 3. Create chore ───────────────────────────────────────── + const choresLink = page.locator('.nb-navbar-link', { hasText: 'Chores' }); + await choresLink.click(); + + // Click "+ New Chore" to open the form + const newChoreBtn = page.getByRole('button', { name: '+ New Chore' }); + await newChoreBtn.click(); + + // Fill out the chore form + await expect(page.getByText('New Chore')).toBeVisible({ timeout: 10000 }); + await page.fill('input[name="cfdName"]', choreName); + await page.fill('input[name="cfdStartDate"]', '2026-12-25'); + + // Submit + await Promise.all([ + page.waitForResponse(resp => resp.url().includes('/rchores') && resp.status() === 200), + page.click('button[type="submit"]'), + ]); + + // Should appear in the chores list + await expect(page.getByText(choreName)).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('One-off on 2026-12-25')).toBeVisible(); + + // ── 4. Verify we're still authenticated ──────────────────── + // Navigate to dashboard — should work since we have a session cookie + await page.goto(URLS.dashboard); + await expect(page.locator('.nb-navbar-brand')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Dashboard')).toBeVisible({ timeout: 5000 }); + }); + +});