feat: add Playwright e2e smoke test covering signup → household → chore flow

- Added smoke.spec.js with Playwright test for full user flow:
  1. Sign up (fill form, submit, click 'Go to Dashboard')
  2. Create household (fill name, submit)
  3. Create chore (fill name + date, submit)
  4. Verify session persistence (revisit dashboard)
- Server now supports --db flag for temp databases and SIS_FRESH_DB env
  to delete the db file on startup (for clean test runs)
- Server reads SIS_PORT env var for port configuration
- scripts/test-e2e: starts server with fresh db, runs tests, cleans up
- playwright.config.js: minimal config pointing at test/e2e/
This commit is contained in:
2026-07-16 10:01:33 -04:00
parent 6bea83be7d
commit 1c19d97dc8
6 changed files with 214 additions and 3 deletions
+23 -3
View File
@@ -1,10 +1,12 @@
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeOperators #-} {-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -Wno-unused-imports -Wno-missing-export-lists -Wno-name-shadowing #-} {-# OPTIONS_GHC -Wno-unused-imports -Wno-missing-export-lists -Wno-name-shadowing #-}
module Main where module Main where
import Control.Exception (IOException, catch)
import Data.ByteString qualified as BS import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as C8 import Data.ByteString.Char8 qualified as C8
import Data.ByteString.Lazy qualified as BL import Data.ByteString.Lazy qualified as BL
@@ -13,8 +15,10 @@ import Effectful
import Network.HTTP.Types qualified as HTTP import Network.HTTP.Types qualified as HTTP
import Network.Wai qualified as Wai import Network.Wai qualified as Wai
import Network.Wai.Handler.Warp qualified as Warp 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.FilePath (takeDirectory)
import System.IO.Error (isDoesNotExistError)
import Sis.Database import Sis.Database
import Sis.Page.Activity import Sis.Page.Activity
@@ -43,13 +47,29 @@ mimeType fp
main :: IO () main :: IO ()
main = do 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) createDirectoryIfMissing True (takeDirectory dbPath)
putStrLn "[sis] opening database..." putStrLn "[sis] opening database..."
conn <- openDatabase dbPath conn <- openDatabase dbPath
let port = 8080
putStrLn $ "[sis] listening on 0.0.0.0:" <> show port putStrLn $ "[sis] listening on 0.0.0.0:" <> show port
let hyperboleApp = let hyperboleApp =
+16
View File
@@ -5,9 +5,25 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@playwright/test": "^1.61.1",
"playwright": "^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": { "node_modules/fsevents": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+1
View File
@@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"@playwright/test": "^1.61.1",
"playwright": "^1.61.1" "playwright": "^1.61.1"
} }
} }
+12
View File
@@ -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,
},
});
+65
View File
@@ -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
+97
View File
@@ -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 });
});
});