Add Playwright E2E testing infrastructure

- New apps/e2e workspace with @playwright/test
- playwright.config.ts targeting Docker Compose stack (http://localhost:8080)
- navigation.spec.ts: smoke tests for all pages
- book.spec.ts: full booking wizard happy-path with API mocking
- clients.spec.ts: client list and detail panel tests
- CI job: spins up docker compose, installs Playwright chromium, runs tests
- Playwright report uploaded as artifact on failure
- README docs for running E2E tests locally

Closes #40

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
groombook-cto[bot]
2026-03-18 02:21:01 +00:00
committed by Groom Book CTO
parent cba502e35f
commit a045749673
9 changed files with 383 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
import { test, expect } from "@playwright/test";
/**
* Booking portal happy-path E2E test.
*
* All API calls are mocked so this runs without a live backend.
* The test walks through all 4 steps of the booking wizard and
* verifies the confirmation screen is shown.
*/
const MOCK_SERVICE = {
id: "svc-1",
name: "Full Groom",
description: "Bath, dry, haircut, nail trim",
basePriceCents: 7500,
durationMinutes: 90,
isActive: true,
};
const MOCK_SLOT = "2026-03-20T09:00:00.000Z";
const MOCK_BOOKING_RESULT = {
appointment: {
id: "appt-1",
startTime: MOCK_SLOT,
endTime: "2026-03-20T10:30:00.000Z",
},
client: { id: "client-1", name: "Jane Smith", email: "jane@example.com" },
pet: { id: "pet-1", name: "Buddy" },
};
test("complete booking flow", async ({ page }) => {
// ── Mock API routes ──────────────────────────────────────────────────────
await page.route("/api/book/services", (route) =>
route.fulfill({ json: [MOCK_SERVICE] })
);
await page.route("/api/book/availability**", (route) =>
route.fulfill({ json: [MOCK_SLOT] })
);
await page.route("/api/book/appointments", (route) =>
route.fulfill({ status: 200, json: MOCK_BOOKING_RESULT })
);
// ── Step 1: Select a service ──────────────────────────────────────────────
await page.goto("/book");
await expect(page.getByText("Book an Appointment")).toBeVisible();
await expect(page.getByText("Choose a service")).toBeVisible();
// Wait for services to load and click the service card
await expect(page.getByText("Full Groom")).toBeVisible();
await page.getByText("Full Groom").click();
// ── Step 2: Pick date and time ────────────────────────────────────────────
await expect(page.getByText("Choose a date and time")).toBeVisible();
// Wait for the slot to appear and select it
await expect(page.getByRole("button", { name: /\d{1,2}:\d{2}/ })).toBeVisible();
await page.getByRole("button", { name: /\d{1,2}:\d{2}/ }).first().click();
await page.getByRole("button", { name: "Continue" }).click();
// ── Step 3: Enter contact info ────────────────────────────────────────────
await expect(page.getByText("Your information")).toBeVisible();
await page.getByPlaceholder("Jane Smith").fill("Jane Smith");
await page.getByPlaceholder("jane@example.com").fill("jane@example.com");
await page.getByPlaceholder("Buddy").fill("Buddy");
await page.locator("select").selectOption("dog");
await page.getByRole("button", { name: "Review booking" }).click();
// ── Step 4: Confirm booking ───────────────────────────────────────────────
await expect(page.getByText("Confirm your booking")).toBeVisible();
await expect(page.getByText("Full Groom")).toBeVisible();
await expect(page.getByText("Jane Smith")).toBeVisible();
await expect(page.getByText("Buddy")).toBeVisible();
await page.getByRole("button", { name: "Confirm booking" }).click();
// ── Step 5: Success screen ────────────────────────────────────────────────
await expect(page.getByText("Booking confirmed!")).toBeVisible();
await expect(page.getByText("jane@example.com")).toBeVisible();
await expect(page.getByRole("button", { name: "Book another appointment" })).toBeVisible();
});
test("booking form validation — required fields", async ({ page }) => {
await page.route("/api/book/services", (route) =>
route.fulfill({ json: [MOCK_SERVICE] })
);
await page.route("/api/book/availability**", (route) =>
route.fulfill({ json: [MOCK_SLOT] })
);
await page.goto("/book");
await page.getByText("Full Groom").click();
await page.getByRole("button", { name: /\d{1,2}:\d{2}/ }).first().click();
await page.getByRole("button", { name: "Continue" }).click();
// Submit without filling required fields
await page.getByRole("button", { name: "Review booking" }).click();
await expect(page.getByText("Please fill in all required fields.")).toBeVisible();
});
test("no services available — shows message", async ({ page }) => {
await page.route("/api/book/services", (route) =>
route.fulfill({ json: [] })
);
await page.goto("/book");
await expect(page.getByText("No services available")).toBeVisible();
});
+59
View File
@@ -0,0 +1,59 @@
import { test, expect } from "@playwright/test";
/**
* Client management E2E tests.
*
* API calls are mocked so tests run without a live backend.
*/
const MOCK_CLIENTS = [
{
id: "client-1",
name: "Alice Johnson",
email: "alice@example.com",
phone: "555-0101",
address: null,
notes: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "client-2",
name: "Bob Williams",
email: "bob@example.com",
phone: null,
address: null,
notes: null,
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
];
test.beforeEach(async ({ page }) => {
await page.route("/api/clients**", (route) =>
route.fulfill({ json: MOCK_CLIENTS })
);
// Pets loaded when a client is selected
await page.route("/api/clients/*/pets", (route) =>
route.fulfill({ json: [] })
);
});
test("clients page shows client list", async ({ page }) => {
await page.goto("/clients");
await expect(page.getByText("Alice Johnson")).toBeVisible();
await expect(page.getByText("Bob Williams")).toBeVisible();
});
test("clients page shows search input", async ({ page }) => {
await page.goto("/clients");
await expect(page.getByPlaceholder(/search/i)).toBeVisible();
});
test("clicking a client shows their details", async ({ page }) => {
await page.goto("/clients");
await expect(page.getByText("Alice Johnson")).toBeVisible();
await page.getByText("Alice Johnson").click();
// Client detail panel shows their email
await expect(page.getByText("alice@example.com")).toBeVisible();
});
+61
View File
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test";
/**
* Navigation smoke tests — verifies that each page loads without errors.
* These tests mock all API calls so they can run without a live backend.
*/
test.beforeEach(async ({ page }) => {
// Intercept all API calls and return empty defaults so pages render
await page.route("/api/**", (route) => {
const url = route.request().url();
if (url.includes("/api/book/services")) {
return route.fulfill({ json: [] });
}
// Appointments, clients, services, staff, invoices, reports, etc.
return route.fulfill({ json: [] });
});
});
test("appointments page loads", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Groom Book")).toBeVisible();
// Calendar/appointments view renders
await expect(page.locator("nav")).toBeVisible();
});
test("clients page loads", async ({ page }) => {
await page.goto("/clients");
await expect(page.getByText("Groom Book")).toBeVisible();
await expect(page.getByRole("link", { name: "Clients" })).toBeVisible();
});
test("services page loads", async ({ page }) => {
await page.goto("/services");
await expect(page.getByText("Groom Book")).toBeVisible();
await expect(page.getByRole("link", { name: "Services" })).toBeVisible();
});
test("staff page loads", async ({ page }) => {
await page.goto("/staff");
await expect(page.getByText("Groom Book")).toBeVisible();
await expect(page.getByRole("link", { name: "Staff" })).toBeVisible();
});
test("invoices page loads", async ({ page }) => {
await page.goto("/invoices");
await expect(page.getByText("Groom Book")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
});
test("reports page loads", async ({ page }) => {
await page.goto("/reports");
await expect(page.getByText("Groom Book")).toBeVisible();
await expect(page.getByRole("link", { name: "Reports" })).toBeVisible();
});
test("booking portal loads", async ({ page }) => {
await page.goto("/book");
await expect(page.getByText("Book an Appointment")).toBeVisible();
await expect(page.getByText("Choose a service")).toBeVisible();
});