Files
phaseflow/e2e/pocketbase-harness.ts
Petru Paler 6df145d916 Fix Garmin token storage and flaky e2e test
1. Increase garminOauth1Token and garminOauth2Token max length from
   5000 to 20000 characters to accommodate encrypted OAuth tokens.
   Add logic to update existing field constraints in addUserFields().

2. Fix flaky pocketbase-harness e2e test by adding retry logic with
   exponential backoff to createAdminUser() and createTestUser().
   Handles SQLite database lock during PocketBase startup migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:52:01 +00:00

388 lines
9.9 KiB
TypeScript

// 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<void> {
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<void> {
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<void> {
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;
}
/**
* Adds custom fields to the users collection.
*/
async function addUserFields(pb: PocketBase): Promise<void> {
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<void> {
// Allow users to update their own user record
const usersCollection = await pb.collections.getOne("users");
await pb.collections.update(usersCollection.id, {
updateRule: "id = @request.auth.id",
});
// 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<void> {
// 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<T>(
operation: () => Promise<T>,
maxRetries = 5,
baseDelayMs = 100,
): Promise<T> {
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;
}
/**
* Creates the test user with period data.
*/
async function createTestUser(
pb: PocketBase,
email: string,
password: string,
): Promise<string> {
// 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 (with retry for transient errors)
const user = await retryAsync(() =>
pb.collection("users").create({
email,
password,
passwordConfirm: password,
emailVisibility: true,
verified: true,
lastPeriodDate,
cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC",
}),
);
// Create a period log entry (with retry for transient errors)
await retryAsync(() =>
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<HarnessState> {
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 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<void> {
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;
}