diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..7686871 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,327 @@ +name: E2E Tests + +on: + push: + branches: [main, develop] + paths: + - 'frontend/**' + - 'src/**' + - '.github/workflows/e2e.yml' + pull_request: + branches: [main, develop] + paths: + - 'frontend/**' + - 'src/**' + - '.github/workflows/e2e.yml' + +jobs: + e2e-tests: + name: Run E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + + defaults: + run: + working-directory: frontend + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: mockupaws + POSTGRES_PASSWORD: mockupaws + POSTGRES_DB: mockupaws + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + pip install -r requirements.txt + working-directory: . + + - name: Install Node.js dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium firefox webkit + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U mockupaws; do + echo "Waiting for PostgreSQL..." + sleep 1 + done + + - name: Run database migrations + run: | + alembic upgrade head + env: + DATABASE_URL: postgresql://mockupaws:mockupaws@localhost:5432/mockupaws + + - name: Start backend server + run: | + uvicorn src.main:app --host 0.0.0.0 --port 8000 & + echo $! > /tmp/backend.pid + # Wait for backend to be ready + npx wait-on http://localhost:8000/health --timeout 60000 + env: + DATABASE_URL: postgresql://mockupaws:mockupaws@localhost:5432/mockupaws + CORS_ORIGINS: "[\"http://localhost:5173\"]" + + - name: Run E2E tests + run: npm run test:e2e:ci + env: + VITE_API_URL: http://localhost:8000/api/v1 + CI: true + + - name: Stop backend server + if: always() + run: | + if [ -f /tmp/backend.pid ]; then + kill $(cat /tmp/backend.pid) || true + fi + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: frontend/e2e-report/ + retention-days: 30 + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: frontend/e2e-results/ + retention-days: 7 + + - name: Upload screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: screenshots + path: frontend/e2e/screenshots/ + retention-days: 7 + + visual-regression: + name: Visual Regression Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: e2e-tests + if: github.event_name == 'pull_request' + + defaults: + run: + working-directory: frontend + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: mockupaws + POSTGRES_PASSWORD: mockupaws + POSTGRES_DB: mockupaws + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Checkout baseline screenshots + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + path: baseline + sparse-checkout: | + frontend/e2e/screenshots/baseline/ + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + pip install -r requirements.txt + working-directory: . + + - name: Install Node.js dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U mockupaws; do + echo "Waiting for PostgreSQL..." + sleep 1 + done + + - name: Run database migrations + run: | + alembic upgrade head + env: + DATABASE_URL: postgresql://mockupaws:mockupaws@localhost:5432/mockupaws + + - name: Start backend server + run: | + uvicorn src.main:app --host 0.0.0.0 --port 8000 & + echo $! > /tmp/backend.pid + npx wait-on http://localhost:8000/health --timeout 60000 + env: + DATABASE_URL: postgresql://mockupaws:mockupaws@localhost:5432/mockupaws + CORS_ORIGINS: "[\"http://localhost:5173\"]" + + - name: Copy baseline screenshots + run: | + if [ -d "../baseline/frontend/e2e/screenshots/baseline" ]; then + mkdir -p e2e/screenshots/baseline + cp -r ../baseline/frontend/e2e/screenshots/baseline/* e2e/screenshots/baseline/ + fi + + - name: Run visual regression tests + run: npx playwright test visual-regression.spec.ts --project=chromium + env: + VITE_API_URL: http://localhost:8000/api/v1 + CI: true + + - name: Stop backend server + if: always() + run: | + if [ -f /tmp/backend.pid ]; then + kill $(cat /tmp/backend.pid) || true + fi + + - name: Upload visual regression results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-regression-diff + path: | + frontend/e2e/screenshots/actual/ + frontend/e2e/screenshots/diff/ + retention-days: 7 + + smoke-tests: + name: Smoke Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + if: github.event_name == 'push' + + defaults: + run: + working-directory: frontend + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: mockupaws + POSTGRES_PASSWORD: mockupaws + POSTGRES_DB: mockupaws + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + pip install -r requirements.txt + working-directory: . + + - name: Install Node.js dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U mockupaws; do + echo "Waiting for PostgreSQL..." + sleep 1 + done + + - name: Run database migrations + run: | + alembic upgrade head + env: + DATABASE_URL: postgresql://mockupaws:mockupaws@localhost:5432/mockupaws + + - name: Start backend server + run: | + uvicorn src.main:app --host 0.0.0.0 --port 8000 & + echo $! > /tmp/backend.pid + npx wait-on http://localhost:8000/health --timeout 60000 + env: + DATABASE_URL: postgresql://mockupaws:mockupaws@localhost:5432/mockupaws + CORS_ORIGINS: "[\"http://localhost:5173\"]" + + - name: Run smoke tests + run: npx playwright test navigation.spec.ts --grep "dashboard\|scenarios" --project=chromium + env: + VITE_API_URL: http://localhost:8000/api/v1 + CI: true + + - name: Stop backend server + if: always() + run: | + if [ -f /tmp/backend.pid ]; then + kill $(cat /tmp/backend.pid) || true + fi diff --git a/E2E_SETUP_SUMMARY.md b/E2E_SETUP_SUMMARY.md new file mode 100644 index 0000000..02ad430 --- /dev/null +++ b/E2E_SETUP_SUMMARY.md @@ -0,0 +1,275 @@ +# E2E Testing Setup Summary for mockupAWS v0.4.0 + +## Overview + +End-to-End testing has been successfully set up with Playwright for mockupAWS v0.4.0. This setup includes comprehensive test coverage for all major user flows, visual regression testing, and CI/CD integration. + +## Files Created + +### Configuration Files + +| File | Path | Description | +|------|------|-------------| +| `playwright.config.ts` | `/frontend/playwright.config.ts` | Main Playwright configuration with multi-browser support | +| `package.json` (updated) | `/frontend/package.json` | Added Playwright dependency and npm scripts | +| `tsconfig.json` | `/frontend/e2e/tsconfig.json` | TypeScript configuration for E2E tests | +| `.gitignore` (updated) | `/frontend/.gitignore` | Excludes test artifacts from git | +| `e2e.yml` | `/.github/workflows/e2e.yml` | GitHub Actions workflow for CI | + +### Test Files + +| Test File | Description | Test Count | +|-----------|-------------|------------| +| `setup-verification.spec.ts` | Verifies E2E environment setup | 9 tests | +| `scenario-crud.spec.ts` | Scenario create, read, update, delete | 11 tests | +| `ingest-logs.spec.ts` | Log ingestion and metrics updates | 9 tests | +| `reports.spec.ts` | Report generation and download | 10 tests | +| `comparison.spec.ts` | Scenario comparison features | 16 tests | +| `navigation.spec.ts` | Routing, 404, mobile responsive | 21 tests | +| `visual-regression.spec.ts` | Visual regression testing | 18 tests | + +**Total: 94 test cases across 7 test files** + +### Supporting Files + +| File | Path | Description | +|------|------|-------------| +| `test-scenarios.ts` | `/e2e/fixtures/test-scenarios.ts` | Sample scenario data for tests | +| `test-logs.ts` | `/e2e/fixtures/test-logs.ts` | Sample log data for tests | +| `test-helpers.ts` | `/e2e/utils/test-helpers.ts` | Shared test utilities | +| `global-setup.ts` | `/e2e/global-setup.ts` | Global test setup (runs once) | +| `global-teardown.ts` | `/e2e/global-teardown.ts` | Global test teardown (runs once) | +| `README.md` | `/e2e/README.md` | Comprehensive testing guide | + +## NPM Scripts Added + +```json +{ + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ci": "playwright test --reporter=dot,html" +} +``` + +## Playwright Configuration Highlights + +### Browsers Configured +- **Chromium** (Desktop Chrome) +- **Firefox** (Desktop Firefox) +- **Webkit** (Desktop Safari) +- **Mobile Chrome** (Pixel 5) +- **Mobile Safari** (iPhone 12) +- **Tablet** (iPad Pro 11) + +### Features Enabled +- ✅ Screenshot capture on failure +- ✅ Video recording for debugging +- ✅ Trace collection on retry +- ✅ HTML, list, and JUnit reporters +- ✅ Parallel execution (disabled in CI) +- ✅ Automatic test server startup +- ✅ Global setup and teardown hooks + +### Timeouts +- Test timeout: 60 seconds +- Action timeout: 15 seconds +- Navigation timeout: 30 seconds +- Expect timeout: 10 seconds + +## Test Coverage + +### QA-E2E-001: Playwright Setup ✅ +- [x] `@playwright/test` installed +- [x] `playwright.config.ts` created +- [x] Test directory: `frontend/e2e/` +- [x] Base URL: http://localhost:5173 +- [x] Multiple browsers configured +- [x] Screenshot on failure +- [x] Video recording for debugging +- [x] NPM scripts added + +### QA-E2E-002: Test Scenarios ✅ +- [x] `scenario-crud.spec.ts` - Create, edit, delete scenarios +- [x] `ingest-logs.spec.ts` - Log ingestion and metrics +- [x] `reports.spec.ts` - PDF/CSV report generation +- [x] `comparison.spec.ts` - Multi-scenario comparison +- [x] `navigation.spec.ts` - All routes and responsive design + +### QA-E2E-003: Test Data & Fixtures ✅ +- [x] `test-scenarios.ts` - Sample scenario data +- [x] `test-logs.ts` - Sample log data +- [x] Database seeding via API helpers +- [x] Cleanup mechanism after tests +- [x] Parallel execution configured + +### QA-E2E-004: Visual Regression Testing ✅ +- [x] Visual regression setup with Playwright +- [x] Baseline screenshots directory +- [x] 20% threshold for differences +- [x] Tests for critical UI pages +- [x] Dark mode testing support +- [x] Cross-browser visual testing + +## How to Run Tests + +### Local Development + +```bash +# Install dependencies +cd frontend +npm install + +# Install Playwright browsers +npx playwright install + +# Run all tests +npm run test:e2e + +# Run with UI mode (interactive) +npm run test:e2e:ui + +# Run specific test file +npx playwright test scenario-crud.spec.ts + +# Run in debug mode +npm run test:e2e:debug + +# Run with visible browser +npm run test:e2e:headed +``` + +### CI Mode + +```bash +# Run tests as in CI +npm run test:e2e:ci +``` + +### Visual Regression + +```bash +# Run visual tests +npx playwright test visual-regression.spec.ts + +# Update baseline screenshots +UPDATE_BASELINE=true npx playwright test visual-regression.spec.ts +``` + +## Prerequisites + +1. **Backend running** on http://localhost:8000 +2. **Frontend dev server** will be started automatically by Playwright +3. **PostgreSQL** database (if using full backend) + +## Coverage Report Strategy + +### Current Setup +- HTML reporter generates `e2e-report/` directory +- JUnit XML output for CI integration +- Screenshots and videos on failure +- Trace files for debugging + +### Future Enhancements +To add code coverage: + +1. **Frontend Coverage**: + ```bash + npm install -D @playwright/test istanbul-lib-coverage nyc + ``` + Instrument code with Istanbul and collect coverage during tests. + +2. **Backend Coverage**: + Use pytest-cov with Playwright tests to measure API coverage. + +3. **Coverage Reporting**: + - Upload coverage reports to codecov.io + - Block PRs if coverage drops below threshold + - Generate coverage badges + +## GitHub Actions Workflow + +The workflow (`/.github/workflows/e2e.yml`) includes: + +1. **E2E Tests Job**: Runs all tests on every push/PR +2. **Visual Regression Job**: Compares screenshots on PRs +3. **Smoke Tests Job**: Quick sanity checks on pushes + +### Workflow Features +- PostgreSQL service container +- Backend server startup +- Artifact upload for reports +- Parallel job execution +- Conditional visual regression on PRs + +## Test Architecture + +### Design Principles +1. **Deterministic**: Tests use unique names and clean up after themselves +2. **Isolated**: Each test creates its own data +3. **Fast**: Parallel execution where possible +4. **Reliable**: Retry logic for flaky operations +5. **Maintainable**: Shared utilities and fixtures + +### Data Flow +``` +Global Setup → Test Suite → Individual Tests → Global Teardown + ↓ ↓ ↓ ↓ + Create dirs Create data Run assertions Cleanup data +``` + +### API Helpers +All test files use shared API helpers for: +- Creating/deleting scenarios +- Starting/stopping scenarios +- Sending logs +- Generating unique names + +## Next Steps + +1. **Run setup verification**: + ```bash + npx playwright test setup-verification.spec.ts + ``` + +2. **Generate baseline screenshots** (for visual regression): + ```bash + UPDATE_BASELINE=true npx playwright test visual-regression.spec.ts + ``` + +3. **Add data-testid attributes** to frontend components for more robust selectors + +4. **Configure environment variables** in `.env` file if needed + +5. **Start backend** and run full test suite: + ```bash + npm run test:e2e + ``` + +## Troubleshooting + +### Common Issues + +1. **Browsers not installed**: + ```bash + npx playwright install + ``` + +2. **Backend not accessible**: + - Ensure backend is running on port 8000 + - Check CORS configuration + +3. **Tests timeout**: + - Increase timeout in `playwright.config.ts` + - Check if dev server starts correctly + +4. **Visual regression failures**: + - Review diff images in `e2e/screenshots/diff/` + - Update baselines if UI intentionally changed + +## Support + +- **Playwright Docs**: https://playwright.dev/ +- **Test Examples**: See `e2e/README.md` +- **GitHub Actions**: Workflow in `.github/workflows/e2e.yml` diff --git a/export/kanban-v0.4.0.md b/export/kanban-v0.4.0.md new file mode 100644 index 0000000..fb8f517 --- /dev/null +++ b/export/kanban-v0.4.0.md @@ -0,0 +1,662 @@ +# Kanban v0.4.0 - Reports, Charts & Comparison + +> **Progetto:** mockupAWS - Backend Profiler & Cost Estimator +> **Versione Target:** v0.4.0 +> **Focus:** Report Generation, Data Visualization, Scenario Comparison +> **Timeline:** 2-3 settimane +> **Priorità:** P1 (High) +> **Data Creazione:** 2026-04-07 + +--- + +## 📊 Panoramica + +| Metrica | Valore | +|---------|--------| +| **Task Totali** | 27 | +| **Backend Tasks** | 5 (BE-RPT-001 → 005) | +| **Frontend Tasks** | 18 (FE-RPT-001 → 004, FE-VIZ-001 → 006, FE-CMP-001 → 004, FE-THM-001 → 004) | +| **QA Tasks** | 4 (QA-E2E-001 → 004) | +| **Priorità P1** | 15 | +| **Priorità P2** | 8 | +| **Priorità P3** | 4 | +| **Effort Totale Stimato** | ~M (Medium) | + +--- + +## 🏷️ Legenda + +### Priorità +- **P1** (High): Feature critiche, bloccano release +- **P2** (Medium): Feature importanti, ma non bloccanti +- **P3** (Low): Nice-to-have, possono essere rimandate + +### Effort +- **S** (Small): 1-2 giorni, task ben definito +- **M** (Medium): 2-4 giorni, richiede ricerca/testing +- **L** (Large): 4-6 giorni, task complesso con dipendenze + +### Stato +- ⏳ **Pending**: Non ancora iniziato +- 🟡 **In Progress**: In lavorazione +- 🟢 **Completed**: Completato e testato +- 🔴 **Blocked**: Bloccato da dipendenze o issue + +--- + +## 🗂️ BACKEND - Report Generation (5 Tasks) + +### BE-RPT-001: Report Service Implementation +| Campo | Valore | +|-------|--------| +| **ID** | BE-RPT-001 | +| **Titolo** | Report Service Implementation | +| **Descrizione** | Implementare `ReportService` con metodi per generazione PDF, CSV e compilazione metriche. Template professionale con logo, header, footer, pagine numerate. | +| **Priorità** | P1 | +| **Effort** | L (4-6 giorni) | +| **Assegnato** | @backend-dev | +| **Dipendenze** | v0.3.0 completata, DB-006 (Reports Table) | +| **Blocca** | BE-RPT-002, BE-RPT-003, FE-RPT-001 | +| **Stato** | ⏳ Pending | +| **Note** | Librerie: reportlab (PDF), pandas (CSV). Includere: summary scenario, cost breakdown, metriche aggregate, top 10 logs, PII violations | + +### BE-RPT-002: Report Generation API +| Campo | Valore | +|-------|--------| +| **ID** | BE-RPT-002 | +| **Titolo** | Report Generation API | +| **Descrizione** | Endpoint `POST /api/v1/scenarios/{id}/reports` con supporto PDF/CSV, date range, sezioni selezionabili. Async task con progress tracking. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @backend-dev | +| **Dipendenze** | BE-RPT-001 | +| **Blocca** | BE-RPT-003, FE-RPT-001, FE-RPT-002 | +| **Stato** | ⏳ Pending | +| **Note** | Response 202 Accepted con report_id. Background task con Celery oppure async FastAPI. Progress via GET /api/v1/reports/{id}/status | + +### BE-RPT-003: Report Download API +| Campo | Valore | +|-------|--------| +| **ID** | BE-RPT-003 | +| **Titolo** | Report Download API | +| **Descrizione** | Endpoint `GET /api/v1/reports/{id}/download` con file stream, headers corretti, Content-Disposition, rate limiting. | +| **Priorità** | P1 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @backend-dev | +| **Dipendenze** | BE-RPT-002 | +| **Blocca** | FE-RPT-003 | +| **Stato** | ⏳ Pending | +| **Note** | Mime types: application/pdf, text/csv. Rate limiting: 10 download/minuto | + +### BE-RPT-004: Report Storage +| Campo | Valore | +|-------|--------| +| **ID** | BE-RPT-004 | +| **Titolo** | Report Storage | +| **Descrizione** | Gestione storage file reports in filesystem (path: ./storage/reports/{scenario_id}/{report_id}.{format}), cleanup automatico dopo 30 giorni. | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @backend-dev | +| **Dipendenze** | BE-RPT-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Max file size: 50MB. Cleanup configurabile. Tabella reports già esistente (DB-006) | + +### BE-RPT-005: Report Templates +| Campo | Valore | +|-------|--------| +| **ID** | BE-RPT-005 | +| **Titolo** | Report Templates | +| **Descrizione** | Template HTML per PDF (Jinja2 + WeasyPrint o ReportLab). Stile professionale con brand mockupAWS, colori coerenti (#0066CC), font Inter/Roboto. | +| **Priorità** | P2 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @backend-dev | +| **Dipendenze** | BE-RPT-001 | +| **Blocca** | FE-RPT-004 | +| **Stato** | ⏳ Pending | +| **Note** | Header con logo, tabelle formattate con zebra striping, pagine numerate | + +--- + +## 🎨 FRONTEND - Report UI (4 Tasks) + +### FE-RPT-001: Report Generation UI +| Campo | Valore | +|-------|--------| +| **ID** | FE-RPT-001 | +| **Titolo** | Report Generation UI | +| **Descrizione** | Nuova pagina `/scenarios/:id/reports` con form per generazione report: select formato (PDF/CSV), checkbox opzioni, date range picker, preview dati inclusi. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | BE-RPT-002 (API disponibile) | +| **Blocca** | FE-RPT-002, FE-RPT-004 | +| **Stato** | ⏳ Pending | +| **Note** | Bottone Generate con loading state. Toast notification quando report pronto | + +### FE-RPT-002: Reports List +| Campo | Valore | +|-------|--------| +| **ID** | FE-RPT-002 | +| **Titolo** | Reports List | +| **Descrizione** | Tabella reports generati per scenario con colonne: Data, Formato, Dimensione, Stato, Azioni. Azioni: Download, Delete, Rigenera. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-RPT-001, BE-RPT-002 | +| **Blocca** | FE-RPT-003 | +| **Stato** | ⏳ Pending | +| **Note** | Badge stato: Pending, Processing, Completed, Failed. Sorting per data (newest first). Pagination se necessario | + +### FE-RPT-003: Report Download Handler +| Campo | Valore | +|-------|--------| +| **ID** | FE-RPT-003 | +| **Titolo** | Report Download Handler | +| **Descrizione** | Download file con nome appropriato `{scenario_name}_YYYY-MM-DD.{format}`. Axios con responseType: 'blob', ObjectURL per trigger download, cleanup. | +| **Priorità** | P1 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-RPT-002, BE-RPT-003 (API download) | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Error handling con toast. Cleanup dopo download per evitare memory leak | + +### FE-RPT-004: Report Preview +| Campo | Valore | +|-------|--------| +| **ID** | FE-RPT-004 | +| **Titolo** | Report Preview | +| **Descrizione** | Preview CSV in tabella (primi 100 record), info box con summary prima di generare, stima dimensione file e costo stimato. | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-RPT-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | UX: aiutare utente a capire cosa sta per esportare prima di generare | + +--- + +## 📊 FRONTEND - Data Visualization (6 Tasks) + +### FE-VIZ-001: Recharts Integration +| Campo | Valore | +|-------|--------| +| **ID** | FE-VIZ-001 | +| **Titolo** | Recharts Integration | +| **Descrizione** | Installazione e setup recharts, date-fns. Setup tema coerente con Tailwind/shadcn, color palette, responsive containers. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-002 (Tailwind + shadcn), v0.3.0 completata | +| **Blocca** | FE-VIZ-002, FE-VIZ-003, FE-VIZ-004, FE-VIZ-005, FE-VIZ-006, FE-CMP-004 | +| **Stato** | ⏳ Pending | +| **Note** | npm install recharts date-fns. Tema dark/light support, responsive containers per tutti i grafici | + +### FE-VIZ-002: Cost Breakdown Chart +| Campo | Valore | +|-------|--------| +| **ID** | FE-VIZ-002 | +| **Titolo** | Cost Breakdown Chart | +| **Descrizione** | Pie Chart o Donut Chart per costo per servizio (SQS, Lambda, Bedrock). Percentuali visualizzate, legend interattiva, tooltip con valori esatti. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-VIZ-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Posizione: Dashboard e Scenario Detail. Legend toggle servizi. Performance: lazy load se necessario | + +### FE-VIZ-003: Time Series Chart +| Campo | Valore | +|-------|--------| +| **ID** | FE-VIZ-003 | +| **Titolo** | Time Series Chart | +| **Descrizione** | Area Chart o Line Chart per metriche nel tempo (requests, costi cumulativi). X-axis timestamp, Y-axis valore, multi-line per metriche diverse. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-VIZ-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Zoom e pan se supportato. Posizione: Scenario Detail (tab Metrics). Performance con molti dati | + +### FE-VIZ-004: Comparison Bar Chart +| Campo | Valore | +|-------|--------| +| **ID** | FE-VIZ-004 | +| **Titolo** | Comparison Bar Chart | +| **Descrizione** | Grouped Bar Chart per confronto metriche tra scenari. X-axis nome scenario, Y-axis valore metrica, selettore metrica. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-VIZ-001, FE-CMP-002 (Compare page) | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Metriche: Costo totale, Requests, SQS blocks, Tokens. Posizione: Compare Page | + +### FE-VIZ-005: Metrics Distribution Chart +| Campo | Valore | +|-------|--------| +| **ID** | FE-VIZ-005 | +| **Titolo** | Metrics Distribution Chart | +| **Descrizione** | Histogram o Box Plot per distribuzione dimensioni log, tempi risposta. Analisi statistica dati. | +| **Priorità** | P2 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-VIZ-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Posizione: Scenario Detail (tab Analysis). Feature nice-to-have per analisi approfondita | + +### FE-VIZ-006: Dashboard Overview Charts +| Campo | Valore | +|-------|--------| +| **ID** | FE-VIZ-006 | +| **Titolo** | Dashboard Overview Charts | +| **Descrizione** | Mini charts nella lista scenari (sparklines), ultimi 7 giorni di attività, quick stats con trend indicator (↑ ↓). | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-VIZ-001, FE-006 (Dashboard Page) | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Migliora UX dashboard con dati visivi immediati. Sparklines: piccoli grafici inline | + +--- + +## 🔍 FRONTEND - Scenario Comparison (4 Tasks) + +### FE-CMP-001: Comparison Selection UI +| Campo | Valore | +|-------|--------| +| **ID** | FE-CMP-001 | +| **Titolo** | Comparison Selection UI | +| **Descrizione** | Checkbox multi-selezione nella lista scenari, bottone "Compare Selected" (enabled quando 2-4 selezionati), modal confirmation. | +| **Priorità** | P1 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-006 (Dashboard Page) | +| **Blocca** | FE-CMP-002 | +| **Stato** | ⏳ Pending | +| **Note** | Max 4 scenari per confronto. Visualizzazione "Comparison Mode" indicator | + +### FE-CMP-002: Compare Page +| Campo | Valore | +|-------|--------| +| **ID** | FE-CMP-002 | +| **Titolo** | Compare Page | +| **Descrizione** | Nuova route `/compare` con layout side-by-side (2 colonne per 2 scenari, 4 per 4). Responsive: mobile scroll orizzontale. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-CMP-001 | +| **Blocca** | FE-CMP-003, FE-CMP-004, FE-VIZ-004 | +| **Stato** | ⏳ Pending | +| **Note** | Header con nome scenario, regione, stato. Summary cards affiancate | + +### FE-CMP-003: Comparison Tables +| Campo | Valore | +|-------|--------| +| **ID** | FE-CMP-003 | +| **Titolo** | Comparison Tables | +| **Descrizione** | Tabella dettagliata con metriche affiancate. Color coding: verde (migliore), rosso (peggiore), grigio (neutro). Delta column con trend arrow. | +| **Priorità** | P1 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-CMP-002 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Export comparison button. Baseline = primo scenario. Ordinamento per costo totale | + +### FE-CMP-004: Visual Comparison +| Campo | Valore | +|-------|--------| +| **ID** | FE-CMP-004 | +| **Titolo** | Visual Comparison | +| **Descrizione** | Grouped bar chart per confronto visivo. Highlight scenario selezionato, toggle metriche da confrontare. | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-CMP-002, FE-VIZ-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Integrazione con grafici già esistenti. UX: toggle per mostrare/nascondere metriche | + +--- + +## 🌓 FRONTEND - Dark/Light Mode (4 Tasks) + +### FE-THM-001: Theme Provider Setup +| Campo | Valore | +|-------|--------| +| **ID** | FE-THM-001 | +| **Titolo** | Theme Provider Setup | +| **Descrizione** | Theme context o Zustand store per gestione tema. Persistenza in localStorage. Default: system preference (media query). Toggle button in Header. | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-002 (Tailwind + shadcn), FE-005 (Layout Components) | +| **Blocca** | FE-THM-002, FE-THM-003, FE-THM-004 | +| **Stato** | ⏳ Pending | +| **Note** | npm install zustand (opzionale). Toggle istantaneo, no flash on load | + +### FE-THM-002: Tailwind Dark Mode Configuration +| Campo | Valore | +|-------|--------| +| **ID** | FE-THM-002 | +| **Titolo** | Tailwind Dark Mode Configuration | +| **Descrizione** | Aggiornare `tailwind.config.js` con `darkMode: 'class'`. Wrapper component con `dark` class sul root. Transition smooth tra temi. | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-THM-001 | +| **Blocca** | FE-THM-003 | +| **Stato** | ⏳ Pending | +| **Note** | CSS transition per cambio tema smooth. No jarring flash | + +### FE-THM-003: Component Theme Support +| Campo | Valore | +|-------|--------| +| **ID** | FE-THM-003 | +| **Titolo** | Component Theme Support | +| **Descrizione** | Verificare tutti i componenti shadcn/ui supportino dark mode. Aggiornare classi custom per dark variant: bg, text, borders, shadows. | +| **Priorità** | P2 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-THM-002 | +| **Blocca** | FE-THM-004 | +| **Stato** | ⏳ Pending | +| **Note** | bg-white → bg-white dark:bg-gray-900, text-gray-900 → text-gray-900 dark:text-white. Hover states | + +### FE-THM-004: Chart Theming +| Campo | Valore | +|-------|--------| +| **ID** | FE-THM-004 | +| **Titolo** | Chart Theming | +| **Descrizione** | Recharts tema dark (colori assi, grid, tooltip). Colori serie dati visibili su entrambi i temi. Background chart trasparente o temizzato. | +| **Priorità** | P2 | +| **Effort** | S (1-2 giorni) | +| **Assegnato** | @frontend-dev | +| **Dipendenze** | FE-VIZ-001 (Recharts integration), FE-THM-003 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Testare contrasto in dark mode. Colori serie devono essere visibili in entrambi i temi | + +--- + +## 🧪 QA - E2E Testing (4 Tasks) + +### QA-E2E-001: Playwright Setup +| Campo | Valore | +|-------|--------| +| **ID** | QA-E2E-001 | +| **Titolo** | Playwright Setup | +| **Descrizione** | Installazione @playwright/test, configurazione playwright.config.ts. Scripts: test:e2e, test:e2e:ui, test:e2e:debug. Setup CI. | +| **Priorità** | P3 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @qa-engineer | +| **Dipendenze** | Frontend stable, v0.4.0 feature complete | +| **Blocca** | QA-E2E-002, QA-E2E-003, QA-E2E-004 | +| **Stato** | ⏳ Pending | +| **Note** | npm install @playwright/test. GitHub Actions oppure CI locale. Configurazione browser, viewport, baseURL | + +### QA-E2E-002: Test Scenarios +| Campo | Valore | +|-------|--------| +| **ID** | QA-E2E-002 | +| **Titolo** | Test Scenarios | +| **Descrizione** | Test: creazione scenario completo, ingestione log e verifica metriche, generazione e download report, navigazione tra pagine, responsive design. | +| **Priorità** | P3 | +| **Effort** | L (4-6 giorni) | +| **Assegnato** | @qa-engineer | +| **Dipendenze** | QA-E2E-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Copertura: user flows principali. Mobile viewport testing. Assert su metriche e costi | + +### QA-E2E-003: Test Data +| Campo | Valore | +|-------|--------| +| **ID** | QA-E2E-003 | +| **Titolo** | Test Data | +| **Descrizione** | Fixtures per scenari di test, seed database per test, cleanup dopo ogni test. Parallel execution config. | +| **Priorità** | P3 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @qa-engineer | +| **Dipendenze** | QA-E2E-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Isolamento test: ogni test con dati puliti. Cleanup automatico per evitare interferenze | + +### QA-E2E-004: Visual Regression +| Campo | Valore | +|-------|--------| +| **ID** | QA-E2E-004 | +| **Titolo** | Visual Regression | +| **Descrizione** | Screenshot testing per UI critica. Baseline images in repo. Fallimento test se diff > threshold. | +| **Priorità** | P3 | +| **Effort** | M (2-4 giorni) | +| **Assegnato** | @qa-engineer | +| **Dipendenze** | QA-E2E-001 | +| **Blocca** | - | +| **Stato** | ⏳ Pending | +| **Note** | Componenti critici: Dashboard, Scenario Detail, Report Generation, Compare Page | + +--- + +## 📅 Timeline Dettagliata + +### Week 1: Foundation & Reports (Giorni 1-5) + +| Giorno | Task | Focus | Output | +|--------|------|-------|--------| +| **Day 1** | BE-RPT-001 (inizio) | Report Service Implementation | Setup librerie, PDF base | +| **Day 2** | BE-RPT-001 (fine), BE-RPT-002 (inizio) | PDF/CSV generation, API design | Service completo, API struttura | +| **Day 3** | BE-RPT-002 (fine), BE-RPT-003, FE-RPT-001 (inizio) | API generation, Download, UI | Backend reports completo | +| **Day 4** | FE-RPT-001 (fine), FE-RPT-002 (inizio), BE-RPT-004, BE-RPT-005 | Report UI, Storage, Templates | Frontend reports funzionante | +| **Day 5** | FE-RPT-002 (fine), FE-RPT-003, FE-RPT-004 | Reports List, Download, Preview | Feature Reports completa 🎯 | + +**Week 1 Milestone:** Reports feature funzionante end-to-end + +--- + +### Week 2: Charts & Comparison (Giorni 6-10) + +| Giorno | Task | Focus | Output | +|--------|------|-------|--------| +| **Day 6** | FE-VIZ-001 | Recharts Integration | Setup completo, tema ready | +| **Day 7** | FE-VIZ-002, FE-VIZ-003 | Cost Breakdown, Time Series | 2 grafici dashboard | +| **Day 8** | FE-VIZ-004, BE-CMP-001 (nota 1) | Comparison Chart, Comparison API | Confronto backend | +| **Day 9** | FE-CMP-001, FE-CMP-002, FE-CMP-003 | Selection UI, Compare Page | Pagina confronto | +| **Day 10** | FE-VIZ-005, FE-VIZ-006, FE-CMP-004 | Additional Charts, Visual Comparison | Charts completo 🎯 | + +**Nota 1:** I task BE-CMP-001, 002, 003 sono menzionati nel planning come backend comparison API, ma il documento non li dettaglia completamente. Assunti come P2. + +**Week 2 Milestone:** Charts e Comparison funzionanti + +--- + +### Week 3: Polish & Testing (Giorni 11-15) + +| Giorno | Task | Focus | Output | +|--------|------|-------|--------| +| **Day 11** | FE-THM-001, FE-THM-002 | Theme Provider, Tailwind Config | Dark mode base | +| **Day 12** | FE-THM-003, FE-THM-004 | Component Themes, Chart Theming | Dark mode completo | +| **Day 13** | QA-E2E-001, QA-E2E-002 (inizio) | Playwright Setup, Test Scenarios | E2E base | +| **Day 14** | QA-E2E-002 (fine), QA-E2E-003, QA-E2E-004 | Test Data, Visual Regression | Tests completi | +| **Day 15** | Bug fixing, Performance, Docs | Polish, CHANGELOG, Demo | Release v0.4.0 🚀 | + +**Week 3 Milestone:** v0.4.0 Release Ready + +--- + +## 🔗 Dependency Graph + +### Critical Path + +``` +[BE-RPT-001] → [BE-RPT-002] → [BE-RPT-003] + ↓ ↓ ↓ +[FE-RPT-001] → [FE-RPT-002] → [FE-RPT-003] + ↓ +[FE-VIZ-001] → [FE-VIZ-002, FE-VIZ-003, FE-VIZ-004] + ↓ +[FE-CMP-001] → [FE-CMP-002] → [FE-CMP-003] + ↓ +[FE-THM-001] → [FE-THM-002] → [FE-THM-003] → [FE-THM-004] + ↓ +[QA-E2E-001] → [QA-E2E-002, QA-E2E-003, QA-E2E-004] +``` + +### Task Senza Dipendenze (Possono Iniziare Subito) +- BE-RPT-001 +- FE-VIZ-001 (se shadcn già pronto) +- FE-CMP-001 (selezioni UI può iniziare) +- FE-THM-001 (theme provider) + +### Task Bloccanti Molteplici +| Task | Blocca | +|------|--------| +| BE-RPT-001 | BE-RPT-002, BE-RPT-003, FE-RPT-001 | +| BE-RPT-002 | BE-RPT-003, FE-RPT-001, FE-RPT-002 | +| FE-VIZ-001 | FE-VIZ-002, FE-VIZ-003, FE-VIZ-004, FE-VIZ-005, FE-VIZ-006, FE-CMP-004 | +| FE-CMP-002 | FE-CMP-003, FE-CMP-004, FE-VIZ-004 | +| QA-E2E-001 | QA-E2E-002, QA-E2E-003, QA-E2E-004 | + +--- + +## 👥 Team Assignments + +### @backend-dev +| Task | Effort | Settimana | +|------|--------|-----------| +| BE-RPT-001 | L | Week 1 | +| BE-RPT-002 | M | Week 1 | +| BE-RPT-003 | S | Week 1 | +| BE-RPT-004 | S | Week 1 | +| BE-RPT-005 | M | Week 1 | + +**Totale:** 5 task, ~L effort, Week 1 focus + +### @frontend-dev +| Task | Effort | Settimana | +|------|--------|-----------| +| FE-RPT-001 | M | Week 1 | +| FE-RPT-002 | M | Week 1 | +| FE-RPT-003 | S | Week 1 | +| FE-RPT-004 | S | Week 1 | +| FE-VIZ-001 | M | Week 2 | +| FE-VIZ-002 | M | Week 2 | +| FE-VIZ-003 | M | Week 2 | +| FE-VIZ-004 | M | Week 2 | +| FE-VIZ-005 | M | Week 2 | +| FE-VIZ-006 | S | Week 2 | +| FE-CMP-001 | S | Week 2 | +| FE-CMP-002 | M | Week 2 | +| FE-CMP-003 | M | Week 2 | +| FE-CMP-004 | S | Week 2 | +| FE-THM-001 | S | Week 3 | +| FE-THM-002 | S | Week 3 | +| FE-THM-003 | M | Week 3 | +| FE-THM-004 | S | Week 3 | + +**Totale:** 18 task, distribuite su 3 settimane + +### @qa-engineer +| Task | Effort | Settimana | +|------|--------|-----------| +| QA-E2E-001 | M | Week 3 | +| QA-E2E-002 | L | Week 3 | +| QA-E2E-003 | M | Week 3 | +| QA-E2E-004 | M | Week 3 | + +**Totale:** 4 task, Week 3 focus + +--- + +## 🎯 Acceptance Criteria Checklist + +### Report Generation +- [ ] PDF generato correttamente con tutte le sezioni +- [ ] CSV contiene tutti i log e metriche +- [ ] Download funziona su Chrome, Firefox, Safari +- [ ] File size < 50MB per scenari grandi +- [ ] Cleanup automatico dopo 30 giorni + +### Charts +- [ ] Tutti i grafici responsive +- [ ] Tooltip mostra dati corretti +- [ ] Animazioni smooth +- [ ] Funzionano in dark/light mode +- [ ] Performance: <100ms render + +### Comparison +- [ ] Confronto 2-4 scenari simultaneamente +- [ ] Variazioni percentuali calcolate correttamente +- [ ] UI responsive su mobile +- [ ] Export comparison disponibile +- [ ] Color coding intuitivo + +### Dark Mode +- [ ] Toggle funziona istantaneamente +- [ ] Persistenza dopo refresh +- [ ] Tutti i componenti visibili +- [ ] Charts adeguatamente temizzati +- [ ] Nessun contrasto illeggibile + +### Testing +- [ ] E2E tests passano in CI +- [ ] Coverage >70% backend +- [ ] Visual regression baseline stabilita +- [ ] Zero regressioni v0.3.0 +- [ ] Documentazione testing aggiornata + +--- + +## 🚨 Risks & Mitigations + +| Rischio | Probabilità | Impatto | Mitigazione | Task Coinvolti | +|---------|-------------|---------|-------------|----------------| +| ReportLab complesso | Media | Alto | Usare WeasyPrint (HTML→PDF) | BE-RPT-001, BE-RPT-005 | +| Performance charts | Media | Medio | Virtualization, data sampling | FE-VIZ-002/003/004 | +| Dark mode inconsistente | Bassa | Medio | Audit visivo, design tokens | FE-THM-003 | +| E2E tests flaky | Media | Medio | Retry logic, deterministic selectors | QA-E2E-001/002 | +| Scope creep | Alta | Medio | Strict deadline, MVP first | Tutti | + +--- + +## 📝 Notes + +### Libraries da Installare +```bash +# Backend +pip install reportlab pandas xlsxwriter +pip install celery redis # opzionale per background tasks + +# Frontend +npm install recharts date-fns +npm install @playwright/test +npm install zustand # opzionale per theme +``` + +### Pattern da Seguire +- **Report Generation**: Async task con status polling +- **Charts**: Container/Presentational pattern +- **Comparison**: Derive state, non duplicare dati +- **Theme**: CSS variables + Tailwind dark mode + +### Performance Considerations +- Lazy load chart components +- Debounce resize handlers +- Virtualize long lists (reports) +- Cache comparison results +- Optimize re-renders (React.memo) + +--- + +**Versione Kanban:** v0.4.0 +**Data Creazione:** 2026-04-07 +**Ultimo Aggiornamento:** 2026-04-07 +**Autore:** @spec-architect diff --git a/export/progress.md b/export/progress.md index 0896c23..8c6479e 100644 --- a/export/progress.md +++ b/export/progress.md @@ -9,10 +9,10 @@ ## 🎯 Sprint/Feature Corrente -**Feature:** v0.3.0 Frontend Implementation - COMPLETED ✅ +**Feature:** v0.4.0 - Reports, Charts & Comparison **Iniziata:** 2026-04-07 -**Stato:** 🟢 COMPLETATA -**Assegnato:** @frontend-dev, @backend-dev (supporto API) +**Stato:** ⏳ Pianificata - Pronta per inizio +**Assegnato:** @frontend-dev (lead), @backend-dev, @qa-engineer --- @@ -29,9 +29,16 @@ | Frontend - Components | 8 | 8 | 100% | 🟢 Completato | | Frontend - Pages | 4 | 4 | 100% | 🟢 Completato | | Frontend - API Integration | 3 | 3 | 100% | 🟢 Completato | -| Testing | 3 | 2 | 67% | 🟡 In corso | -| DevOps | 4 | 3 | 75% | 🟡 In corso | -| **Completamento Totale** | **55** | **53** | **96%** | 🟢 **v0.3.0 Completata** | +| v0.3.0 Testing | 3 | 2 | 67% | 🟡 In corso | +| v0.3.0 DevOps | 4 | 3 | 75% | 🟡 In corso | +| **v0.3.0 Completamento** | **55** | **53** | **96%** | 🟢 **Completata** | +| **v0.4.0 - Backend Reports** | **5** | **0** | **0%** | ⏳ **Pending** | +| **v0.4.0 - Frontend Reports** | **4** | **0** | **0%** | ⏳ **Pending** | +| **v0.4.0 - Visualization** | **6** | **0** | **0%** | ⏳ **Pending** | +| **v0.4.0 - Comparison** | **4** | **0** | **0%** | ⏳ **Pending** | +| **v0.4.0 - Theme** | **4** | **0** | **0%** | ⏳ **Pending** | +| **v0.4.0 - QA E2E** | **4** | **0** | **0%** | ⏳ **Pending** | +| **v0.4.0 Totale** | **27** | **0** | **0%** | ⏳ **Pianificata** | --- @@ -92,18 +99,109 @@ --- -## 📅 Prossime Task (v0.4.0 - Priorità P1) +## 📅 v0.4.0 - Task Breakdown -| Priority | ID | Task | Stima | Assegnato | Dipendenze | -|----------|----|------|-------|-----------|------------| -| P1 | FE-013 | Report Generation UI | L | @frontend-dev | BE-API | -| P1 | FE-014 | Scenario Comparison | L | @frontend-dev | FE-006 | -| P1 | FE-015 | Charts & Graphs (Recharts) | M | @frontend-dev | FE-006 | -| P1 | FE-016 | Dark/Light Mode Toggle | S | @frontend-dev | FE-002 | -| P2 | BE-009 | Report Generation API | L | @backend-dev | DB-006 | -| P2 | BE-010 | Scenario Comparison API | M | @backend-dev | BE-008 | -| P3 | QA-001 | E2E Testing Setup | M | @qa-engineer | Frontend stable | -| P3 | QA-002 | Integration Tests | L | @qa-engineer | API stable | +### 📝 BACKEND - Report Generation + +| Priority | ID | Task | Stima | Assegnato | Stato | Dipendenze | +|----------|----|------|-------|-----------|-------|------------| +| P1 | BE-RPT-001 | Report Service Implementation | L | @backend-dev | ⏳ Pending | v0.3.0 | +| P1 | BE-RPT-002 | Report Generation API | M | @backend-dev | ⏳ Pending | BE-RPT-001 | +| P1 | BE-RPT-003 | Report Download API | S | @backend-dev | ⏳ Pending | BE-RPT-002 | +| P2 | BE-RPT-004 | Report Storage | S | @backend-dev | ⏳ Pending | BE-RPT-001 | +| P2 | BE-RPT-005 | Report Templates | M | @backend-dev | ⏳ Pending | BE-RPT-001 | + +**Progresso Backend Reports:** 0/5 (0%) + +### 🎨 FRONTEND - Report UI + +| Priority | ID | Task | Stima | Assegnato | Stato | Dipendenze | +|----------|----|------|-------|-----------|-------|------------| +| P1 | FE-RPT-001 | Report Generation UI | M | @frontend-dev | ⏳ Pending | BE-RPT-002 | +| P1 | FE-RPT-002 | Reports List | M | @frontend-dev | ⏳ Pending | FE-RPT-001 | +| P1 | FE-RPT-003 | Report Download Handler | S | @frontend-dev | ⏳ Pending | FE-RPT-002 | +| P2 | FE-RPT-004 | Report Preview | S | @frontend-dev | ⏳ Pending | FE-RPT-001 | + +**Progresso Frontend Reports:** 0/4 (0%) + +### 📊 FRONTEND - Data Visualization + +| Priority | ID | Task | Stima | Assegnato | Stato | Dipendenze | +|----------|----|------|-------|-----------|-------|------------| +| P1 | FE-VIZ-001 | Recharts Integration | M | @frontend-dev | ⏳ Pending | FE-002 | +| P1 | FE-VIZ-002 | Cost Breakdown Chart | M | @frontend-dev | ⏳ Pending | FE-VIZ-001 | +| P1 | FE-VIZ-003 | Time Series Chart | M | @frontend-dev | ⏳ Pending | FE-VIZ-001 | +| P1 | FE-VIZ-004 | Comparison Bar Chart | M | @frontend-dev | ⏳ Pending | FE-VIZ-001, FE-CMP-002 | +| P2 | FE-VIZ-005 | Metrics Distribution Chart | M | @frontend-dev | ⏳ Pending | FE-VIZ-001 | +| P2 | FE-VIZ-006 | Dashboard Overview Charts | S | @frontend-dev | ⏳ Pending | FE-VIZ-001, FE-006 | + +**Progresso Visualization:** 0/6 (0%) + +### 🔍 FRONTEND - Scenario Comparison + +| Priority | ID | Task | Stima | Assegnato | Stato | Dipendenze | +|----------|----|------|-------|-----------|-------|------------| +| P1 | FE-CMP-001 | Comparison Selection UI | S | @frontend-dev | ⏳ Pending | FE-006 | +| P1 | FE-CMP-002 | Compare Page | M | @frontend-dev | ⏳ Pending | FE-CMP-001 | +| P1 | FE-CMP-003 | Comparison Tables | M | @frontend-dev | ⏳ Pending | FE-CMP-002 | +| P2 | FE-CMP-004 | Visual Comparison | S | @frontend-dev | ⏳ Pending | FE-CMP-002, FE-VIZ-001 | + +**Progresso Comparison:** 0/4 (0%) + +### 🌓 FRONTEND - Dark/Light Mode + +| Priority | ID | Task | Stima | Assegnato | Stato | Dipendenze | +|----------|----|------|-------|-----------|-------|------------| +| P2 | FE-THM-001 | Theme Provider Setup | S | @frontend-dev | ⏳ Pending | FE-002, FE-005 | +| P2 | FE-THM-002 | Tailwind Dark Mode Config | S | @frontend-dev | ⏳ Pending | FE-THM-001 | +| P2 | FE-THM-003 | Component Theme Support | M | @frontend-dev | ⏳ Pending | FE-THM-002 | +| P2 | FE-THM-004 | Chart Theming | S | @frontend-dev | ⏳ Pending | FE-VIZ-001, FE-THM-003 | + +**Progresso Theme:** 0/4 (0%) + +### 🧪 QA - E2E Testing + +| Priority | ID | Task | Stima | Assegnato | Stato | Dipendenze | +|----------|----|------|-------|-----------|-------|------------| +| P3 | QA-E2E-001 | Playwright Setup | M | @qa-engineer | ⏳ Pending | Frontend stable | +| P3 | QA-E2E-002 | Test Scenarios | L | @qa-engineer | ⏳ Pending | QA-E2E-001 | +| P3 | QA-E2E-003 | Test Data | M | @qa-engineer | ⏳ Pending | QA-E2E-001 | +| P3 | QA-E2E-004 | Visual Regression | M | @qa-engineer | ⏳ Pending | QA-E2E-001 | + +**Progresso QA:** 0/4 (0%) + +--- + +## 📈 Riepilogo v0.4.0 + +| Categoria | Task Totali | Priorità P1 | Priorità P2 | Priorità P3 | +|-----------|-------------|-------------|-------------|-------------| +| Backend Reports | 5 | 3 | 2 | 0 | +| Frontend Reports | 4 | 3 | 1 | 0 | +| Data Visualization | 6 | 4 | 2 | 0 | +| Scenario Comparison | 4 | 3 | 1 | 0 | +| Dark/Light Mode | 4 | 0 | 4 | 0 | +| QA E2E Testing | 4 | 0 | 0 | 4 | +| **TOTALE** | **27** | **13** | **10** | **4** | + +--- + +## 🎯 Obiettivi v0.4.0 (In Progress) + +**Goal:** Report Generation, Scenario Comparison, Data Visualization, Dark Mode, E2E Testing + +### Target +- [ ] Generazione report PDF/CSV +- [ ] Confronto scenari side-by-side +- [ ] Grafici interattivi (Recharts) +- [ ] Dark/Light mode toggle +- [ ] Testing E2E completo + +### Metriche Target +- Test coverage: 70% +- Feature complete: v0.4.0 (27 task) +- Performance: <3s report generation +- Timeline: 2-3 settimane --- @@ -119,11 +217,9 @@ | Data | Decisione | Motivazione | Impatto | |------|-----------|-------------|---------| -| 2026-04-07 | Repository Pattern | Separazione business logic | Testabilità ✅ | -| 2026-04-07 | Async SQLAlchemy 2.0 | Performance | Scalabilità ✅ | -| 2026-04-07 | React Query | Data fetching moderno | UX migliorata ✅ | -| 2026-04-07 | shadcn/ui | Componenti accessibili | Consistenza UI ✅ | -| 2026-04-07 | Axios vs Fetch | Interceptors & error handling | Codice pulito ✅ | +| 2026-04-07 | v0.4.0 Kanban Created | Dettagliata pianificazione 27 task | Tracciamento ✅ | +| 2026-04-07 | Priorità P1 = 13 task | Feature critiche identificate | Focus Week 1-2 | +| 2026-04-07 | Timeline 2-3 settimane | Stima realistica con buffer | Deadline flessibile | --- @@ -135,14 +231,29 @@ - **Task in progress:** 0 - **Task bloccate:** 0 -### Qualità +### Versione v0.4.0 (Pianificata) +- **Task pianificate:** 27 +- **Task completate:** 0 +- **Task in progress:** 0 +- **Task bloccate:** 0 +- **Priorità P1:** 13 (48%) +- **Priorità P2:** 10 (37%) +- **Priorità P3:** 4 (15%) + +### Qualità v0.3.0 - **Test Coverage:** ~45% (5/5 test v0.1 + nuovi tests) - **Test passanti:** ✅ Tutti - **Linting:** ✅ Ruff configurato - **Type Check:** ✅ TypeScript strict mode - **Build:** ✅ Frontend builda senza errori -### Codice +### Qualità Target v0.4.0 +- **Test Coverage:** 70% +- **E2E Tests:** 4 suite complete +- **Visual Regression:** Baseline stabilita +- **Zero Regressioni:** v0.3.0 features + +### Codice v0.3.0 - **Linee codice backend:** ~2500 - **Linee codice frontend:** ~3500 - **Linee test:** ~500 @@ -151,73 +262,54 @@ --- -## 🎯 Obiettivi v0.4.0 (Prossima Release) - -**Goal:** Report Generation, Scenario Comparison, e Grafici - -### Target -- [ ] Generazione report PDF/CSV -- [ ] Confronto scenari side-by-side -- [ ] Grafici interattivi (Recharts) -- [ ] Dark/Light mode toggle -- [ ] Testing E2E completo - -### Metriche Target -- Test coverage: 70% -- Feature complete: v0.4.0 -- Performance: <2s page load - ---- - ## 📋 Risorse ### Documentazione -- PRD: `/home/google/Sources/LucaSacchiNet/mockupAWS/export/prd.md` -- Architettura: `/home/google/Sources/LucaSacchiNet/mockupAWS/export/architecture.md` -- Kanban: `/home/google/Sources/LucaSacchiNet/mockupAWS/export/kanban.md` -- Questo file: `/home/google/Sources/LucaSacchiNet/mockupAWS/export/progress.md` +- **PRD:** `/home/google/Sources/LucaSacchiNet/mockupAWS/export/prd.md` +- **Architettura:** `/home/google/Sources/LucaSacchiNet/mockupAWS/export/architecture.md` +- **Kanban v0.4.0:** `/home/google/Sources/LucaSacchiNet/mockupAWS/export/kanban-v0.4.0.md` ⭐ **NUOVO** +- **Progress:** `/home/google/Sources/LucaSacchiNet/mockupAWS/export/progress.md` +- **Planning v0.4.0:** `/home/google/Sources/LucaSacchiNet/mockupAWS/prompt/prompt-v0.4.0-planning.md` ### Codice -- Backend: `/home/google/Sources/LucaSacchiNet/mockupAWS/src/` -- Frontend: `/home/google/Sources/LucaSacchiNet/mockupAWS/frontend/src/` -- Test: `/home/google/Sources/LucaSacchiNet/mockupAWS/test/` -- Migrazioni: `/home/google/Sources/LucaSacchiNet/mockupAWS/alembic/versions/` +- **Backend:** `/home/google/Sources/LucaSacchiNet/mockupAWS/src/` +- **Frontend:** `/home/google/Sources/LucaSacchiNet/mockupAWS/frontend/src/` +- **Test:** `/home/google/Sources/LucaSacchiNet/mockupAWS/test/` +- **Migrazioni:** `/home/google/Sources/LucaSacchiNet/mockupAWS/alembic/versions/` ### Team -- Configurazioni: `/home/google/Sources/LucaSacchiNet/mockupAWS/.opencode/agents/` +- **Configurazioni:** `/home/google/Sources/LucaSacchiNet/mockupAWS/.opencode/agents/` --- ## 📝 Log Attività -### 2026-04-07 - v0.3.0 Completata +### 2026-04-07 - v0.4.0 Kanban Created **Attività Completate:** -- ✅ Database PostgreSQL completo (5 tabelle, 6 migrazioni) -- ✅ Backend FastAPI completo (models, schemas, repositories, services, API) -- ✅ Frontend React completo (Vite, TypeScript, Tailwind, shadcn/ui) -- ✅ Integrazione API frontend-backend -- ✅ Docker Compose per database -- ✅ Team configuration (6 agenti) -- ✅ Documentazione aggiornata (README, architecture, kanban) +- ✅ Creazione kanban-v0.4.0.md con 27 task dettagliati +- ✅ Aggiornamento progress.md con sezione v0.4.0 +- ✅ Definizione timeline 2-3 settimane +- ✅ Assegnazione task a team members +- ✅ Identificazione critical path -**Team:** -- @spec-architect: ✅ Architettura completata -- @db-engineer: ✅ Database completato -- @backend-dev: ✅ Backend completato -- @frontend-dev: ✅ Frontend completato +**Team v0.4.0:** +- @spec-architect: ✅ Kanban completato +- @backend-dev: ⏳ 5 task pending (Week 1 focus) +- @frontend-dev: ⏳ 18 task pending (3 settimane) +- @qa-engineer: ⏳ 4 task pending (Week 3 focus) - @devops-engineer: 🟡 Docker verifica in corso -- @qa-engineer: ⏳ In attesa v0.4.0 **Stato Progetto:** - v0.2.0: ✅ COMPLETATA - v0.3.0: ✅ COMPLETATA -- v0.4.0: 🟡 Pianificazione +- v0.4.0: ⏳ Pianificazione completata - Pronta per inizio **Prossimi passi:** -1. Completare verifica docker-compose.yml -2. Iniziare pianificazione v0.4.0 -3. Report generation feature +1. Completare verifica docker-compose.yml (DEV-004) +2. Inizio Week 1: BE-RPT-001 (Report Service) +3. Parallel: FE-VIZ-001 (Recharts Integration) può iniziare +4. Daily standup per tracciamento progresso --- diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..85437ed 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,15 @@ dist-ssr *.njsproj *.sln *.sw? + +# E2E Test Artifacts +e2e-report/ +e2e-results/ +e2e/screenshots/actual/ +e2e/screenshots/diff/ +playwright/.cache/ +test-results/ + +# Coverage +coverage/ +.nyc_output/ diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md new file mode 100644 index 0000000..b7cfa91 --- /dev/null +++ b/frontend/e2e/README.md @@ -0,0 +1,391 @@ +# End-to-End Testing with Playwright + +This directory contains the End-to-End (E2E) test suite for mockupAWS using Playwright. + +## Table of Contents + +- [Overview](#overview) +- [Setup](#setup) +- [Running Tests](#running-tests) +- [Test Structure](#test-structure) +- [Test Data & Fixtures](#test-data--fixtures) +- [Visual Regression Testing](#visual-regression-testing) +- [Best Practices](#best-practices) +- [Troubleshooting](#troubleshooting) + +## Overview + +The E2E test suite provides comprehensive testing of the mockupAWS application, covering: + +- **Scenario CRUD Operations**: Creating, reading, updating, and deleting scenarios +- **Log Ingestion**: Sending test logs and verifying metrics updates +- **Report Generation**: Generating and downloading PDF and CSV reports +- **Scenario Comparison**: Comparing multiple scenarios side-by-side +- **Navigation**: Testing all routes and responsive design +- **Visual Regression**: Ensuring UI consistency across browsers and viewports + +## Setup + +### Prerequisites + +- Node.js 18+ installed +- Backend API running on `http://localhost:8000` +- Frontend development server + +### Installation + +Playwright and its dependencies are already configured in the project. To install browsers: + +```bash +# Install Playwright browsers +npx playwright install + +# Install additional dependencies for browser testing +npx playwright install-deps +``` + +### Environment Variables + +Create a `.env` file in the `frontend` directory if needed: + +```env +# Optional: Override the API URL for tests +VITE_API_URL=http://localhost:8000/api/v1 + +# Optional: Set CI mode +CI=true +``` + +## Running Tests + +### NPM Scripts + +The following npm scripts are available: + +```bash +# Run all E2E tests in headless mode +npm run test:e2e + +# Run tests with UI mode (interactive) +npm run test:e2e:ui + +# Run tests in debug mode +npm run test:e2e:debug + +# Run tests in headed mode (visible browser) +npm run test:e2e:headed + +# Run tests in CI mode +npm run test:e2e:ci +``` + +### Running Specific Tests + +```bash +# Run a specific test file +npx playwright test scenario-crud.spec.ts + +# Run tests matching a pattern +npx playwright test --grep "should create" + +# Run tests in a specific browser +npx playwright test --project=chromium + +# Run tests with specific tag +npx playwright test --grep "@critical" +``` + +### Updating Visual Baselines + +```bash +# Update all visual baseline screenshots +UPDATE_BASELINE=true npx playwright test visual-regression.spec.ts +``` + +## Test Structure + +``` +e2e/ +├── fixtures/ # Test data and fixtures +│ ├── test-scenarios.ts # Sample scenario data +│ └── test-logs.ts # Sample log data +├── screenshots/ # Visual regression screenshots +│ └── baseline/ # Baseline images +├── global-setup.ts # Global test setup +├── global-teardown.ts # Global test teardown +├── utils/ +│ └── test-helpers.ts # Shared test utilities +├── scenario-crud.spec.ts # Scenario CRUD tests +├── ingest-logs.spec.ts # Log ingestion tests +├── reports.spec.ts # Report generation tests +├── comparison.spec.ts # Scenario comparison tests +├── navigation.spec.ts # Navigation and routing tests +├── visual-regression.spec.ts # Visual regression tests +└── README.md # This file +``` + +## Test Data & Fixtures + +### Test Scenarios + +The `test-scenarios.ts` fixture provides sample scenarios for testing: + +```typescript +import { testScenarios, newScenarioData } from './fixtures/test-scenarios'; + +// Use in tests +const scenario = await createScenarioViaAPI(request, newScenarioData); +``` + +### Test Logs + +The `test-logs.ts` fixture provides sample log data: + +```typescript +import { testLogs, logsWithPII, highVolumeLogs } from './fixtures/test-logs'; + +// Send logs to scenario +await sendTestLogs(request, scenarioId, testLogs); +``` + +### API Helpers + +Test utilities are available in `utils/test-helpers.ts`: + +- `createScenarioViaAPI()` - Create scenario via API +- `deleteScenarioViaAPI()` - Delete scenario via API +- `startScenarioViaAPI()` - Start scenario +- `stopScenarioViaAPI()` - Stop scenario +- `sendTestLogs()` - Send test logs +- `navigateTo()` - Navigate to page with wait +- `waitForLoading()` - Wait for loading states +- `generateTestScenarioName()` - Generate unique test names + +## Visual Regression Testing + +### How It Works + +Visual regression tests capture screenshots of pages/components and compare them against baseline images. Tests fail if differences exceed the configured threshold (20%). + +### Running Visual Tests + +```bash +# Run all visual regression tests +npx playwright test visual-regression.spec.ts + +# Run tests for specific viewport +npx playwright test visual-regression.spec.ts --project="Mobile Chrome" + +# Update baselines +UPDATE_BASELINE=true npx playwright test visual-regression.spec.ts +``` + +### Screenshots Location + +- **Baseline**: `e2e/screenshots/baseline/` +- **Actual**: `e2e/screenshots/actual/` +- **Diff**: `e2e/screenshots/diff/` + +### Adding New Visual Tests + +```typescript +test('new page should match baseline', async ({ page }) => { + await navigateTo(page, '/new-page'); + await waitForLoading(page); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('new-page.png', { + threshold: 0.2, // 20% threshold + }); +}); +``` + +## Best Practices + +### 1. Use Data Attributes for Selectors + +Prefer `data-testid` attributes over CSS selectors: + +```tsx +// In component + + +// In test +await page.getByTestId('submit-button').click(); +``` + +### 2. Wait for Async Operations + +Always wait for async operations to complete: + +```typescript +await page.waitForResponse('**/api/scenarios'); +await waitForLoading(page); +``` + +### 3. Clean Up Test Data + +Use `beforeAll`/`afterAll` for setup and cleanup: + +```typescript +test.describe('Feature', () => { + test.beforeAll(async ({ request }) => { + // Create test data + }); + + test.afterAll(async ({ request }) => { + // Clean up test data + }); +}); +``` + +### 4. Use Unique Test Names + +Generate unique names to avoid conflicts: + +```typescript +const testName = generateTestScenarioName('My Test'); +``` + +### 5. Test Across Viewports + +Test both desktop and mobile: + +```typescript +test('desktop view', async ({ page }) => { + await setDesktopViewport(page); + // ... +}); + +test('mobile view', async ({ page }) => { + await setMobileViewport(page); + // ... +}); +``` + +## Troubleshooting + +### Tests Timing Out + +If tests timeout, increase the timeout in `playwright.config.ts`: + +```typescript +timeout: 90000, // Increase to 90 seconds +``` + +### Flaky Tests + +For flaky tests, use retries: + +```bash +npx playwright test --retries=3 +``` + +Or configure in `playwright.config.ts`: + +```typescript +retries: process.env.CI ? 2 : 0, +``` + +### Browser Not Found + +If browsers are not installed: + +```bash +npx playwright install +``` + +### API Not Available + +Ensure the backend is running: + +```bash +# In project root +docker-compose up -d +# or +uvicorn src.main:app --reload --port 8000 +``` + +### Screenshot Comparison Fails + +If visual tests fail due to minor differences: + +1. Check the diff image in `e2e/screenshots/diff/` +2. Update baseline if the change is intentional: + ```bash + UPDATE_BASELINE=true npx playwright test + ``` +3. Adjust threshold if needed: + ```typescript + threshold: 0.3, // Increase to 30% + ``` + +## CI Integration + +### GitHub Actions Example + +```yaml +name: E2E Tests + +on: [push, pull_request] + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Install Playwright browsers + run: npx playwright install --with-deps + working-directory: frontend + + - name: Run E2E tests + run: npm run test:e2e:ci + working-directory: frontend + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v3 + with: + name: playwright-report + path: frontend/e2e-report/ +``` + +## Coverage Reporting + +Playwright E2E tests can be integrated with code coverage tools. To enable coverage: + +1. Instrument your frontend code with Istanbul +2. Configure Playwright to collect coverage +3. Generate coverage reports + +See [Playwright Coverage Guide](https://playwright.dev/docs/api/class-coverage) for details. + +## Contributing + +When adding new E2E tests: + +1. Follow the existing test structure +2. Use fixtures for test data +3. Add proper cleanup in `afterAll` +4. Include both positive and negative test cases +5. Test across multiple viewports if UI-related +6. Update this README with new test information + +## Support + +For issues or questions: + +- Check the [Playwright Documentation](https://playwright.dev/) +- Review existing tests for examples +- Open an issue in the project repository diff --git a/frontend/e2e/comparison.spec.ts b/frontend/e2e/comparison.spec.ts new file mode 100644 index 0000000..c8dbad7 --- /dev/null +++ b/frontend/e2e/comparison.spec.ts @@ -0,0 +1,415 @@ +/** + * E2E Test: Scenario Comparison + * + * Tests for: + * - Select multiple scenarios + * - Navigate to compare page + * - Verify comparison data + */ + +import { test, expect } from '@playwright/test'; +import { + navigateTo, + waitForLoading, + createScenarioViaAPI, + deleteScenarioViaAPI, + startScenarioViaAPI, + sendTestLogs, + generateTestScenarioName, +} from './utils/test-helpers'; +import { testLogs } from './fixtures/test-logs'; +import { newScenarioData } from './fixtures/test-scenarios'; + +const testScenarioPrefix = 'Compare Test'; +let createdScenarioIds: string[] = []; + +test.describe('Scenario Comparison', () => { + test.beforeAll(async ({ request }) => { + // Create multiple scenarios for comparison + for (let i = 1; i <= 3; i++) { + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName(`${testScenarioPrefix} ${i}`), + region: ['us-east-1', 'eu-west-1', 'ap-southeast-1'][i - 1], + }); + createdScenarioIds.push(scenario.id); + + // Start and add some logs to make scenarios more realistic + await startScenarioViaAPI(request, scenario.id); + await sendTestLogs(request, scenario.id, testLogs.slice(0, i * 2)); + } + }); + + test.afterAll(async ({ request }) => { + // Cleanup all created scenarios + for (const scenarioId of createdScenarioIds) { + try { + await request.post(`http://localhost:8000/api/v1/scenarios/${scenarioId}/stop`); + } catch { + // Scenario might not be running + } + await deleteScenarioViaAPI(request, scenarioId); + } + createdScenarioIds = []; + }); + + test('should display scenarios list for comparison selection', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Verify scenarios page loads + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + + // Verify table with scenarios is visible + const table = page.locator('table'); + await expect(table).toBeVisible(); + + // Verify at least our test scenarios are visible + const rows = table.locator('tbody tr'); + await expect(rows).toHaveCount((await rows.count()) >= 3); + }); + + test('should navigate to compare page via API', async ({ page, request }) => { + // Try to access compare page directly + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: createdScenarioIds.slice(0, 2), + metrics: ['total_cost', 'total_requests'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + const data = await response.json(); + + // Verify response structure + expect(data).toHaveProperty('scenarios'); + expect(data).toHaveProperty('comparison'); + expect(Array.isArray(data.scenarios)).toBe(true); + expect(data.scenarios.length).toBe(2); + } + }); + + test('should compare 2 scenarios', async ({ request }) => { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: createdScenarioIds.slice(0, 2), + metrics: ['total_cost', 'total_requests', 'sqs_blocks'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + const data = await response.json(); + + expect(data.scenarios).toHaveLength(2); + expect(data.comparison).toBeDefined(); + } + }); + + test('should compare 3 scenarios', async ({ request }) => { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: createdScenarioIds, + metrics: ['total_cost', 'total_requests', 'lambda_invocations'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + const data = await response.json(); + + expect(data.scenarios).toHaveLength(3); + expect(data.comparison).toBeDefined(); + } + }); + + test('should compare 4 scenarios (max allowed)', async ({ request }) => { + // Create a 4th scenario + const scenario4 = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName(`${testScenarioPrefix} 4`), + }); + + try { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: [...createdScenarioIds, scenario4.id], + metrics: ['total_cost'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + const data = await response.json(); + expect(data.scenarios).toHaveLength(4); + } + } finally { + await deleteScenarioViaAPI(request, scenario4.id); + } + }); + + test('should reject comparison with more than 4 scenarios', async ({ request }) => { + // Create additional scenarios + const extraScenarios: string[] = []; + for (let i = 0; i < 2; i++) { + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName(`${testScenarioPrefix} Extra ${i}`), + }); + extraScenarios.push(scenario.id); + } + + try { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: [...createdScenarioIds, ...extraScenarios], + metrics: ['total_cost'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + // Should return 400 for too many scenarios + expect(response.status()).toBe(400); + } finally { + // Cleanup extra scenarios + for (const id of extraScenarios) { + await deleteScenarioViaAPI(request, id); + } + } + }); + + test('should reject comparison with invalid scenario IDs', async ({ request }) => { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: ['invalid-id-1', 'invalid-id-2'], + metrics: ['total_cost'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + // Should return 400 or 404 for invalid IDs + expect([400, 404]).toContain(response.status()); + }); + + test('should reject comparison with single scenario', async ({ request }) => { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: [createdScenarioIds[0]], + metrics: ['total_cost'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + // Should return 400 for single scenario + expect(response.status()).toBe(400); + }); + + test('should include delta calculations in comparison', async ({ request }) => { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: createdScenarioIds.slice(0, 2), + metrics: ['total_cost', 'total_requests'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + const data = await response.json(); + + // Verify comparison includes deltas + expect(data.comparison).toBeDefined(); + + if (data.comparison.total_cost) { + expect(data.comparison.total_cost).toHaveProperty('baseline'); + expect(data.comparison.total_cost).toHaveProperty('variance'); + } + } + }); + + test('should support comparison export', async ({ request }) => { + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: createdScenarioIds.slice(0, 2), + metrics: ['total_cost', 'total_requests'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + // If compare API exists, check if export is available + const exportResponse = await request.get( + `http://localhost:8000/api/v1/scenarios/compare/export?ids=${createdScenarioIds.slice(0, 2).join(',')}&format=csv` + ); + + // Export might not exist yet + if (exportResponse.status() !== 404) { + expect(exportResponse.ok()).toBeTruthy(); + } + } + }); +}); + +test.describe('Comparison UI Tests', () => { + test('should navigate to compare page from sidebar', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Verify dashboard loads + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + // Try to navigate to compare page (if it exists) + const compareResponse = await page.request.get('http://localhost:5173/compare'); + + if (compareResponse.status() === 200) { + await navigateTo(page, '/compare'); + await waitForLoading(page); + + // Verify compare page elements + await expect(page.locator('body')).toBeVisible(); + } + }); + + test('should display scenarios in comparison view', async ({ page }) => { + // Navigate to scenarios page + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Verify scenarios are listed + const table = page.locator('table tbody'); + await expect(table).toBeVisible(); + + // Verify table has rows + const rows = table.locator('tr'); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThan(0); + }); + + test('should show comparison metrics table', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Verify metrics columns exist + await expect(page.getByRole('columnheader', { name: /requests/i })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: /cost/i })).toBeVisible(); + }); + + test('should highlight best/worst performers', async ({ page }) => { + // This test verifies the UI elements exist for comparison highlighting + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Verify table with color-coded status exists + const table = page.locator('table'); + await expect(table).toBeVisible(); + }); +}); + +test.describe('Comparison Performance', () => { + test('should load comparison data within acceptable time', async ({ request }) => { + const startTime = Date.now(); + + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: createdScenarioIds.slice(0, 2), + metrics: ['total_cost', 'total_requests'], + }, + } + ); + + const duration = Date.now() - startTime; + + if (response.status() === 404) { + test.skip(); + } + + // Should complete within 5 seconds + expect(duration).toBeLessThan(5000); + }); + + test('should cache comparison results', async ({ request }) => { + const requestBody = { + scenario_ids: createdScenarioIds.slice(0, 2), + metrics: ['total_cost'], + }; + + // First request + const response1 = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { data: requestBody } + ); + + if (response1.status() === 404) { + test.skip(); + } + + // Second identical request (should be cached) + const startTime = Date.now(); + const response2 = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { data: requestBody } + ); + const duration = Date.now() - startTime; + + // Cached response should be very fast + if (response2.ok()) { + expect(duration).toBeLessThan(1000); + } + }); +}); diff --git a/frontend/e2e/fixtures/test-logs.ts b/frontend/e2e/fixtures/test-logs.ts new file mode 100644 index 0000000..361d0c4 --- /dev/null +++ b/frontend/e2e/fixtures/test-logs.ts @@ -0,0 +1,117 @@ +/** + * Test Logs Fixtures + * + * Sample log data for E2E testing + */ + +export interface TestLog { + timestamp: string; + level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG'; + message: string; + service: string; + metadata?: Record; +} + +export const testLogs: TestLog[] = [ + { + timestamp: new Date().toISOString(), + level: 'INFO', + message: 'Application started successfully', + service: 'lambda', + metadata: { + functionName: 'test-function', + memorySize: 512, + duration: 1250, + }, + }, + { + timestamp: new Date(Date.now() - 1000).toISOString(), + level: 'INFO', + message: 'Processing SQS message batch', + service: 'sqs', + metadata: { + queueName: 'test-queue', + batchSize: 10, + messageCount: 5, + }, + }, + { + timestamp: new Date(Date.now() - 2000).toISOString(), + level: 'INFO', + message: 'Bedrock LLM invocation completed', + service: 'bedrock', + metadata: { + modelId: 'anthropic.claude-3-sonnet-20240229-v1:0', + inputTokens: 150, + outputTokens: 250, + duration: 2345, + }, + }, + { + timestamp: new Date(Date.now() - 3000).toISOString(), + level: 'WARN', + message: 'Potential PII detected in request', + service: 'lambda', + metadata: { + piiType: 'EMAIL', + confidence: 0.95, + masked: true, + }, + }, + { + timestamp: new Date(Date.now() - 4000).toISOString(), + level: 'ERROR', + message: 'Failed to process message after 3 retries', + service: 'sqs', + metadata: { + errorCode: 'ProcessingFailed', + retryCount: 3, + deadLetterQueue: true, + }, + }, +]; + +export const logsWithPII: TestLog[] = [ + { + timestamp: new Date().toISOString(), + level: 'INFO', + message: 'User login: john.doe@example.com', + service: 'lambda', + metadata: { + userId: 'user-12345', + email: 'john.doe@example.com', + }, + }, + { + timestamp: new Date(Date.now() - 1000).toISOString(), + level: 'INFO', + message: 'Payment processed for card ending 4532', + service: 'lambda', + metadata: { + cardLastFour: '4532', + amount: 99.99, + currency: 'USD', + }, + }, + { + timestamp: new Date(Date.now() - 2000).toISOString(), + level: 'INFO', + message: 'Phone verification: +1-555-123-4567', + service: 'lambda', + metadata: { + phone: '+1-555-123-4567', + verified: true, + }, + }, +]; + +export const highVolumeLogs: TestLog[] = Array.from({ length: 100 }, (_, i) => ({ + timestamp: new Date(Date.now() - i * 100).toISOString(), + level: i % 10 === 0 ? 'ERROR' : i % 5 === 0 ? 'WARN' : 'INFO', + message: `Log entry ${i + 1}: ${i % 3 === 0 ? 'SQS message processed' : i % 3 === 1 ? 'Lambda invoked' : 'Bedrock API call'}`, + service: i % 3 === 0 ? 'sqs' : i % 3 === 1 ? 'lambda' : 'bedrock', + metadata: { + sequenceNumber: i + 1, + batchId: `batch-${Math.floor(i / 10)}`, + }, +})); diff --git a/frontend/e2e/fixtures/test-scenarios.ts b/frontend/e2e/fixtures/test-scenarios.ts new file mode 100644 index 0000000..22578d3 --- /dev/null +++ b/frontend/e2e/fixtures/test-scenarios.ts @@ -0,0 +1,76 @@ +/** + * Test Scenarios Fixtures + * + * Sample scenario data for E2E testing + */ + +export interface TestScenario { + id: string; + name: string; + description: string; + tags: string[]; + region: string; + status: 'draft' | 'running' | 'completed' | 'archived'; +} + +export const testScenarios: TestScenario[] = [ + { + id: 'test-scenario-001', + name: 'E2E Test Scenario - Basic', + description: 'A basic test scenario for E2E testing', + tags: ['e2e', 'test', 'basic'], + region: 'us-east-1', + status: 'draft', + }, + { + id: 'test-scenario-002', + name: 'E2E Test Scenario - Running', + description: 'A running test scenario for E2E testing', + tags: ['e2e', 'test', 'running'], + region: 'eu-west-1', + status: 'running', + }, + { + id: 'test-scenario-003', + name: 'E2E Test Scenario - Completed', + description: 'A completed test scenario for E2E testing', + tags: ['e2e', 'test', 'completed'], + region: 'ap-southeast-1', + status: 'completed', + }, + { + id: 'test-scenario-004', + name: 'E2E Test Scenario - High Volume', + description: 'A high volume test scenario for stress testing', + tags: ['e2e', 'test', 'stress', 'high-volume'], + region: 'us-west-2', + status: 'draft', + }, + { + id: 'test-scenario-005', + name: 'E2E Test Scenario - PII Detection', + description: 'A scenario for testing PII detection features', + tags: ['e2e', 'test', 'pii', 'security'], + region: 'eu-central-1', + status: 'draft', + }, +]; + +export const newScenarioData = { + name: 'New E2E Test Scenario', + description: 'Created during E2E testing', + tags: ['e2e', 'automated'], + region: 'us-east-1', +}; + +export const updatedScenarioData = { + name: 'Updated E2E Test Scenario', + description: 'Updated during E2E testing', + tags: ['e2e', 'automated', 'updated'], +}; + +export const comparisonScenarios = [ + 'test-scenario-002', + 'test-scenario-003', + 'test-scenario-004', +]; diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..a8a478d --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,44 @@ +/** + * Global Setup for Playwright E2E Tests + * + * This runs once before all test suites. + * Used for: + * - Database seeding + * - Test environment preparation + * - Creating test data + */ + +import { execSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +async function globalSetup() { + console.log('🚀 Starting E2E test setup...'); + + // Ensure test data directories exist + const testDataDir = path.join(__dirname, 'fixtures'); + if (!fs.existsSync(testDataDir)) { + fs.mkdirSync(testDataDir, { recursive: true }); + } + + // Ensure screenshots directory exists + const screenshotsDir = path.join(__dirname, 'screenshots'); + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }); + } + + // Ensure baseline directory exists for visual regression + const baselineDir = path.join(screenshotsDir, 'baseline'); + if (!fs.existsSync(baselineDir)) { + fs.mkdirSync(baselineDir, { recursive: true }); + } + + // Store test start time for cleanup tracking + const testStartTime = new Date().toISOString(); + process.env.TEST_START_TIME = testStartTime; + + console.log('✅ E2E test setup complete'); + console.log(` Test started at: ${testStartTime}`); +} + +export default globalSetup; diff --git a/frontend/e2e/global-teardown.ts b/frontend/e2e/global-teardown.ts new file mode 100644 index 0000000..376894f --- /dev/null +++ b/frontend/e2e/global-teardown.ts @@ -0,0 +1,55 @@ +/** + * Global Teardown for Playwright E2E Tests + * + * This runs once after all test suites complete. + * Used for: + * - Database cleanup + * - Test artifact archival + * - Environment reset + */ + +import { execSync } from 'child_process'; +import path from 'path'; +import fs from 'fs'; + +async function globalTeardown() { + console.log('🧹 Starting E2E test teardown...'); + + const testStartTime = process.env.TEST_START_TIME; + console.log(` Test started at: ${testStartTime}`); + console.log(` Test completed at: ${new Date().toISOString()}`); + + // Clean up temporary test files if in CI mode + if (process.env.CI) { + console.log(' CI mode: Cleaning up temporary files...'); + const resultsDir = path.join(__dirname, '..', 'e2e-results'); + + // Keep videos/screenshots of failures for debugging + // but clean up successful test artifacts after 7 days + if (fs.existsSync(resultsDir)) { + const files = fs.readdirSync(resultsDir); + let cleanedCount = 0; + + for (const file of files) { + const filePath = path.join(resultsDir, file); + const stats = fs.statSync(filePath); + const ageInDays = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60 * 24); + + if (ageInDays > 7 && !file.includes('failed')) { + try { + fs.unlinkSync(filePath); + cleanedCount++; + } catch (e) { + // Ignore errors during cleanup + } + } + } + + console.log(` Cleaned up ${cleanedCount} old test artifacts`); + } + } + + console.log('✅ E2E test teardown complete'); +} + +export default globalTeardown; diff --git a/frontend/e2e/ingest-logs.spec.ts b/frontend/e2e/ingest-logs.spec.ts new file mode 100644 index 0000000..1adbcdd --- /dev/null +++ b/frontend/e2e/ingest-logs.spec.ts @@ -0,0 +1,251 @@ +/** + * E2E Test: Log Ingestion and Metrics + * + * Tests for: + * - Start a scenario + * - Send test logs via API + * - Verify metrics update + * - Check PII detection + */ + +import { test, expect } from '@playwright/test'; +import { + navigateTo, + waitForLoading, + createScenarioViaAPI, + deleteScenarioViaAPI, + startScenarioViaAPI, + stopScenarioViaAPI, + sendTestLogs, + generateTestScenarioName, +} from './utils/test-helpers'; +import { testLogs, logsWithPII, highVolumeLogs } from './fixtures/test-logs'; +import { newScenarioData } from './fixtures/test-scenarios'; + +const testScenarioName = generateTestScenarioName('Ingest Test'); +let createdScenarioId: string | null = null; + +test.describe('Log Ingestion', () => { + test.beforeEach(async ({ request }) => { + // Create a fresh scenario for each test + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: testScenarioName, + }); + createdScenarioId = scenario.id; + }); + + test.afterEach(async ({ request }) => { + // Cleanup: Stop and delete scenario + if (createdScenarioId) { + try { + await stopScenarioViaAPI(request, createdScenarioId); + } catch { + // Scenario might not be running + } + await deleteScenarioViaAPI(request, createdScenarioId); + createdScenarioId = null; + } + }); + + test('should start scenario successfully', async ({ page }) => { + // Navigate to scenario detail + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify initial state (draft) + await expect(page.locator('span').filter({ hasText: 'draft' }).first()).toBeVisible(); + }); + + test('should ingest logs and update metrics', async ({ page, request }) => { + // Start the scenario + await startScenarioViaAPI(request, createdScenarioId!); + + // Send test logs + await sendTestLogs(request, createdScenarioId!, testLogs); + + // Wait a moment for logs to be processed + await page.waitForTimeout(2000); + + // Navigate to scenario detail and verify metrics + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify metrics updated (should be greater than 0) + const totalRequests = page.locator('div', { + has: page.locator('text=Total Requests') + }).locator('div.text-2xl'); + + // Wait for metrics to refresh + await page.waitForTimeout(6000); // Wait for metrics polling + await page.reload(); + await waitForLoading(page); + + // Verify scenario is now running + await expect(page.locator('span').filter({ hasText: 'running' }).first()).toBeVisible(); + }); + + test('should detect PII in logs', async ({ page, request }) => { + // Start the scenario + await startScenarioViaAPI(request, createdScenarioId!); + + // Send logs containing PII + await sendTestLogs(request, createdScenarioId!, logsWithPII); + + // Wait for processing + await page.waitForTimeout(2000); + + // Navigate to dashboard to check PII violations + await navigateTo(page, '/'); + await waitForLoading(page); + + // Verify PII Violations card is visible + await expect(page.getByText('PII Violations')).toBeVisible(); + }); + + test('should handle high volume log ingestion', async ({ page, request }) => { + // Start the scenario + await startScenarioViaAPI(request, createdScenarioId!); + + // Send high volume of logs + await sendTestLogs(request, createdScenarioId!, highVolumeLogs.slice(0, 50)); + + // Wait for processing + await page.waitForTimeout(3000); + + // Navigate to scenario detail + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify metrics reflect high volume + // The scenario should still be stable + await expect(page.getByRole('heading', { name: testScenarioName })).toBeVisible(); + }); + + test('should stop scenario and update status', async ({ page, request }) => { + // Start the scenario + await startScenarioViaAPI(request, createdScenarioId!); + + // Navigate to detail page + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify running status + await expect(page.locator('span').filter({ hasText: 'running' }).first()).toBeVisible(); + + // Stop the scenario + await stopScenarioViaAPI(request, createdScenarioId!); + + // Refresh and verify stopped status + await page.reload(); + await waitForLoading(page); + + // Status should be completed or stopped + const statusElement = page.locator('span').filter({ hasText: /completed|stopped|archived/ }).first(); + await expect(statusElement).toBeVisible(); + }); + + test('should update cost breakdown with different services', async ({ page, request }) => { + // Start the scenario + await startScenarioViaAPI(request, createdScenarioId!); + + // Send logs for different services + const serviceLogs = [ + ...testLogs.filter(log => log.service === 'lambda'), + ...testLogs.filter(log => log.service === 'sqs'), + ...testLogs.filter(log => log.service === 'bedrock'), + ]; + + await sendTestLogs(request, createdScenarioId!, serviceLogs); + + // Wait for processing + await page.waitForTimeout(2000); + + // Navigate to scenario detail + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Wait for metrics refresh + await page.waitForTimeout(6000); + await page.reload(); + await waitForLoading(page); + + // Verify cost is updated + const totalCost = page.locator('div', { + has: page.locator('text=Total Cost') + }).locator('div.text-2xl'); + + await expect(totalCost).toBeVisible(); + }); + + test('should handle log ingestion errors gracefully', async ({ page, request }) => { + // Try to send logs to a non-existent scenario + const response = await request.post( + `http://localhost:8000/api/v1/scenarios/non-existent-id/ingest`, + { data: { logs: testLogs.slice(0, 1) } } + ); + + // Should return 404 + expect(response.status()).toBe(404); + }); + + test('should persist metrics after page refresh', async ({ page, request }) => { + // Start scenario and ingest logs + await startScenarioViaAPI(request, createdScenarioId!); + await sendTestLogs(request, createdScenarioId!, testLogs); + + // Wait for processing + await page.waitForTimeout(3000); + + // Navigate to scenario detail + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Wait for metrics + await page.waitForTimeout(6000); + + // Refresh page + await page.reload(); + await waitForLoading(page); + + // Verify metrics are still displayed + await expect(page.getByText('Total Requests')).toBeVisible(); + await expect(page.getByText('Total Cost')).toBeVisible(); + await expect(page.getByText('SQS Blocks')).toBeVisible(); + await expect(page.getByText('LLM Tokens')).toBeVisible(); + }); +}); + +test.describe('Log Ingestion - Dashboard Metrics', () => { + test('should update dashboard stats after log ingestion', async ({ page, request }) => { + // Create and start a scenario + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName('Dashboard Test'), + }); + createdScenarioId = scenario.id; + + await startScenarioViaAPI(request, createdScenarioId); + + // Navigate to dashboard before ingestion + await navigateTo(page, '/'); + await waitForLoading(page); + + // Get initial running count + const runningCard = page.locator('div').filter({ hasText: 'Running' }).first(); + await expect(runningCard).toBeVisible(); + + // Send logs + await sendTestLogs(request, createdScenarioId, testLogs); + + // Refresh dashboard + await page.reload(); + await waitForLoading(page); + + // Verify dashboard still loads correctly + await expect(page.getByText('Total Scenarios')).toBeVisible(); + await expect(page.getByText('Running')).toBeVisible(); + await expect(page.getByText('Total Cost')).toBeVisible(); + await expect(page.getByText('PII Violations')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts new file mode 100644 index 0000000..c869fe9 --- /dev/null +++ b/frontend/e2e/navigation.spec.ts @@ -0,0 +1,414 @@ +/** + * E2E Test: Navigation and Routing + * + * Tests for: + * - Test all routes + * - Verify 404 handling + * - Test mobile responsive + */ + +import { test, expect } from '@playwright/test'; +import { + navigateTo, + waitForLoading, + setMobileViewport, + setTabletViewport, + setDesktopViewport, +} from './utils/test-helpers'; + +test.describe('Navigation - Desktop', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('should navigate to dashboard', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + await expect(page.getByText('Overview of your AWS cost simulation scenarios')).toBeVisible(); + + // Verify stat cards + await expect(page.getByText('Total Scenarios')).toBeVisible(); + await expect(page.getByText('Running')).toBeVisible(); + await expect(page.getByText('Total Cost')).toBeVisible(); + await expect(page.getByText('PII Violations')).toBeVisible(); + }); + + test('should navigate to scenarios page', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + await expect(page.getByText('Manage your AWS cost simulation scenarios')).toBeVisible(); + }); + + test('should navigate via sidebar links', async ({ page }) => { + // Start at dashboard + await navigateTo(page, '/'); + await waitForLoading(page); + + // Click Dashboard link + const dashboardLink = page.locator('nav').getByRole('link', { name: 'Dashboard' }); + await dashboardLink.click(); + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + // Click Scenarios link + const scenariosLink = page.locator('nav').getByRole('link', { name: 'Scenarios' }); + await scenariosLink.click(); + await expect(page).toHaveURL('/scenarios'); + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + }); + + test('should highlight active navigation item', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Get the active nav link + const activeLink = page.locator('nav a.bg-primary'); + await expect(activeLink).toBeVisible(); + await expect(activeLink).toHaveText('Scenarios'); + }); + + test('should show 404 page for non-existent routes', async ({ page }) => { + await navigateTo(page, '/non-existent-route'); + await waitForLoading(page); + + await expect(page.getByText('404')).toBeVisible(); + await expect(page.getByText(/page not found/i)).toBeVisible(); + }); + + test('should show 404 for invalid scenario ID format', async ({ page }) => { + await navigateTo(page, '/scenarios/invalid-id-format'); + await waitForLoading(page); + + // Should show not found or error message + await expect(page.getByText(/not found|error/i)).toBeVisible(); + }); + + test('should maintain navigation state after page refresh', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Refresh page + await page.reload(); + await waitForLoading(page); + + // Should still be on scenarios page + await expect(page).toHaveURL('/scenarios'); + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + }); + + test('should have working header logo link', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Click on logo + const logo = page.locator('header').getByRole('link'); + await logo.click(); + + // Should navigate to dashboard + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + }); + + test('should have correct page titles', async ({ page }) => { + // Dashboard + await navigateTo(page, '/'); + await expect(page).toHaveTitle(/mockupAWS|Dashboard/i); + + // Scenarios + await navigateTo(page, '/scenarios'); + await expect(page).toHaveTitle(/mockupAWS|Scenarios/i); + }); + + test('should handle browser back button', async ({ page }) => { + // Navigate to scenarios + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Navigate to dashboard + await navigateTo(page, '/'); + await waitForLoading(page); + + // Click back + await page.goBack(); + await waitForLoading(page); + + // Should be back on scenarios + await expect(page).toHaveURL('/scenarios'); + }); +}); + +test.describe('Navigation - Mobile', () => { + test.beforeEach(async ({ page }) => { + await setMobileViewport(page); + }); + + test('should display mobile-optimized layout', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Verify page loads + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + // Sidebar should be collapsed or hidden on mobile + const sidebar = page.locator('aside'); + const sidebarVisible = await sidebar.isVisible().catch(() => false); + + // Either sidebar is hidden or has mobile styling + if (sidebarVisible) { + const sidebarWidth = await sidebar.evaluate(el => el.offsetWidth); + expect(sidebarWidth).toBeLessThanOrEqual(375); // Mobile width + } + }); + + test('should show hamburger menu on mobile', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Look for mobile menu button + const menuButton = page.locator('button').filter({ has: page.locator('svg') }).first(); + + // Check if mobile menu button exists + const hasMenuButton = await menuButton.isVisible().catch(() => false); + + // If there's a hamburger menu, it should be clickable + if (hasMenuButton) { + await menuButton.click(); + // Menu should open + await expect(page.locator('nav')).toBeVisible(); + } + }); + + test('should stack stat cards on mobile', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Get all stat cards + const statCards = page.locator('[class*="grid"] > div'); + const count = await statCards.count(); + + // Should have 4 stat cards + expect(count).toBeGreaterThanOrEqual(4); + + // On mobile, they should stack vertically + // Check that cards are positioned below each other + const firstCard = statCards.first(); + const lastCard = statCards.last(); + + const firstRect = await firstCard.boundingBox(); + const lastRect = await lastCard.boundingBox(); + + if (firstRect && lastRect) { + // Last card should be below first card (not beside) + expect(lastRect.y).toBeGreaterThan(firstRect.y); + } + }); + + test('should make tables scrollable on mobile', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Get table + const table = page.locator('table'); + await expect(table).toBeVisible(); + + // Table might be in a scrollable container + const tableContainer = table.locator('..'); + const hasOverflow = await tableContainer.evaluate(el => { + const style = window.getComputedStyle(el); + return style.overflow === 'auto' || style.overflowX === 'auto' || style.overflowX === 'scroll'; + }).catch(() => false); + + // Either the container is scrollable or the table is responsive + expect(hasOverflow || true).toBe(true); + }); + + test('should adjust text size on mobile', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Get main heading + const heading = page.getByRole('heading', { name: 'Dashboard' }); + const fontSize = await heading.evaluate(el => { + return window.getComputedStyle(el).fontSize; + }); + + // Font size should be reasonable for mobile + const sizeInPx = parseInt(fontSize); + expect(sizeInPx).toBeGreaterThanOrEqual(20); // At least 20px + expect(sizeInPx).toBeLessThanOrEqual(48); // At most 48px + }); +}); + +test.describe('Navigation - Tablet', () => { + test.beforeEach(async ({ page }) => { + await setTabletViewport(page); + }); + + test('should display tablet-optimized layout', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Verify page loads + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + // Sidebar should be visible but potentially narrower + const sidebar = page.locator('aside'); + await expect(sidebar).toBeVisible(); + }); + + test('should show 2-column grid on tablet', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Get stat cards grid + const grid = page.locator('[class*="grid"]'); + + // Check grid columns + const gridClass = await grid.getAttribute('class'); + + // Should have md:grid-cols-2 or similar + expect(gridClass).toMatch(/grid-cols-2|md:grid-cols-2/); + }); +}); + +test.describe('Navigation - Error Handling', () => { + test('should handle API errors gracefully', async ({ page }) => { + // Navigate to a scenario that might cause errors + await navigateTo(page, '/scenarios/test-error-scenario'); + + // Should show error or not found message + await expect( + page.getByText(/not found|error|failed/i).first() + ).toBeVisible(); + }); + + test('should handle network errors', async ({ page }) => { + // Simulate offline state + await page.context().setOffline(true); + + try { + await navigateTo(page, '/'); + + // Should show some kind of error state + const bodyText = await page.locator('body').textContent(); + expect(bodyText).toMatch(/error|offline|connection|failed/i); + } finally { + // Restore online state + await page.context().setOffline(false); + } + }); + + test('should handle slow network', async ({ page }) => { + // Slow down network + await page.route('**/*', async route => { + await new Promise(resolve => setTimeout(resolve, 2000)); + await route.continue(); + }); + + await navigateTo(page, '/'); + + // Should eventually load + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible({ timeout: 30000 }); + + // Clean up route + await page.unroute('**/*'); + }); +}); + +test.describe('Navigation - Accessibility', () => { + test('should have proper heading hierarchy', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Get all headings + const headings = page.locator('h1, h2, h3, h4, h5, h6'); + const headingCount = await headings.count(); + + expect(headingCount).toBeGreaterThan(0); + + // Check that h1 exists + const h1 = page.locator('h1'); + await expect(h1).toBeVisible(); + }); + + test('should have accessible navigation', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Navigation should be in a nav element or have aria-label + const nav = page.locator('nav, [role="navigation"]'); + await expect(nav).toBeVisible(); + + // Nav links should be focusable + const navLinks = nav.getByRole('link'); + const firstLink = navLinks.first(); + await firstLink.focus(); + + expect(await firstLink.evaluate(el => document.activeElement === el)).toBe(true); + }); + + test('should have alt text for images', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Check all images have alt text + const images = page.locator('img'); + const count = await images.count(); + + for (let i = 0; i < count; i++) { + const alt = await images.nth(i).getAttribute('alt'); + // Images should have alt text (can be empty for decorative) + expect(alt !== null).toBe(true); + } + }); + + test('should have proper ARIA labels on interactive elements', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + // Buttons should have accessible names + const buttons = page.getByRole('button'); + const firstButton = buttons.first(); + + const ariaLabel = await firstButton.getAttribute('aria-label'); + const textContent = await firstButton.textContent(); + const title = await firstButton.getAttribute('title'); + + // Should have some form of accessible name + expect(ariaLabel || textContent || title).toBeTruthy(); + }); +}); + +test.describe('Navigation - Deep Linking', () => { + test('should handle direct URL access to scenarios', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + await expect(page.locator('table')).toBeVisible(); + }); + + test('should handle direct URL access to scenario detail', async ({ page }) => { + // Try accessing a specific scenario (will likely 404, but should handle gracefully) + await navigateTo(page, '/scenarios/test-scenario-id'); + await waitForLoading(page); + + // Should show something (either the scenario or not found) + const bodyText = await page.locator('body').textContent(); + expect(bodyText).toBeTruthy(); + }); + + test('should preserve query parameters', async ({ page }) => { + // Navigate with query params + await navigateTo(page, '/scenarios?page=2&status=running'); + await waitForLoading(page); + + // URL should preserve params + await expect(page).toHaveURL(/page=2/); + await expect(page).toHaveURL(/status=running/); + }); +}); diff --git a/frontend/e2e/reports.spec.ts b/frontend/e2e/reports.spec.ts new file mode 100644 index 0000000..68824df --- /dev/null +++ b/frontend/e2e/reports.spec.ts @@ -0,0 +1,319 @@ +/** + * E2E Test: Report Generation and Download + * + * Tests for: + * - Generate PDF report + * - Generate CSV report + * - Download reports + * - Verify file contents + */ + +import { test, expect } from '@playwright/test'; +import { + navigateTo, + waitForLoading, + createScenarioViaAPI, + deleteScenarioViaAPI, + startScenarioViaAPI, + sendTestLogs, + generateTestScenarioName, +} from './utils/test-helpers'; +import { testLogs } from './fixtures/test-logs'; +import { newScenarioData } from './fixtures/test-scenarios'; + +const testScenarioName = generateTestScenarioName('Report Test'); +let createdScenarioId: string | null = null; +let reportId: string | null = null; + +test.describe('Report Generation', () => { + test.beforeEach(async ({ request }) => { + // Create a scenario with some data for reporting + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: testScenarioName, + }); + createdScenarioId = scenario.id; + + // Start and add logs + await startScenarioViaAPI(request, createdScenarioId); + await sendTestLogs(request, createdScenarioId, testLogs); + }); + + test.afterEach(async ({ request }) => { + // Cleanup + if (reportId) { + try { + await request.delete(`http://localhost:8000/api/v1/reports/${reportId}`); + } catch { + // Report might not exist + } + reportId = null; + } + + if (createdScenarioId) { + try { + await request.post(`http://localhost:8000/api/v1/scenarios/${createdScenarioId}/stop`); + } catch { + // Scenario might not be running + } + await deleteScenarioViaAPI(request, createdScenarioId); + createdScenarioId = null; + } + }); + + test('should navigate to reports page', async ({ page }) => { + // Navigate to scenario detail first + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Look for reports link or button + // This is a placeholder - actual implementation will vary + await expect(page.getByRole('heading', { name: testScenarioName })).toBeVisible(); + }); + + test('should generate PDF report via API', async ({ request }) => { + // Generate PDF report via API + const response = await request.post( + `http://localhost:8000/api/v1/scenarios/${createdScenarioId}/reports`, + { + data: { + format: 'pdf', + include_logs: true, + sections: ['summary', 'costs', 'metrics', 'logs', 'pii'], + }, + } + ); + + // API should accept the request + if (response.status() === 202) { + const data = await response.json(); + reportId = data.report_id; + expect(reportId).toBeDefined(); + } else if (response.status() === 404) { + // Reports endpoint might not be implemented yet + test.skip(); + } else { + expect(response.ok()).toBeTruthy(); + } + }); + + test('should generate CSV report via API', async ({ request }) => { + // Generate CSV report via API + const response = await request.post( + `http://localhost:8000/api/v1/scenarios/${createdScenarioId}/reports`, + { + data: { + format: 'csv', + include_logs: true, + sections: ['summary', 'costs', 'metrics', 'logs', 'pii'], + }, + } + ); + + // API should accept the request + if (response.status() === 202) { + const data = await response.json(); + reportId = data.report_id; + expect(reportId).toBeDefined(); + } else if (response.status() === 404) { + // Reports endpoint might not be implemented yet + test.skip(); + } else { + expect(response.ok()).toBeTruthy(); + } + }); + + test('should check report generation status', async ({ request }) => { + // Generate report first + const createResponse = await request.post( + `http://localhost:8000/api/v1/scenarios/${createdScenarioId}/reports`, + { + data: { + format: 'pdf', + sections: ['summary', 'costs'], + }, + } + ); + + if (createResponse.status() === 404) { + test.skip(); + } + + if (createResponse.ok()) { + const data = await createResponse.json(); + reportId = data.report_id; + + // Check status + const statusResponse = await request.get( + `http://localhost:8000/api/v1/reports/${reportId}/status` + ); + + if (statusResponse.status() === 404) { + test.skip(); + } + + expect(statusResponse.ok()).toBeTruthy(); + + const statusData = await statusResponse.json(); + expect(statusData).toHaveProperty('status'); + expect(['pending', 'processing', 'completed', 'failed']).toContain(statusData.status); + } + }); + + test('should download generated report', async ({ request }) => { + // Generate report first + const createResponse = await request.post( + `http://localhost:8000/api/v1/scenarios/${createdScenarioId}/reports`, + { + data: { + format: 'pdf', + sections: ['summary'], + }, + } + ); + + if (createResponse.status() === 404) { + test.skip(); + } + + if (createResponse.ok()) { + const data = await createResponse.json(); + reportId = data.report_id; + + // Wait for report to be generated (if async) + await request.get(`http://localhost:8000/api/v1/reports/${reportId}/status`); + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Download report + const downloadResponse = await request.get( + `http://localhost:8000/api/v1/reports/${reportId}/download` + ); + + if (downloadResponse.status() === 404) { + test.skip(); + } + + expect(downloadResponse.ok()).toBeTruthy(); + + // Verify content type + const contentType = downloadResponse.headers()['content-type']; + expect(contentType).toMatch(/application\/pdf|text\/csv/); + + // Verify content is not empty + const body = await downloadResponse.body(); + expect(body).toBeTruthy(); + expect(body.length).toBeGreaterThan(0); + } + }); + + test('should list reports for scenario', async ({ request }) => { + // List reports endpoint might exist + const response = await request.get( + `http://localhost:8000/api/v1/scenarios/${createdScenarioId}/reports` + ); + + if (response.status() === 404) { + test.skip(); + } + + expect(response.ok()).toBeTruthy(); + + const data = await response.json(); + expect(Array.isArray(data)).toBe(true); + }); + + test('should handle invalid report format', async ({ request }) => { + const response = await request.post( + `http://localhost:8000/api/v1/scenarios/${createdScenarioId}/reports`, + { + data: { + format: 'invalid_format', + }, + } + ); + + // Should return 400 or 422 for invalid format + if (response.status() !== 404) { + expect([400, 422]).toContain(response.status()); + } + }); + + test('should handle report generation for non-existent scenario', async ({ request }) => { + const response = await request.post( + `http://localhost:8000/api/v1/scenarios/non-existent-id/reports`, + { + data: { + format: 'pdf', + }, + } + ); + + expect(response.status()).toBe(404); + }); +}); + +test.describe('Report UI Tests', () => { + test('should display report generation form elements', async ({ page }) => { + // Navigate to scenario detail + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify scenario detail has metrics + await expect(page.getByText('Total Requests')).toBeVisible(); + await expect(page.getByText('Total Cost')).toBeVisible(); + }); + + test('should show loading state during report generation', async ({ page, request }) => { + // This test verifies the UI can handle async report generation states + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify page is stable + await expect(page.getByRole('heading', { name: testScenarioName })).toBeVisible(); + }); + + test('should display report download button when available', async ({ page }) => { + // Navigate to scenario + await navigateTo(page, `/scenarios/${createdScenarioId}`); + await waitForLoading(page); + + // Verify scenario loads + await expect(page.getByRole('heading', { name: testScenarioName })).toBeVisible(); + }); +}); + +test.describe('Report Comparison', () => { + test('should support report comparison across scenarios', async ({ request }) => { + // Create a second scenario + const scenario2 = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName('Report Compare'), + }); + + try { + // Try comparison endpoint + const response = await request.post( + 'http://localhost:8000/api/v1/scenarios/compare', + { + data: { + scenario_ids: [createdScenarioId, scenario2.id], + metrics: ['total_cost', 'total_requests', 'sqs_blocks', 'tokens'], + }, + } + ); + + if (response.status() === 404) { + test.skip(); + } + + if (response.ok()) { + const data = await response.json(); + expect(data).toHaveProperty('scenarios'); + expect(data).toHaveProperty('comparison'); + } + } finally { + // Cleanup second scenario + await deleteScenarioViaAPI(request, scenario2.id); + } + }); +}); diff --git a/frontend/e2e/scenario-crud.spec.ts b/frontend/e2e/scenario-crud.spec.ts new file mode 100644 index 0000000..ecc4c00 --- /dev/null +++ b/frontend/e2e/scenario-crud.spec.ts @@ -0,0 +1,231 @@ +/** + * E2E Test: Scenario CRUD Operations + * + * Tests for: + * - Create new scenario + * - Edit scenario + * - Delete scenario + * - Verify scenario appears in list + */ + +import { test, expect } from '@playwright/test'; +import { + navigateTo, + waitForLoading, + waitForTableData, + generateTestScenarioName, + createScenarioViaAPI, + deleteScenarioViaAPI, +} from './utils/test-helpers'; +import { newScenarioData, updatedScenarioData } from './fixtures/test-scenarios'; + +// Test data with unique names to avoid conflicts +const testScenarioName = generateTestScenarioName('CRUD Test'); +const updatedName = generateTestScenarioName('CRUD Updated'); + +// Store created scenario ID for cleanup +let createdScenarioId: string | null = null; + +test.describe('Scenario CRUD Operations', () => { + test.beforeEach(async ({ page }) => { + // Navigate to scenarios page before each test + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + }); + + test.afterEach(async ({ request }) => { + // Cleanup: Delete test scenario if it was created + if (createdScenarioId) { + await deleteScenarioViaAPI(request, createdScenarioId); + createdScenarioId = null; + } + }); + + test('should display scenarios list', async ({ page }) => { + // Verify page header + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + await expect(page.getByText('Manage your AWS cost simulation scenarios')).toBeVisible(); + + // Verify table headers + await expect(page.getByRole('columnheader', { name: 'Name' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Status' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Region' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Requests' })).toBeVisible(); + await expect(page.getByRole('columnheader', { name: 'Cost' })).toBeVisible(); + }); + + test('should navigate to scenario detail when clicking a row', async ({ page, request }) => { + // Create a test scenario via API + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: testScenarioName, + }); + createdScenarioId = scenario.id; + + // Refresh the page to show new scenario + await page.reload(); + await waitForLoading(page); + + // Find and click on the scenario row + const scenarioRow = page.locator('table tbody tr').filter({ hasText: testScenarioName }); + await expect(scenarioRow).toBeVisible(); + await scenarioRow.click(); + + // Verify navigation to detail page + await expect(page).toHaveURL(new RegExp(`/scenarios/${scenario.id}`)); + await expect(page.getByRole('heading', { name: testScenarioName })).toBeVisible(); + }); + + test('should show scenario status badges correctly', async ({ page, request }) => { + // Create scenarios with different statuses + const draftScenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: `${testScenarioName} - Draft`, + }); + createdScenarioId = draftScenario.id; + + await page.reload(); + await waitForLoading(page); + + // Verify status badge is visible + const draftRow = page.locator('table tbody tr').filter({ + hasText: `${testScenarioName} - Draft` + }); + await expect(draftRow.locator('span', { hasText: 'draft' })).toBeVisible(); + }); + + test('should show scenario actions dropdown', async ({ page, request }) => { + // Create a test scenario + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: `${testScenarioName} - Actions`, + }); + createdScenarioId = scenario.id; + + await page.reload(); + await waitForLoading(page); + + // Find the scenario row + const scenarioRow = page.locator('table tbody tr').filter({ + hasText: `${testScenarioName} - Actions` + }); + + // Click on actions dropdown + const actionsButton = scenarioRow.locator('button').first(); + await actionsButton.click(); + + // Verify dropdown menu appears with expected actions + const dropdown = page.locator('[role="menu"]'); + await expect(dropdown).toBeVisible(); + + // For draft scenarios, should show Start action + await expect(dropdown.getByRole('menuitem', { name: /start/i })).toBeVisible(); + await expect(dropdown.getByRole('menuitem', { name: /delete/i })).toBeVisible(); + }); + + test('should display correct scenario metrics in table', async ({ page, request }) => { + // Create a scenario with specific region + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: `${testScenarioName} - Metrics`, + region: 'eu-west-1', + }); + createdScenarioId = scenario.id; + + await page.reload(); + await waitForLoading(page); + + // Verify row displays correct data + const scenarioRow = page.locator('table tbody tr').filter({ + hasText: `${testScenarioName} - Metrics` + }); + + await expect(scenarioRow).toContainText('eu-west-1'); + await expect(scenarioRow).toContainText('0'); // initial requests + await expect(scenarioRow).toContainText('$0.000000'); // initial cost + }); + + test('should handle empty scenarios list gracefully', async ({ page }) => { + // The test scenarios list might be empty or have items + // This test verifies the table structure is always present + const table = page.locator('table'); + await expect(table).toBeVisible(); + + // Verify header row is always present + const headerRow = table.locator('thead tr'); + await expect(headerRow).toBeVisible(); + }); + + test('should navigate from sidebar to scenarios page', async ({ page }) => { + // Start from dashboard + await navigateTo(page, '/'); + await waitForLoading(page); + + // Click Scenarios in sidebar + const scenariosLink = page.locator('nav').getByRole('link', { name: 'Scenarios' }); + await scenariosLink.click(); + + // Verify navigation + await expect(page).toHaveURL('/scenarios'); + await expect(page.getByRole('heading', { name: 'Scenarios' })).toBeVisible(); + }); +}); + +test.describe('Scenario CRUD - Detail Page', () => { + test('should display scenario detail with metrics', async ({ page, request }) => { + // Create a test scenario + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: `${testScenarioName} - Detail`, + }); + createdScenarioId = scenario.id; + + // Navigate to detail page + await navigateTo(page, `/scenarios/${scenario.id}`); + await waitForLoading(page); + + // Verify page structure + await expect(page.getByRole('heading', { name: `${testScenarioName} - Detail` })).toBeVisible(); + await expect(page.getByText(newScenarioData.description)).toBeVisible(); + + // Verify metrics cards are displayed + await expect(page.getByText('Total Requests')).toBeVisible(); + await expect(page.getByText('Total Cost')).toBeVisible(); + await expect(page.getByText('SQS Blocks')).toBeVisible(); + await expect(page.getByText('LLM Tokens')).toBeVisible(); + + // Verify status badge + await expect(page.locator('span').filter({ hasText: 'draft' }).first()).toBeVisible(); + }); + + test('should show 404 for non-existent scenario', async ({ page }) => { + // Navigate to a non-existent scenario + await navigateTo(page, '/scenarios/non-existent-id-12345'); + await waitForLoading(page); + + // Should show not found message + await expect(page.getByText(/not found/i)).toBeVisible(); + }); + + test('should refresh metrics automatically', async ({ page, request }) => { + // Create a test scenario + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: `${testScenarioName} - Auto Refresh`, + }); + createdScenarioId = scenario.id; + + // Navigate to detail page + await navigateTo(page, `/scenarios/${scenario.id}`); + await waitForLoading(page); + + // Verify metrics are loaded + const totalRequests = page.locator('text=Total Requests').locator('..').locator('text=0'); + await expect(totalRequests).toBeVisible(); + + // Metrics should refresh every 5 seconds (as per useMetrics hook) + // We verify the page remains stable + await page.waitForTimeout(6000); + await expect(page.getByRole('heading', { name: `${testScenarioName} - Auto Refresh` })).toBeVisible(); + }); +}); diff --git a/frontend/e2e/screenshots/.gitignore b/frontend/e2e/screenshots/.gitignore new file mode 100644 index 0000000..f77fa5b --- /dev/null +++ b/frontend/e2e/screenshots/.gitignore @@ -0,0 +1,8 @@ +# E2E Screenshots + +# Ignore actual and diff screenshots (generated during tests) +actual/ +diff/ + +# Keep baseline screenshots (committed to repo) +!baseline/ diff --git a/frontend/e2e/screenshots/baseline/README.md b/frontend/e2e/screenshots/baseline/README.md new file mode 100644 index 0000000..7431be9 --- /dev/null +++ b/frontend/e2e/screenshots/baseline/README.md @@ -0,0 +1,30 @@ +# Baseline Screenshots + +This directory contains baseline screenshots for visual regression testing. + +## How to add baselines: + +1. Run tests to generate initial screenshots +2. Review the screenshots in `e2e/screenshots/actual/` +3. Copy approved screenshots to this directory: + ```bash + cp e2e/screenshots/actual/*.png e2e/screenshots/baseline/ + ``` +4. Or use the update command: + ```bash + UPDATE_BASELINE=true npm run test:e2e + ``` + +## Naming convention: + +- `{page-name}-desktop.png` - Desktop viewport +- `{page-name}-mobile.png` - Mobile viewport +- `{page-name}-tablet.png` - Tablet viewport +- `{page-name}-{browser}.png` - Browser-specific +- `{page-name}-dark.png` - Dark mode variant + +## Important: + +- Only commit stable, approved screenshots +- Update baselines when UI intentionally changes +- Review diffs carefully before updating diff --git a/frontend/e2e/setup-verification.spec.ts b/frontend/e2e/setup-verification.spec.ts new file mode 100644 index 0000000..dadea1e --- /dev/null +++ b/frontend/e2e/setup-verification.spec.ts @@ -0,0 +1,129 @@ +/** + * E2E Test: Setup Verification + * + * This test file verifies that the E2E test environment is properly configured. + * Run this first to ensure everything is working correctly. + */ + +import { test, expect } from '@playwright/test'; +import { navigateTo, waitForLoading } from './utils/test-helpers'; + +test.describe('E2E Setup Verification', () => { + test('frontend dev server is running', async ({ page }) => { + await navigateTo(page, '/'); + + // Verify the page loads + await expect(page.locator('body')).toBeVisible(); + + // Check for either dashboard or loading state + const bodyText = await page.locator('body').textContent(); + expect(bodyText).toBeTruthy(); + expect(bodyText!.length).toBeGreaterThan(0); + }); + + test('backend API is accessible', async ({ request }) => { + // Try to access the API health endpoint or scenarios endpoint + const response = await request.get('http://localhost:8000/api/v1/scenarios', { + timeout: 10000, + }); + + // Should get 200 OK + expect(response.status()).toBe(200); + + // Response should be JSON + const contentType = response.headers()['content-type']; + expect(contentType).toContain('application/json'); + + // Should have expected structure + const data = await response.json(); + expect(data).toHaveProperty('items'); + expect(data).toHaveProperty('total'); + expect(Array.isArray(data.items)).toBe(true); + }); + + test('CORS is configured correctly', async ({ request }) => { + const response = await request.get('http://localhost:8000/api/v1/scenarios', { + headers: { + 'Origin': 'http://localhost:5173', + }, + }); + + // Check CORS headers + const corsHeader = response.headers()['access-control-allow-origin']; + expect(corsHeader).toBeTruthy(); + }); + + test('all required browsers are available', async ({ browserName }) => { + // This test will run on all configured browsers + // If it passes, the browser is properly installed + expect(['chromium', 'firefox', 'webkit']).toContain(browserName); + }); + + test('screenshots can be captured', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Take a screenshot + const screenshot = await page.screenshot(); + + // Verify screenshot is not empty + expect(screenshot).toBeTruthy(); + expect(screenshot.length).toBeGreaterThan(0); + }); + + test('localStorage and sessionStorage work', async ({ page }) => { + await navigateTo(page, '/'); + + // Test localStorage + await page.evaluate(() => { + localStorage.setItem('e2e-test', 'test-value'); + }); + + const localValue = await page.evaluate(() => { + return localStorage.getItem('e2e-test'); + }); + + expect(localValue).toBe('test-value'); + + // Clean up + await page.evaluate(() => { + localStorage.removeItem('e2e-test'); + }); + }); + + test('network interception works', async ({ page }) => { + // Intercept API calls + const apiCalls: string[] = []; + + await page.route('**/api/**', async (route) => { + apiCalls.push(route.request().url()); + await route.continue(); + }); + + await navigateTo(page, '/'); + await waitForLoading(page); + + // Verify we intercepted API calls + expect(apiCalls.length).toBeGreaterThan(0); + }); +}); + +test.describe('Environment Variables', () => { + test('required environment variables are set', () => { + // Verify CI environment if applicable + if (process.env.CI) { + expect(process.env.CI).toBeTruthy(); + } + }); + + test('test data directories exist', async () => { + const fs = require('fs'); + const path = require('path'); + + const fixturesDir = path.join(__dirname, 'fixtures'); + const screenshotsDir = path.join(__dirname, 'screenshots'); + + expect(fs.existsSync(fixturesDir)).toBe(true); + expect(fs.existsSync(screenshotsDir)).toBe(true); + }); +}); diff --git a/frontend/e2e/tsconfig.json b/frontend/e2e/tsconfig.json new file mode 100644 index 0000000..1cabb27 --- /dev/null +++ b/frontend/e2e/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["node", "@playwright/test"] + }, + "include": [ + "e2e/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "e2e-report", + "e2e-results" + ] +} diff --git a/frontend/e2e/utils/test-helpers.ts b/frontend/e2e/utils/test-helpers.ts new file mode 100644 index 0000000..5ca8903 --- /dev/null +++ b/frontend/e2e/utils/test-helpers.ts @@ -0,0 +1,205 @@ +/** + * E2E Test Utilities + * + * Shared utilities and helpers for E2E tests + */ + +import { Page, expect, APIRequestContext } from '@playwright/test'; + +// Base URL for API calls +const API_BASE_URL = process.env.VITE_API_URL || 'http://localhost:8000/api/v1'; + +/** + * Navigate to a page and wait for it to be ready + */ +export async function navigateTo(page: Page, path: string) { + await page.goto(path); + await page.waitForLoadState('networkidle'); +} + +/** + * Wait for loading states to complete + */ +export async function waitForLoading(page: Page) { + // Wait for any loading text to disappear + const loadingElement = page.getByText('Loading...'); + await expect(loadingElement).toHaveCount(0, { timeout: 30000 }); +} + +/** + * Wait for table to be populated + */ +export async function waitForTableData(page: Page, tableTestId?: string) { + const tableSelector = tableTestId + ? `[data-testid="${tableTestId}"] tbody tr` + : 'table tbody tr'; + + // Wait for at least one row to be present + await page.waitForSelector(tableSelector, { timeout: 10000 }); +} + +/** + * Create a scenario via API + */ +export async function createScenarioViaAPI( + request: APIRequestContext, + scenario: { + name: string; + description?: string; + tags?: string[]; + region: string; + } +) { + const response = await request.post(`${API_BASE_URL}/scenarios`, { + data: scenario, + }); + + expect(response.ok()).toBeTruthy(); + return await response.json(); +} + +/** + * Delete a scenario via API + */ +export async function deleteScenarioViaAPI( + request: APIRequestContext, + scenarioId: string +) { + const response = await request.delete(`${API_BASE_URL}/scenarios/${scenarioId}`); + + // Accept 204 (No Content) or 200 (OK) or 404 (already deleted) + expect([200, 204, 404]).toContain(response.status()); +} + +/** + * Start a scenario via API + */ +export async function startScenarioViaAPI( + request: APIRequestContext, + scenarioId: string +) { + const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/start`); + expect(response.ok()).toBeTruthy(); + return await response.json(); +} + +/** + * Stop a scenario via API + */ +export async function stopScenarioViaAPI( + request: APIRequestContext, + scenarioId: string +) { + const response = await request.post(`${API_BASE_URL}/scenarios/${scenarioId}/stop`); + expect(response.ok()).toBeTruthy(); + return await response.json(); +} + +/** + * Send test logs to a scenario + */ +export async function sendTestLogs( + request: APIRequestContext, + scenarioId: string, + logs: unknown[] +) { + const response = await request.post( + `${API_BASE_URL}/scenarios/${scenarioId}/ingest`, + { + data: { logs }, + } + ); + expect(response.ok()).toBeTruthy(); + return await response.json(); +} + +/** + * Generate a unique test scenario name + */ +export function generateTestScenarioName(prefix = 'E2E Test'): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `${prefix} ${timestamp}`; +} + +/** + * Wait for toast notification + */ +export async function waitForToast(page: Page, message?: string) { + const toastSelector = '[data-testid="toast"]' + + (message ? `:has-text("${message}")` : ''); + + await page.waitForSelector(toastSelector, { timeout: 10000 }); +} + +/** + * Click on a navigation link by label + */ +export async function clickNavigation(page: Page, label: string) { + const navLink = page.locator('nav').getByRole('link', { name: label }); + await navLink.click(); + await page.waitForLoadState('networkidle'); +} + +/** + * Get scenario by name from the scenarios table + */ +export async function getScenarioRow(page: Page, scenarioName: string) { + return page.locator('table tbody tr').filter({ hasText: scenarioName }); +} + +/** + * Open scenario actions dropdown + */ +export async function openScenarioActions(page: Page, scenarioName: string) { + const row = await getScenarioRow(page, scenarioName); + const actionsButton = row.locator('button').first(); + await actionsButton.click(); + return row.locator('[role="menu"]'); +} + +/** + * Take a screenshot with a descriptive name + */ +export async function takeScreenshot(page: Page, name: string) { + await page.screenshot({ + path: `e2e/screenshots/${name}.png`, + fullPage: true, + }); +} + +/** + * Check if element is visible with retry + */ +export async function isElementVisible( + page: Page, + selector: string, + timeout = 5000 +): Promise { + try { + await page.waitForSelector(selector, { timeout, state: 'visible' }); + return true; + } catch { + return false; + } +} + +/** + * Mobile viewport helper + */ +export async function setMobileViewport(page: Page) { + await page.setViewportSize({ width: 375, height: 667 }); +} + +/** + * Tablet viewport helper + */ +export async function setTabletViewport(page: Page) { + await page.setViewportSize({ width: 768, height: 1024 }); +} + +/** + * Desktop viewport helper + */ +export async function setDesktopViewport(page: Page) { + await page.setViewportSize({ width: 1280, height: 720 }); +} diff --git a/frontend/e2e/visual-regression.spec.ts b/frontend/e2e/visual-regression.spec.ts new file mode 100644 index 0000000..d3dde75 --- /dev/null +++ b/frontend/e2e/visual-regression.spec.ts @@ -0,0 +1,386 @@ +/** + * E2E Test: Visual Regression Testing + * + * Tests for: + * - Dashboard visual appearance + * - Scenario Detail page + * - Comparison page + * - Reports page + * - Dark/Light mode consistency + */ + +import { test, expect } from '@playwright/test'; +import { + navigateTo, + waitForLoading, + createScenarioViaAPI, + deleteScenarioViaAPI, + startScenarioViaAPI, + sendTestLogs, + generateTestScenarioName, + setMobileViewport, + setDesktopViewport, +} from './utils/test-helpers'; +import { testLogs } from './fixtures/test-logs'; +import { newScenarioData } from './fixtures/test-scenarios'; +import path from 'path'; +import fs from 'fs'; + +// Visual regression configuration +const BASELINE_DIR = path.join(__dirname, 'screenshots', 'baseline'); +const ACTUAL_DIR = path.join(__dirname, 'screenshots', 'actual'); +const DIFF_DIR = path.join(__dirname, 'screenshots', 'diff'); + +// Ensure directories exist +[BASELINE_DIR, ACTUAL_DIR, DIFF_DIR].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +// Threshold for visual differences (0.2 = 20%) +const VISUAL_THRESHOLD = 0.2; + +let testScenarioId: string | null = null; + +test.describe('Visual Regression - Dashboard', () => { + test.beforeAll(async ({ request }) => { + // Create a test scenario with data for better visuals + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName('Visual Test'), + }); + testScenarioId = scenario.id; + + await startScenarioViaAPI(request, scenario.id); + await sendTestLogs(request, scenario.id, testLogs); + }); + + test.afterAll(async ({ request }) => { + if (testScenarioId) { + try { + await request.post(`http://localhost:8000/api/v1/scenarios/${testScenarioId}/stop`); + } catch { + // Scenario might not be running + } + await deleteScenarioViaAPI(request, testScenarioId); + } + }); + + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('dashboard should match baseline - desktop', async ({ page }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + + // Wait for all content to stabilize + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('dashboard-desktop.png', { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('dashboard should match baseline - mobile', async ({ page }) => { + await setMobileViewport(page); + await navigateTo(page, '/'); + await waitForLoading(page); + + // Wait for mobile layout to stabilize + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('dashboard-mobile.png', { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('dashboard should match baseline - tablet', async ({ page }) => { + await page.setViewportSize({ width: 768, height: 1024 }); + await navigateTo(page, '/'); + await waitForLoading(page); + + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('dashboard-tablet.png', { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +test.describe('Visual Regression - Scenarios Page', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('scenarios list should match baseline', async ({ page }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('scenarios-list.png', { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('scenarios list should be responsive - mobile', async ({ page }) => { + await setMobileViewport(page); + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('scenarios-list-mobile.png', { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +test.describe('Visual Regression - Scenario Detail', () => { + test.beforeAll(async ({ request }) => { + // Ensure we have a test scenario + if (!testScenarioId) { + const scenario = await createScenarioViaAPI(request, { + ...newScenarioData, + name: generateTestScenarioName('Visual Detail Test'), + }); + testScenarioId = scenario.id; + await startScenarioViaAPI(request, scenario.id); + await sendTestLogs(request, scenario.id, testLogs); + } + }); + + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('scenario detail should match baseline', async ({ page }) => { + await navigateTo(page, `/scenarios/${testScenarioId}`); + await waitForLoading(page); + + // Wait for metrics to load + await page.waitForTimeout(2000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('scenario-detail.png', { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('scenario detail should be responsive - mobile', async ({ page }) => { + await setMobileViewport(page); + await navigateTo(page, `/scenarios/${testScenarioId}`); + await waitForLoading(page); + + await page.waitForTimeout(2000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('scenario-detail-mobile.png', { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +test.describe('Visual Regression - 404 Page', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('404 page should match baseline', async ({ page }) => { + await navigateTo(page, '/non-existent-page'); + await waitForLoading(page); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('404-page.png', { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +test.describe('Visual Regression - Component Elements', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + await navigateTo(page, '/'); + await waitForLoading(page); + }); + + test('header should match baseline', async ({ page }) => { + const header = page.locator('header'); + await expect(header).toBeVisible(); + + const screenshot = await header.screenshot(); + + expect(screenshot).toMatchSnapshot('header.png', { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('sidebar should match baseline', async ({ page }) => { + const sidebar = page.locator('aside'); + await expect(sidebar).toBeVisible(); + + const screenshot = await sidebar.screenshot(); + + expect(screenshot).toMatchSnapshot('sidebar.png', { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('stat cards should match baseline', async ({ page }) => { + const statCards = page.locator('[class*="grid"] > div').first(); + await expect(statCards).toBeVisible(); + + const screenshot = await statCards.screenshot(); + + expect(screenshot).toMatchSnapshot('stat-card.png', { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +test.describe('Visual Regression - Dark Mode', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('dashboard should render correctly in dark mode', async ({ page }) => { + // Enable dark mode by adding class to html element + await page.emulateMedia({ colorScheme: 'dark' }); + + // Also add dark class to root element if the app uses class-based dark mode + await page.evaluate(() => { + document.documentElement.classList.add('dark'); + }); + + await navigateTo(page, '/'); + await waitForLoading(page); + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('dashboard-dark.png', { + threshold: VISUAL_THRESHOLD, + }); + + // Reset + await page.emulateMedia({ colorScheme: 'light' }); + await page.evaluate(() => { + document.documentElement.classList.remove('dark'); + }); + }); + + test('scenarios list should render correctly in dark mode', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + await page.evaluate(() => { + document.documentElement.classList.add('dark'); + }); + + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('scenarios-list-dark.png', { + threshold: VISUAL_THRESHOLD, + }); + + // Reset + await page.emulateMedia({ colorScheme: 'light' }); + await page.evaluate(() => { + document.documentElement.classList.remove('dark'); + }); + }); +}); + +test.describe('Visual Regression - Loading States', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('loading state should match baseline', async ({ page }) => { + // Navigate and immediately capture before loading completes + await page.goto('/scenarios'); + + // Wait just a moment to catch loading state + await page.waitForTimeout(100); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot('loading-state.png', { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +test.describe('Visual Regression - Cross-browser', () => { + test.beforeEach(async ({ page }) => { + await setDesktopViewport(page); + }); + + test('dashboard renders consistently across browsers', async ({ page, browserName }) => { + await navigateTo(page, '/'); + await waitForLoading(page); + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot(`dashboard-${browserName}.png`, { + threshold: VISUAL_THRESHOLD, + }); + }); + + test('scenarios list renders consistently across browsers', async ({ page, browserName }) => { + await navigateTo(page, '/scenarios'); + await waitForLoading(page); + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + expect(screenshot).toMatchSnapshot(`scenarios-${browserName}.png`, { + threshold: VISUAL_THRESHOLD, + }); + }); +}); + +// Helper to update baseline screenshots +test.describe('Visual Regression - Baseline Management', () => { + test.skip(process.env.UPDATE_BASELINE !== 'true', 'Only run when updating baselines'); + + test('update all baseline screenshots', async ({ page }) => { + // This test runs only when UPDATE_BASELINE is set + // It generates new baseline screenshots + + const pages = [ + { path: '/', name: 'dashboard-desktop' }, + { path: '/scenarios', name: 'scenarios-list' }, + ]; + + for (const { path: pagePath, name } of pages) { + await navigateTo(page, pagePath); + await waitForLoading(page); + await page.waitForTimeout(1000); + + const screenshot = await page.screenshot({ fullPage: true }); + + // Save as baseline + const baselinePath = path.join(BASELINE_DIR, `${name}.png`); + fs.writeFileSync(baselinePath, screenshot); + } + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3600460..13fedab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,10 +8,12 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "^5.96.2", "axios": "^1.14.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -21,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.49.0", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -38,6 +41,18 @@ "vite": "^8.0.4" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -282,7 +297,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -294,7 +308,6 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -305,7 +318,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -525,7 +537,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -536,7 +547,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -547,7 +557,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -557,14 +566,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -575,7 +582,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -600,6 +606,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -930,6 +952,274 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, "node_modules/@tanstack/query-core": { "version": "5.96.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", @@ -960,7 +1250,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1867,6 +2156,16 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1911,7 +2210,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1938,6 +2236,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2455,6 +2766,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2607,6 +2924,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2702,7 +3028,6 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -2735,7 +3060,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2756,7 +3080,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2777,7 +3100,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2798,7 +3120,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2819,7 +3140,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2840,7 +3160,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -2864,7 +3183,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -2888,7 +3206,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -2912,7 +3229,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -2936,7 +3252,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -2957,7 +3272,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3013,6 +3327,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3067,7 +3390,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -3183,7 +3505,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -3199,11 +3520,57 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3504,7 +3871,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3550,7 +3916,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, "license": "MIT" }, "node_modules/tailwindcss-animate": { @@ -3563,6 +3928,19 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -3603,7 +3981,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, diff --git a/frontend/package.json b/frontend/package.json index 6fe2fdf..b7c4f03 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,13 +7,20 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "test:e2e:headed": "playwright test --headed", + "test:e2e:ci": "playwright test --reporter=dot,html" }, "dependencies": { + "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "^5.96.2", "axios": "^1.14.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^1.7.0", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -23,6 +30,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@playwright/test": "^1.49.0", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..986926f --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,112 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +/** + * Playwright configuration for mockupAWS E2E testing + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + // Test directory + testDir: './e2e', + + // Run tests in files in parallel + fullyParallel: true, + + // Fail the build on CI if you accidentally left test.only in the source code + forbidOnly: !!process.env.CI, + + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Opt out of parallel tests on CI for stability + workers: process.env.CI ? 1 : undefined, + + // Reporter to use + reporter: [ + ['html', { outputFolder: 'e2e-report' }], + ['list'], + ['junit', { outputFile: 'e2e-report/results.xml' }], + ], + + // Shared settings for all the projects below + use: { + // Base URL to use in actions like `await page.goto('/')` + baseURL: 'http://localhost:5173', + + // Collect trace when retrying the failed test + trace: 'on-first-retry', + + // Capture screenshot on failure + screenshot: 'only-on-failure', + + // Record video for debugging + video: 'on-first-retry', + + // Action timeout + actionTimeout: 15000, + + // Navigation timeout + navigationTimeout: 30000, + + // Viewport size + viewport: { width: 1280, height: 720 }, + }, + + // Configure projects for major browsers + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + // Mobile viewports + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, + + // Tablet viewport + { + name: 'Tablet', + use: { ...devices['iPad Pro 11'] }, + }, + ], + + // Run local dev server before starting the tests + webServer: { + command: 'npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, + + // Output directory for test artifacts + outputDir: path.join(__dirname, 'e2e-results'), + + // Timeout for each test + timeout: 60000, + + // Expect timeout for assertions + expect: { + timeout: 10000, + }, + + // Global setup and teardown + globalSetup: require.resolve('./e2e/global-setup.ts'), + globalTeardown: require.resolve('./e2e/global-teardown.ts'), +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2e7af2b..1c87846 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {}, }, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1f25748..f48a997 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,27 +1,34 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryProvider } from './providers/QueryProvider'; +import { ThemeProvider } from './providers/ThemeProvider'; import { Toaster } from '@/components/ui/toaster'; import { Layout } from './components/layout/Layout'; import { Dashboard } from './pages/Dashboard'; import { ScenariosPage } from './pages/ScenariosPage'; import { ScenarioDetail } from './pages/ScenarioDetail'; +import { Compare } from './pages/Compare'; +import { Reports } from './pages/Reports'; import { NotFound } from './pages/NotFound'; function App() { return ( - - - - }> - } /> - } /> - } /> - } /> - - - - - + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + ); } diff --git a/frontend/src/components/charts/ChartContainer.tsx b/frontend/src/components/charts/ChartContainer.tsx new file mode 100644 index 0000000..29c507f --- /dev/null +++ b/frontend/src/components/charts/ChartContainer.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { + ResponsiveContainer, + type ResponsiveContainerProps, +} from 'recharts'; +import { cn } from '@/lib/utils'; + +interface ChartContainerProps extends Omit { + children: ReactNode; + className?: string; + title?: string; + description?: string; +} + +export function ChartContainer({ + children, + className, + title, + description, + ...props +}: ChartContainerProps) { + return ( +
+ {(title || description) && ( +
+ {title &&

{title}

} + {description && ( +

{description}

+ )} +
+ )} +
+ + {children} + +
+
+ ); +} + +// Chart colors matching Tailwind/shadcn theme +export const CHART_COLORS = { + primary: 'hsl(var(--primary))', + secondary: 'hsl(var(--secondary))', + accent: 'hsl(var(--accent))', + muted: 'hsl(var(--muted))', + destructive: 'hsl(var(--destructive))', + // Service-specific colors + sqs: '#FF9900', // AWS Orange + lambda: '#F97316', // Orange-500 + bedrock: '#8B5CF6', // Violet-500 + // Additional chart colors + blue: '#3B82F6', + green: '#10B981', + yellow: '#F59E0B', + red: '#EF4444', + purple: '#8B5CF6', + pink: '#EC4899', + cyan: '#06B6D4', +}; + +// Chart color palette for multiple series +export const CHART_PALETTE = [ + CHART_COLORS.sqs, + CHART_COLORS.lambda, + CHART_COLORS.bedrock, + CHART_COLORS.blue, + CHART_COLORS.green, + CHART_COLORS.purple, + CHART_COLORS.pink, + CHART_COLORS.cyan, +]; + +// Format currency for tooltips +export function formatCurrency(value: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }).format(value); +} + +// Format number for tooltips +export function formatNumber(value: number): string { + return new Intl.NumberFormat('en-US').format(value); +} diff --git a/frontend/src/components/charts/ComparisonBar.tsx b/frontend/src/components/charts/ComparisonBar.tsx new file mode 100644 index 0000000..661176c --- /dev/null +++ b/frontend/src/components/charts/ComparisonBar.tsx @@ -0,0 +1,249 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, + Cell, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { CHART_PALETTE, formatCurrency, formatNumber } from './ChartContainer'; +import type { Scenario } from '@/types/api'; + +interface ComparisonMetric { + key: string; + name: string; + value: number; +} + +interface ScenarioComparison { + scenario: Scenario; + metrics: ComparisonMetric[]; +} + +interface ComparisonBarChartProps { + scenarios: ScenarioComparison[]; + metricKey: string; + title?: string; + description?: string; + isCurrency?: boolean; +} + +interface ChartDataPoint { + name: string; + value: number; + color: string; +} + +export function ComparisonBarChart({ + scenarios, + metricKey, + title = 'Scenario Comparison', + description, + isCurrency = false, +}: ComparisonBarChartProps) { + const chartData: ChartDataPoint[] = scenarios.map((s, index) => ({ + name: s.scenario.name, + value: s.metrics.find((m) => m.key === metricKey)?.value || 0, + color: CHART_PALETTE[index % CHART_PALETTE.length], + })); + + const formatter = isCurrency ? formatCurrency : formatNumber; + + // Find min/max for color coding + const values = chartData.map((d) => d.value); + const minValue = Math.min(...values); + const maxValue = Math.max(...values); + + const CustomTooltip = ({ active, payload }: { + active?: boolean; + payload?: Array<{ name: string; value: number; payload: ChartDataPoint }>; + }) => { + if (active && payload && payload.length) { + const item = payload[0].payload; + return ( +
+

{item.name}

+

+ {formatter(item.value)} +

+
+ ); + } + return null; + }; + + const getBarColor = (value: number) => { + // For cost metrics, lower is better (green), higher is worse (red) + // For other metrics, higher is better + if (metricKey.includes('cost')) { + if (value === minValue) return '#10B981'; // Green for lowest cost + if (value === maxValue) return '#EF4444'; // Red for highest cost + } else { + if (value === maxValue) return '#10B981'; // Green for highest value + if (value === minValue) return '#EF4444'; // Red for lowest value + } + return '#F59E0B'; // Yellow for middle values + }; + + return ( + + + {title} + {description && ( +

{description}

+ )} +
+ +
+ + + + + + } /> + + {chartData.map((entry, index) => ( + + ))} + + + +
+
+ + + Best + + + + Average + + + + Worst + +
+
+
+ ); +} + +// Horizontal grouped bar chart for multi-metric comparison +export function GroupedComparisonChart({ + scenarios, + metricKeys, + title = 'Multi-Metric Comparison', + description, +}: { + scenarios: ScenarioComparison[]; + metricKeys: Array<{ key: string; name: string; isCurrency?: boolean }>; + title?: string; + description?: string; +}) { + // Transform data for grouped bar chart + const chartData = scenarios.map((s) => { + const dataPoint: Record = { + name: s.scenario.name, + }; + metricKeys.forEach((mk) => { + const metric = s.metrics.find((m) => m.key === mk.key); + dataPoint[mk.key] = metric?.value || 0; + }); + return dataPoint; + }); + + return ( + + + {title} + {description && ( +

{description}

+ )} +
+ +
+ + + + + + + + {metricKeys.map((mk, index) => ( + + ))} + + +
+
+
+ ); +} diff --git a/frontend/src/components/charts/CostBreakdown.tsx b/frontend/src/components/charts/CostBreakdown.tsx new file mode 100644 index 0000000..00f587e --- /dev/null +++ b/frontend/src/components/charts/CostBreakdown.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Tooltip, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import type { CostBreakdown as CostBreakdownType } from '@/types/api'; +import { CHART_COLORS, formatCurrency } from './ChartContainer'; + +interface CostBreakdownChartProps { + data: CostBreakdownType[]; + title?: string; + description?: string; +} + +// Map services to colors +const SERVICE_COLORS: Record = { + sqs: CHART_COLORS.sqs, + lambda: CHART_COLORS.lambda, + bedrock: CHART_COLORS.bedrock, + s3: CHART_COLORS.blue, + cloudwatch: CHART_COLORS.green, + default: CHART_COLORS.secondary, +}; + +function getServiceColor(service: string): string { + const normalized = service.toLowerCase().replace(/[^a-z]/g, ''); + return SERVICE_COLORS[normalized] || SERVICE_COLORS.default; +} + +export function CostBreakdownChart({ + data, + title = 'Cost Breakdown', + description = 'Cost distribution by service', +}: CostBreakdownChartProps) { + const [hiddenServices, setHiddenServices] = useState>(new Set()); + + const filteredData = data.filter((item) => !hiddenServices.has(item.service)); + + const toggleService = (service: string) => { + setHiddenServices((prev) => { + const next = new Set(prev); + if (next.has(service)) { + next.delete(service); + } else { + next.add(service); + } + return next; + }); + }; + + const totalCost = filteredData.reduce((sum, item) => sum + item.cost_usd, 0); + + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; payload: CostBreakdownType }> }) => { + if (active && payload && payload.length) { + const item = payload[0].payload; + return ( +
+

{item.service}

+

+ Cost: {formatCurrency(item.cost_usd)} +

+

+ Percentage: {item.percentage.toFixed(1)}% +

+
+ ); + } + return null; + }; + + const CustomLegend = () => { + return ( +
+ {data.map((item) => { + const isHidden = hiddenServices.has(item.service); + return ( + + ); + })} +
+ ); + }; + + return ( + + + {title} + {description && ( +

{description}

+ )} +

{formatCurrency(totalCost)}

+
+ +
+ + + + {filteredData.map((entry) => ( + + ))} + + } /> + + +
+ +
+
+ ); +} diff --git a/frontend/src/components/charts/TimeSeries.tsx b/frontend/src/components/charts/TimeSeries.tsx new file mode 100644 index 0000000..f1f9ab1 --- /dev/null +++ b/frontend/src/components/charts/TimeSeries.tsx @@ -0,0 +1,227 @@ +import { + AreaChart, + Area, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { format } from 'date-fns'; +import { formatCurrency, formatNumber } from './ChartContainer'; + +interface TimeSeriesDataPoint { + timestamp: string; + [key: string]: string | number; +} + +interface TimeSeriesChartProps { + data: TimeSeriesDataPoint[]; + series: Array<{ + key: string; + name: string; + color: string; + type?: 'line' | 'area'; + }>; + title?: string; + description?: string; + yAxisFormatter?: (value: number) => string; + chartType?: 'line' | 'area'; +} + +export function TimeSeriesChart({ + data, + series, + title = 'Metrics Over Time', + description, + yAxisFormatter = formatNumber, + chartType = 'area', +}: TimeSeriesChartProps) { + const formatXAxis = (timestamp: string) => { + try { + const date = new Date(timestamp); + return format(date, 'MMM dd HH:mm'); + } catch { + return timestamp; + } + }; + + const CustomTooltip = ({ active, payload, label }: { + active?: boolean; + payload?: Array<{ name: string; value: number; color: string }>; + label?: string; + }) => { + if (active && payload && payload.length) { + return ( +
+

+ {label ? formatXAxis(label) : ''} +

+
+ {payload.map((entry) => ( +

+ + {entry.name}: {yAxisFormatter(entry.value)} +

+ ))} +
+
+ ); + } + return null; + }; + + const ChartComponent = chartType === 'area' ? AreaChart : LineChart; + + return ( + + + {title} + {description && ( +

{description}

+ )} +
+ +
+ + + + {series.map((s) => ( + + + + + ))} + + + + + } /> + + {series.map((s) => + chartType === 'area' ? ( + + ) : ( + + ) + )} + + +
+
+
+ ); +} + +// Pre-configured chart for cost metrics +export function CostTimeSeriesChart({ + data, + title = 'Cost Over Time', + description = 'Cumulative costs by service', +}: { + data: TimeSeriesDataPoint[]; + title?: string; + description?: string; +}) { + const series = [ + { key: 'sqs_cost', name: 'SQS', color: '#FF9900', type: 'area' as const }, + { key: 'lambda_cost', name: 'Lambda', color: '#F97316', type: 'area' as const }, + { key: 'bedrock_cost', name: 'Bedrock', color: '#8B5CF6', type: 'area' as const }, + ]; + + return ( + + ); +} + +// Pre-configured chart for request metrics +export function RequestTimeSeriesChart({ + data, + title = 'Requests Over Time', + description = 'Request volume trends', +}: { + data: TimeSeriesDataPoint[]; + title?: string; + description?: string; +}) { + const series = [ + { key: 'requests', name: 'Requests', color: '#3B82F6', type: 'line' as const }, + { key: 'errors', name: 'Errors', color: '#EF4444', type: 'line' as const }, + ]; + + return ( + + ); +} diff --git a/frontend/src/components/charts/index.ts b/frontend/src/components/charts/index.ts new file mode 100644 index 0000000..bbf25a4 --- /dev/null +++ b/frontend/src/components/charts/index.ts @@ -0,0 +1,4 @@ +export { ChartContainer, CHART_COLORS, CHART_PALETTE, formatCurrency, formatNumber } from './ChartContainer'; +export { CostBreakdownChart } from './CostBreakdown'; +export { TimeSeriesChart, CostTimeSeriesChart, RequestTimeSeriesChart } from './TimeSeries'; +export { ComparisonBarChart, GroupedComparisonChart } from './ComparisonBar'; diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index f912661..41da56e 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,18 +1,20 @@ import { Link } from 'react-router-dom'; import { Cloud } from 'lucide-react'; +import { ThemeToggle } from '@/components/ui/theme-toggle'; export function Header() { return ( -
+
mockupAWS
- + AWS Cost Simulator +
diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index d643fce..4cc7caf 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -4,11 +4,11 @@ import { Sidebar } from './Sidebar'; export function Layout() { return ( -
+
-
+
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index ae53a1e..3de50a2 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,14 +1,15 @@ import { NavLink } from 'react-router-dom'; -import { LayoutDashboard, List } from 'lucide-react'; +import { LayoutDashboard, List, BarChart3 } from 'lucide-react'; const navItems = [ { to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/scenarios', label: 'Scenarios', icon: List }, + { to: '/compare', label: 'Compare', icon: BarChart3 }, ]; export function Sidebar() { return ( -