Add Playwright E2E testing #43

Merged
ghost merged 4 commits from feat/playwright-e2e into main 2026-03-18 02:52:59 +00:00
10 changed files with 384 additions and 1 deletions
+39
View File
@@ -48,6 +48,45 @@ jobs:
- name: Run tests
run: pnpm test
e2e:
name: E2E Tests
runs-on: ubuntu-latest
needs: [lint-typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
run: pnpm --filter @groombook/e2e exec playwright install --with-deps chromium
- name: Start Docker Compose stack
run: docker compose up -d --wait
timeout-minutes: 5
- name: Run E2E tests
run: pnpm --filter @groombook/e2e test
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7
- name: Stop Docker Compose stack
if: always()
run: docker compose down
build:
name: Build
runs-on: ubuntu-latest
+13
View File
@@ -85,9 +85,22 @@ PORT=3000
### Running Tests
```bash
# Unit tests (vitest)
pnpm test
# E2E tests (Playwright) — requires the full Docker Compose stack to be running
docker compose up -d --wait
pnpm --filter @groombook/e2e test
# Open the Playwright UI (interactive test runner)
pnpm --filter @groombook/e2e test:ui
# View the last E2E test report
pnpm --filter @groombook/e2e test:report
```
E2E tests target the Docker Compose stack (`http://localhost:8080`). They use API route mocking where needed so happy-path tests are deterministic without requiring seed data.
### Building
```bash
+3
View File
@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@groombook/e2e",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.50.1"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for Groom Book E2E tests.
*
* Targets the Docker Compose stack:
* - Web: http://localhost:8080
* - API: http://localhost:3000 (proxied via the web container's nginx)
*
* Run locally:
* docker compose up -d
* pnpm --filter @groombook/e2e test
*/
export default defineConfig({
testDir: "./tests",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://localhost:8080",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+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/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();
// Email appears in both the list row and the detail panel once selected
await expect(page.getByText("alice@example.com")).toHaveCount(2);
});
+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();
});
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test",
"test": "pnpm -r --filter='!@groombook/e2e' test",
"db:migrate": "pnpm --filter @groombook/db migrate",
"db:seed": "pnpm --filter @groombook/db seed"
},
+41
View File
@@ -69,6 +69,12 @@ importers:
specifier: ^3.0.4
version: 3.2.4(@types/node@22.19.15)(jsdom@26.1.0)(terser@5.46.1)(tsx@4.21.0)
apps/e2e:
devDependencies:
'@playwright/test':
specifier: ^1.50.1
version: 1.58.2
apps/web:
dependencies:
'@groombook/types':
@@ -1409,6 +1415,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -2331,6 +2342,11 @@ packages:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2890,6 +2906,16 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -4706,6 +4732,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/plugin-babel@5.3.1(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@2.80.0)':
@@ -5716,6 +5746,9 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -6273,6 +6306,14 @@ snapshots:
picomatch@4.0.3: {}
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
possible-typed-array-names@1.1.0: {}
postcss@8.5.8: