- Fix race conditions: Set workers: 1 since all tests share test user state - Fix stale data: GET /api/user and /api/cycle/current now fetch fresh data from database instead of returning stale PocketBase auth store cache - Fix timing: Replace waitForTimeout with retry-based Playwright assertions - Fix mobile test: Use exact heading match to avoid strict mode violation - Add test user setup: Include notificationTime and update rule for users All 1014 unit tests and 190 E2E tests pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
316 lines
8.0 KiB
TypeScript
316 lines
8.0 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 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);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
notificationTime: "07:00",
|
|
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;
|
|
}
|