From 61ea97265c3dbeda5a301a15b132f2f10705b208 Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Sat, 1 Aug 2026 21:24:48 -0400 Subject: [PATCH] add playwright tests --- .gitignore | 3 ++ README.md | 21 +++++++++ e2e/fixtures.ts | 98 ++++++++++++++++++++++++++++++++++++++++ e2e/global-setup.ts | 13 ++++++ e2e/helpers.ts | 20 ++++++++ e2e/package-lock.json | 74 ++++++++++++++++++++++++++++++ e2e/package.json | 12 +++++ e2e/playwright.config.ts | 11 +++++ e2e/tests/auth.spec.ts | 15 ++++++ e2e/tests/meals.spec.ts | 21 +++++++++ src/http.rs | 16 ++++--- src/main.rs | 4 +- 12 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 e2e/fixtures.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/helpers.ts create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/auth.spec.ts create mode 100644 e2e/tests/meals.spec.ts diff --git a/.gitignore b/.gitignore index 0669a83..a8e4a56 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ /sustenance.db* /.env /seed.json +/e2e/node_modules/ +/e2e/test-results/ +/e2e/playwright-report/ diff --git a/README.md b/README.md index 0ba755a..af28b72 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,24 @@ cargo fmt --all -- --check cargo check cargo test ``` + +### End-to-end tests (Playwright) + +The e2e tests live in `e2e/` and use Playwright with a real browser. Each test +starts its own server against a fresh, throwaway database on a unique port, so +tests are fully isolated from each other and from your real `sustenance.db`. + +```sh +# one-time setup +cd e2e +npm install +npx playwright install chromium + +# run the tests (builds the server automatically via globalSetup) +cd e2e +npx playwright test +``` + +The Playwright `globalSetup` runs `cargo build` before the suite, and each test +launches its own server against a fresh database, so no manual build or server +start is required. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..ad191a6 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,98 @@ +import { test as base, expect, Page } from "@playwright/test"; +import { spawn, ChildProcess } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +/** + * Starts a fresh Sustenance server against a unique, throwaway database on a + * unique port for each test, and tears it down afterwards. This gives every + * test a clean DB with no shared state between tests. + */ +export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ + server: [ + async ({}, use) => { + const dbPath = path.join( + os.tmpdir(), + `sustenance-e2e-${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}.db`, + ); + const port = 3200 + Math.floor(Math.random() * 2000); + const baseURL = `http://127.0.0.1:${port}`; + + const child = spawn( + path.resolve(__dirname, "..", "target", "debug", "sustenance"), + [], + { + env: { + ...process.env, + DATABASE_PATH: dbPath, + REGISTRATION_MODE: "open", + BIND_ADDRESS: `127.0.0.1:${port}`, + // Point SEED_CONFIG at a nonexistent file so no default user is created. + SEED_CONFIG: path.join(os.tmpdir(), "sustenance-e2e-no-seed.json"), + }, + stdio: "ignore", + // Run in its own process group so we can kill the whole tree. + detached: true, + }, + ); + + await waitForServer(baseURL, child); + + await use({ baseURL }); + + await killTree(child); + // Clean up the DB files (including -wal / -shm). + for (const suffix of ["", "-wal", "-shm"]) { + fs.rmSync(dbPath + suffix, { force: true }); + } + }, + { scope: "test", auto: true }, + ], + + // Provide a page whose baseURL points at this test's server. + page: async ({ browser, server }, use) => { + const context = await browser.newContext({ baseURL: server.baseURL }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); + +async function waitForServer(baseURL: string, child: ChildProcess) { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`server exited early with code ${child.exitCode}`); + } + try { + const res = await fetch(baseURL + "/login"); + if (res.ok) return; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error("timed out waiting for server to start"); +} + +async function killTree(child: ChildProcess) { + try { + process.kill(-child.pid!, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + // Give it a moment to shut down gracefully, then force-kill if needed. + const exited = new Promise((resolve) => child.once("exit", resolve)); + const timeout = new Promise((resolve) => setTimeout(resolve, 5000)); + await Promise.race([exited, timeout]); + try { + process.kill(-child.pid!, "SIGKILL"); + } catch { + /* already gone */ + } +} + +export { expect }; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..793eba6 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,13 @@ +import { execSync } from "child_process"; +import * as path from "path"; + +/** + * Builds the Rust server before the test suite runs, so the fixture can launch + * `target/debug/sustenance` without requiring a manual `cargo build`. + */ +export default function globalSetup() { + execSync("cargo build", { + cwd: path.resolve(__dirname, ".."), + stdio: "inherit", + }); +} diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..ca91b1f --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,20 @@ +import { Page, expect } from "@playwright/test"; + +/** Registers a fresh account and lands on the lists page. */ +export async function registerAndLogin(page: Page, email: string) { + await page.goto("/register"); + await page.fill("#display-name", "Test User"); + await page.fill("#email", email); + await page.fill("#password", "a-strong-password"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/lists/); +} + +/** Creates a meal with the given name and markdown description. */ +export async function createMeal(page: Page, name: string, description: string) { + await page.goto("/meals/new"); + await page.fill("#meal-name", name); + await page.fill("#meal-description", description); + await page.click('button:has-text("Save meal")'); + await expect(page).toHaveURL(/\/meals\/\d+/); +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..80246be --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "sustenance-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sustenance-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.45.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..bf98660 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "sustenance-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "^1.45.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..e20cac1 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 30_000, + retries: 0, + globalSetup: "./global-setup.ts", + use: { + trace: "on-first-retry", + }, +}); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 0000000..d7d56d3 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,15 @@ +import { expect } from "@playwright/test"; +import { test } from "../fixtures"; +import { registerAndLogin } from "../helpers"; + +test("a user can register and log in", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await expect(page.locator("h1")).toContainText("Grocery lists"); +}); + +test("a user can log out", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + await page.click('button:has-text("Sign out")'); + await expect(page).toHaveURL(/\/login/); + await expect(page.locator("h1")).toContainText("Welcome back"); +}); diff --git a/e2e/tests/meals.spec.ts b/e2e/tests/meals.spec.ts new file mode 100644 index 0000000..43d4681 --- /dev/null +++ b/e2e/tests/meals.spec.ts @@ -0,0 +1,21 @@ +import { expect } from "@playwright/test"; +import { test } from "../fixtures"; +import { registerAndLogin } from "../helpers"; + +test("a user can create a meal", async ({ page }) => { + await registerAndLogin(page, "alice@example.com"); + + await page.goto("/meals/new"); + await page.fill("#meal-name", "Spaghetti Bolognese"); + await page.fill("#meal-description", "## Ingredients\n\nA classic weeknight dinner."); + await page.click('button:has-text("Save meal")'); + + // Lands on the meal detail page and renders the markdown description. + await expect(page).toHaveURL(/\/meals\/\d+/); + await expect(page.locator("h1")).toContainText("Spaghetti Bolognese"); + await expect(page.locator(".markdown h2")).toContainText("Ingredients"); + + // The meal appears on the meals index. + await page.goto("/meals"); + await expect(page.locator(".list-card")).toContainText("Spaghetti Bolognese"); +}); diff --git a/src/http.rs b/src/http.rs index 827790d..5216b11 100644 --- a/src/http.rs +++ b/src/http.rs @@ -16,11 +16,7 @@ use axum::{ use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, de::DeserializeOwned}; use thiserror::Error; -use tower_http::{ - services::ServeDir, - set_header::SetResponseHeaderLayer, - trace::TraceLayer, -}; +use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; use tracing::{error, warn}; use crate::domain::{DomainError, SessionUser}; @@ -87,8 +83,14 @@ pub fn build_router(state: AppState) -> Router { .route("/meals/{meal_id}/edit", post(edit_meal)) .route("/meals/{meal_id}/delete", post(delete_meal)) .route("/meals/{meal_id}/ingredients", post(add_ingredient)) - .route("/meals/{meal_id}/ingredients/{ingredient_id}/edit", post(edit_ingredient)) - .route("/meals/{meal_id}/ingredients/{ingredient_id}/delete", post(delete_ingredient)) + .route( + "/meals/{meal_id}/ingredients/{ingredient_id}/edit", + post(edit_ingredient), + ) + .route( + "/meals/{meal_id}/ingredients/{ingredient_id}/delete", + post(delete_ingredient), + ) .route("/lists/{list_id}/add-meal", post(add_meal_to_list)) .route("/lists/{list_id}/stream", get(list_stream)) .route("/invite/{token}", get(invitation_page)) diff --git a/src/main.rs b/src/main.rs index d77c173..87a7897 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,9 +25,9 @@ use crate::ports::{ use crate::security::{Argon2PasswordHasher, RandomTokenGenerator}; use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode}; use crate::sqlite::{ - SqliteCategoryRepository, SqliteInvitationRepository, SqliteItemRepository, + SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository, SqliteListRepository, SqliteMealIngredientRepository, SqliteMealRepository, - SqliteSessionRepository, SqliteDatabase, SqliteUserRepository, + SqliteSessionRepository, SqliteUserRepository, }; #[tokio::main]