// 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; }