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>
This commit is contained in:
308
e2e/pocketbase-harness.ts
Normal file
308
e2e/pocketbase-harness.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user