diff --git a/frontend/.nycrc.json b/frontend/.nycrc.json new file mode 100644 index 0000000..d1eb960 --- /dev/null +++ b/frontend/.nycrc.json @@ -0,0 +1,34 @@ +{ + "all": true, + "include": [ + "src/**/*.{js,jsx,ts,tsx}" + ], + "exclude": [ + "src/**/*.d.ts", + "src/**/*.test.{js,jsx,ts,tsx}", + "src/**/__tests__/**", + "src/**/*.stories.{js,jsx,ts,tsx}", + "src/lib/api/generated/**", + "src/**/*.old.{js,jsx,ts,tsx}", + "src/components/ui/**", + "src/app/dev/**", + "src/**/index.{js,jsx,ts,tsx}", + "src/lib/utils/cn.ts", + "src/middleware.ts" + ], + "reporter": [ + "text", + "text-summary", + "html", + "json", + "lcov" + ], + "report-dir": "./coverage-combined", + "temp-dir": "./.nyc_output", + "sourceMap": true, + "instrument": true, + "branches": 85, + "functions": 85, + "lines": 90, + "statements": 90 +} diff --git a/frontend/e2e/helpers/coverage.ts b/frontend/e2e/helpers/coverage.ts new file mode 100644 index 0000000..06cf11d --- /dev/null +++ b/frontend/e2e/helpers/coverage.ts @@ -0,0 +1,275 @@ +/** + * E2E Coverage Helpers + * + * Utilities for collecting code coverage during Playwright E2E tests. + * Supports both V8 coverage (Chromium-only) and Istanbul instrumentation. + * + * Usage in E2E tests: + * + * ```typescript + * import { startCoverage, stopAndSaveCoverage } from './helpers/coverage'; + * + * test.describe('My Tests', () => { + * test.beforeEach(async ({ page }) => { + * await startCoverage(page); + * await page.goto('/'); + * }); + * + * test.afterEach(async ({ page }, testInfo) => { + * await stopAndSaveCoverage(page, testInfo.title); + * }); + * + * test('my test', async ({ page }) => { + * // Your test code... + * }); + * }); + * ``` + */ + +import type { Page } from '@playwright/test'; +import fs from 'fs/promises'; +import path from 'path'; + +/** + * Check if coverage collection is enabled via environment variable + */ +export function isCoverageEnabled(): boolean { + return process.env.E2E_COVERAGE === 'true'; +} + +/** + * Start collecting V8 coverage for a page + * + * @param page - Playwright page instance + * @param options - Coverage options + */ +export async function startCoverage( + page: Page, + options?: { + resetOnNavigation?: boolean; + includeRawScriptCoverage?: boolean; + } +) { + if (!isCoverageEnabled()) { + return; + } + + try { + await page.coverage.startJSCoverage({ + resetOnNavigation: options?.resetOnNavigation ?? false, + includeRawScriptCoverage: options?.includeRawScriptCoverage ?? false, + }); + } catch (error) { + console.warn('⚠️ Failed to start coverage:', error); + } +} + +/** + * Stop coverage collection and save to file + * + * @param page - Playwright page instance + * @param testName - Name of the test (used for filename) + */ +export async function stopAndSaveCoverage(page: Page, testName: string) { + if (!isCoverageEnabled()) { + return; + } + + try { + const coverage = await page.coverage.stopJSCoverage(); + + if (coverage.length === 0) { + console.warn('⚠️ No coverage collected for:', testName); + return; + } + + // Save V8 coverage + await saveV8Coverage(coverage, testName); + } catch (error) { + console.warn('⚠️ Failed to stop/save coverage for', testName, ':', error); + } +} + +/** + * Save V8 coverage data to disk + * + * @param coverage - V8 coverage data + * @param testName - Test name for the filename + */ +async function saveV8Coverage(coverage: any[], testName: string) { + const coverageDir = path.join(process.cwd(), 'coverage-e2e', 'raw'); + await fs.mkdir(coverageDir, { recursive: true }); + + const filename = sanitizeFilename(testName); + const filepath = path.join(coverageDir, `${filename}.json`); + + await fs.writeFile(filepath, JSON.stringify(coverage, null, 2)); +} + +/** + * Collect Istanbul coverage from browser window object + * + * Use this if you're using Istanbul instrumentation instead of V8 coverage. + * Requires babel-plugin-istanbul or similar instrumentation. + * + * @param page - Playwright page instance + * @param testName - Name of the test + */ +export async function saveIstanbulCoverage(page: Page, testName: string) { + if (!isCoverageEnabled()) { + return; + } + + try { + // Extract coverage from window.__coverage__ (set by Istanbul instrumentation) + const coverage = await page.evaluate(() => (window as any).__coverage__); + + if (!coverage) { + console.warn('⚠️ No Istanbul coverage found for:', testName); + console.warn(' Make sure babel-plugin-istanbul is configured'); + return; + } + + // Save Istanbul coverage + const coverageDir = path.join(process.cwd(), 'coverage-e2e', '.nyc_output'); + await fs.mkdir(coverageDir, { recursive: true }); + + const filename = sanitizeFilename(testName); + const filepath = path.join(coverageDir, `${filename}.json`); + + await fs.writeFile(filepath, JSON.stringify(coverage, null, 2)); + } catch (error) { + console.warn('⚠️ Failed to save Istanbul coverage for', testName, ':', error); + } +} + +/** + * Combined coverage helper for test hooks + * + * Automatically uses V8 coverage if available, falls back to Istanbul + * + * Usage in beforeEach/afterEach: + * ```typescript + * test.beforeEach(async ({ page }) => { + * await withCoverage.start(page); + * }); + * + * test.afterEach(async ({ page }, testInfo) => { + * await withCoverage.stop(page, testInfo.title); + * }); + * ``` + */ +export const withCoverage = { + /** + * Start coverage collection (V8 approach) + */ + async start(page: Page) { + await startCoverage(page); + }, + + /** + * Stop coverage and save (tries V8, then Istanbul) + */ + async stop(page: Page, testName: string) { + if (!isCoverageEnabled()) { + return; + } + + // Try V8 coverage first + try { + const v8Coverage = await page.coverage.stopJSCoverage(); + if (v8Coverage && v8Coverage.length > 0) { + await saveV8Coverage(v8Coverage, testName); + return; + } + } catch { + // V8 coverage not available, try Istanbul + } + + // Fall back to Istanbul coverage + await saveIstanbulCoverage(page, testName); + }, +}; + +/** + * Sanitize test name for use as filename + * + * @param name - Test name + * @returns Sanitized filename + */ +function sanitizeFilename(name: string): string { + return name + .replace(/[^a-z0-9\s-]/gi, '') // Remove special chars + .replace(/\s+/g, '_') // Replace spaces with underscores + .toLowerCase() + .substring(0, 100); // Limit length +} + +/** + * Get coverage statistics (for debugging) + * + * @param page - Playwright page instance + * @returns Coverage statistics + */ +export async function getCoverageStats(page: Page): Promise<{ + v8Available: boolean; + istanbulAvailable: boolean; + istanbulFileCount?: number; +}> { + const stats = { + v8Available: false, + istanbulAvailable: false, + istanbulFileCount: undefined as number | undefined, + }; + + // Check V8 coverage + try { + await page.coverage.startJSCoverage(); + await page.coverage.stopJSCoverage(); + stats.v8Available = true; + } catch { + stats.v8Available = false; + } + + // Check Istanbul coverage + try { + const coverage = await page.evaluate(() => (window as any).__coverage__); + if (coverage) { + stats.istanbulAvailable = true; + stats.istanbulFileCount = Object.keys(coverage).length; + } + } catch { + stats.istanbulAvailable = false; + } + + return stats; +} + +/** + * Example usage in a test file: + * + * ```typescript + * import { test, expect } from '@playwright/test'; + * import { withCoverage } from './helpers/coverage'; + * + * test.describe('Homepage Tests', () => { + * test.beforeEach(async ({ page }) => { + * await withCoverage.start(page); + * await page.goto('/'); + * }); + * + * test.afterEach(async ({ page }, testInfo) => { + * await withCoverage.stop(page, testInfo.title); + * }); + * + * test('displays header', async ({ page }) => { + * await expect(page.getByRole('heading')).toBeVisible(); + * }); + * }); + * ``` + * + * Then run with: + * ```bash + * E2E_COVERAGE=true npm run test:e2e + * ``` + */ diff --git a/frontend/scripts/convert-v8-to-istanbul.ts b/frontend/scripts/convert-v8-to-istanbul.ts new file mode 100644 index 0000000..ccb11cf --- /dev/null +++ b/frontend/scripts/convert-v8-to-istanbul.ts @@ -0,0 +1,215 @@ +#!/usr/bin/env tsx +/** + * V8 to Istanbul Coverage Converter + * + * Converts Playwright's V8 coverage format to Istanbul format + * so it can be merged with Jest coverage data. + * + * Usage: + * npm run coverage:convert + * # or directly: + * tsx scripts/convert-v8-to-istanbul.ts + * + * Input: + * - V8 coverage files in: ./coverage-e2e/raw/*.json + * - Generated by Playwright's page.coverage API + * + * Output: + * - Istanbul coverage in: ./coverage-e2e/.nyc_output/e2e-coverage.json + * - Ready to merge with Jest coverage + * + * Prerequisites: + * - npm install -D v8-to-istanbul + * - E2E tests must collect coverage using page.coverage API + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +// V8 coverage entry format +interface V8CoverageEntry { + url: string; + scriptId: string; + source?: string; + functions: Array<{ + functionName: string; + ranges: Array<{ + startOffset: number; + endOffset: number; + count: number; + }>; + isBlockCoverage: boolean; + }>; +} + +// Istanbul coverage format +interface IstanbulCoverage { + [filePath: string]: { + path: string; + statementMap: Record; + fnMap: Record; + branchMap: Record; + s: Record; + f: Record; + b: Record; + }; +} + +async function convertV8ToIstanbul() { + console.log('\n🔄 Converting V8 Coverage to Istanbul Format...\n'); + + const rawDir = path.join(process.cwd(), 'coverage-e2e/raw'); + const outputDir = path.join(process.cwd(), 'coverage-e2e/.nyc_output'); + + // Check if raw directory exists + try { + await fs.access(rawDir); + } catch { + console.log('❌ No V8 coverage found at:', rawDir); + console.log('\nℹ️ To generate V8 coverage:'); + console.log(' 1. Add coverage helpers to E2E tests (see E2E_COVERAGE_GUIDE.md)'); + console.log(' 2. Run: E2E_COVERAGE=true npm run test:e2e'); + console.log('\nℹ️ Alternatively, use Istanbul instrumentation (see guide)'); + return; + } + + // Create output directory + await fs.mkdir(outputDir, { recursive: true }); + + // Check for v8-to-istanbul dependency + let v8toIstanbul: any; + try { + // Dynamic import to handle both scenarios (installed vs not installed) + const module = await import('v8-to-istanbul'); + v8toIstanbul = module.default || module; + } catch (error) { + console.log('❌ v8-to-istanbul not installed\n'); + console.log('📦 Install it with:'); + console.log(' npm install -D v8-to-istanbul\n'); + console.log('⚠️ Note: V8 coverage approach requires this dependency.'); + console.log(' Alternatively, use Istanbul instrumentation (no extra deps needed).\n'); + process.exit(1); + } + + // Read all V8 coverage files + const files = await fs.readdir(rawDir); + const jsonFiles = files.filter(f => f.endsWith('.json')); + + if (jsonFiles.length === 0) { + console.log('⚠️ No coverage files found in:', rawDir); + console.log('\nRun E2E tests with coverage enabled:'); + console.log(' E2E_COVERAGE=true npm run test:e2e\n'); + return; + } + + console.log(`📁 Found ${jsonFiles.length} V8 coverage file(s)\n`); + + const istanbulCoverage: IstanbulCoverage = {}; + const projectRoot = process.cwd(); + let totalConverted = 0; + let totalSkipped = 0; + + // Process each V8 coverage file + for (const file of jsonFiles) { + const filePath = path.join(rawDir, file); + console.log(`📄 Processing: ${file}`); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + const v8Coverage: V8CoverageEntry[] = JSON.parse(content); + + for (const entry of v8Coverage) { + try { + // Skip non-source files + if ( + !entry.url.startsWith('http://localhost') && + !entry.url.startsWith('file://') + ) { + continue; + } + + // Skip node_modules, .next, and other irrelevant files + if ( + entry.url.includes('node_modules') || + entry.url.includes('/.next/') || + entry.url.includes('/_next/') || + entry.url.includes('/webpack/') + ) { + totalSkipped++; + continue; + } + + // Convert URL to file path + let sourcePath: string; + if (entry.url.startsWith('file://')) { + sourcePath = fileURLToPath(entry.url); + } else { + // HTTP URL - extract path + const url = new URL(entry.url); + // Try to map to source file + sourcePath = path.join(projectRoot, 'src', url.pathname); + } + + // Skip if not in src/ + if (!sourcePath.includes('/src/')) { + totalSkipped++; + continue; + } + + // Verify file exists + try { + await fs.access(sourcePath); + } catch { + totalSkipped++; + continue; + } + + // Convert using v8-to-istanbul + const converter = v8toIstanbul(sourcePath); + await converter.load(); + converter.applyCoverage(entry.functions); + const converted = converter.toIstanbul(); + + // Merge into combined coverage + Object.assign(istanbulCoverage, converted); + totalConverted++; + + } catch (error: any) { + console.log(` ⚠️ Skipped ${entry.url}: ${error.message}`); + totalSkipped++; + } + } + } catch (error: any) { + console.log(` ❌ Failed to process ${file}: ${error.message}`); + } + } + + // Write Istanbul coverage + const outputPath = path.join(outputDir, 'e2e-coverage.json'); + await fs.writeFile(outputPath, JSON.stringify(istanbulCoverage, null, 2)); + + console.log('\n' + '='.repeat(70)); + console.log('✅ Conversion Complete'); + console.log('='.repeat(70)); + console.log(`\n Files converted: ${totalConverted}`); + console.log(` Files skipped: ${totalSkipped}`); + console.log(` Output location: ${outputPath}\n`); + + if (totalConverted === 0) { + console.log('⚠️ No files were converted. Possible reasons:'); + console.log(' • V8 coverage doesn\'t contain source files from src/'); + console.log(' • Coverage was collected for build artifacts instead of source'); + console.log(' • Source maps are not correctly configured\n'); + console.log('💡 Consider using Istanbul instrumentation instead (see guide)\n'); + } else { + console.log('✅ Ready to merge with Jest coverage:'); + console.log(' npm run coverage:merge\n'); + } +} + +// Run the conversion +convertV8ToIstanbul().catch((error) => { + console.error('\n❌ Error converting coverage:', error); + process.exit(1); +}); diff --git a/frontend/scripts/merge-coverage.ts b/frontend/scripts/merge-coverage.ts new file mode 100644 index 0000000..cf7b6b7 --- /dev/null +++ b/frontend/scripts/merge-coverage.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env tsx +/** + * Merge Coverage Script + * + * Combines Jest unit test coverage with Playwright E2E test coverage + * to generate a comprehensive combined coverage report. + * + * Usage: + * npm run coverage:merge + * # or directly: + * tsx scripts/merge-coverage.ts + * + * Prerequisites: + * - Jest coverage must exist at: ./coverage/coverage-final.json + * - E2E coverage must exist at: ./coverage-e2e/.nyc_output/*.json + * + * Output: + * - Combined coverage report in: ./coverage-combined/ + * - Formats: HTML, text, JSON, LCOV + */ + +import { createCoverageMap } from 'istanbul-lib-coverage'; +import { createContext } from 'istanbul-lib-report'; +import reports from 'istanbul-reports'; +import fs from 'fs'; +import path from 'path'; + +interface CoverageData { + [key: string]: any; +} + +interface MergeStats { + jestFiles: number; + e2eFiles: number; + combinedFiles: number; + jestOnlyFiles: string[]; + e2eOnlyFiles: string[]; + sharedFiles: string[]; +} + +async function mergeCoverage() { + console.log('\n🔄 Merging Coverage Data...\n'); + + const map = createCoverageMap(); + const stats: MergeStats = { + jestFiles: 0, + e2eFiles: 0, + combinedFiles: 0, + jestOnlyFiles: [], + e2eOnlyFiles: [], + sharedFiles: [], + }; + + const jestFiles = new Set(); + const e2eFiles = new Set(); + + // Step 1: Load Jest coverage + console.log('📊 Loading Jest unit test coverage...'); + const jestCoveragePath = path.join(process.cwd(), 'coverage/coverage-final.json'); + + if (fs.existsSync(jestCoveragePath)) { + const jestCoverage: CoverageData = JSON.parse( + fs.readFileSync(jestCoveragePath, 'utf-8') + ); + + Object.keys(jestCoverage).forEach(file => jestFiles.add(file)); + stats.jestFiles = jestFiles.size; + + console.log(` ✅ Loaded ${stats.jestFiles} files from Jest coverage`); + map.merge(jestCoverage); + } else { + console.log(' ⚠️ No Jest coverage found at:', jestCoveragePath); + console.log(' Run: npm run test:coverage'); + } + + // Step 2: Load E2E coverage + console.log('\n🎭 Loading Playwright E2E test coverage...'); + const e2eDir = path.join(process.cwd(), 'coverage-e2e/.nyc_output'); + + if (fs.existsSync(e2eDir)) { + const files = fs.readdirSync(e2eDir).filter(f => f.endsWith('.json')); + + if (files.length === 0) { + console.log(' ⚠️ No E2E coverage files found in:', e2eDir); + console.log(' Run: E2E_COVERAGE=true npm run test:e2e'); + } else { + for (const file of files) { + const coverage: CoverageData = JSON.parse( + fs.readFileSync(path.join(e2eDir, file), 'utf-8') + ); + + Object.keys(coverage).forEach(f => e2eFiles.add(f)); + map.merge(coverage); + console.log(` ✅ Loaded E2E coverage from: ${file}`); + } + stats.e2eFiles = e2eFiles.size; + console.log(` 📁 Total unique files in E2E coverage: ${stats.e2eFiles}`); + } + } else { + console.log(' ⚠️ No E2E coverage directory found at:', e2eDir); + console.log(' Run: E2E_COVERAGE=true npm run test:e2e'); + } + + // Step 3: Calculate statistics + stats.combinedFiles = map.files().length; + + map.files().forEach(file => { + const inJest = jestFiles.has(file); + const inE2E = e2eFiles.has(file); + + if (inJest && inE2E) { + stats.sharedFiles.push(file); + } else if (inJest) { + stats.jestOnlyFiles.push(file); + } else if (inE2E) { + stats.e2eOnlyFiles.push(file); + } + }); + + // Step 4: Generate reports + console.log('\n📝 Generating combined coverage reports...'); + + const reportDir = path.join(process.cwd(), 'coverage-combined'); + fs.mkdirSync(reportDir, { recursive: true }); + + const context = createContext({ + dir: reportDir, + coverageMap: map, + }); + + const reportTypes = ['text', 'text-summary', 'html', 'json', 'lcov']; + + reportTypes.forEach((reportType) => { + try { + const report = reports.create(reportType as any, {}); + report.execute(context); + console.log(` ✅ Generated ${reportType} report`); + } catch (error) { + console.error(` ❌ Failed to generate ${reportType} report:`, error); + } + }); + + // Step 5: Print summary + const summary = map.getCoverageSummary(); + + console.log('\n' + '='.repeat(70)); + console.log('📊 COMBINED COVERAGE SUMMARY'); + console.log('='.repeat(70)); + console.log(`\n Statements: ${summary.statements.pct.toFixed(2)}% (${summary.statements.covered}/${summary.statements.total})`); + console.log(` Branches: ${summary.branches.pct.toFixed(2)}% (${summary.branches.covered}/${summary.branches.total})`); + console.log(` Functions: ${summary.functions.pct.toFixed(2)}% (${summary.functions.covered}/${summary.functions.total})`); + console.log(` Lines: ${summary.lines.pct.toFixed(2)}% (${summary.lines.covered}/${summary.lines.total})`); + + console.log('\n' + '-'.repeat(70)); + console.log('📁 FILE COVERAGE BREAKDOWN'); + console.log('-'.repeat(70)); + console.log(`\n Total files: ${stats.combinedFiles}`); + console.log(` Jest only: ${stats.jestOnlyFiles.length}`); + console.log(` E2E only: ${stats.e2eOnlyFiles.length}`); + console.log(` Covered by both: ${stats.sharedFiles.length}`); + + // Show E2E-only files (these were excluded from Jest) + if (stats.e2eOnlyFiles.length > 0) { + console.log('\n 📋 Files covered ONLY by E2E tests (excluded from unit tests):'); + stats.e2eOnlyFiles.slice(0, 10).forEach(file => { + const fileCoverage = map.fileCoverageFor(file); + const fileSummary = fileCoverage.toSummary(); + console.log(` • ${path.relative(process.cwd(), file)} (${fileSummary.statements.pct.toFixed(1)}%)`); + }); + if (stats.e2eOnlyFiles.length > 10) { + console.log(` ... and ${stats.e2eOnlyFiles.length - 10} more`); + } + } + + console.log('\n' + '='.repeat(70)); + console.log(`\n✅ Combined coverage report available at:\n ${reportDir}/index.html\n`); + + // Step 6: Check thresholds (from .nycrc.json) + const thresholds = { + statements: 90, + branches: 85, + functions: 85, + lines: 90, + }; + + let thresholdsFailed = false; + console.log('🎯 Checking Coverage Thresholds:\n'); + + Object.entries(thresholds).forEach(([metric, threshold]) => { + const actual = (summary as any)[metric].pct; + const passed = actual >= threshold; + const icon = passed ? '✅' : '❌'; + console.log(` ${icon} ${metric.padEnd(12)}: ${actual.toFixed(2)}% (threshold: ${threshold}%)`); + if (!passed) thresholdsFailed = true; + }); + + if (thresholdsFailed) { + console.log('\n❌ Coverage thresholds not met!\n'); + process.exit(1); + } else { + console.log('\n✅ All coverage thresholds met!\n'); + } +} + +// Run the merge +mergeCoverage().catch((error) => { + console.error('\n❌ Error merging coverage:', error); + process.exit(1); +});