E2E Testing

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();
});

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 #

  1. Use serial mode for tests that share state:

    test.describe.configure({ mode: 'serial' });
    
  2. Set appropriate timeouts for setup:

    test.beforeAll(async ({ browser }) => {
      test.setTimeout(180000); // 3 minutes
    });
    
  3. Dismiss dialogs after navigation:

    await dismissAnyDialogs(page, 3, 300);
    
  4. Wait for page elements before interacting:

    await element.waitFor({ state: "visible", timeout: 30000 });