diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..9c43afe --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,45 @@ +import fs from 'fs/promises'; +import path from 'path'; +import type { TestInfo } from '@playwright/test'; +import { expect, test as base, Page } from '@bgotink/playwright-coverage'; + +const coverageDir = path.join(process.cwd(), 'coverage-reports', 'e2e', '.nyc_output'); + +async function writeCoverageFile(page: Page, testInfo: TestInfo) { + try { + const coverage = await page.evaluate(() => (globalThis as any).__coverage__ ?? null); + if (!coverage || Object.keys(coverage).length === 0) return; + + await fs.mkdir(coverageDir, { recursive: true }); + + const titleParts = + typeof testInfo.titlePath === 'function' + ? testInfo.titlePath() + : Array.isArray((testInfo as any).titlePath) + ? (testInfo as any).titlePath + : [testInfo.title]; + const safeTitle = titleParts + .filter(Boolean) + .map((part) => part.replace(/[^a-zA-Z0-9-_]+/g, '_')) + .join('--') + .slice(0, 200); + const worker = typeof testInfo.workerIndex === 'number' ? `w${testInfo.workerIndex}` : 'w'; + const filename = path.join(coverageDir, `coverage-${worker}-${Date.now()}-${safeTitle || 'test'}.json`); + + await fs.writeFile(filename, JSON.stringify(coverage)); + console.log('Wrote coverage file:', path.relative(process.cwd(), filename)); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + console.warn('Failed to collect coverage from page:', msg); + } +} + +export const test = base.extend({ + page: async ({ page }, use, testInfo) => { + await use(page); + await writeCoverageFile(page, testInfo); + }, +}); + +export { expect }; +export type { Page }; diff --git a/e2e/picklist.spec.ts b/e2e/picklist.spec.ts index 09e9643..df0d2fc 100644 --- a/e2e/picklist.spec.ts +++ b/e2e/picklist.spec.ts @@ -1,5 +1,4 @@ -import { expect, test } from '@bgotink/playwright-coverage'; -import type { Page } from '@playwright/test'; +import { expect, test, Page } from './fixtures'; import { areaName, diff --git a/e2e/playwright-collect-coverage.spec.ts b/e2e/playwright-collect-coverage.spec.ts deleted file mode 100644 index 4ed5e3f..0000000 --- a/e2e/playwright-collect-coverage.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -// e2e/playwright-collect-coverage.spec.ts -import fs from 'fs'; -import path from 'path'; -import { test } from '@playwright/test'; - -const EVAL_TIMEOUT_MS = 5000; -const WRITE_TIMEOUT_MS = 15000; - -function withTimeout(p: Promise, ms: number, onTimeout?: () => void) { - let timer: NodeJS.Timeout | undefined; - const timeout = new Promise((res) => { - timer = setTimeout(() => { - onTimeout?.(); - // @ts-ignore resolve null on timeout - res(null); - }, ms); - }); - return Promise.race([p, timeout]).then((r) => { - if (timer) clearTimeout(timer); - return r; - }); -} - -test.afterEach(async ({ page }, testInfo) => { - try { - console.log(`Collecting coverage for: ${testInfo.title}`); - - // Evaluate coverage in page, but don't hang forever - const evalPromise = page.evaluate(() => (globalThis as any).__coverage__ || null); - const coverage = await withTimeout(evalPromise, EVAL_TIMEOUT_MS, () => { - console.warn('page.evaluate for coverage timed out'); - }); - - if (!coverage || Object.keys(coverage).length === 0) { - console.log('No coverage collected (empty or timed out).'); - return; - } - - const dir = path.join(process.cwd(), '.nyc_output'); - await fs.promises.mkdir(dir, { recursive: true }); - - const safeTitle = (testInfo.title || 'test').replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 120); - const worker = typeof testInfo.workerIndex === 'number' ? testInfo.workerIndex : process.pid; - const filename = path.join(dir, `coverage-e2e-w${worker}-${Date.now()}-${safeTitle}.json`); - - // write asynchronously, bounded by timeout - const writePromise = fs.promises.writeFile(filename, JSON.stringify(coverage)); - await withTimeout(writePromise, WRITE_TIMEOUT_MS, () => { - console.warn('Writing coverage file timed out'); - }); - - console.log('Wrote coverage:', filename); - } catch (error: unknown) { - const msg = error instanceof Error ? error.message : String(error); - console.warn('Failed to collect coverage from page:', msg); - } -}); diff --git a/e2e/product-filter.spec.ts b/e2e/product-filter.spec.ts index d7f065a..a1ddf7a 100644 --- a/e2e/product-filter.spec.ts +++ b/e2e/product-filter.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@bgotink/playwright-coverage'; +import { expect, test } from './fixtures'; import { ensureProductsExist, areaName, diff --git a/e2e/test-helpers.ts b/e2e/test-helpers.ts index 4d2b922..dc63c51 100644 --- a/e2e/test-helpers.ts +++ b/e2e/test-helpers.ts @@ -1,5 +1,6 @@ // e2e/test-helpers.ts -import { expect, Page } from '@playwright/test'; +import { expect } from './fixtures'; +import type { Page } from '@playwright/test'; /* ----------------------- Shared constants diff --git a/package.json b/package.json index dba95e5..46bd052 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "coverage:unit": "npm run test:coverage", - "coverage:e2e": "npm run test:e2e", - "coverage:merge": "node scripts/merge-lcov.cjs 'coverage-reports/unit/lcov.info' 'coverage-reports/e2e/coverage.lcov' > coverage-reports/merged/merged.lcov && node scripts/normalize-lcov.cjs coverage-reports/merged/merged.lcov && genhtml coverage-reports/merged/merged.normalized.lcov -o coverage-reports/merged", + "coverage:e2e": "node -e \"const fs=require('fs'); const path=require('path'); const target=path.join('coverage-reports','e2e'); fs.rmSync(target,{recursive:true,force:true});\" && npm run test:e2e && node scripts/remap-and-report.cjs", + "coverage:merge": "node -e \"const fs=require('fs'); fs.mkdirSync('coverage-reports/merged',{recursive:true});\" && node scripts/merge-lcov.cjs 'coverage-reports/unit/lcov.info' 'coverage-reports/e2e/lcov.info' > coverage-reports/merged/merged.lcov && node scripts/normalize-lcov.cjs coverage-reports/merged/merged.lcov && (command -v genhtml >/dev/null 2>&1 && genhtml coverage-reports/merged/merged.normalized.lcov -o coverage-reports/merged || echo 'genhtml not found, skipping HTML merge output')", "coverage:ci": "npm run coverage:unit && npm run coverage:e2e && npm run coverage:merge" }, "dependencies": { diff --git a/scripts/playwright-collect-coverage.cjs b/scripts/playwright-collect-coverage.cjs index 4229e64..8a4af70 100755 --- a/scripts/playwright-collect-coverage.cjs +++ b/scripts/playwright-collect-coverage.cjs @@ -8,7 +8,7 @@ test.afterEach(async ({ page }, testInfo) => { // evaluate coverage from the page const coverage = await page.evaluate(() => (globalThis.__coverage__ || {})); if (coverage && Object.keys(coverage).length) { - const dir = path.join(process.cwd(), '.nyc_output'); + const dir = path.join(process.cwd(), 'coverage-reports', 'e2e', '.nyc_output'); fs.mkdirSync(dir, { recursive: true }); // unique file per worker/test diff --git a/scripts/remap-and-report.cjs b/scripts/remap-and-report.cjs index 04507fb..5400aaa 100644 --- a/scripts/remap-and-report.cjs +++ b/scripts/remap-and-report.cjs @@ -6,30 +6,39 @@ const libSourceMaps = require('istanbul-lib-source-maps'); const libReport = require('istanbul-lib-report'); const reports = require('istanbul-reports'); -const out = libCoverage.createCoverageMap({}); -const dir = path.join(process.cwd(), '.nyc_output'); -if (!fs.existsSync(dir)) { - console.error('.nyc_output not found'); - process.exit(2); -} -const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); -for (const f of files) { - const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); - out.merge(data); +async function main() { + const out = libCoverage.createCoverageMap({}); + const dir = path.join(process.cwd(), 'coverage-reports', 'e2e', '.nyc_output'); + if (!fs.existsSync(dir)) { + console.error('.nyc_output not found at', dir); + process.exit(2); + } + const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); + for (const f of files) { + const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); + out.merge(data); + } + + if (out.size === 0) { + console.error('No coverage entries were merged.'); + process.exit(3); + } + + const sourceMapStore = libSourceMaps.createSourceMapStore(); + const transformed = await sourceMapStore.transformCoverage(out); + const remapped = libCoverage.createCoverageMap(transformed.map || transformed.data || {}); + + const outDir = path.join(process.cwd(), 'coverage-reports', 'e2e'); + fs.mkdirSync(outDir, { recursive: true }); + const context = libReport.createContext({ dir: outDir, coverageMap: remapped }); + + reports.create('lcovonly', {}).execute(context); + reports.create('html', {}).execute(context); + + console.log('Wrote remapped e2e coverage to', outDir); } -// Remap using source maps -const sourceMapStore = libSourceMaps.createSourceMapStore(); -const transformed = sourceMapStore.transformCoverage(out); -const remapped = transformed.map; - -// Write reports -const outDir = path.join(process.cwd(), 'coverage-reports', 'e2e'); -fs.mkdirSync(outDir, { recursive: true }); -const context = libReport.createContext({ dir: outDir, coverageMap: remapped }); - -// lcov + html -reports.create('lcovonly', {}).execute(context); -reports.create('html', {}).execute(context); - -console.log('Wrote remapped e2e coverage to', outDir); +main().catch(err => { + console.error(err); + process.exit(1); +});