StarterApp Docs
Testing

E2E Smoke Tests

Critical user journey validation with Playwright

End-to-end tests confirm that applications work in real browsers. These tests catch issues that other approaches miss - browser quirks, JavaScript errors, and broken user flows. Place E2E tests in the e2e/ directory with *.spec.ts naming.

E2E Testing Philosophy

Test critical user journeys in production-like environments. Keep tests fast and focused on blocking issues. Run as deployment gates.

Playwright Configuration

The framework uses Playwright for browser automation:

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

export default defineConfig({
  testDir: "./e2e/tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",
  use: {
    baseURL: process.env.APP_BASE_URL || "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"] }
    }
  ],
  webServer: {
    command: "pnpm dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI
  }
});

Smoke Test Pattern

Ultra-fast PR blocking checks for critical paths:

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

/**
 * Smoke Tests - Ultra-fast PR blocking checks
 *
 * Minimal critical path validation:
 * - App starts and loads
 * - Protected routes gate properly
 *
 * These tests should run in <30 seconds for PR feedback.
 */

test.describe("Smoke Tests", () => {
  // Collect client-side errors but ignore benign noise
  test.beforeEach(async ({ page }) => {
    const benign = [
      /favicon\.ico/i,
      /manifest\.webmanifest/i,
      /DevTools.*source map/i,
      /Failed to load resource.*404/i,
      /chrome-extension:\/\//i,
      /metadataBase/i,
      /Invalid environment variables/i
    ];

    (page as any)._errors = [] as string[];

    page.on("pageerror", (err) => {
      const message = err?.message || String(err);
      if (!benign.some((re) => re.test(message))) {
        (page as any)._errors.push(`pageerror: ${message}`);
      }
    });

    page.on("console", (msg) => {
      if (msg.type() === "error") {
        const text = msg.text();
        if (!benign.some((re) => re.test(text))) {
          (page as any)._errors.push(`console.error: ${text}`);
        }
      }
    });
  });

  test("app should start and load homepage", async ({ page }) => {
    await page.goto("/");
    await page.waitForLoadState("domcontentloaded");

    const errors = (page as any)._errors as string[];
    expect(
      errors,
      `Client errors detected:\n${errors.join("\n")}`
    ).toHaveLength(0);

    // Should have basic page structure
    const hasContent = await page.locator("body").textContent();
    expect(hasContent).toBeTruthy();
    expect(hasContent?.length).toBeGreaterThan(10);
  });

  test("protected routes should redirect unauthenticated users", async ({
    page
  }) => {
    // Clear any existing auth
    await page.context().clearCookies();

    // Try to access protected route
    await page.goto("/dashboard");

    // Should redirect to sign-in
    await page.waitForURL(/\/sign-in/);
    expect(page.url()).toMatch(/\/sign-in/);
  });

  test("public routes should be accessible", async ({ page }) => {
    // Test pricing page
    await page.goto("/pricing");
    await page.waitForLoadState("domcontentloaded");

    // Verify we're on the pricing page
    expect(page.url()).toContain("/pricing");

    // Should have content
    const hasContent = await page.locator("body").textContent();
    expect(hasContent).toBeTruthy();
  });
});

Error Filtering

Filter benign console errors to focus on real issues. Favicon 404s and browser extension errors are not application failures.

Security Header Testing

E2E tests validate security headers in production-like environments:

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

test("should apply security headers on dashboard page", async ({ page }) => {
  const dashboardOrigin = process.env.DASHBOARD_BASE_URL ?? "http://localhost:3001";
  const response = await page.request.get(`${dashboardOrigin}/sign-in`);

  const headers = response.headers();

  // CSP with nonce and strict-dynamic
  const csp = headers["content-security-policy"];
  expect(csp).toBeTruthy();
  expect(headers["x-nonce"]).toBeTruthy();
  expect(csp).toMatch(/script-src[^;]*'nonce-/);
  expect(csp).toContain("'strict-dynamic'");

  const scriptDirective = csp.match(/script-src[^;]*/)?.[0] ?? "";
  expect(scriptDirective).not.toContain("'unsafe-inline'");

  // Style nonce
  const styleDirective = csp.match(/style-src[^;]*/)?.[0] ?? "";
  expect(styleDirective).toContain("'nonce-");

  // Standard security headers
  expect(headers["referrer-policy"]).toBe("strict-origin-when-cross-origin");
  expect(headers["x-content-type-options"]).toBe("nosniff");
});
test("should apply security headers on marketing pages", async ({ page }) => {
  const response = await page.goto("/pricing");
  const headers = response?.headers();

  // CSP with unsafe-inline (no strict-dynamic)
  const csp =
    headers["content-security-policy"] ||
    headers["content-security-policy-report-only"];

  expect(csp).toBeTruthy();
  expect(csp).toContain("default-src");
  expect(csp).toContain("'unsafe-inline'");
  expect(headers["x-nonce"]).toBeFalsy();
});
test("should NOT apply CSP to API routes", async ({ page }) => {
  // Make a direct request to an API endpoint
  const response = await page.request.get("/api/support", {
    headers: { Accept: "application/json" }
  });

  const headers = response.headers();

  // API routes should not have CSP from middleware
  expect(headers["content-security-policy"]).toBeFalsy();
  expect(headers["content-security-policy-report-only"]).toBeFalsy();

  // API routes should not get page-level security headers
  expect(headers["permissions-policy"]).toBeFalsy();
});

CSP Directive Validation

Comprehensive CSP testing for external resources:

test("should verify CSP directives for external resources", async ({
  page
}) => {
  const response = await page.goto("/");
  const headers = response?.headers();
  const csp =
    headers["content-security-policy"] ||
    headers["content-security-policy-report-only"];

  // Verify font sources
  expect(csp).toContain("font-src 'self' https://fonts.gstatic.com data:");

  // Verify style sources include Google Fonts
  expect(csp).toContain(
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com"
  );

  // Verify img-src is locked down to known hosts
  expect(csp).toContain("img-src 'self' data: blob:");
  expect(csp).toContain("https://lh3.googleusercontent.com");
  expect(csp).toContain("https://avatars.githubusercontent.com");
  expect(csp).toContain("https://secure.gravatar.com");
  expect(csp).toContain("https://www.gravatar.com");

  // Verify connect-src includes Convex domains
  expect(csp).toContain("connect-src 'self'");
  expect(csp).toContain("https://*.convexusercontent.com");
  expect(csp).toContain("wss://*.convex.cloud");
  expect(csp).toContain("https://*.convex.cloud");
  expect(csp).toContain("https://*.convex.site");
  expect(csp).toContain("wss://*.convex.site");
  expect(csp).toContain("https://*.posthog.com");
});

Conservative Security Headers

Modern security headers for all pages:

test("middleware provides comprehensive security headers", async ({
  page
}) => {
  const response = await page.goto("/");
  const headers = response?.headers();

  const csp =
    headers["content-security-policy"] ||
    headers["content-security-policy-report-only"];

  // Verify CSP includes frame-ancestors directive
  expect(csp).toContain("frame-ancestors 'none'");
  expect(headers["x-frame-options"]).toBeFalsy(); // Replaced by frame-ancestors

  // Verify new CSP directives
  expect(csp).toContain("object-src 'none'");
  expect(csp).toContain("worker-src 'self' blob:");
  expect(csp).toContain("manifest-src 'self'");

  // Verify conservative headers
  expect(headers["origin-agent-cluster"]).toBe("?1");
  expect(headers["x-dns-prefetch-control"]).toBe("off");
  expect(headers["x-permitted-cross-domain-policies"]).toBe("none");
});

Heads up

X-Frame-Options is deprecated. Use CSP frame-ancestors directive instead for better control.

Error Page Security

Security headers apply to 404 and error pages:

test("should apply security headers on error pages", async ({ page }) => {
  const notFoundResponse = await page.goto(
    "/this-page-definitely-does-not-exist-404",
    { waitUntil: "domcontentloaded" }
  );

  const notFoundHeaders = notFoundResponse?.headers();

  // Verify CSP on 404 pages
  const csp404 =
    notFoundHeaders["content-security-policy"] ||
    notFoundHeaders["content-security-policy-report-only"];

  expect(csp404).toBeTruthy();
  expect(csp404).toContain("frame-ancestors 'none'");

  // Verify conservative headers on error pages
  expect(notFoundHeaders["origin-agent-cluster"]).toBe("?1");
  expect(notFoundHeaders["x-dns-prefetch-control"]).toBe("off");
  expect(notFoundHeaders["x-content-type-options"]).toBe("nosniff");
  expect(notFoundHeaders["referrer-policy"]).toBe(
    "strict-origin-when-cross-origin"
  );
});

Running E2E Tests

# Run all E2E tests
pnpm test:e2e

# Run specific test file
pnpm playwright test e2e/tests/smoke.spec.ts

# Run in headed mode (see browser)
pnpm playwright test --headed

# Run in specific browser
pnpm playwright test --project=chromium
- name: Install Playwright Browsers
  run: pnpm playwright install --with-deps

- name: Run E2E smoke tests
  run: pnpm test:e2e
  env:
    APP_BASE_URL: ${{ secrets.APP_BASE_URL }}
    DASHBOARD_BASE_URL: ${{ secrets.DASHBOARD_BASE_URL }}

- name: Upload Playwright Report
  uses: actions/upload-artifact@v3
  if: always()
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 30
# Run with Playwright Inspector
pnpm playwright test --debug

# Generate trace for failed tests
pnpm playwright test --trace on

# Show trace viewer
pnpm playwright show-trace trace.zip

Best Practices

Keep tests fast - Target under 30 seconds for smoke tests:

// Good: Direct navigation and simple assertions
test("homepage loads", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator("body")).toBeVisible();
});

// Bad: Complex user interactions in smoke tests
test("complete checkout flow", async ({ page }) => {
  // This belongs in a separate E2E suite, not smoke tests
});

Test HTML vs API separately - Different assertions for different response types:

// HTML page - check headers via page.goto()
const response = await page.goto("/");
expect(response?.headers()["content-security-policy"]).toBeTruthy();

// API endpoint - check headers via page.request
const apiResponse = await page.request.get("/api/data");
expect(apiResponse.headers()["content-type"]).toContain("application/json");

Filter benign errors -

Focus on actionable failures:

const benign = [
  /favicon\.ico/i,
  /chrome-extension/i,
  /DevTools.*source map/i
];

page.on("console", (msg) => {
  if (msg.type() === "error") {
    const text = msg.text();
    if (!benign.some((re) => re.test(text))) {
      errors.push(text);
    }
  }
});

Use production builds -

E2E tests run against production builds:

// playwright.config.ts
webServer: {
  command: "pnpm build && pnpm start",
  url: "http://localhost:3000",
  reuseExistingServer: !process.env.CI
}

Debugging Failed Tests

E2E Scope

Focus E2E tests on critical paths:

Test TypeCoverageTarget Time
SmokeHomepage loads, protected routes redirect, no console errorsUnder 30s
SecurityCSP headers, HSTS when enabled, frame-ancestors~1min
Not in ScopeFull user journeys, complex interactions, visual regression, auth flowsSeparate suite

Pro Tip

Keep smoke tests under 30 seconds for fast PR feedback. Save complex user journeys for dedicated E2E suites that run less frequently.

Next Steps