VibecoderMcSwaggins's picture
feat(frontend): React + Vite + NiiVue frontend (replaces Gradio) (#32)
e4daa3b unverified
|
raw
history blame
14.4 kB

Spec 37.5: E2E Tests & CI/CD

Status: READY FOR IMPLEMENTATION Phase: 5 of 5 Depends On: Spec 37.4 (App Integration) Goal: End-to-end tests with Playwright and GitHub Actions CI pipeline


Deliverables

By the end of this phase, you will have:

  1. Playwright E2E tests for critical user flows
  2. Page Object Models for maintainable tests
  3. GitHub Actions workflow for CI
  4. Coverage reporting integration

Step 1: Install Playwright

cd frontend
npm install -D @playwright/test@1.49.1
npx playwright install

Step 2: Playwright Configuration

Create playwright.config.ts:

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

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['list'],
    ...(process.env.CI ? [['github' as const]] : []),
  ],
  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    // Uncomment for cross-browser testing:
    // {
    //   name: 'firefox',
    //   use: { ...devices['Desktop Firefox'] },
    // },
    // {
    //   name: 'webkit',
    //   use: { ...devices['Desktop Safari'] },
    // },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
})

Step 3: Update package.json Scripts

Add to package.json scripts:

{
  "scripts": {
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:e2e:headed": "playwright test --headed",
    "test:e2e:debug": "playwright test --debug"
  }
}

Step 4: Page Object Model

Create e2e/pages/HomePage.ts:

import { type Page, type Locator, expect } from '@playwright/test'

export class HomePage {
  readonly page: Page
  readonly heading: Locator
  readonly caseSelector: Locator
  readonly runButton: Locator
  readonly processingText: Locator
  readonly metricsPanel: Locator
  readonly diceScore: Locator
  readonly viewer: Locator
  readonly placeholderText: Locator
  readonly errorAlert: Locator

  constructor(page: Page) {
    this.page = page
    this.heading = page.getByRole('heading', {
      name: /stroke lesion segmentation/i,
    })
    this.caseSelector = page.getByRole('combobox')
    this.runButton = page.getByRole('button', { name: /run segmentation/i })
    this.processingText = page.getByText(/processing/i)
    this.metricsPanel = page.getByRole('heading', { name: /results/i })
    this.diceScore = page.getByText(/0\.\d{3}/)
    this.viewer = page.locator('canvas')
    this.placeholderText = page.getByText(/select a case and run segmentation/i)
    this.errorAlert = page.getByRole('alert')
  }

  async goto() {
    await this.page.goto('/')
    await expect(this.heading).toBeVisible()
  }

  async waitForCasesToLoad() {
    await expect(this.caseSelector).toBeEnabled({ timeout: 10000 })
  }

  async selectCase(caseId: string) {
    await this.caseSelector.selectOption(caseId)
  }

  async runSegmentation() {
    await this.runButton.click()
  }

  async waitForResults() {
    await expect(this.metricsPanel).toBeVisible({ timeout: 30000 })
  }

  async expectViewerVisible() {
    await expect(this.viewer).toBeVisible()
  }

  async expectPlaceholderVisible() {
    await expect(this.placeholderText).toBeVisible()
  }

  async expectErrorVisible() {
    await expect(this.errorAlert).toBeVisible()
  }
}

Step 5: Global API Mocking Fixture

CRITICAL: E2E tests run against npm run dev which has no backend. Without API mocking, tests will hang or fail on API calls.

Create e2e/fixtures.ts - Global mock for all API calls:

import { test as base, expect } from '@playwright/test'

// API response mocks matching MSW handlers
const MOCK_CASES = ['sub-stroke0001', 'sub-stroke0002', 'sub-stroke0003']
const MOCK_SEGMENT_RESPONSE = {
  caseId: 'sub-stroke0001',
  diceScore: 0.847,
  volumeMl: 15.32,
  elapsedSeconds: 12.5,
  // Use a real public NIfTI for visual testing (NiiVue demo image)
  dwiUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
  predictionUrl: 'https://niivue.github.io/niivue-demo-images/mni152.nii.gz',
}

// Extend base test to include API mocking
export const test = base.extend({
  // Auto-mock API routes for every test
  page: async ({ page }, use) => {
    // Mock GET /api/cases
    await page.route('**/api/cases', (route) => {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ cases: MOCK_CASES }),
      })
    })

    // Mock POST /api/segment - return different caseId based on request
    await page.route('**/api/segment', async (route) => {
      const request = route.request()
      const body = JSON.parse(request.postData() || '{}')

      // Simulate network delay
      await new Promise((r) => setTimeout(r, 200))

      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({
          ...MOCK_SEGMENT_RESPONSE,
          caseId: body.case_id || 'sub-stroke0001',
        }),
      })
    })

    await use(page)
  },
})

export { expect }

Step 6: E2E Tests

Create e2e/home.spec.ts:

import { test, expect } from './fixtures'
import { HomePage } from './pages/HomePage'

test.describe('Home Page', () => {
  test('displays main heading', async ({ page }) => {
    const homePage = new HomePage(page)
    await homePage.goto()

    await expect(homePage.heading).toBeVisible()
  })

  test('loads case selector with options', async ({ page }) => {
    const homePage = new HomePage(page)
    await homePage.goto()
    await homePage.waitForCasesToLoad()

    // Verify selector has options
    const options = await homePage.caseSelector.locator('option').count()
    expect(options).toBeGreaterThan(1) // placeholder + cases
  })

  test('shows placeholder viewer initially', async ({ page }) => {
    const homePage = new HomePage(page)
    await homePage.goto()

    await homePage.expectPlaceholderVisible()
  })

  test('run button disabled without case selected', async ({ page }) => {
    const homePage = new HomePage(page)
    await homePage.goto()
    await homePage.waitForCasesToLoad()

    await expect(homePage.runButton).toBeDisabled()
  })
})

Create e2e/segmentation-flow.spec.ts:

import { test, expect } from './fixtures'
import { HomePage } from './pages/HomePage'

test.describe('Segmentation Flow', () => {
  test('complete segmentation workflow', async ({ page }) => {
    const homePage = new HomePage(page)
    await homePage.goto()
    await homePage.waitForCasesToLoad()

    // Select a case
    await homePage.selectCase('sub-stroke0001')
    await expect(homePage.runButton).toBeEnabled()

    // Run segmentation
    await homePage.runSegmentation()

    // Verify processing state
    await expect(homePage.processingText).toBeVisible()

    // Wait for results
    await homePage.waitForResults()

    // Verify results displayed
    await expect(homePage.diceScore).toBeVisible()
    await homePage.expectViewerVisible()

    // Placeholder should be gone
    await expect(homePage.placeholderText).not.toBeVisible()
  })

  test('can run multiple segmentations', async ({ page }) => {
    const homePage = new HomePage(page)
    await homePage.goto()
    await homePage.waitForCasesToLoad()

    // First run
    await homePage.selectCase('sub-stroke0001')
    await homePage.runSegmentation()
    await homePage.waitForResults()

    // Second run with different case
    await homePage.selectCase('sub-stroke0002')
    await homePage.runSegmentation()
    await homePage.waitForResults()

    // Results should still be visible
    await expect(homePage.metricsPanel).toBeVisible()
  })
})

Create e2e/error-handling.spec.ts:

import { test as base, expect } from '@playwright/test'
import { HomePage } from './pages/HomePage'

// Error tests need to override the default mocks, so use base test
const test = base

test.describe('Error Handling', () => {
  test('shows error when API fails', async ({ page }) => {
    // Mock cases API (needed for page to load)
    await page.route('**/api/cases', (route) => {
      route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ cases: ['sub-stroke0001'] }),
      })
    })

    // Mock segment API to return error
    await page.route('**/api/segment', (route) => {
      route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ detail: 'Segmentation failed' }),
      })
    })

    const homePage = new HomePage(page)
    await homePage.goto()
    await homePage.waitForCasesToLoad()

    await homePage.selectCase('sub-stroke0001')
    await homePage.runSegmentation()

    await homePage.expectErrorVisible()
    await expect(homePage.errorAlert).toContainText(/failed/i)
  })

  test('shows error when cases fail to load', async ({ page }) => {
    // Mock cases API to return error
    await page.route('**/api/cases', (route) => {
      route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ detail: 'Server error' }),
      })
    })

    const homePage = new HomePage(page)
    await homePage.goto()

    // Case selector should show error state
    await expect(page.getByText(/failed to load/i)).toBeVisible()
  })
})

Step 7: GitHub Actions CI Workflow

Create .github/workflows/frontend-ci.yml:

name: Frontend CI

on:
  push:
    branches: [main]
    paths:
      - 'frontend/**'
      - '.github/workflows/frontend-ci.yml'
  pull_request:
    paths:
      - 'frontend/**'

defaults:
  run:
    working-directory: frontend

jobs:
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - run: npm ci
      - run: npx tsc --noEmit

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - run: npm ci
      - run: npm run test:coverage

      - uses: codecov/codecov-action@v4
        with:
          files: frontend/coverage/coverage-final.json
          flags: frontend
          fail_ci_if_error: false

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - run: npm run test:e2e

      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: frontend/playwright-report/
          retention-days: 7

  build:
    runs-on: ubuntu-latest
    needs: [typecheck, test]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: frontend/package-lock.json

      - run: npm ci
      - run: npm run build

      - uses: actions/upload-artifact@v4
        with:
          name: frontend-dist
          path: frontend/dist/
          retention-days: 7

Step 8: Add Coverage Thresholds

Update vite.config.ts coverage section:

coverage: {
  provider: 'v8',
  reporter: ['text', 'json', 'html'],
  include: ['src/**/*.{ts,tsx}'],
  exclude: [
    'src/**/*.test.{ts,tsx}',
    'src/test/**',
    'src/mocks/**',
    'src/main.tsx',
    'src/vite-env.d.ts',
  ],
  thresholds: {
    statements: 80,
    branches: 75,
    functions: 80,
    lines: 80,
  },
},

Step 9: Run All Tests

# Unit & Integration tests
npm test
# Expected: ~45+ tests passing

# E2E tests
npm run test:e2e
# Expected: 7 tests passing

# Coverage report
npm run test:coverage
# Expected: >80% coverage

Verification Checklist

  • npm test - All unit/integration tests pass
  • npm run test:coverage - Coverage meets thresholds
  • npm run test:e2e - All E2E tests pass
  • npm run build - Production build succeeds
  • CI workflow runs successfully (push to branch)

File Structure After This Phase

frontend/
β”œβ”€β”€ e2e/
β”‚   β”œβ”€β”€ pages/
β”‚   β”‚   └── HomePage.ts
β”‚   β”œβ”€β”€ fixtures.ts              # <-- NEW: Global API mocking
β”‚   β”œβ”€β”€ home.spec.ts
β”‚   β”œβ”€β”€ segmentation-flow.spec.ts
β”‚   └── error-handling.spec.ts
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ types/
β”‚   β”œβ”€β”€ test/
β”‚   β”œβ”€β”€ mocks/
β”‚   β”œβ”€β”€ App.tsx
β”‚   β”œβ”€β”€ App.test.tsx
β”‚   └── ...
β”œβ”€β”€ .github/
β”‚   └── workflows/
β”‚       └── frontend-ci.yml
β”œβ”€β”€ playwright.config.ts
β”œβ”€β”€ vite.config.ts
β”œβ”€β”€ package.json
└── ...

Summary: Complete Testing Stack

Layer Tool Test Count Purpose
Unit Vitest + RTL ~35 Component isolation
Integration Vitest + MSW ~15 API + hooks
E2E Playwright ~7 Full user flows
Total ~57

Next Steps After All Phases Complete

  1. Deploy Frontend: Push to HuggingFace Static Space
  2. Connect to Backend: Update VITE_API_URL to real backend
  3. Test Against Real API: Run E2E tests with real backend
  4. Monitor: Set up error tracking (optional)

Congratulations!

You now have a fully tested React frontend with:

  • Type-safe TypeScript code
  • Comprehensive unit tests
  • API mocking with MSW
  • End-to-end browser tests
  • Automated CI/CD pipeline
  • 80%+ code coverage