From 8c59b3bd6740e3934ccb48539582b5ab45a4fba0 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Tue, 13 Jan 2026 09:38:24 +0000 Subject: [PATCH] Add self-contained e2e test harness with ephemeral PocketBase Previously, 15 e2e tests were skipped because TEST_USER_EMAIL and TEST_USER_PASSWORD env vars weren't set. Now the test harness: - Starts a fresh PocketBase instance in /tmp on port 8091 - Creates admin user, collections, and API rules automatically - Seeds test user with period data for authenticated tests - Cleans up temp directory after tests complete Also fixes: - Override toggle tests now use checkbox role (not button) - Adds proper wait for OVERRIDES section before testing toggles - Suppresses document.cookie lint warning with explanation Test results: 64 e2e tests pass, 1014 unit tests pass Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + e2e/calendar.spec.ts | 7 +- e2e/dashboard.spec.ts | 47 +++-- e2e/global-setup.ts | 32 ++++ e2e/global-teardown.ts | 54 ++++++ e2e/period-logging.spec.ts | 16 +- e2e/pocketbase-harness.test.ts | 140 +++++++++++++++ e2e/pocketbase-harness.ts | 308 +++++++++++++++++++++++++++++++++ playwright.config.ts | 13 ++ scripts/setup-db.ts | 4 +- src/lib/pocketbase.ts | 3 + vitest.config.ts | 6 +- 12 files changed, 603 insertions(+), 28 deletions(-) create mode 100644 e2e/global-setup.ts create mode 100644 e2e/global-teardown.ts create mode 100644 e2e/pocketbase-harness.test.ts create mode 100644 e2e/pocketbase-harness.ts diff --git a/.gitignore b/.gitignore index fcd99c4..b81a450 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # playwright /playwright-report/ /test-results/ +e2e/.harness-state.json # next.js /.next/ diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 9732629..cba589f 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -47,8 +47,11 @@ test.describe("calendar", () => { }); test("displays calendar page with heading", async ({ page }) => { - // Check for calendar heading - const heading = page.getByRole("heading", { name: /calendar/i }); + // Check for the main calendar heading (h1) + const heading = page.getByRole("heading", { + name: "Calendar", + exact: true, + }); await expect(heading).toBeVisible(); }); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index c609aff..8d0f66c 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -81,45 +81,60 @@ test.describe("dashboard", () => { test("shows override toggles when user has period data", async ({ page, }) => { + // Wait for dashboard data to load + await page.waitForLoadState("networkidle"); + // Override toggles should be visible if user has period data - const overrideSection = page.getByRole("button", { - name: /flare|stress|sleep|pms/i, + const overrideCheckbox = page.getByRole("checkbox", { + name: /flare mode|high stress|poor sleep|pms/i, }); // These may not be visible if user hasn't set up period date - const hasOverrides = await overrideSection + const hasOverrides = await overrideCheckbox .first() .isVisible() .catch(() => false); if (hasOverrides) { - await expect(overrideSection.first()).toBeVisible(); + await expect(overrideCheckbox.first()).toBeVisible(); } }); - test("can toggle override buttons", async ({ page }) => { - // Find an override toggle button - const toggleButton = page - .getByRole("button", { name: /flare|stress|sleep|pms/i }) + test("can toggle override checkboxes", async ({ page }) => { + // Wait for the OVERRIDES section to appear (indicates dashboard data loaded) + const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" }); + const hasOverridesSection = await overridesHeading + .waitFor({ timeout: 10000 }) + .then(() => true) + .catch(() => false); + + if (!hasOverridesSection) { + test.skip(); + return; + } + + // Find an override toggle checkbox (Flare Mode, High Stress, etc.) + const toggleCheckbox = page + .getByRole("checkbox", { + name: /flare mode|high stress|poor sleep|pms/i, + }) .first(); - const hasToggle = await toggleButton.isVisible().catch(() => false); + const hasToggle = await toggleCheckbox.isVisible().catch(() => false); if (hasToggle) { // Get initial state - const initialPressed = await toggleButton.getAttribute("aria-pressed"); + const initialChecked = await toggleCheckbox.isChecked(); // Click the toggle - await toggleButton.click(); + await toggleCheckbox.click(); // Wait a moment for the API call await page.waitForTimeout(500); - // Toggle should change state (or show error) - const afterPressed = await toggleButton.getAttribute("aria-pressed"); - - // Either state changed or we should see some feedback - expect(afterPressed !== initialPressed || true).toBe(true); + // Toggle should change state + const afterChecked = await toggleCheckbox.isChecked(); + expect(afterChecked).not.toBe(initialChecked); } else { test.skip(); } diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..6bc2e16 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,32 @@ +// ABOUTME: Playwright global setup - starts PocketBase and sets test environment variables. +// ABOUTME: Runs before all e2e tests to provide a fresh database with test data. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { DEFAULT_CONFIG, start } from "./pocketbase-harness"; + +const STATE_FILE = path.join(__dirname, ".harness-state.json"); + +export default async function globalSetup(): Promise { + console.log("Starting PocketBase for e2e tests..."); + + const state = await start(DEFAULT_CONFIG); + + // Save state for teardown + fs.writeFileSync( + STATE_FILE, + JSON.stringify({ + dataDir: state.dataDir, + url: state.url, + pid: state.process.pid, + }), + ); + + // Set environment variables for the test process + process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url; + process.env.POCKETBASE_URL = state.url; + process.env.TEST_USER_EMAIL = DEFAULT_CONFIG.testUserEmail; + process.env.TEST_USER_PASSWORD = DEFAULT_CONFIG.testUserPassword; + + console.log(`PocketBase running at ${state.url}`); + console.log(`Test user: ${DEFAULT_CONFIG.testUserEmail}`); +} diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 0000000..fff95fb --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,54 @@ +// ABOUTME: Playwright global teardown - stops PocketBase and cleans up temp data. +// ABOUTME: Runs after all e2e tests to ensure clean shutdown. +import * as fs from "node:fs"; +import * as path from "node:path"; + +const STATE_FILE = path.join(__dirname, ".harness-state.json"); + +interface HarnessStateFile { + dataDir: string; + url: string; + pid: number; +} + +export default async function globalTeardown(): Promise { + console.log("Stopping PocketBase..."); + + // Read the saved state + if (!fs.existsSync(STATE_FILE)) { + console.log("No harness state file found, nothing to clean up."); + return; + } + + const state: HarnessStateFile = JSON.parse( + fs.readFileSync(STATE_FILE, "utf-8"), + ); + + // Kill the PocketBase process + if (state.pid) { + try { + process.kill(state.pid, "SIGTERM"); + // Wait for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 500)); + // Force kill if still running + try { + process.kill(state.pid, "SIGKILL"); + } catch { + // Process already dead, which is fine + } + } catch { + // Process might already be dead + } + } + + // Clean up the temporary data directory + if (state.dataDir && fs.existsSync(state.dataDir)) { + fs.rmSync(state.dataDir, { recursive: true, force: true }); + console.log(`Cleaned up temp directory: ${state.dataDir}`); + } + + // Remove the state file + fs.unlinkSync(STATE_FILE); + + console.log("PocketBase stopped and cleaned up."); +} diff --git a/e2e/period-logging.spec.ts b/e2e/period-logging.spec.ts index 655873e..2005349 100644 --- a/e2e/period-logging.spec.ts +++ b/e2e/period-logging.spec.ts @@ -73,18 +73,20 @@ test.describe("period logging", () => { test("period history shows table or empty state", async ({ page }) => { await page.goto("/period-history"); + // Wait for loading to complete + await page.waitForLoadState("networkidle"); + // Look for either table or empty state message const table = page.getByRole("table"); - const emptyState = page.getByText(/no period|no data|start tracking/i); + const emptyState = page.getByText("No period history found"); + const totalText = page.getByText(/\d+ periods/); const hasTable = await table.isVisible().catch(() => false); - const hasEmpty = await emptyState - .first() - .isVisible() - .catch(() => false); + const hasEmpty = await emptyState.isVisible().catch(() => false); + const hasTotal = await totalText.isVisible().catch(() => false); - // Either should be present - expect(hasTable || hasEmpty).toBe(true); + // Either table, empty state, or total count should be present + expect(hasTable || hasEmpty || hasTotal).toBe(true); }); test("period history shows average cycle length if data exists", async ({ diff --git a/e2e/pocketbase-harness.test.ts b/e2e/pocketbase-harness.test.ts new file mode 100644 index 0000000..baa0568 --- /dev/null +++ b/e2e/pocketbase-harness.test.ts @@ -0,0 +1,140 @@ +// ABOUTME: Integration tests for the PocketBase e2e test harness. +// ABOUTME: Verifies the harness can start, setup, and stop PocketBase instances. +// @vitest-environment node + +import PocketBase from "pocketbase"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + DEFAULT_CONFIG, + getState, + type HarnessState, + start, + stop, +} from "./pocketbase-harness"; + +describe("pocketbase-harness", () => { + describe("start/stop lifecycle", () => { + let state: HarnessState; + + beforeAll(async () => { + state = await start(); + }, 60000); + + afterAll(async () => { + await stop(); + }); + + it("returns a valid harness state", () => { + expect(state).toBeDefined(); + expect(state.url).toBe(`http://127.0.0.1:${DEFAULT_CONFIG.port}`); + expect(state.dataDir).toContain("pocketbase-e2e-"); + expect(state.process).toBeDefined(); + }); + + it("getState returns the current state while running", () => { + const currentState = getState(); + expect(currentState).toBe(state); + }); + + it("PocketBase is accessible at the expected URL", async () => { + const response = await fetch(`${state.url}/api/health`); + expect(response.ok).toBe(true); + }); + + it("admin can authenticate", async () => { + const pb = new PocketBase(state.url); + pb.autoCancellation(false); + + const auth = await pb + .collection("_superusers") + .authWithPassword( + DEFAULT_CONFIG.adminEmail, + DEFAULT_CONFIG.adminPassword, + ); + + expect(auth.token).toBeDefined(); + }); + + it("test user can authenticate", async () => { + const pb = new PocketBase(state.url); + pb.autoCancellation(false); + + const auth = await pb + .collection("users") + .authWithPassword( + DEFAULT_CONFIG.testUserEmail, + DEFAULT_CONFIG.testUserPassword, + ); + + expect(auth.token).toBeDefined(); + expect(auth.record.email).toBe(DEFAULT_CONFIG.testUserEmail); + }); + + it("test user has period data configured", async () => { + const pb = new PocketBase(state.url); + pb.autoCancellation(false); + + await pb + .collection("users") + .authWithPassword( + DEFAULT_CONFIG.testUserEmail, + DEFAULT_CONFIG.testUserPassword, + ); + + const user = pb.authStore.record; + expect(user).toBeDefined(); + expect(user?.lastPeriodDate).toBeDefined(); + expect(user?.cycleLength).toBe(28); + expect(user?.timezone).toBe("UTC"); + }); + + it("period_logs collection exists with test data", async () => { + const pb = new PocketBase(state.url); + pb.autoCancellation(false); + + await pb + .collection("users") + .authWithPassword( + DEFAULT_CONFIG.testUserEmail, + DEFAULT_CONFIG.testUserPassword, + ); + + const userId = pb.authStore.record?.id; + const logs = await pb + .collection("period_logs") + .getList(1, 10, { filter: `user="${userId}"` }); + + expect(logs.totalItems).toBeGreaterThan(0); + expect(logs.items[0].startDate).toBeDefined(); + }); + + it("dailyLogs collection exists", async () => { + const pb = new PocketBase(state.url); + pb.autoCancellation(false); + + await pb + .collection("_superusers") + .authWithPassword( + DEFAULT_CONFIG.adminEmail, + DEFAULT_CONFIG.adminPassword, + ); + + const collections = await pb.collections.getFullList(); + const collectionNames = collections.map((c) => c.name); + + expect(collectionNames).toContain("period_logs"); + expect(collectionNames).toContain("dailyLogs"); + }); + }); + + describe("after stop", () => { + it("getState returns null after stop", async () => { + // Start and immediately stop + await start({ ...DEFAULT_CONFIG, port: 8092 }); + await stop(); + + const state = getState(); + expect(state).toBeNull(); + }, 60000); + }); +}); diff --git a/e2e/pocketbase-harness.ts b/e2e/pocketbase-harness.ts new file mode 100644 index 0000000..ab26991 --- /dev/null +++ b/e2e/pocketbase-harness.ts @@ -0,0 +1,308 @@ +// ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase. +// ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests. +import { type ChildProcess, execSync, spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import PocketBase from "pocketbase"; +import { + createCollection, + getExistingCollectionNames, + getMissingCollections, +} from "../scripts/setup-db"; + +/** + * Configuration for the test harness. + */ +export interface HarnessConfig { + port: number; + adminEmail: string; + adminPassword: string; + testUserEmail: string; + testUserPassword: string; +} + +/** + * Default configuration for e2e tests. + */ +export const DEFAULT_CONFIG: HarnessConfig = { + port: 8091, + adminEmail: "admin@e2e-test.local", + adminPassword: "admin-password-e2e-123", + testUserEmail: "e2e-test@phaseflow.local", + testUserPassword: "e2e-test-password-123", +}; + +/** + * State of a running PocketBase harness instance. + */ +export interface HarnessState { + process: ChildProcess; + dataDir: string; + url: string; + config: HarnessConfig; +} + +let currentState: HarnessState | null = null; + +/** + * Gets the URL for the PocketBase instance. + */ +function getPocketBaseUrl(port: number): string { + return `http://127.0.0.1:${port}`; +} + +/** + * Creates a temporary directory for PocketBase data. + */ +function createTempDataDir(): string { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pocketbase-e2e-")); + return tempDir; +} + +/** + * Waits for PocketBase to be ready by polling the health endpoint. + */ +async function waitForReady(url: string, timeoutMs = 30000): Promise { + const startTime = Date.now(); + const healthUrl = `${url}/api/health`; + + while (Date.now() - startTime < timeoutMs) { + try { + const response = await fetch(healthUrl); + if (response.ok) { + return; + } + } catch { + // Server not ready yet, continue polling + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`PocketBase did not become ready within ${timeoutMs}ms`); +} + +/** + * Creates the admin superuser using the PocketBase CLI. + */ +function createAdminUser( + dataDir: string, + email: string, + password: string, +): void { + execSync( + `pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`, + { + stdio: "pipe", + }, + ); +} + +/** + * Adds custom fields to the users collection. + */ +async function addUserFields(pb: PocketBase): Promise { + const usersCollection = await pb.collections.getOne("users"); + + // Define the custom user fields + const customFields = [ + { name: "garminConnected", type: "bool" }, + { name: "garminOauth1Token", type: "text" }, + { name: "garminOauth2Token", type: "text" }, + { name: "garminTokenExpiresAt", type: "date" }, + { name: "calendarToken", type: "text" }, + { name: "lastPeriodDate", type: "date" }, + { name: "cycleLength", type: "number" }, + { name: "notificationTime", type: "text" }, + { name: "timezone", type: "text" }, + { name: "activeOverrides", type: "json" }, + ]; + + // Get existing field names + const existingFieldNames = new Set( + (usersCollection.fields || []).map((f: { name: string }) => f.name), + ); + + // Filter to only new fields + const newFields = customFields.filter((f) => !existingFieldNames.has(f.name)); + + if (newFields.length > 0) { + // Combine existing fields with new ones + const allFields = [...(usersCollection.fields || []), ...newFields]; + + await pb.collections.update(usersCollection.id, { + fields: allFields, + }); + } +} + +/** + * Sets up API rules for collections to allow user access. + */ +async function setupApiRules(pb: PocketBase): Promise { + // Allow users to read/write their own period_logs + const periodLogs = await pb.collections.getOne("period_logs"); + await pb.collections.update(periodLogs.id, { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }); + + // Allow users to read/write their own dailyLogs + const dailyLogs = await pb.collections.getOne("dailyLogs"); + await pb.collections.update(dailyLogs.id, { + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: "user = @request.auth.id", + updateRule: "user = @request.auth.id", + deleteRule: "user = @request.auth.id", + }); +} + +/** + * Sets up the database collections using the SDK. + */ +async function setupCollections(pb: PocketBase): Promise { + // Add custom fields to users collection + await addUserFields(pb); + + // Create period_logs and dailyLogs collections + const existingNames = await getExistingCollectionNames(pb); + const missing = getMissingCollections(existingNames); + + for (const collection of missing) { + await createCollection(pb, collection); + } + + // Set up API rules + await setupApiRules(pb); +} + +/** + * Creates the test user with period data. + */ +async function createTestUser( + pb: PocketBase, + email: string, + password: string, +): Promise { + // Calculate date 14 days ago for mid-cycle test data + const fourteenDaysAgo = new Date(); + fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); + const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; + + // Create the test user + const user = await pb.collection("users").create({ + email, + password, + passwordConfirm: password, + emailVisibility: true, + verified: true, + lastPeriodDate, + cycleLength: 28, + timezone: "UTC", + }); + + // Create a period log entry + await pb.collection("period_logs").create({ + user: user.id, + startDate: lastPeriodDate, + }); + + return user.id; +} + +/** + * Starts a fresh PocketBase instance for e2e testing. + */ +export async function start( + config: HarnessConfig = DEFAULT_CONFIG, +): Promise { + if (currentState) { + throw new Error( + "PocketBase harness is already running. Call stop() first.", + ); + } + + const dataDir = createTempDataDir(); + const url = getPocketBaseUrl(config.port); + + // Start PocketBase process + const pbProcess = spawn( + "pocketbase", + ["serve", `--dir=${dataDir}`, `--http=127.0.0.1:${config.port}`], + { + stdio: "pipe", + detached: false, + }, + ); + + // Handle process errors + pbProcess.on("error", (err) => { + console.error("PocketBase process error:", err); + }); + + // Wait for PocketBase to be ready + await waitForReady(url); + + // Create admin user via CLI + createAdminUser(dataDir, config.adminEmail, config.adminPassword); + + // Connect to PocketBase as admin + const pb = new PocketBase(url); + pb.autoCancellation(false); + await pb + .collection("_superusers") + .authWithPassword(config.adminEmail, config.adminPassword); + + // Set up collections + await setupCollections(pb); + + // Create test user with period data + await createTestUser(pb, config.testUserEmail, config.testUserPassword); + + currentState = { + process: pbProcess, + dataDir, + url, + config, + }; + + return currentState; +} + +/** + * Stops the running PocketBase instance and cleans up. + */ +export async function stop(): Promise { + if (!currentState) { + return; + } + + const { process: pbProcess, dataDir } = currentState; + + // Kill the PocketBase process + pbProcess.kill("SIGTERM"); + + // Wait a moment for graceful shutdown + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Force kill if still running + if (!pbProcess.killed) { + pbProcess.kill("SIGKILL"); + } + + // Clean up the temporary data directory + fs.rmSync(dataDir, { recursive: true, force: true }); + + currentState = null; +} + +/** + * Gets the current harness state if running. + */ +export function getState(): HarnessState | null { + return currentState; +} diff --git a/playwright.config.ts b/playwright.config.ts index 77bf5da..36babd1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,6 +6,13 @@ export default defineConfig({ // Test directory for E2E tests testDir: "./e2e", + // Global setup/teardown for PocketBase harness + globalSetup: "./e2e/global-setup.ts", + globalTeardown: "./e2e/global-teardown.ts", + + // Exclude vitest test files + testIgnore: ["**/pocketbase-harness.test.ts"], + // Run tests in parallel fullyParallel: true, @@ -37,10 +44,16 @@ export default defineConfig({ projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], // Run dev server before starting tests + // Note: POCKETBASE_URL is set by global-setup.ts for the test PocketBase instance webServer: { command: "pnpm dev", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 120 * 1000, // 2 minutes for Next.js to start + env: { + // Use the test PocketBase instance (port 8091) + NEXT_PUBLIC_POCKETBASE_URL: "http://127.0.0.1:8091", + POCKETBASE_URL: "http://127.0.0.1:8091", + }, }, }); diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index 33d17cc..2f884b7 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -19,7 +19,7 @@ interface CollectionField { /** * Collection definition for PocketBase. */ -interface CollectionDefinition { +export interface CollectionDefinition { name: string; type: string; fields: CollectionField[]; @@ -173,7 +173,7 @@ async function resolveCollectionId( /** * Creates a collection in PocketBase. */ -async function createCollection( +export async function createCollection( pb: PocketBase, collection: CollectionDefinition, ): Promise { diff --git a/src/lib/pocketbase.ts b/src/lib/pocketbase.ts index 66ba043..32ff780 100644 --- a/src/lib/pocketbase.ts +++ b/src/lib/pocketbase.ts @@ -24,8 +24,11 @@ pb.autoCancellation(false); // Sync auth state to cookie on any change (login, logout, token refresh) // This allows Next.js middleware to read auth state for route protection // Cookie is non-HttpOnly because the client-side SDK needs read/write access +// Using document.cookie is intentional here - Cookie Store API has limited browser +// support and PocketBase SDK's exportToCookie() returns a cookie string format if (typeof window !== "undefined") { pb.authStore.onChange(() => { + // biome-ignore lint/suspicious/noDocumentCookie: PocketBase SDK requires document.cookie for auth sync document.cookie = pb.authStore.exportToCookie({ httpOnly: false }); }); } diff --git a/vitest.config.ts b/vitest.config.ts index f0dfc46..b60a3b3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,7 +13,11 @@ export default defineConfig({ }, test: { environment: "jsdom", - include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"], + include: [ + "src/**/*.test.{ts,tsx}", + "scripts/**/*.test.ts", + "e2e/**/*.test.ts", + ], setupFiles: ["./src/test-setup.ts"], }, });