From a0457496731829057e0c57e8c2f69cd0bc059b42 Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" Date: Wed, 18 Mar 2026 02:21:01 +0000 Subject: [PATCH 1/4] 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 --- .github/workflows/ci.yml | 39 ++++++++++ README.md | 13 ++++ apps/e2e/.gitignore | 3 + apps/e2e/package.json | 14 ++++ apps/e2e/playwright.config.ts | 33 ++++++++ apps/e2e/tests/book.spec.ts | 120 ++++++++++++++++++++++++++++++ apps/e2e/tests/clients.spec.ts | 59 +++++++++++++++ apps/e2e/tests/navigation.spec.ts | 61 +++++++++++++++ pnpm-lock.yaml | 41 ++++++++++ 9 files changed, 383 insertions(+) create mode 100644 apps/e2e/.gitignore create mode 100644 apps/e2e/package.json create mode 100644 apps/e2e/playwright.config.ts create mode 100644 apps/e2e/tests/book.spec.ts create mode 100644 apps/e2e/tests/clients.spec.ts create mode 100644 apps/e2e/tests/navigation.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b84615..c50089e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 4f8852e..fe93200 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/e2e/.gitignore b/apps/e2e/.gitignore new file mode 100644 index 0000000..945fcd0 --- /dev/null +++ b/apps/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +playwright-report/ +test-results/ diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..e86ffc1 --- /dev/null +++ b/apps/e2e/package.json @@ -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" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 0000000..c25a7d2 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -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"] }, + }, + ], +}); diff --git a/apps/e2e/tests/book.spec.ts b/apps/e2e/tests/book.spec.ts new file mode 100644 index 0000000..4a76f43 --- /dev/null +++ b/apps/e2e/tests/book.spec.ts @@ -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(); +}); diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts new file mode 100644 index 0000000..2d2a2ee --- /dev/null +++ b/apps/e2e/tests/clients.spec.ts @@ -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(); +}); diff --git a/apps/e2e/tests/navigation.spec.ts b/apps/e2e/tests/navigation.spec.ts new file mode 100644 index 0000000..381cb12 --- /dev/null +++ b/apps/e2e/tests/navigation.spec.ts @@ -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(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1905a6..324ce26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: -- 2.52.0 From 6e087cc16becc55c8fd51cb67b059ec3de7522af Mon Sep 17 00:00:00 2001 From: "groombook-cto[bot]" Date: Wed, 18 Mar 2026 02:28:26 +0000 Subject: [PATCH 2/4] Fix pets API mock route in clients E2E test The pets endpoint is /api/pets?clientId=... not /api/clients/*/pets. Co-Authored-By: Paperclip --- apps/e2e/tests/clients.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 2d2a2ee..5891671 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -34,7 +34,7 @@ test.beforeEach(async ({ page }) => { route.fulfill({ json: MOCK_CLIENTS }) ); // Pets loaded when a client is selected - await page.route("/api/clients/*/pets", (route) => + await page.route("/api/pets**", (route) => route.fulfill({ json: [] }) ); }); -- 2.52.0 From aa3b080c05d9d1774cb4549c2a23999b0210ffac Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Wed, 18 Mar 2026 02:44:04 +0000 Subject: [PATCH 3/4] fix: exclude e2e workspace from root pnpm test command The root `pnpm test` runs across all workspaces. The apps/e2e workspace requires Playwright browsers to be installed before tests can run, but the unit-test CI job does not install them. Exclude @groombook/e2e from the root test command so E2E tests only run in the dedicated e2e CI job. Co-Authored-By: Paperclip --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b984ebf..463a4ae 100644 --- a/package.json +++ b/package.json @@ -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" }, -- 2.52.0 From c957ee2504c2ca78fc3b0397f5d3c66d70a824b3 Mon Sep 17 00:00:00 2001 From: Groom Book CTO Date: Wed, 18 Mar 2026 02:50:17 +0000 Subject: [PATCH 4/4] fix(e2e): fix strict mode violation in clients detail test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After clicking a client, their email appears in both the list row and the detail panel — causing a strict-mode violation with toBeVisible(). Use toHaveCount(2) instead to assert the detail panel is open. Co-Authored-By: Paperclip --- apps/e2e/tests/clients.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/e2e/tests/clients.spec.ts b/apps/e2e/tests/clients.spec.ts index 5891671..8e94e1a 100644 --- a/apps/e2e/tests/clients.spec.ts +++ b/apps/e2e/tests/clients.spec.ts @@ -54,6 +54,6 @@ 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(); + // Email appears in both the list row and the detail panel once selected + await expect(page.getByText("alice@example.com")).toHaveCount(2); }); -- 2.52.0