Add Playwright E2E testing #43
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+41
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user