Fix Playwright coverage collection and merge
This commit is contained in:
@@ -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 };
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, test } from '@bgotink/playwright-coverage';
|
import { expect, test, Page } from './fixtures';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
areaName,
|
areaName,
|
||||||
|
|||||||
@@ -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<T>(p: Promise<T>, ms: number, onTimeout?: () => void) {
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
|
||||||
const timeout = new Promise<T>((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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from '@bgotink/playwright-coverage';
|
import { expect, test } from './fixtures';
|
||||||
import {
|
import {
|
||||||
ensureProductsExist,
|
ensureProductsExist,
|
||||||
areaName,
|
areaName,
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
// e2e/test-helpers.ts
|
// e2e/test-helpers.ts
|
||||||
import { expect, Page } from '@playwright/test';
|
import { expect } from './fixtures';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
/* -----------------------
|
/* -----------------------
|
||||||
Shared constants
|
Shared constants
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"coverage:unit": "npm run test:coverage",
|
"coverage:unit": "npm run test:coverage",
|
||||||
"coverage:e2e": "npm run test:e2e",
|
"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 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: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"
|
"coverage:ci": "npm run coverage:unit && npm run coverage:e2e && npm run coverage:merge"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test.afterEach(async ({ page }, testInfo) => {
|
|||||||
// evaluate coverage from the page
|
// evaluate coverage from the page
|
||||||
const coverage = await page.evaluate(() => (globalThis.__coverage__ || {}));
|
const coverage = await page.evaluate(() => (globalThis.__coverage__ || {}));
|
||||||
if (coverage && Object.keys(coverage).length) {
|
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 });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
// unique file per worker/test
|
// unique file per worker/test
|
||||||
|
|||||||
@@ -6,30 +6,39 @@ const libSourceMaps = require('istanbul-lib-source-maps');
|
|||||||
const libReport = require('istanbul-lib-report');
|
const libReport = require('istanbul-lib-report');
|
||||||
const reports = require('istanbul-reports');
|
const reports = require('istanbul-reports');
|
||||||
|
|
||||||
const out = libCoverage.createCoverageMap({});
|
async function main() {
|
||||||
const dir = path.join(process.cwd(), '.nyc_output');
|
const out = libCoverage.createCoverageMap({});
|
||||||
if (!fs.existsSync(dir)) {
|
const dir = path.join(process.cwd(), 'coverage-reports', 'e2e', '.nyc_output');
|
||||||
console.error('.nyc_output not found');
|
if (!fs.existsSync(dir)) {
|
||||||
|
console.error('.nyc_output not found at', dir);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
const data = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
||||||
out.merge(data);
|
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
|
main().catch(err => {
|
||||||
const sourceMapStore = libSourceMaps.createSourceMapStore();
|
console.error(err);
|
||||||
const transformed = sourceMapStore.transformCoverage(out);
|
process.exit(1);
|
||||||
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);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user