E2E Testing #
Playwright-based end-to-end tests for the BonsAI webapp.
Quick Start #
mise run test-e2e-init # Install Playwright browsers
mise run test-e2e # Run tests (CLI)
mise run test-e2e-ui # Run tests (UI mode)
Project Structure #
tests/e2e/
├── core/test.ts # Custom fixtures (ctx, unauthenticatedCtx)
├── helpers/ # Reusable utilities
│ ├── index.ts # Main exports
│ ├── page-actions.ts # Navigation, dialogs, etc.
│ ├── page-assertions.ts
│ └── seed.ts # Database seeding
├── pages/ # Page Object Models
├── setup/auth.setup.ts # Authentication setup
└── tests/ # Test specs
Using Custom Fixtures #
Always import from core/test instead of @playwright/test:
// ✅ Correct - uses custom fixtures with Cloudflare headers
import { test, expect } from "../../core/test";
// ❌ Wrong - missing Cloudflare auth
import { test, expect } from "@playwright/test";
Available Fixtures #
| Fixture | Description |
|---|---|
ctx |
Authenticated browser context with Cloudflare headers |
unauthenticatedCtx |
Cloudflare headers but no auth (for login tests) |
Basic Test Example #
import { test, expect } from "../../core/test";
import { EntityPage } from "../../pages/entity-page";
test("should load entity page", async ({ ctx }) => {
const page = await ctx.newPage();
const entityPage = new EntityPage(page);
await entityPage.gotoEntityBySlug("org-slug", "entity-slug", "review");
await entityPage.expectEntityLayoutVisible();
});
Navigation with Retry #
Use navigateWithRetry for reliable navigation in unstable network environments:
import { navigateWithRetry } from "../helpers";
// Retries on transient network errors (ERR_NETWORK_CHANGED, etc.)
await navigateWithRetry(page, "/path/to/page", {
maxRetries: 3, // default: 3
retryDelay: 1000, // default: 1000ms
timeout: 60000, // default: 60000ms
});
Page objects like EntityPage use this internally:
// These methods use navigateWithRetry internally
await entityPage.gotoEntityBySlug(orgSlug, entitySlug, "review");
await entityPage.gotoEntitySettings(orgSlug, entitySlug);
Database Seeding #
Option 1: Create Entity with Seeds #
Seed data when creating a test entity:
import { test, createCloudflareContext } from "../../core/test";
import { EntityPage } from "../../pages/entity-page";
import {
DocumentStatus,
ExtractionStatus,
InvoiceStatus,
} from "../../helpers";
test.beforeAll(async ({ browser }) => {
const ctx = await createCloudflareContext(browser, {
storageState: AUTH_FILE,
});
const page = await ctx.newPage();
const entityPage = new EntityPage(page);
// Navigate to app
await page.goto("/");
await page.waitForURL(/\/summary/);
// Create entity and seed in one call
const testEntity = await entityPage.createEntity(
"Test Entity Name",
"United States of America",
{
// Documents (generates real PDFs via docgen)
documents: 10,
documentStatus: DocumentStatus.NEEDS_REVIEW,
// Extraction counts
apBills: 5,
arInvoices: 3,
directExpenses: 2,
bankStatements: 0,
// Date range (days ago from today)
daysAgo: 90, // oldest records
daysTo: 1, // newest records
// Statuses
extractionStatus: ExtractionStatus.NEEDS_REVIEW,
invoiceStatus: InvoiceStatus.DRAFT,
// Cleanup existing data first
cleanup: true,
}
);
// testEntity contains: { entityId, entitySlug }
});
Option 2: Seed Separately #
Seed data for an existing entity:
import { seedTestData, cleanupSeedData } from "../../helpers";
// Seed data
await seedTestData(entityId, {
documents: 10,
documentStatus: DocumentStatus.PENDING,
apBills: 5,
extractionStatus: ExtractionStatus.NEEDS_REVIEW,
cleanup: true,
});
// Cleanup data
await cleanupSeedData(entityId);
Seed Options Reference #
| Option | Type | Description |
|---|---|---|
documents |
number |
Number of PDF documents to generate |
documentStatus |
DocumentStatus |
Document status (PENDING, NEEDS_REVIEW, DONE, etc.) |
language |
DocumentLanguage |
Document language (en, ja, etc.) |
apBills |
number |
Number of AP bills |
arInvoices |
number |
Number of AR invoices |
directExpenses |
number |
Number of direct expenses |
bankStatements |
number |
Number of bank statements |
daysAgo |
number |
Oldest records (days from today) |
daysTo |
number |
Newest records (days from today) |
extractionStatus |
ExtractionStatus |
Extraction status |
invoiceStatus |
InvoiceStatus |
Invoice status |
directExpenseStatus |
DirectExpenseStatus |
Direct expense status |
cleanup |
boolean |
Delete existing seed data first |
Status Enums #
import {
DocumentStatus,
ExtractionStatus,
InvoiceStatus,
DirectExpenseStatus,
DocumentLanguage,
} from "../../helpers";
// Document statuses
DocumentStatus.PENDING // "p"
DocumentStatus.PROCESSING // "pr"
DocumentStatus.NEEDS_REVIEW // "n"
DocumentStatus.DONE // "d"
DocumentStatus.ERROR // "e"
// Extraction statuses
ExtractionStatus.PENDING // "p"
ExtractionStatus.AWAITING_PROCESSING // "a"
ExtractionStatus.NEEDS_REVIEW // "n"
ExtractionStatus.VERIFIED // "v"
ExtractionStatus.REVIEW_LATER // "r"
// Invoice statuses
InvoiceStatus.DRAFT // "d"
InvoiceStatus.SUBMITTED // "s"
InvoiceStatus.AUTHORIZED // "a"
InvoiceStatus.PAID // "p"
Test Cleanup #
Always cleanup seed data when tests finish:
test.afterAll(async ({ browser }) => {
const ctx = await createCloudflareContext(browser, {
storageState: AUTH_FILE,
});
const page = await ctx.newPage();
const entityPage = new EntityPage(page);
// Navigate to entity settings
await entityPage.gotoEntitySettings(orgSlug, testEntity.entitySlug);
// Delete entity (automatically cleans up seed data)
await entityPage.deleteEntity(testEntityName, testEntity.entityId);
await ctx.close();
});
Helper Functions #
Import from helpers/index.ts:
import {
// Navigation
navigateWithRetry,
dismissAnyDialogs,
waitForNetworkIdle,
// Toasts
waitForToast,
waitForToastComplete,
dismissToasts,
// Assertions
expectUrlToMatch,
expectToastVisible,
expectNoToasts,
// Seeding
seedTestData,
cleanupSeedData,
getEntityIdBySlug,
// Enums
DocumentStatus,
ExtractionStatus,
InvoiceStatus,
DirectExpenseStatus,
} from "../helpers";
Tips #
-
Use serial mode for tests that share state:
test.describe.configure({ mode: 'serial' }); -
Set appropriate timeouts for setup:
test.beforeAll(async ({ browser }) => { test.setTimeout(180000); // 3 minutes }); -
Dismiss dialogs after navigation:
await dismissAnyDialogs(page, 3, 300); -
Wait for page elements before interacting:
await element.waitFor({ state: "visible", timeout: 30000 });