diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08f9243..be7a067 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,39 @@ jobs: ].join('\n') }); + web-e2e: + name: Web E2E (Dev) + runs-on: ubuntu-latest + needs: [docker, deploy-dev] + if: github.event_name == 'pull_request' + 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/web exec playwright install --with-deps chromium + + - name: Run Web E2E tests + run: pnpm --filter @groombook/web test:e2e + timeout-minutes: 10 + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-web-e2e-report + path: apps/web/playwright-report/ + retention-days: 7 + cd: name: Update Infra Image Tags runs-on: ubuntu-latest diff --git a/apps/web/e2e/package.json b/apps/web/e2e/package.json new file mode 100644 index 0000000..3f95e23 --- /dev/null +++ b/apps/web/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "@groombook/web-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" + }, + "license": "AGPL-3.0-only" +} \ No newline at end of file diff --git a/apps/web/e2e/playwright.config.ts b/apps/web/e2e/playwright.config.ts new file mode 100644 index 0000000..fad7857 --- /dev/null +++ b/apps/web/e2e/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for GroomBook Web E2E tests. + * + * Targets the deployed dev environment at groombook.dev.farh.net. + * Uses the dev login selector (/login) for authentication — no hardcoded credentials. + * + * Run locally: + * pnpm --filter @groombook/web-e2e test + * + * CI: Runs on every PR targeting main, blocking merge on failure. + */ +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: "https://groombook.dev.farh.net", + trace: "on-first-retry", + screenshot: "only-on-failure", + serviceWorkers: "block", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); \ No newline at end of file diff --git a/apps/web/e2e/tests/admin-reports.spec.ts b/apps/web/e2e/tests/admin-reports.spec.ts new file mode 100644 index 0000000..f79fe7c --- /dev/null +++ b/apps/web/e2e/tests/admin-reports.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E test: Reports Data (GRO-306) + * + * Verifies that the reports page loads with non-zero data when date range + * is set to the last 60 days. + * + * This test runs against current dev state (no GRO-300 dependency). + */ +test.describe("Admin Reports Data", () => { + test("reports page shows non-zero data for last 60 days", async ({ + staffPage, + }) => { + await staffPage.goto("/admin/reports"); + await staffPage.waitForLoadState("networkidle"); + + // Wait for reports to load + await expect(staffPage.getByText("Reports")).toBeVisible({ timeout: 10000 }); + + // Calculate 60 days ago date + const today = new Date(); + const sixtyDaysAgo = new Date(); + sixtyDaysAgo.setDate(today.getDate() - 60); + + const formatDate = (d: Date) => d.toISOString().slice(0, 10); + + // Set the date range to last 60 days + // The page has "From" and "To" date inputs + const fromInput = staffPage.locator('input[type="date"]').first(); + const toInput = staffPage.locator('input[type="date"]').nth(1); + + await fromInput.fill(formatDate(sixtyDaysAgo)); + await toInput.fill(formatDate(today)); + + // Click Refresh to reload the report + await staffPage.getByRole("button", { name: /refresh/i }).click(); + + // Wait for data to reload + await staffPage.waitForLoadState("networkidle"); + await staffPage.waitForTimeout(1000); + + // At least one StatCard should show non-zero data + // The StatCards show: Revenue, Appointments, No-shows, Cancellations, New Clients + // We look for any card where the main value is not "0" or "$0.00" + const statCardValues = staffPage.locator('[style*="fontSize: 26"]'); + const count = await statCardValues.count(); + expect(count).toBeGreaterThan(0); + + const hasNonZero = await staffPage.evaluate(() => { + const cards = document.querySelectorAll('[style*="fontSize: 26"]'); + for (const card of Array.from(cards)) { + const text = card.textContent?.trim() ?? ""; + // Check if it's a non-zero value + if (text !== "0" && text !== "$0.00") { + return true; + } + } + return false; + }); + + expect(hasNonZero).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/apps/web/e2e/tests/admin-services.spec.ts b/apps/web/e2e/tests/admin-services.spec.ts new file mode 100644 index 0000000..e840580 --- /dev/null +++ b/apps/web/e2e/tests/admin-services.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E test: Services Deduplication (GRO-306) + * + * Verifies there are no duplicate service names in: + * 1. The admin services table (/admin/services) + * 2. The booking wizard service picker (/admin/book) + * + * This test runs against current dev state (no GRO-300 dependency). + */ +test.describe("Admin Services Deduplication", () => { + test("admin services table has no duplicate names", async ({ + staffPage, + }) => { + await staffPage.goto("/admin/services"); + await staffPage.waitForLoadState("networkidle"); + + // Wait for the table to load + await expect(staffPage.locator("table")).toBeVisible({ timeout: 10000 }); + + // Collect all service name cells from the Name column (first column) + const nameCells = staffPage.locator("table tbody tr td:first-child"); + const count = await nameCells.count(); + expect(count).toBeGreaterThan(0); + + const names: string[] = []; + for (let i = 0; i < count; i++) { + const text = (await nameCells.nth(i).textContent())?.trim() ?? ""; + names.push(text); + } + + // Check for duplicates + const seen = new Set(); + const duplicates: string[] = []; + for (const name of names) { + if (name === "—") continue; // skip empty/placeholder + if (seen.has(name)) { + duplicates.push(name); + } + seen.add(name); + } + + expect(duplicates).toHaveLength(0); + }); + + test("booking wizard service picker has no duplicate names", async ({ + staffPage, + }) => { + await staffPage.goto("/admin/book"); + await staffPage.waitForLoadState("networkidle"); + + // Wait for services to load + await expect( + staffPage.getByText("Choose a service") + ).toBeVisible({ timeout: 10000 }); + + // Wait a bit for the services to render + await staffPage.waitForTimeout(1000); + + // Collect all service names from the service cards + // Each service card shows the name in a div with fontWeight 600 + const serviceNames = await staffPage + .locator("text=/^[^-].*$/") // rough: get text nodes that aren't empty + .all(); + + // More precise: get service name elements from the service cards + // The service cards have div > div:first-child with the name + const cards = staffPage.locator('[role="button"]'); + const cardCount = await cards.count(); + expect(cardCount).toBeGreaterThan(0); + + const names: string[] = []; + for (let i = 0; i < cardCount; i++) { + const card = cards.nth(i); + // The name is in the first child div with fontWeight 600 + const nameEl = card.locator("div").first(); + const text = (await nameEl.textContent())?.trim() ?? ""; + if (text) names.push(text); + } + + // Check for duplicates + const seen = new Set(); + const duplicates: string[] = []; + for (const name of names) { + if (seen.has(name)) { + duplicates.push(name); + } + seen.add(name); + } + + expect(duplicates).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/apps/web/e2e/tests/console-health.spec.ts b/apps/web/e2e/tests/console-health.spec.ts new file mode 100644 index 0000000..6652266 --- /dev/null +++ b/apps/web/e2e/tests/console-health.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E test: Baseline Console Health (GRO-306) + * + * Verifies baseline console health on initial page load for both + * admin and portal views: + * - No 404s for favicon or PWA assets + * - No uncaught JS exceptions on initial render + * + * This test runs against current dev state (no GRO-300 dependency). + */ +test.describe("Baseline Console Health", () => { + test("admin page has no console errors on initial load", async ({ + staffPage, + }) => { + const errors: string[] = []; + const failedRequests: string[] = []; + + staffPage.on("console", (msg) => { + if (msg.type() === "error") { + errors.push(msg.text()); + } + }); + + staffPage.on("requestfailed", (request) => { + const url = request.url(); + // Only care about asset failures, not API failures (which may be expected in dev) + if ( + url.includes("favicon") || + url.includes(".ico") || + url.includes("manifest") || + url.includes(".js") || + url.includes(".css") || + url.includes(".png") || + url.includes(".svg") + ) { + failedRequests.push(`${request.failure()?.errorText} — ${url}`); + } + }); + + await staffPage.goto("/admin"); + await staffPage.waitForLoadState("networkidle"); + + // Filter out non-critical errors + const criticalErrors = errors.filter( + (e) => + !e.includes("favicon") && + !e.includes("net::ERR_") && + !e.includes("Failed to load resource") + ); + + expect(criticalErrors).toHaveLength(0); + expect(failedRequests).toHaveLength(0); + }); + + test("portal page has no console errors on initial load", async ({ + clientPage, + }) => { + const errors: string[] = []; + const failedRequests: string[] = []; + + clientPage.on("console", (msg) => { + if (msg.type() === "error") { + errors.push(msg.text()); + } + }); + + clientPage.on("requestfailed", (request) => { + const url = request.url(); + if ( + url.includes("favicon") || + url.includes(".ico") || + url.includes("manifest") || + url.includes(".js") || + url.includes(".css") || + url.includes(".png") || + url.includes(".svg") + ) { + failedRequests.push(`${request.failure()?.errorText} — ${url}`); + } + }); + + await clientPage.goto("/"); + await clientPage.waitForLoadState("networkidle"); + + const criticalErrors = errors.filter( + (e) => + !e.includes("favicon") && + !e.includes("net::ERR_") && + !e.includes("Failed to load resource") + ); + + expect(criticalErrors).toHaveLength(0); + expect(failedRequests).toHaveLength(0); + }); + + test("no 404s for favicon or PWA assets", async ({ staffPage }) => { + const notFound: string[] = []; + + staffPage.on("response", (response) => { + const status = response.status(); + const url = response.url(); + if ( + status === 404 && + (url.includes("favicon") || + url.includes(".ico") || + url.includes("manifest") || + url.includes("sw.js") || + url.includes("workbox")) + ) { + notFound.push(url); + } + }); + + await staffPage.goto("/admin"); + await staffPage.waitForLoadState("networkidle"); + + expect(notFound).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/apps/web/e2e/tests/fixtures.ts b/apps/web/e2e/tests/fixtures.ts new file mode 100644 index 0000000..c2d1316 --- /dev/null +++ b/apps/web/e2e/tests/fixtures.ts @@ -0,0 +1,110 @@ +import { test as base, Page, Browser, BrowserContext } from "@playwright/test"; +import path from "path"; + +const STAFF_STORAGE = path.join(process.cwd(), ".auth/staff.json"); +const CLIENT_STORAGE = path.join(process.cwd(), ".auth/client.json"); + +/** + * Authenticates as a staff user via the dev login selector and saves storage state. + */ +async function authenticateStaff(browser: Browser): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("/login"); + + // Click "Alice Groomer" (first staff user) + const alice = page.getByText("Alice Groomer"); + if (await alice.isVisible({ timeout: 5000 })) { + await alice.click(); + } else { + // Fallback: click any staff user + await page.getByText(/groomer|manager/i).first().click(); + } + + await page.waitForURL(/\/(admin|portal)/, { timeout: 10000 }); + + const storageState = await context.storageState(); + await context.close(); + return JSON.stringify(storageState); +} + +/** + * Authenticates as a client via the dev login selector and saves storage state. + */ +async function authenticateClient(browser: Browser): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto("/login"); + + // Click "Carol Client" (first client user) + const carol = page.getByText("Carol Client"); + if (await carol.isVisible({ timeout: 5000 })) { + await carol.click(); + } else { + // Fallback: click any client user + await page.getByText(/\d+ pets?/i).first().click(); + } + + await page.waitForURL(/\//, { timeout: 10000 }); + await page.waitForLoadState("networkidle"); + + const storageState = await context.storageState(); + await context.close(); + return JSON.stringify(storageState); +} + +export type UserType = "staff" | "client"; + +/** + * Returns the storage state file path for the given user type. + * Creates the auth file if it doesn't exist. + */ +async function getStorageState(browser: Browser, userType: UserType): Promise { + const filePath = userType === "staff" ? STAFF_STORAGE : CLIENT_STORAGE; + const dir = path.dirname(filePath); + + if (!require("fs").existsSync(filePath)) { + const state = + userType === "staff" + ? await authenticateStaff(browser) + : await authenticateClient(browser); + + require("fs").mkdirSync(dir, { recursive: true }); + require("fs").writeFileSync(filePath, state); + } + + return filePath; +} + +/** + * Custom test fixture that provides an authenticated page for E2E tests. + * Automatically handles login via the dev login selector. + * + * Usage: + * test("my test", async ({ staffPage }) => { ... }); // staff user + * test("my test", async ({ clientPage }) => { ... }); // client user + */ +export const test = base.extend<{ + staffPage: Page; + clientPage: Page; +}>({ + staffPage: async ({ browser }, use, workerInfo): Promise => { + const storageStatePath = await getStorageState(browser, "staff"); + const context = await browser.newContext({ storageState: storageStatePath }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, + + clientPage: async ({ browser }, use, workerInfo): Promise => { + const storageStatePath = await getStorageState(browser, "client"); + const context = await browser.newContext({ storageState: storageStatePath }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); + +export { expect } from "@playwright/test"; \ No newline at end of file diff --git a/apps/web/e2e/tests/portal-auth.spec.ts b/apps/web/e2e/tests/portal-auth.spec.ts new file mode 100644 index 0000000..2208975 --- /dev/null +++ b/apps/web/e2e/tests/portal-auth.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E test: Client Portal Auth (GRO-306 / GRO-300) + * + * Verifies that after logging in as a client via the dev login selector, + * the portal displays the client's actual name (not "Hi, Guest" or "Please sign in"). + * + * DEPENDENCY: Requires GRO-300 to be deployed to dev. This test will only + * pass once the portal auth fix (proper session → customer name resolution) lands. + * + * Journey: + * 1. Navigate to /login + * 2. Select a client (Carol Client or any available client) + * 3. Navigate to / + * 4. Assert: heading contains client name (NOT "Hi, Guest" or "Please sign in") + * 5. Assert: portal dashboard section renders with actual content + */ +test.describe("Client Portal Auth", () => { + test("portal shows client name after login, not 'Hi, Guest'", async ({ + clientPage, + }) => { + await clientPage.goto("/"); + + // Wait for the portal to fully load + await clientPage.waitForLoadState("networkidle"); + + // The portal heading should contain the logged-in client's name, not "Guest" + // We check for either the client name being present OR the anti-patterns being absent + const bodyText = await clientPage.textContent("body"); + + // Assert the anti-patterns are NOT present + await expect(clientPage.locator("text=Please sign in")).not.toBeVisible({ + timeout: 5000, + }); + + // The portal should show something other than "Hi, Guest" + // If the session is properly loaded, it should show the actual client name + // We check that "Hi, Guest" is NOT visible + const hiGuest = clientPage.locator("text=Hi, Guest"); + await expect(hiGuest).not.toBeVisible({ timeout: 5000 }); + + // The portal dashboard should be visible — the nav and main content area + await expect(clientPage.locator("nav")).toBeVisible(); + }); + + test("portal dashboard section renders with content", async ({ clientPage }) => { + await clientPage.goto("/"); + await clientPage.waitForLoadState("networkidle"); + + // Check that the dashboard/home section renders + // The portal has a nav with items like "Home", "Appointments", etc. + const nav = clientPage.locator("nav"); + await expect(nav).toBeVisible(); + + // The greeting should NOT be the static mock default + // After GRO-300, it should reflect the actual logged-in client + const pageContent = await clientPage.textContent("body"); + expect(pageContent).not.toContain("Please sign in"); + }); +}); \ No newline at end of file diff --git a/apps/web/e2e/tests/portal-data.spec.ts b/apps/web/e2e/tests/portal-data.spec.ts new file mode 100644 index 0000000..92de4e8 --- /dev/null +++ b/apps/web/e2e/tests/portal-data.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E test: Portal Data Integrity (GRO-306) + * + * Verifies that the client portal sections render correctly with actual data + * and don't show auth-gate messages after login. + * + * DEPENDENCY: Requires GRO-300 to be deployed. Tests 1 & 2 share this dependency. + * + * Journey: + * 1. Login as client + * 2. Navigate to appointments section — assert no "Please sign in", content renders + * 3. Navigate to pets section — assert content renders (or explicit empty state) + * 4. Navigate to billing section — assert no JS errors, section renders + */ +test.describe("Portal Data Integrity", () => { + test.beforeEach(async ({ clientPage }) => { + await clientPage.goto("/"); + await clientPage.waitForLoadState("networkidle"); + }); + + test("appointments section renders without auth gate", async ({ + clientPage, + }) => { + // Click the Appointments nav item + const appointmentsNav = clientPage.getByRole("button", { name: /appointments/i }); + await appointmentsNav.click(); + await clientPage.waitForLoadState("networkidle"); + + // Must NOT show "Please sign in" gate + await expect( + clientPage.locator("text=Please sign in") + ).not.toBeVisible({ timeout: 5000 }); + + // The section heading or nav should indicate we're in appointments + await expect( + clientPage.locator("text=Appointments") + ).toBeVisible(); + }); + + test("pets section renders with content or explicit empty state", async ({ + clientPage, + }) => { + // Click the My Pets nav item + const petsNav = clientPage.getByRole("button", { name: /my pets/i }); + await petsNav.click(); + await clientPage.waitForLoadState("networkidle"); + + // Must NOT show auth gate + await expect( + clientPage.locator("text=Please sign in") + ).not.toBeVisible({ timeout: 5000 }); + + // Should show either pet content or a legitimate empty state + const hasPetsContent = + (await clientPage.locator("text=Add a pet").isVisible()) || + (await clientPage.locator("text=No pets").isVisible()) || + (await clientPage.locator('[role="button"]').count()) > 0; + + expect(hasPetsContent).toBeTruthy(); + }); + + test("billing section renders without JS errors", async ({ clientPage }) => { + // Capture console errors + const consoleErrors: string[] = []; + clientPage.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Click the Billing nav item + const billingNav = clientPage.getByRole("button", { name: /billing/i }); + await billingNav.click(); + await clientPage.waitForLoadState("networkidle"); + + // Must NOT show auth gate + await expect( + clientPage.locator("text=Please sign in") + ).not.toBeVisible({ timeout: 5000 }); + + // No JS exceptions on this section + const jsExceptions = consoleErrors.filter( + (e) => !e.includes("favicon") && !e.includes("404") + ); + expect(jsExceptions).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index bab5329..2cf5416 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "test:e2e": "playwright test -c e2e/playwright.config.ts" }, "dependencies": { "@groombook/types": "workspace:*", @@ -23,6 +24,7 @@ "tailwindcss": "^4.2.2" }, "devDependencies": { + "@playwright/test": "^1.50.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1",