add playwright tests

This commit is contained in:
2026-08-01 21:24:48 -04:00
parent 250a18cfbb
commit 61ea97265c
12 changed files with 299 additions and 9 deletions
+3
View File
@@ -3,3 +3,6 @@
/sustenance.db*
/.env
/seed.json
/e2e/node_modules/
/e2e/test-results/
/e2e/playwright-report/
+21
View File
@@ -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.
+98
View File
@@ -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 };
+13
View File
@@ -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",
});
}
+20
View File
@@ -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+/);
}
+74
View File
@@ -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"
}
}
}
}
+12
View File
@@ -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"
}
}
+11
View File
@@ -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",
},
});
+15
View File
@@ -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");
});
+21
View File
@@ -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");
});
+9 -7
View File
@@ -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))
+2 -2
View File
@@ -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]