Skip to main content
CharleOS uses Playwright for end-to-end testing of critical user flows. This page covers how to write and run E2E tests.

What to E2E Test

Focus on critical user journeys and integration points:

Authentication

Sign in, sign out, session management

Core Workflows

Create task, approve quote, assign subtask

Client Portal

Client sign-in, view tasks, submit tickets

Multi-User Flows

PM assigns task → IC completes → PM reviews

Test Structure

File Organization

e2e/
├── auth.spec.ts            # Authentication flows
├── tasks.spec.ts           # Task management
├── quotes.spec.ts          # Quote workflows
├── client-portal.spec.ts   # Client portal
├── permissions.spec.ts     # Access control
├── global-setup.ts         # Test data setup
└── seed-e2e-data.ts        # Seed script

Basic Test Example

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("redirects to auth page when not logged in", async ({ page }) => {
    await page.goto("/dashboard");
    
    await expect(page).toHaveURL("/auth");
  });

  test("shows sign in with Google button", async ({ page }) => {
    await page.goto("/auth");
    
    const signInButton = page.locator('button:has-text("Sign in with Google")');
    await expect(signInButton).toBeVisible();
  });
});

Playwright Features

// Navigate to page
await page.goto("/tasks");

// Wait for navigation
await page.click('a[href="/tasks"]');
await page.waitForURL("/tasks");

// Go back/forward
await page.goBack();
await page.goForward();

Locators

// By text
page.locator('text=Create Task');
page.locator('button:has-text("Submit")');

// By role
page.getByRole("button", { name: "Create Task" });
page.getByRole("link", { name: "Tasks" });

// By test ID
page.locator('[data-testid="task-card"]');

// By CSS selector
page.locator('.task-card');
page.locator('#task-123');

// By placeholder
page.getByPlaceholder("Search tasks...");

// By label
page.getByLabel("Task title");

Interactions

// Click
await page.click('button:has-text("Create")');
await page.getByRole("button", { name: "Submit" }).click();

// Type
await page.fill('input[name="title"]', "New Task");
await page.type('input[name="title"]', "New Task");

// Select
await page.selectOption('select[name="priority"]', "high");
await page.selectOption('select[name="priority"]', { label: "High" });

// Check/uncheck
await page.check('input[type="checkbox"]');
await page.uncheck('input[type="checkbox"]');

// Upload file
await page.setInputFiles('input[type="file"]', "path/to/file.pdf");

Assertions

// Visibility
await expect(page.locator('text=Success')).toBeVisible();
await expect(page.locator('text=Error')).toBeHidden();

// Text content
await expect(page.locator('h1')).toHaveText("Dashboard");
await expect(page.locator('.status')).toContainText("Complete");

// URL
await expect(page).toHaveURL("/tasks");
await expect(page).toHaveURL(/\/tasks\/task-/);

// Count
await expect(page.locator('.task-card')).toHaveCount(3);

// Attribute
await expect(page.locator('button')).toHaveAttribute("disabled");
await expect(page.locator('input')).toHaveValue("Initial value");

Common Test Patterns

Authentication Flow

// e2e/auth.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("successful Google OAuth flow", async ({ page }) => {
    await page.goto("/auth");
    
    // Click sign in
    await page.click('button:has-text("Sign in with Google")');
    
    // Verify redirect to Google
    await expect(page).toHaveURL(/accounts\.google\.com/);
    
    // (In real tests, you'd mock OAuth or use test credentials)
  });

  test("pending user sees waiting screen", async ({ page, context }) => {
    // Sign in as pending user
    // (Auth setup here)
    
    await page.goto("/dashboard");
    
    // Should see pending approval screen
    await expect(page.locator('text=Pending Approval')).toBeVisible();
    await expect(page.locator('text=waiting for admin approval')).toBeVisible();
  });
});

Task Creation Flow

// e2e/tasks.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Task Management", () => {
  test.beforeEach(async ({ page }) => {
    // Sign in as CSM (can create tasks)
    await signIn(page, "[email protected]");
  });

  test("creates a new task", async ({ page }) => {
    await page.goto("/tasks");
    
    // Click create button
    await page.click('button:has-text("Create Task")');
    
    // Fill form
    await page.fill('input[name="title"]', "E2E Test Task");
    await page.selectOption('select[name="clientId"]', { label: "Test Client" });
    await page.selectOption('select[name="pmId"]', { label: "John PM" });
    await page.fill('textarea[name="description"]', "This is a test task");
    await page.selectOption('select[name="priority"]', "3");
    
    // Submit
    await page.click('button[type="submit"]');
    
    // Verify success
    await expect(page.locator('text=Task created')).toBeVisible();
    await expect(page).toHaveURL(/\/tasks\/task-/);
    
    // Verify task appears in list
    await page.goto("/tasks");
    await expect(page.locator('text=E2E Test Task')).toBeVisible();
  });

  test("cannot create task without required fields", async ({ page }) => {
    await page.goto("/tasks");
    
    await page.click('button:has-text("Create Task")');
    
    // Submit without filling form
    await page.click('button[type="submit"]');
    
    // Should show validation errors
    await expect(page.locator('text=Title is required')).toBeVisible();
    await expect(page.locator('text=Client is required')).toBeVisible();
  });
});

Quote Approval Flow

// e2e/quotes.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Quote Workflow", () => {
  test("full quote lifecycle", async ({ page, context }) => {
    // 1. CSM creates quote
    await signIn(page, "[email protected]");
    await page.goto("/quotes");
    await page.click('button:has-text("Create Quote")');
    
    await page.fill('input[name="title"]', "New Feature");
    await page.selectOption('select[name="clientId"]', { label: "Test Client" });
    await page.click('button[type="submit"]');
    
    const quoteUrl = page.url();
    const quoteId = quoteUrl.match(/quote-(\w+)/)?.[1];
    
    // 2. Developer adds scope
    await signIn(page, "[email protected]");
    await page.goto(quoteUrl);
    
    await page.click('button:has-text("Add Development")');
    await page.fill('textarea[name="scope"]', "Implement new feature");
    await page.selectOption('select[name="tshirtSize"]', "m");
    await page.click('button:has-text("Save")');
    
    // 3. CSM reviews and sends to client
    await signIn(page, "[email protected]");
    await page.goto(quoteUrl);
    
    await page.click('button:has-text("Send to Client")');
    
    // Verify status changed
    await expect(page.locator('text=Awaiting Client Approval')).toBeVisible();
    
    // 4. Client approves
    // (Would require client portal sign-in)
  });
});

Client Portal Flow

// e2e/client-portal.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Client Portal", () => {
  test("client can view their tasks", async ({ page }) => {
    await page.goto("/client-portal/sign-in");
    
    // Sign in
    await page.fill('input[name="email"]', "[email protected]");
    await page.fill('input[name="password"]', "password");
    await page.click('button[type="submit"]');
    
    // Navigate to tasks
    await page.click('a:has-text("Tasks")');
    
    // Verify tasks are visible
    await expect(page.locator('[data-testid="task-card"]')).toHaveCount(3);
    await expect(page.locator('text=Website Redesign')).toBeVisible();
  });

  test("client can submit help desk ticket", async ({ page }) => {
    await signInAsClient(page, "[email protected]");
    
    await page.goto("/client-portal/help-desk");
    
    // Create ticket
    await page.click('button:has-text("New Ticket")');
    await page.fill('input[name="title"]', "Bug Report");
    await page.fill('textarea[name="description"]', "Something is broken");
    await page.selectOption('select[name="type"]', "bug");
    await page.selectOption('select[name="priority"]', "high");
    await page.click('button[type="submit"]');
    
    // Verify success
    await expect(page.locator('text=Ticket created')).toBeVisible();
    await expect(page.locator('text=Bug Report')).toBeVisible();
  });
});

Permission Tests

// e2e/permissions.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Access Control", () => {
  test("IC cannot access admin panel", async ({ page }) => {
    await signIn(page, "[email protected]");
    
    // Try to access admin panel
    await page.goto("/admin");
    
    // Should redirect to dashboard
    await expect(page).toHaveURL("/dashboard");
  });

  test("PM can view schedule", async ({ page }) => {
    await signIn(page, "[email protected]");
    
    await page.goto("/schedule");
    
    // Should see schedule page
    await expect(page.locator('h1:has-text("Schedule")')).toBeVisible();
  });

  test("CSM can create quotes", async ({ page }) => {
    await signIn(page, "[email protected]");
    
    await page.goto("/quotes");
    
    // Should see create button
    await expect(page.locator('button:has-text("Create Quote")')).toBeVisible();
  });
});

Test Setup

Global Setup

// e2e/global-setup.ts
import { chromium, FullConfig } from "@playwright/test";
import { seedE2EData } from "./seed-e2e-data";

async function globalSetup(config: FullConfig) {
  // Seed test data
  await seedE2EData();

  // Optionally: Create authenticated state files
  const browser = await chromium.launch();
  const page = await browser.newPage();
  
  // Sign in and save state
  await page.goto("http://localhost:3000/auth");
  // (Sign in logic)
  
  await page.context().storageState({ path: "auth-state.json" });
  await browser.close();
}

export default globalSetup;

Seed Data

// e2e/seed-e2e-data.ts
import { db } from "@/lib/db";
import { user, client, task } from "@/lib/db/schema";

export async function seedE2EData() {
  // Create test users
  await db.insert(user).values([
    { email: "[email protected]", name: "Test Admin", accessLevel: "admin" },
    { email: "[email protected]", name: "Test Developer", workType: "development" },
    { email: "[email protected]", name: "Test PM", workType: "pm" },
  ]);

  // Create test client
  const [testClient] = await db.insert(client).values({
    name: "Test Client",
    slug: "test-client",
  }).returning();

  // Create test tasks
  await db.insert(task).values([
    { displayId: 1, title: "Test Task 1", clientId: testClient.id },
    { displayId: 2, title: "Test Task 2", clientId: testClient.id },
  ]);
}

Running E2E Tests

Basic Commands

# Run all E2E tests (headless)
npm run test:e2e

# Run with UI (interactive)
npm run test:e2e:ui

# Run specific file
npx playwright test e2e/auth.spec.ts

# Run specific test
npx playwright test -g "creates a new task"

# Run with debugging
npx playwright test --debug

# Run in headed mode
npx playwright test --headed

Watch Mode

# Run in UI mode (auto-reruns on changes)
npx playwright test --ui

Debugging

Trace Viewer

Playwright records traces on failures:
# View trace after test failure
npx playwright show-trace trace.zip
Trace viewer shows:
  • Screenshots at each step
  • Network requests
  • Console logs
  • DOM snapshots

Code Generator

Generate test code by recording actions:
npx playwright codegen http://localhost:3000
Opens a browser where you can interact with the app. Playwright generates test code automatically.

Debug Mode

# Run with debugger
npx playwright test --debug

# Run specific test with debugger
npx playwright test -g "creates task" --debug
Opens Inspector where you can step through test execution.

Best Practices

Focus on what users actually do:
// ✅ Good: Complete user flow
test("user creates and completes task", async ({ page }) => {
  await signIn(page);
  await createTask(page, "New Task");
  await navigateToTask(page, "New Task");
  await completeTask(page);
});

// ❌ Bad: Testing implementation details
test("API returns 200", async ({ page }) => {
  // Too low-level
});
Prefer semantic locators over CSS selectors:
// ✅ Good: Semantic, resilient
page.getByRole("button", { name: "Create Task" });
page.getByLabel("Task title");

// ❌ Bad: Fragile, breaks on styling changes
page.locator('.btn-primary.create-btn');
Delete test data to avoid pollution:
test.afterEach(async () => {
  // Delete created tasks
  await db.delete(task).where(like(task.title, "E2E Test%"));
});
Extract common interactions:
// e2e/pages/tasks.ts
export class TasksPage {
  constructor(private page: Page) {}
  
  async createTask(title: string, client: string) {
    await this.page.click('button:has-text("Create Task")');
    await this.page.fill('input[name="title"]', title);
    await this.page.selectOption('select[name="clientId"]', client);
    await this.page.click('button[type="submit"]');
  }
}

// Usage
const tasksPage = new TasksPage(page);
await tasksPage.createTask("New Task", "Test Client");

CI Integration

E2E tests run in GitHub Actions:
# .github/workflows/e2e.yml
jobs:
  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright
        run: npx playwright install --with-deps
      
      - name: Run E2E tests
        run: npm run test:e2e
        
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/