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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@
|
|||||||
# playwright
|
# playwright
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/test-results/
|
/test-results/
|
||||||
|
e2e/.harness-state.json
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ test.describe("calendar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("displays calendar page with heading", async ({ page }) => {
|
test("displays calendar page with heading", async ({ page }) => {
|
||||||
// Check for calendar heading
|
// Check for the main calendar heading (h1)
|
||||||
const heading = page.getByRole("heading", { name: /calendar/i });
|
const heading = page.getByRole("heading", {
|
||||||
|
name: "Calendar",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
await expect(heading).toBeVisible();
|
await expect(heading).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -81,45 +81,60 @@ test.describe("dashboard", () => {
|
|||||||
test("shows override toggles when user has period data", async ({
|
test("shows override toggles when user has period data", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Wait for dashboard data to load
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Override toggles should be visible if user has period data
|
// Override toggles should be visible if user has period data
|
||||||
const overrideSection = page.getByRole("button", {
|
const overrideCheckbox = page.getByRole("checkbox", {
|
||||||
name: /flare|stress|sleep|pms/i,
|
name: /flare mode|high stress|poor sleep|pms/i,
|
||||||
});
|
});
|
||||||
|
|
||||||
// These may not be visible if user hasn't set up period date
|
// These may not be visible if user hasn't set up period date
|
||||||
const hasOverrides = await overrideSection
|
const hasOverrides = await overrideCheckbox
|
||||||
.first()
|
.first()
|
||||||
.isVisible()
|
.isVisible()
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
if (hasOverrides) {
|
if (hasOverrides) {
|
||||||
await expect(overrideSection.first()).toBeVisible();
|
await expect(overrideCheckbox.first()).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can toggle override buttons", async ({ page }) => {
|
test("can toggle override checkboxes", async ({ page }) => {
|
||||||
// Find an override toggle button
|
// Wait for the OVERRIDES section to appear (indicates dashboard data loaded)
|
||||||
const toggleButton = page
|
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
|
||||||
.getByRole("button", { name: /flare|stress|sleep|pms/i })
|
const hasOverridesSection = await overridesHeading
|
||||||
|
.waitFor({ timeout: 10000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!hasOverridesSection) {
|
||||||
|
test.skip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an override toggle checkbox (Flare Mode, High Stress, etc.)
|
||||||
|
const toggleCheckbox = page
|
||||||
|
.getByRole("checkbox", {
|
||||||
|
name: /flare mode|high stress|poor sleep|pms/i,
|
||||||
|
})
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
const hasToggle = await toggleButton.isVisible().catch(() => false);
|
const hasToggle = await toggleCheckbox.isVisible().catch(() => false);
|
||||||
|
|
||||||
if (hasToggle) {
|
if (hasToggle) {
|
||||||
// Get initial state
|
// Get initial state
|
||||||
const initialPressed = await toggleButton.getAttribute("aria-pressed");
|
const initialChecked = await toggleCheckbox.isChecked();
|
||||||
|
|
||||||
// Click the toggle
|
// Click the toggle
|
||||||
await toggleButton.click();
|
await toggleCheckbox.click();
|
||||||
|
|
||||||
// Wait a moment for the API call
|
// Wait a moment for the API call
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Toggle should change state (or show error)
|
// Toggle should change state
|
||||||
const afterPressed = await toggleButton.getAttribute("aria-pressed");
|
const afterChecked = await toggleCheckbox.isChecked();
|
||||||
|
expect(afterChecked).not.toBe(initialChecked);
|
||||||
// Either state changed or we should see some feedback
|
|
||||||
expect(afterPressed !== initialPressed || true).toBe(true);
|
|
||||||
} else {
|
} else {
|
||||||
test.skip();
|
test.skip();
|
||||||
}
|
}
|
||||||
|
|||||||
32
e2e/global-setup.ts
Normal file
32
e2e/global-setup.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// ABOUTME: Playwright global setup - starts PocketBase and sets test environment variables.
|
||||||
|
// ABOUTME: Runs before all e2e tests to provide a fresh database with test data.
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { DEFAULT_CONFIG, start } from "./pocketbase-harness";
|
||||||
|
|
||||||
|
const STATE_FILE = path.join(__dirname, ".harness-state.json");
|
||||||
|
|
||||||
|
export default async function globalSetup(): Promise<void> {
|
||||||
|
console.log("Starting PocketBase for e2e tests...");
|
||||||
|
|
||||||
|
const state = await start(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
// Save state for teardown
|
||||||
|
fs.writeFileSync(
|
||||||
|
STATE_FILE,
|
||||||
|
JSON.stringify({
|
||||||
|
dataDir: state.dataDir,
|
||||||
|
url: state.url,
|
||||||
|
pid: state.process.pid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set environment variables for the test process
|
||||||
|
process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url;
|
||||||
|
process.env.POCKETBASE_URL = state.url;
|
||||||
|
process.env.TEST_USER_EMAIL = DEFAULT_CONFIG.testUserEmail;
|
||||||
|
process.env.TEST_USER_PASSWORD = DEFAULT_CONFIG.testUserPassword;
|
||||||
|
|
||||||
|
console.log(`PocketBase running at ${state.url}`);
|
||||||
|
console.log(`Test user: ${DEFAULT_CONFIG.testUserEmail}`);
|
||||||
|
}
|
||||||
54
e2e/global-teardown.ts
Normal file
54
e2e/global-teardown.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// ABOUTME: Playwright global teardown - stops PocketBase and cleans up temp data.
|
||||||
|
// ABOUTME: Runs after all e2e tests to ensure clean shutdown.
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
const STATE_FILE = path.join(__dirname, ".harness-state.json");
|
||||||
|
|
||||||
|
interface HarnessStateFile {
|
||||||
|
dataDir: string;
|
||||||
|
url: string;
|
||||||
|
pid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalTeardown(): Promise<void> {
|
||||||
|
console.log("Stopping PocketBase...");
|
||||||
|
|
||||||
|
// Read the saved state
|
||||||
|
if (!fs.existsSync(STATE_FILE)) {
|
||||||
|
console.log("No harness state file found, nothing to clean up.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: HarnessStateFile = JSON.parse(
|
||||||
|
fs.readFileSync(STATE_FILE, "utf-8"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kill the PocketBase process
|
||||||
|
if (state.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(state.pid, "SIGTERM");
|
||||||
|
// Wait for graceful shutdown
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
// Force kill if still running
|
||||||
|
try {
|
||||||
|
process.kill(state.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Process already dead, which is fine
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Process might already be dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the temporary data directory
|
||||||
|
if (state.dataDir && fs.existsSync(state.dataDir)) {
|
||||||
|
fs.rmSync(state.dataDir, { recursive: true, force: true });
|
||||||
|
console.log(`Cleaned up temp directory: ${state.dataDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the state file
|
||||||
|
fs.unlinkSync(STATE_FILE);
|
||||||
|
|
||||||
|
console.log("PocketBase stopped and cleaned up.");
|
||||||
|
}
|
||||||
@@ -73,18 +73,20 @@ test.describe("period logging", () => {
|
|||||||
test("period history shows table or empty state", async ({ page }) => {
|
test("period history shows table or empty state", async ({ page }) => {
|
||||||
await page.goto("/period-history");
|
await page.goto("/period-history");
|
||||||
|
|
||||||
|
// Wait for loading to complete
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for either table or empty state message
|
// Look for either table or empty state message
|
||||||
const table = page.getByRole("table");
|
const table = page.getByRole("table");
|
||||||
const emptyState = page.getByText(/no period|no data|start tracking/i);
|
const emptyState = page.getByText("No period history found");
|
||||||
|
const totalText = page.getByText(/\d+ periods/);
|
||||||
|
|
||||||
const hasTable = await table.isVisible().catch(() => false);
|
const hasTable = await table.isVisible().catch(() => false);
|
||||||
const hasEmpty = await emptyState
|
const hasEmpty = await emptyState.isVisible().catch(() => false);
|
||||||
.first()
|
const hasTotal = await totalText.isVisible().catch(() => false);
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
// Either should be present
|
// Either table, empty state, or total count should be present
|
||||||
expect(hasTable || hasEmpty).toBe(true);
|
expect(hasTable || hasEmpty || hasTotal).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("period history shows average cycle length if data exists", async ({
|
test("period history shows average cycle length if data exists", async ({
|
||||||
|
|||||||
140
e2e/pocketbase-harness.test.ts
Normal file
140
e2e/pocketbase-harness.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// ABOUTME: Integration tests for the PocketBase e2e test harness.
|
||||||
|
// ABOUTME: Verifies the harness can start, setup, and stop PocketBase instances.
|
||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
getState,
|
||||||
|
type HarnessState,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
} from "./pocketbase-harness";
|
||||||
|
|
||||||
|
describe("pocketbase-harness", () => {
|
||||||
|
describe("start/stop lifecycle", () => {
|
||||||
|
let state: HarnessState;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
state = await start();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a valid harness state", () => {
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state.url).toBe(`http://127.0.0.1:${DEFAULT_CONFIG.port}`);
|
||||||
|
expect(state.dataDir).toContain("pocketbase-e2e-");
|
||||||
|
expect(state.process).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getState returns the current state while running", () => {
|
||||||
|
const currentState = getState();
|
||||||
|
expect(currentState).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PocketBase is accessible at the expected URL", async () => {
|
||||||
|
const response = await fetch(`${state.url}/api/health`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin can authenticate", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
const auth = await pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.adminEmail,
|
||||||
|
DEFAULT_CONFIG.adminPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auth.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test user can authenticate", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
const auth = await pb
|
||||||
|
.collection("users")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.testUserEmail,
|
||||||
|
DEFAULT_CONFIG.testUserPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auth.token).toBeDefined();
|
||||||
|
expect(auth.record.email).toBe(DEFAULT_CONFIG.testUserEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test user has period data configured", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
await pb
|
||||||
|
.collection("users")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.testUserEmail,
|
||||||
|
DEFAULT_CONFIG.testUserPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = pb.authStore.record;
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.lastPeriodDate).toBeDefined();
|
||||||
|
expect(user?.cycleLength).toBe(28);
|
||||||
|
expect(user?.timezone).toBe("UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("period_logs collection exists with test data", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
await pb
|
||||||
|
.collection("users")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.testUserEmail,
|
||||||
|
DEFAULT_CONFIG.testUserPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userId = pb.authStore.record?.id;
|
||||||
|
const logs = await pb
|
||||||
|
.collection("period_logs")
|
||||||
|
.getList(1, 10, { filter: `user="${userId}"` });
|
||||||
|
|
||||||
|
expect(logs.totalItems).toBeGreaterThan(0);
|
||||||
|
expect(logs.items[0].startDate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dailyLogs collection exists", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
await pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.adminEmail,
|
||||||
|
DEFAULT_CONFIG.adminPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = await pb.collections.getFullList();
|
||||||
|
const collectionNames = collections.map((c) => c.name);
|
||||||
|
|
||||||
|
expect(collectionNames).toContain("period_logs");
|
||||||
|
expect(collectionNames).toContain("dailyLogs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("after stop", () => {
|
||||||
|
it("getState returns null after stop", async () => {
|
||||||
|
// Start and immediately stop
|
||||||
|
await start({ ...DEFAULT_CONFIG, port: 8092 });
|
||||||
|
await stop();
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
expect(state).toBeNull();
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@ export default defineConfig({
|
|||||||
// Test directory for E2E tests
|
// Test directory for E2E tests
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|
||||||
|
// Global setup/teardown for PocketBase harness
|
||||||
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
|
globalTeardown: "./e2e/global-teardown.ts",
|
||||||
|
|
||||||
|
// Exclude vitest test files
|
||||||
|
testIgnore: ["**/pocketbase-harness.test.ts"],
|
||||||
|
|
||||||
// Run tests in parallel
|
// Run tests in parallel
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
|
||||||
@@ -37,10 +44,16 @@ export default defineConfig({
|
|||||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
|
||||||
// Run dev server before starting tests
|
// Run dev server before starting tests
|
||||||
|
// Note: POCKETBASE_URL is set by global-setup.ts for the test PocketBase instance
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "pnpm dev",
|
command: "pnpm dev",
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120 * 1000, // 2 minutes for Next.js to start
|
timeout: 120 * 1000, // 2 minutes for Next.js to start
|
||||||
|
env: {
|
||||||
|
// Use the test PocketBase instance (port 8091)
|
||||||
|
NEXT_PUBLIC_POCKETBASE_URL: "http://127.0.0.1:8091",
|
||||||
|
POCKETBASE_URL: "http://127.0.0.1:8091",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface CollectionField {
|
|||||||
/**
|
/**
|
||||||
* Collection definition for PocketBase.
|
* Collection definition for PocketBase.
|
||||||
*/
|
*/
|
||||||
interface CollectionDefinition {
|
export interface CollectionDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
fields: CollectionField[];
|
fields: CollectionField[];
|
||||||
@@ -173,7 +173,7 @@ async function resolveCollectionId(
|
|||||||
/**
|
/**
|
||||||
* Creates a collection in PocketBase.
|
* Creates a collection in PocketBase.
|
||||||
*/
|
*/
|
||||||
async function createCollection(
|
export async function createCollection(
|
||||||
pb: PocketBase,
|
pb: PocketBase,
|
||||||
collection: CollectionDefinition,
|
collection: CollectionDefinition,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ pb.autoCancellation(false);
|
|||||||
// Sync auth state to cookie on any change (login, logout, token refresh)
|
// Sync auth state to cookie on any change (login, logout, token refresh)
|
||||||
// This allows Next.js middleware to read auth state for route protection
|
// This allows Next.js middleware to read auth state for route protection
|
||||||
// Cookie is non-HttpOnly because the client-side SDK needs read/write access
|
// Cookie is non-HttpOnly because the client-side SDK needs read/write access
|
||||||
|
// Using document.cookie is intentional here - Cookie Store API has limited browser
|
||||||
|
// support and PocketBase SDK's exportToCookie() returns a cookie string format
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
pb.authStore.onChange(() => {
|
pb.authStore.onChange(() => {
|
||||||
|
// biome-ignore lint/suspicious/noDocumentCookie: PocketBase SDK requires document.cookie for auth sync
|
||||||
document.cookie = pb.authStore.exportToCookie({ httpOnly: false });
|
document.cookie = pb.authStore.exportToCookie({ httpOnly: false });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"],
|
include: [
|
||||||
|
"src/**/*.test.{ts,tsx}",
|
||||||
|
"scripts/**/*.test.ts",
|
||||||
|
"e2e/**/*.test.ts",
|
||||||
|
],
|
||||||
setupFiles: ["./src/test-setup.ts"],
|
setupFiles: ["./src/test-setup.ts"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user