Global WatchGlobal Watch Docs
Testing

E2E Testing

E2E Testing with Playwright

Global Watch uses Playwright for end-to-end testing. E2E tests verify complete user flows work correctly across the entire application stack, including the browser, server, and database.

Configuration

Playwright Setup

The Playwright configuration is located at apps/e2e/playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: 'pnpm --filter web dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Project Structure

E2E tests are organized by feature:

apps/e2e/
├── tests/
│   ├── authentication/
│   │   ├── auth.po.ts           # Page Object
│   │   ├── sign-in.spec.ts
│   │   └── sign-up.spec.ts
│   ├── projects/
│   │   ├── projects.po.ts
│   │   ├── create-project.spec.ts
│   │   └── project-members.spec.ts
│   ├── team-accounts/
│   │   ├── team-accounts.po.ts
│   │   └── team-management.spec.ts
│   └── fixtures/
│       ├── test-data.ts
│       └── auth.fixture.ts
├── playwright.config.ts
└── package.json

Page Object Pattern

Use Page Objects to encapsulate page interactions:

// tests/authentication/auth.po.ts
import { Page, Locator } from '@playwright/test';

export class AuthPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly signInButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-test="email-input"]');
    this.passwordInput = page.locator('[data-test="password-input"]');
    this.signInButton = page.locator('[data-test="sign-in-button"]');
    this.errorMessage = page.locator('[data-test="error-message"]');
  }

  async goto() {
    await this.page.goto('/auth/sign-in');
  }

  async signIn(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.signInButton.click();
  }

  async waitForRedirect() {
    // Use regex for flexible URL matching
    await this.page.waitForURL(/\/(home|workspace)/);
  }

  async getErrorMessage() {
    return this.errorMessage.textContent();
  }
}

Using Page Objects in Tests

// tests/authentication/sign-in.spec.ts
import { test, expect } from '@playwright/test';
import { AuthPage } from './auth.po';

test.describe('Sign In', () => {
  let authPage: AuthPage;

  test.beforeEach(async ({ page }) => {
    authPage = new AuthPage(page);
    await authPage.goto();
  });

  test('should sign in with valid credentials', async () => {
    await authPage.signIn('user@example.com', 'password123');
    await authPage.waitForRedirect();
    
    expect(authPage.page.url()).toContain('/home');
  });

  test('should show error for invalid credentials', async () => {
    await authPage.signIn('user@example.com', 'wrong-password');
    
    const error = await authPage.getErrorMessage();
    expect(error).toContain('Invalid credentials');
  });
});

Writing E2E Tests

Basic Test Structure

import { test, expect } from '@playwright/test';

test.describe('Feature Name', () => {
  test.beforeEach(async ({ page }) => {
    // Setup before each test
    await page.goto('/');
  });

  test('should perform expected action', async ({ page }) => {
    // Arrange
    await page.click('[data-test="action-button"]');
    
    // Act
    await page.fill('[data-test="input-field"]', 'test value');
    await page.click('[data-test="submit-button"]');
    
    // Assert
    await expect(page.locator('[data-test="success-message"]')).toBeVisible();
  });
});

Testing User Flows

// tests/projects/create-project.spec.ts
import { test, expect } from '@playwright/test';
import { AuthPage } from '../authentication/auth.po';
import { ProjectsPage } from './projects.po';

test.describe('Create Project Flow', () => {
  test.beforeEach(async ({ page }) => {
    // Authenticate before each test
    const authPage = new AuthPage(page);
    await authPage.goto();
    await authPage.signIn('owner@example.com', 'password123');
    await authPage.waitForRedirect();
  });

  test('should create a new project', async ({ page }) => {
    const projectsPage = new ProjectsPage(page);
    await projectsPage.goto();
    
    // Click create button
    await page.click('[data-test="create-project-button"]');
    
    // Fill form
    await page.fill('[data-test="project-name-input"]', 'New Project');
    await page.fill('[data-test="project-description-input"]', 'Description');
    
    // Submit
    await page.click('[data-test="submit-project-button"]');
    
    // Verify success
    await expect(page.locator('[data-test="success-toast"]')).toBeVisible();
    await expect(page.locator('text=New Project')).toBeVisible();
  });

  test('should validate required fields', async ({ page }) => {
    const projectsPage = new ProjectsPage(page);
    await projectsPage.goto();
    
    await page.click('[data-test="create-project-button"]');
    await page.click('[data-test="submit-project-button"]');
    
    await expect(page.locator('[data-test="name-error"]')).toBeVisible();
  });
});

Testing Multi-Tenant Flows

Global Watch uses subdomain-based multi-tenancy:

// tests/team-accounts/team-management.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Team Account Management', () => {
  test('should access team workspace via subdomain', async ({ page }) => {
    // Navigate to team subdomain
    await page.goto('http://acme.localhost.direct:3000');
    
    // Verify team context
    await expect(page.locator('[data-test="team-name"]')).toContainText('Acme');
  });

  test('should switch between personal and team accounts', async ({ page }) => {
    // Start at personal account
    await page.goto('http://app.localhost.direct:3000/home');
    
    // Open account switcher
    await page.click('[data-test="account-switcher"]');
    
    // Select team account
    await page.click('[data-test="team-acme"]');
    
    // Verify redirect to team subdomain
    await expect(page).toHaveURL(/acme\.localhost\.direct/);
  });
});

Testing with Authentication

Use fixtures for authenticated tests:

// tests/fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { AuthPage } from '../authentication/auth.po';

type AuthFixtures = {
  authenticatedPage: Page;
  ownerPage: Page;
  memberPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    const authPage = new AuthPage(page);
    await authPage.goto();
    await authPage.signIn('user@example.com', 'password123');
    await authPage.waitForRedirect();
    await use(page);
  },

  ownerPage: async ({ page }, use) => {
    const authPage = new AuthPage(page);
    await authPage.goto();
    await authPage.signIn('owner@example.com', 'password123');
    await authPage.waitForRedirect();
    await use(page);
  },

  memberPage: async ({ page }, use) => {
    const authPage = new AuthPage(page);
    await authPage.goto();
    await authPage.signIn('member@example.com', 'password123');
    await authPage.waitForRedirect();
    await use(page);
  },
});

export { expect } from '@playwright/test';

Using the fixture:

// tests/projects/project-permissions.spec.ts
import { test, expect } from '../fixtures/auth.fixture';

test.describe('Project Permissions', () => {
  test('owner should see all project settings', async ({ ownerPage }) => {
    await ownerPage.goto('/projects/123/settings');
    
    await expect(ownerPage.locator('[data-test="danger-zone"]')).toBeVisible();
    await expect(ownerPage.locator('[data-test="delete-button"]')).toBeVisible();
  });

  test('member should not see danger zone', async ({ memberPage }) => {
    await memberPage.goto('/projects/123/settings');
    
    await expect(memberPage.locator('[data-test="danger-zone"]')).not.toBeVisible();
  });
});

Selectors Best Practices

Use Data-Test Attributes

// ✅ GOOD - Stable selectors
await page.click('[data-test="submit-button"]');
await page.fill('[data-test="email-input"]', 'test@example.com');

// ❌ AVOID - Fragile selectors
await page.click('button.primary-btn');
await page.click('text=Submit');
await page.click('.form-container > div:nth-child(2) > button');

Add Data-Test Attributes to Components

// In your React components
<Button data-test="submit-button" type="submit">
  Submit
</Button>

<Input 
  data-test="email-input"
  type="email"
  placeholder="Enter email"
/>

<div data-test="error-message" className="text-red-500">
  {error}
</div>

Selector Priority

  1. data-test attributes - Most stable
  2. ARIA roles - Accessible and semantic
  3. Text content - For user-visible text
  4. CSS selectors - Last resort
// Priority order
await page.locator('[data-test="login-button"]').click();
await page.getByRole('button', { name: 'Login' }).click();
await page.getByText('Login').click();
await page.locator('.login-btn').click();

Handling Async Operations

Wait for Elements

// Wait for element to be visible
await expect(page.locator('[data-test="loading"]')).not.toBeVisible();
await expect(page.locator('[data-test="content"]')).toBeVisible();

// Wait for specific text
await expect(page.locator('[data-test="status"]')).toContainText('Success');

// Wait for URL change
await page.waitForURL('**/dashboard');
await page.waitForURL(/\/projects\/\d+/);

Wait for Network Requests

// Wait for API response
const responsePromise = page.waitForResponse('**/api/projects');
await page.click('[data-test="load-projects"]');
const response = await responsePromise;
expect(response.status()).toBe(200);

// Wait for navigation
await Promise.all([
  page.waitForNavigation(),
  page.click('[data-test="submit-button"]'),
]);

Database Integration

Query Database in Tests

import { test, expect } from '@playwright/test';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

test('should create project in database', async ({ page }) => {
  // Perform UI action
  await page.goto('/projects/new');
  await page.fill('[data-test="project-name"]', 'E2E Test Project');
  await page.click('[data-test="submit"]');
  
  // Wait for success
  await expect(page.locator('[data-test="success-toast"]')).toBeVisible();
  
  // Verify in database
  const { data } = await supabase
    .from('projects')
    .select('*')
    .eq('name', 'E2E Test Project')
    .single();
  
  expect(data).toBeDefined();
  expect(data.name).toBe('E2E Test Project');
});

Extract Tokens from Database

test('should accept invitation via magic link', async ({ page }) => {
  // Create invitation via UI
  await page.goto('/team/members');
  await page.click('[data-test="invite-member"]');
  await page.fill('[data-test="email-input"]', 'newmember@example.com');
  await page.click('[data-test="send-invite"]');
  
  // Get token from database (not visible in UI)
  const { data: invitation } = await supabase
    .from('invitations')
    .select('token')
    .eq('email', 'newmember@example.com')
    .single();
  
  // Use token to accept invitation
  await page.goto(`/join?token=${invitation.token}`);
  await expect(page.locator('[data-test="welcome-message"]')).toBeVisible();
});

Running E2E Tests

Common Commands

# Run all E2E tests
pnpm --filter e2e test

# Run specific test file
pnpm --filter e2e test authentication/sign-in.spec.ts

# Run with visible browser
pnpm --filter e2e test --headed

# Run in debug mode
pnpm --filter e2e test --debug

# Run with UI mode
pnpm --filter e2e test --ui

# Run specific browser
pnpm --filter e2e test --project=chromium
pnpm --filter e2e test --project=firefox

# Generate test report
pnpm --filter e2e test --reporter=html

Debugging Failed Tests

# Run with trace enabled
pnpm --filter e2e test --trace on

# View trace
npx playwright show-trace trace.zip

# Run in debug mode (step through)
pnpm --filter e2e test --debug

# Take screenshots on failure (default)
# Screenshots saved to test-results/

Best Practices

1. Keep Tests Independent

// ✅ GOOD - Each test is independent
test('should create project', async ({ page }) => {
  // Complete setup within test
  await login(page);
  await createProject(page, 'Test Project');
  // Assertions
});

test('should delete project', async ({ page }) => {
  // Complete setup within test
  await login(page);
  const project = await createProject(page, 'To Delete');
  await deleteProject(page, project.id);
  // Assertions
});

// ❌ BAD - Tests depend on each other
let projectId: string;

test('should create project', async ({ page }) => {
  projectId = await createProject(page, 'Test');
});

test('should delete project', async ({ page }) => {
  // Depends on previous test
  await deleteProject(page, projectId);
});

2. Use Serial Mode When Necessary

// For tests that must run in order
test.describe.serial('User Onboarding Flow', () => {
  test('step 1: sign up', async ({ page }) => {
    // ...
  });

  test('step 2: verify email', async ({ page }) => {
    // ...
  });

  test('step 3: complete profile', async ({ page }) => {
    // ...
  });
});

3. Clean Up Test Data

test.afterEach(async () => {
  // Clean up test data
  await supabase
    .from('projects')
    .delete()
    .like('name', 'E2E Test%');
});

4. Handle Flaky Tests

// Retry flaky tests
test('potentially flaky test', async ({ page }) => {
  test.slow(); // Mark as slow
  
  // Add explicit waits
  await page.waitForLoadState('networkidle');
  
  // Use retry assertions
  await expect(page.locator('[data-test="data"]'))
    .toBeVisible({ timeout: 10000 });
});

Next Steps

On this page