Files
phaseflow/e2e/pocketbase-harness.ts
Petru Paler 8c59b3bd67
Some checks failed
CI / quality (push) Failing after 29s
Deploy / deploy (push) Successful in 2m37s
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 <noreply@anthropic.com>
2026-01-13 09:38:24 +00:00

309 lines
7.7 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`);
}
/**
* 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<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 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);
}
/**
* 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
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<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
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;
}