/* global global */ const fs = require("fs"); const path = require("path"); const TestExclude = require("test-exclude"); const { recorder, event, output } = require("codeceptjs"); const Container = require("codeceptjs/lib/container"); const { clearString } = require("codeceptjs/lib/utils"); function hashCode(str) { let hash = 0; if (str.length === 0) { return `${hash}`; } for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32bit integer } return hash.toString(16); } const defaultConfig = { coverageDir: "output/coverage", actionCoverage: false, uniqueFileName: true, }; const defaultActionCoverageConfig = { enabled: true, beginActionName: "performActionBegin", endActionName: "performActionEnd", coverageDir: "output/actionCoverage", include: true, exclude: false, }; const supportedHelpers = ["Puppeteer", "Playwright"]; function buildFileName(test, uniqueFileName) { let fileName = clearString(test.title); const originalName = fileName; // This prevent data driven to be included in the failed screenshot file name if (fileName.indexOf("{") !== -1) { fileName = fileName.substr(0, fileName.indexOf("{") - 3).trim(); } if (test.ctx && test.ctx.test && test.ctx.test.type === "hook") { fileName = clearString(`${test.title}_${test.ctx.test.title}`); } if (uniqueFileName) { const uuid = hashCode(originalName); fileName = `${fileName.substring(0, 15)}_${uuid}.coverage.json`; } else { fileName = `${fileName}.coverage.json`; } return fileName; } function prepareActionStepConfig(actionCoverageConfig) { const config = typeof actionCoverageConfig === "boolean" ? { enabled: actionCoverageConfig, } : actionCoverageConfig; return Object.assign({}, defaultActionCoverageConfig, config); } /** * Dumps code coverage from Playwright/Puppeteer after every test. * * #### Configuration * * * ```js * plugins: { * istanbulCoverage: { * require: "./path/to/istanbulCoverage" * enabled: true * } * } * ``` * * Possible config options: * * * `coverageDir`: directory to dump coverage files * * `uniqueFileName`: generate a unique filename by adding uuid */ module.exports = (config) => { const helpers = Container.helpers(); let helper; for (const helperName of supportedHelpers) { if (Object.keys(helpers).indexOf(helperName) > -1) { helper = helpers[helperName]; } } if (!helper) { console.error("Coverage is only supported in Puppeteer, Playwright"); return; } const options = Object.assign(defaultConfig, helper.options, config); options.actionCoverage = prepareActionStepConfig(options.actionCoverage); const excludeTester = new TestExclude({ ...options.actionCoverage, cwd: path.resolve("../"), }); let lastCoverages = {}; let lastActionKeys = new Set(); let actionCoverages = {}; const actionsStack = []; let prevActionsStack = []; const performActionBegin = (name) => { actionsStack.push(name); }; const performActionEnd = () => { actionsStack.pop(); }; if (options.actionCoverage.enabled) { global[options.actionCoverage.beginActionName] = performActionBegin; global[options.actionCoverage.endActionName] = performActionEnd; } else { global[options.actionCoverage.beginActionName] = global[options.actionCoverage.endActionName] = () => {}; } const getCoverage = async () => { const coverageInfo = await helper.page.evaluate(() => { const coverageInfo = window.__coverage__; return coverageInfo; }); return coverageInfo; }; function hasActionChanges() { return ( actionsStack.length !== prevActionsStack.length || actionsStack.some((val, key) => val !== prevActionsStack[key]) ); } function filterActionCoverage(actionCoverage) { return Object.fromEntries(Object.entries(actionCoverage).filter(([path]) => excludeTester.shouldInstrument(path))); } async function collectLastCoverage(actionKeys, endOfTest = false) { const coverageInfo = await getCoverage(); if (!coverageInfo) return {}; const actionCoverageInfo = filterActionCoverage(coverageInfo); actionKeys.forEach((actionKey) => { if (!lastCoverages[actionKey]) { lastCoverages[actionKey] = actionCoverageInfo; } }); for (const lastActionKey of lastActionKeys) { if (endOfTest || actionKeys.indexOf(lastActionKey) === -1) { const additionalCoverage = subCoverage(actionCoverageInfo, lastCoverages[lastActionKey]); if (!actionCoverages[lastActionKey]) { actionCoverages[lastActionKey] = additionalCoverage; } else { actionCoverages[lastActionKey] = addCoverage(actionCoverages[lastActionKey], additionalCoverage); } lastCoverages[lastActionKey] = undefined; delete lastCoverages[lastActionKey]; } } lastActionKeys = [...actionKeys]; return coverageInfo; } function operateCoverage(aCoverage, bCoverage, op) { const resultCoverage = {}; for (const [filePath, aFileCoverage] of Object.entries(aCoverage)) { const bFileCoverage = bCoverage[filePath]; const resultFileCoverage = { ...aFileCoverage, s: {}, f: {}, b: {} }; for (const [key, value] of Object.entries(aFileCoverage.s)) { resultFileCoverage.s[key] = op(value, bFileCoverage.s[key]); } for (const [key, value] of Object.entries(aFileCoverage.f)) { resultFileCoverage.f[key] = op(value, bFileCoverage.f[key]); } for (const [key, values] of Object.entries(aFileCoverage.b)) { resultFileCoverage.b[key] = values.map((val, idx) => op(val, bFileCoverage.b[key][idx])); } resultCoverage[filePath] = resultFileCoverage; } return resultCoverage; } function subCoverage(aCoverage, bCoverage) { return operateCoverage(aCoverage, bCoverage, (a, b) => a - b); } function addCoverage(aCoverage, bCoverage) { return operateCoverage(aCoverage, bCoverage, (a, b) => a + b); } event.dispatcher.on(event.all.before, async () => { output.debug("*** Collecting istanbul coverage for tests ****"); if (!options.actionCoverage.enabled) return; actionCoverages = {}; }); event.dispatcher.on(event.all.after, async () => { if (!options.actionCoverage.enabled) return; recorder.add( "saving action coverage", async () => { try { const coverageDir = path.resolve(process.cwd(), options.actionCoverage.coverageDir); if (!fs.existsSync(coverageDir)) { fs.mkdirSync(coverageDir, { recursive: true }); } for (const [actionName, coverage] of Object.entries(actionCoverages)) { const coveragePath = path.resolve(coverageDir, `${actionName}.coverage.json`); output.print(`writing ${coveragePath}`); fs.writeFileSync(coveragePath, JSON.stringify(coverage)); } } catch (err) { console.error(err); } }, true, ); }); event.dispatcher.on(event.test.before, async () => { if (!options.actionCoverage.enabled) return; lastCoverages = {}; lastActionKeys = new Set(); prevActionsStack = []; }); // Save coverage data after every test run event.dispatcher.on(event.test.after, async (test) => { recorder.add( "saving coverage", async () => { try { const coverageInfo = await collectLastCoverage(actionsStack, true); const coverageDir = path.resolve(process.cwd(), options.coverageDir); if (!fs.existsSync(coverageDir)) { fs.mkdirSync(coverageDir, { recursive: true }); } const coveragePath = path.resolve(coverageDir, buildFileName(test, options.uniqueFileName)); output.print(`writing ${coveragePath}`); fs.writeFileSync(coveragePath, JSON.stringify(coverageInfo)); } catch (err) { console.error(err); } }, true, ); }); event.dispatcher.on(event.step.before, async () => { if (!options.actionCoverage.enabled) return; if (!hasActionChanges()) return; prevActionsStack = [...actionsStack]; const stack = [...actionsStack]; recorder.add("collect last coverage", async () => { try { await collectLastCoverage(stack); } catch (err) { console.error(err); } }); }); };