// 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 { createCipheriv, randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import PocketBase from "pocketbase"; import { addUserFields, createCollection, getExistingCollectionNames, getMissingCollections, setupApiRules, } from "../scripts/setup-db"; /** * Test user presets for different e2e test scenarios. */ export type TestUserPreset = | "onboarding" | "established" | "calendar" | "garmin" | "garminExpired"; /** * Configuration for each test user type. */ export const TEST_USERS: Record< TestUserPreset, { email: string; password: string } > = { onboarding: { email: "e2e-onboarding@phaseflow.local", password: "e2e-onboarding-123", }, established: { email: "e2e-test@phaseflow.local", password: "e2e-test-password-123", }, calendar: { email: "e2e-calendar@phaseflow.local", password: "e2e-calendar-123", }, garmin: { email: "e2e-garmin@phaseflow.local", password: "e2e-garmin-123", }, garminExpired: { email: "e2e-garmin-expired@phaseflow.local", password: "e2e-garmin-expired-123", }, }; /** * 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`); } /** * Sleeps for the specified number of milliseconds. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Creates the admin superuser using the PocketBase CLI. * Retries on database lock errors since PocketBase may still be running migrations. */ async function createAdminUser( dataDir: string, email: string, password: string, maxRetries = 5, ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt < maxRetries; attempt++) { try { execSync( `pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`, { stdio: "pipe", }, ); return; } catch (err) { lastError = err as Error; const errorMsg = String(lastError.message || lastError); // Retry on database lock errors if ( errorMsg.includes("database is locked") || errorMsg.includes("SQLITE_BUSY") ) { await sleep(100 * (attempt + 1)); // Exponential backoff: 100ms, 200ms, 300ms... continue; } // For other errors, throw immediately throw err; } } throw lastError; } /** * 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); } /** * Retries an async operation with exponential backoff. */ async function retryAsync( operation: () => Promise, maxRetries = 5, baseDelayMs = 100, ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await operation(); } catch (err) { lastError = err as Error; const errorMsg = String(lastError.message || lastError); // Retry on transient errors (database busy, connection issues) if ( errorMsg.includes("database is locked") || errorMsg.includes("SQLITE_BUSY") || errorMsg.includes("Failed to create record") ) { await sleep(baseDelayMs * (attempt + 1)); continue; } // For other errors, throw immediately throw err; } } throw lastError; } /** * Encrypts a string using AES-256-GCM (matches src/lib/encryption.ts format). * Uses the test encryption key from playwright.config.ts. */ function encryptForTest(plaintext: string): string { const key = Buffer.from( "e2e-test-encryption-key-32chars".padEnd(32, "0").slice(0, 32), ); const iv = randomBytes(16); const cipher = createCipheriv("aes-256-gcm", key, iv); let encrypted = cipher.update(plaintext, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; } /** * Creates the onboarding test user (no period data). */ async function createOnboardingUser(pb: PocketBase): Promise { const { email, password } = TEST_USERS.onboarding; const user = await retryAsync(() => pb.collection("users").create({ email, password, passwordConfirm: password, emailVisibility: true, verified: true, cycleLength: 28, notificationTime: "07:00", timezone: "UTC", }), ); return user.id; } /** * Creates the established test user with period data (default user). */ async function createEstablishedUser(pb: PocketBase): Promise { const { email, password } = TEST_USERS.established; const fourteenDaysAgo = new Date(); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; const user = await retryAsync(() => pb.collection("users").create({ email, password, passwordConfirm: password, emailVisibility: true, verified: true, lastPeriodDate, cycleLength: 28, notificationTime: "07:00", timezone: "UTC", }), ); await retryAsync(() => pb.collection("period_logs").create({ user: user.id, startDate: lastPeriodDate, }), ); return user.id; } /** * Creates the calendar test user with period data and calendar token. */ async function createCalendarUser(pb: PocketBase): Promise { const { email, password } = TEST_USERS.calendar; const fourteenDaysAgo = new Date(); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; const user = await retryAsync(() => pb.collection("users").create({ email, password, passwordConfirm: password, emailVisibility: true, verified: true, lastPeriodDate, cycleLength: 28, notificationTime: "07:00", timezone: "UTC", calendarToken: "e2e-test-calendar-token-12345678", }), ); await retryAsync(() => pb.collection("period_logs").create({ user: user.id, startDate: lastPeriodDate, }), ); return user.id; } /** * Creates the Garmin test user with period data and valid Garmin tokens. */ async function createGarminUser(pb: PocketBase): Promise { const { email, password } = TEST_USERS.garmin; const fourteenDaysAgo = new Date(); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; // Token expires 90 days in the future const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + 90); const oauth1Token = encryptForTest( JSON.stringify({ oauth_token: "test-oauth1-token", oauth_token_secret: "test-oauth1-secret", }), ); const oauth2Token = encryptForTest( JSON.stringify({ access_token: "test-access-token", refresh_token: "test-refresh-token", token_type: "Bearer", expires_in: 7776000, }), ); const user = await retryAsync(() => pb.collection("users").create({ email, password, passwordConfirm: password, emailVisibility: true, verified: true, lastPeriodDate, cycleLength: 28, notificationTime: "07:00", timezone: "UTC", garminConnected: true, garminOauth1Token: oauth1Token, garminOauth2Token: oauth2Token, garminTokenExpiresAt: expiresAt.toISOString(), }), ); await retryAsync(() => pb.collection("period_logs").create({ user: user.id, startDate: lastPeriodDate, }), ); return user.id; } /** * Creates the Garmin expired test user with period data and expired Garmin tokens. */ async function createGarminExpiredUser(pb: PocketBase): Promise { const { email, password } = TEST_USERS.garminExpired; const fourteenDaysAgo = new Date(); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; // Token expired 1 day ago const expiredAt = new Date(); expiredAt.setDate(expiredAt.getDate() - 1); const oauth1Token = encryptForTest( JSON.stringify({ oauth_token: "test-expired-oauth1-token", oauth_token_secret: "test-expired-oauth1-secret", }), ); const oauth2Token = encryptForTest( JSON.stringify({ access_token: "test-expired-access-token", refresh_token: "test-expired-refresh-token", token_type: "Bearer", expires_in: 7776000, }), ); const user = await retryAsync(() => pb.collection("users").create({ email, password, passwordConfirm: password, emailVisibility: true, verified: true, lastPeriodDate, cycleLength: 28, notificationTime: "07:00", timezone: "UTC", garminConnected: true, garminOauth1Token: oauth1Token, garminOauth2Token: oauth2Token, garminTokenExpiresAt: expiredAt.toISOString(), }), ); await retryAsync(() => pb.collection("period_logs").create({ user: user.id, startDate: lastPeriodDate, }), ); return user.id; } /** * Creates all test users for e2e tests. */ async function createAllTestUsers(pb: PocketBase): Promise { await createOnboardingUser(pb); await createEstablishedUser(pb); await createCalendarUser(pb); await createGarminUser(pb); await createGarminExpiredUser(pb); } /** * 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 (with retry for database lock during migrations) await 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 all test users for different e2e scenarios await createAllTestUsers(pb); 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; }