add playwright tests
This commit is contained in:
@@ -3,3 +3,6 @@
|
|||||||
/sustenance.db*
|
/sustenance.db*
|
||||||
/.env
|
/.env
|
||||||
/seed.json
|
/seed.json
|
||||||
|
/e2e/node_modules/
|
||||||
|
/e2e/test-results/
|
||||||
|
/e2e/playwright-report/
|
||||||
|
|||||||
@@ -62,3 +62,24 @@ cargo fmt --all -- --check
|
|||||||
cargo check
|
cargo check
|
||||||
cargo test
|
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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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+/);
|
||||||
|
}
|
||||||
Generated
+74
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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
@@ -16,11 +16,7 @@ use axum::{
|
|||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use serde::{Deserialize, de::DeserializeOwned};
|
use serde::{Deserialize, de::DeserializeOwned};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tower_http::{
|
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
|
||||||
services::ServeDir,
|
|
||||||
set_header::SetResponseHeaderLayer,
|
|
||||||
trace::TraceLayer,
|
|
||||||
};
|
|
||||||
use tracing::{error, warn};
|
use tracing::{error, warn};
|
||||||
|
|
||||||
use crate::domain::{DomainError, SessionUser};
|
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}/edit", post(edit_meal))
|
||||||
.route("/meals/{meal_id}/delete", post(delete_meal))
|
.route("/meals/{meal_id}/delete", post(delete_meal))
|
||||||
.route("/meals/{meal_id}/ingredients", post(add_ingredient))
|
.route("/meals/{meal_id}/ingredients", post(add_ingredient))
|
||||||
.route("/meals/{meal_id}/ingredients/{ingredient_id}/edit", post(edit_ingredient))
|
.route(
|
||||||
.route("/meals/{meal_id}/ingredients/{ingredient_id}/delete", post(delete_ingredient))
|
"/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}/add-meal", post(add_meal_to_list))
|
||||||
.route("/lists/{list_id}/stream", get(list_stream))
|
.route("/lists/{list_id}/stream", get(list_stream))
|
||||||
.route("/invite/{token}", get(invitation_page))
|
.route("/invite/{token}", get(invitation_page))
|
||||||
|
|||||||
+2
-2
@@ -25,9 +25,9 @@ use crate::ports::{
|
|||||||
use crate::security::{Argon2PasswordHasher, RandomTokenGenerator};
|
use crate::security::{Argon2PasswordHasher, RandomTokenGenerator};
|
||||||
use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode};
|
use crate::services::{AuthService, InvitationService, ListService, MealService, RegistrationMode};
|
||||||
use crate::sqlite::{
|
use crate::sqlite::{
|
||||||
SqliteCategoryRepository, SqliteInvitationRepository, SqliteItemRepository,
|
SqliteCategoryRepository, SqliteDatabase, SqliteInvitationRepository, SqliteItemRepository,
|
||||||
SqliteListRepository, SqliteMealIngredientRepository, SqliteMealRepository,
|
SqliteListRepository, SqliteMealIngredientRepository, SqliteMealRepository,
|
||||||
SqliteSessionRepository, SqliteDatabase, SqliteUserRepository,
|
SqliteSessionRepository, SqliteUserRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|||||||
Reference in New Issue
Block a user