feat: extract groombook/web from monorepo

- Copy apps/web/ with all src, components, pages, portal
- Inline packages/types/ as local packages/types module
- Add tsconfig path aliases for @groombook/types
- Port Dockerfile and CI workflow
- Image name: ghcr.io/groombook/web

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
groombook-engineer[bot]
2026-05-02 21:38:42 +00:00
parent e03d052ec6
commit 45ed3587ba
131 changed files with 22602 additions and 0 deletions
+15
View File
@@ -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"
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for GroomBook Web E2E tests.
*
* Targets the deployed dev environment at dev.groombook.dev.
* 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://dev.groombook.dev",
trace: "on-first-retry",
screenshot: "only-on-failure",
serviceWorkers: "block",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+50
View File
@@ -0,0 +1,50 @@
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).
* NOTE: Skipped because dev environment may have no report data in the last 60 days.
*/
test.describe("Admin Reports Data", () => {
test.skip("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.getByRole("heading", { name: "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);
// Verify StatCards render with values (may be $0 or 0 in dev with no data)
// The StatCards show: Revenue, Appointments, No-shows, Cancellations, New Clients
const statCardValues = staffPage.locator('[style*="fontSize: 26"]');
const count = await statCardValues.count();
expect(count).toBeGreaterThan(0);
});
});
+94
View File
@@ -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.skip("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<string>();
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.skip("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<string>();
const duplicates: string[] = [];
for (const name of names) {
if (seen.has(name)) {
duplicates.push(name);
}
seen.add(name);
}
expect(duplicates).toHaveLength(0);
});
});
+121
View File
@@ -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);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { test as base, Page, Browser, BrowserContext } from "@playwright/test";
import path from "path";
import fs from "fs";
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<string> {
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<string> {
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<string> {
const filePath = userType === "staff" ? STAFF_STORAGE : CLIENT_STORAGE;
const dir = path.dirname(filePath);
if (!fs.existsSync(filePath)) {
const state =
userType === "staff"
? await authenticateStaff(browser)
: await authenticateClient(browser);
fs.mkdirSync(dir, { recursive: true });
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<void> => {
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<void> => {
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";
+61
View File
@@ -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.skip("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.skip("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");
});
});
+89
View File
@@ -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.skip("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.getByRole("heading", { name: "Appointments" })
).toBeVisible();
});
test.skip("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.skip("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);
});
});