UNPKG

@hyperse/vitest-coverage-reporter

Version:

Using gitHub action for creating markdown coverage report, badges from Istanbul json report

708 lines (687 loc) 22.8 kB
import minimist from 'minimist'; import { resolve, join } from 'path'; import * as core5 from '@actions/core'; import { stripIndent, oneLine } from 'common-tags'; import { readFile, writeFile } from 'fs/promises'; import { existsSync, mkdirSync, createWriteStream, promises, constants } from 'fs'; import https from 'https'; import * as github3 from '@actions/github'; import { getPackages } from '@manypkg/get-packages'; import { toString } from 'mdast-util-to-string'; import remarkParse from 'remark-parse'; import remarkStringify from 'remark-stringify'; import { unified } from 'unified'; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __commonJS = (cb, mod) => function __require() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); // node_modules/wrappy/wrappy.js var require_wrappy = __commonJS({ "node_modules/wrappy/wrappy.js"(exports, module) { module.exports = wrappy; function wrappy(fn, cb) { if (fn && cb) return wrappy(fn)(cb); if (typeof fn !== "function") throw new TypeError("need wrapper function"); Object.keys(fn).forEach(function(k) { wrapper[k] = fn[k]; }); return wrapper; function wrapper() { var args = new Array(arguments.length); for (var i = 0; i < args.length; i++) { args[i] = arguments[i]; } var ret = fn.apply(this, args); var cb2 = args[args.length - 1]; if (typeof ret === "function" && ret !== cb2) { Object.keys(cb2).forEach(function(k) { ret[k] = cb2[k]; }); } return ret; } } } }); // node_modules/once/once.js var require_once = __commonJS({ "node_modules/once/once.js"(exports, module) { var wrappy = require_wrappy(); module.exports = wrappy(once2); module.exports.strict = wrappy(onceStrict); once2.proto = once2(function() { Object.defineProperty(Function.prototype, "once", { value: function() { return once2(this); }, configurable: true }); Object.defineProperty(Function.prototype, "onceStrict", { value: function() { return onceStrict(this); }, configurable: true }); }); function once2(fn) { var f = function() { if (f.called) return f.value; f.called = true; return f.value = fn.apply(this, arguments); }; f.called = false; return f; } function onceStrict(fn) { var f = function() { if (f.called) throw new Error(f.onceError); f.called = true; return f.value = fn.apply(this, arguments); }; var name = fn.name || "Function wrapped with `once`"; f.onceError = name + " shouldn't be called more than once"; f.called = false; return f; } } }); // src/constants.ts var icons = { red: "\u{1F534}", green: "\u{1F7E2}", blue: "\u{1F535}", increase: "\u2B06\uFE0F", decrease: "\u2B07\uFE0F", equal: "\u{1F7F0}", target: "\u{1F3AF}" }; var COVERAGE_README_MARKER = `<!-- hyperse-vitest-coverage-reporter-marker-readme -->`; var defaultJsonSummaryPath = "coverage/coverage-summary.json"; var defaultJsonFinalPath = "coverage/coverage-final.json"; // src/inputs/getVitestJsonPath.ts var getVitestJsonPath = (projectCwd) => { const jsonSummaryPath = resolve( projectCwd, core5.getInput("json-summary-path") || defaultJsonSummaryPath ); const jsonFinalPath = resolve( projectCwd, core5.getInput("json-final-path") || defaultJsonFinalPath ); const jsonSummaryCompareInput = core5.getInput("json-summary-compare-path"); let jsonSummaryComparePath = null; if (jsonSummaryCompareInput) { jsonSummaryComparePath = resolve(projectCwd, jsonSummaryCompareInput); } return { jsonFinalPath, jsonSummaryPath, jsonSummaryComparePath }; }; var parseVitestCoverageReport = async (jsonPath) => { const resolvedJsonSummaryPath = resolve(process.cwd(), jsonPath); const jsonSummaryRaw = await readFile(resolvedJsonSummaryPath); return JSON.parse(jsonSummaryRaw.toString()); }; var parseVitestJsonSummaryReport = async (jsonSummaryPath) => { try { return await parseVitestCoverageReport(jsonSummaryPath); } catch (err) { const stack = err instanceof Error ? err.stack : ""; core5.setFailed(stripIndent` Failed to parse the json-summary at path "${jsonSummaryPath}." Make sure to run vitest before this action and to include the "json-summary" reporter. Original Error: ${stack} `); throw err; } }; var generateUrl = (summaryData, summaryKey) => { const percentage = summaryData[summaryKey].pct; let color = "brightgreen"; if (percentage < 70) { color = "red"; } else if (percentage < 80) { color = "yellow"; } else if (percentage < 90) { color = "orange"; } return `https://img.shields.io/badge/coverage%3A${summaryKey}-${percentage}%25-${color}.svg`; }; var downloadBadge = (url, filename) => { return new Promise((resolve7, reject) => { https.get(url, (res) => { if (res.statusCode !== 200) { const newError = new Error( `Error downloading badge: ${res.statusMessage}` ); console.error(newError); return reject(newError); } const file = createWriteStream(filename); res.pipe(file); file.on("finish", () => { file.close(); resolve7(); }); file.on("error", (err) => { console.error(`Error saving badge: ${err}`); reject(err); }); }); }); }; var generateBadges = async (options) => { const { badgesSavedTo, totalCoverageReport } = options; const statementsBadgeUrl = generateUrl(totalCoverageReport, "statements"); const branchesBadgeUrl = generateUrl(totalCoverageReport, "branches"); const functionsBadgeUrl = generateUrl(totalCoverageReport, "functions"); const linesBadgeUrl = generateUrl(totalCoverageReport, "lines"); if (!existsSync(badgesSavedTo)) { mkdirSync(badgesSavedTo); } await downloadBadge( statementsBadgeUrl, resolve(badgesSavedTo, "statements.svg") ); await downloadBadge( functionsBadgeUrl, resolve(badgesSavedTo, "functions.svg") ); await downloadBadge(linesBadgeUrl, resolve(badgesSavedTo, "lines.svg")); await downloadBadge(branchesBadgeUrl, resolve(badgesSavedTo, "branches.svg")); console.log("Code coverage badges created successfully."); }; // node_modules/deprecation/dist-web/index.js var Deprecation = class extends Error { constructor(message) { super(message); if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } this.name = "Deprecation"; } }; // node_modules/@octokit/request-error/dist-src/index.js var import_once = __toESM(require_once()); var logOnceCode = (0, import_once.default)((deprecation) => console.warn(deprecation)); var logOnceHeaders = (0, import_once.default)((deprecation) => console.warn(deprecation)); var RequestError = class extends Error { constructor(message, statusCode, options) { super(message); if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } this.name = "HttpError"; this.status = statusCode; let headers; if ("headers" in options && typeof options.headers !== "undefined") { headers = options.headers; } if ("response" in options) { this.response = options.response; headers = options.response.headers; } const requestCopy = Object.assign({}, options.request); if (options.request.headers.authorization) { requestCopy.headers = Object.assign({}, options.request.headers, { authorization: options.request.headers.authorization.replace( /(?<! ) .*$/, " [REDACTED]" ) }); } requestCopy.url = requestCopy.url.replace(/\bclient_secret=\w+/g, "client_secret=[REDACTED]").replace(/\baccess_token=\w+/g, "access_token=[REDACTED]"); this.request = requestCopy; Object.defineProperty(this, "code", { get() { logOnceCode( new Deprecation( "[@octokit/request-error] `error.code` is deprecated, use `error.status`." ) ); return statusCode; } }); Object.defineProperty(this, "headers", { get() { logOnceHeaders( new Deprecation( "[@octokit/request-error] `error.headers` is deprecated, use `error.response.headers`." ) ); return headers || {}; } }); } }; var getGithubToken = () => { const gitHubToken = core5.getInput("github-token").trim(); return gitHubToken; }; // src/utils/getOctokit.ts var getOctokit2 = () => { const gitHubToken = getGithubToken(); const octokit = github3.getOctokit(gitHubToken); return octokit; }; // src/inputs/getPullChanges.ts async function getPullChanges(fileCoverageMode) { var _a; if (!((_a = github3.context.payload) == null ? void 0 : _a.pull_request)) { return []; } const prNumber = github3.context.payload.pull_request.number; try { const paths = []; const octokit = getOctokit2(); core5.startGroup( `Fetching list of changed files for PR#${prNumber} from Github API` ); const iterator = octokit.paginate.iterator(octokit.rest.pulls.listFiles, { owner: github3.context.repo.owner, repo: github3.context.repo.repo, pull_number: prNumber, per_page: 100 }); for await (const response of iterator) { core5.info(`Received ${response.data.length} items`); for (const file of response.data) { core5.debug(`[${file.status}] ${file.filename}`); if (["added", "modified"].includes(file.status)) { paths.push(file.filename); } } } return paths; } catch (error) { if (error instanceof RequestError && (error.status === 404 || error.status === 403)) { core5.warning( `Couldn't fetch changes of PR due to error: [${error.name}] ${error.message}` ); return []; } throw error; } finally { core5.endGroup(); } } async function getWorkspacePackages(cwd) { const { packages } = await getPackages(cwd); const sortedPackages = packages.map((x) => [ x.dir, { name: x.packageJson.name, version: x.packageJson.version, relativeDir: x.relativeDir } ]); sortedPackages.sort((a, b) => { if (a[1].name < b[1].name) { return -1; } if (a[1].name > b[1].name) { return 1; } return 0; }); return new Map(sortedPackages); } // src/inputs/getChangedPackages.ts async function getChangedPackages(repoCwd, includeAllProjects = false) { const workspacePackages = await getWorkspacePackages(repoCwd); const changedPackages = /* @__PURE__ */ new Set(); const allChangedFiles = includeAllProjects ? [] : await getPullChanges(); core5.debug(`allChangedFiles: ${JSON.stringify(allChangedFiles, null, 2)}`); for (const [dir, { name, relativeDir, version }] of workspacePackages) { const includeThisProject = includeAllProjects || allChangedFiles.find((s) => !!~s.indexOf(relativeDir)); core5.info(`package(${name}: ${relativeDir}) is: ${includeThisProject}`); if (includeThisProject) { changedPackages.add({ dir, relativeDir, packageJson: { name, version } }); } } return [...changedPackages]; } var testFilePath = async (workingDirectory, filePath) => { const resolvedPath = resolve(workingDirectory, filePath); await promises.access(resolvedPath, constants.R_OK); return resolvedPath; }; var defaultPaths = [ "vitest.config.ts", "vitest.config.mts", "vitest.config.cts", "vitest.config.js", "vitest.config.mjs", "vitest.config.cjs", "vite.config.ts", "vite.config.mts", "vite.config.cts", "vite.config.js", "vite.config.mjs", "vite.config.cjs", "vitest.workspace.ts", "vitest.workspace.mts", "vitest.workspace.cts", "vitest.workspace.js", "vitest.workspace.mjs", "vitest.workspace.cjs" ]; var getViteConfigPath = async (workingDirectory, configPath = "") => { try { if (configPath === "") { return await Promise.any( defaultPaths.map((filePath) => testFilePath(workingDirectory, filePath)) ); } return await testFilePath(workingDirectory, configPath); } catch { const searchPath = configPath ? resolve(workingDirectory, configPath) : `any default location in "${workingDirectory}"`; core5.warning(stripIndent` Failed to read vite config file at ${searchPath}. Make sure you provide the vite-config-path option if you're using a non-default location or name of your config file. Will not include thresholds in the final report. `); return null; } }; var regex100 = /100"?\s*:\s*true/; var regexStatements = /statements\s*:\s*(\d+)/; var regexLines = /lines:\s*(\d+)/; var regexBranches = /branches\s*:\s*(\d+)/; var regexFunctions = /functions\s*:\s*(\d+)/; var parseCoverageThresholds = async (vitestConfigPath) => { try { const resolvedViteConfigPath = resolve(process.cwd(), vitestConfigPath); const rawContent = await readFile(resolvedViteConfigPath, "utf8"); const has100Value = rawContent.match(regex100); if (has100Value) { return { lines: 100, branches: 100, functions: 100, statements: 100 }; } const lines = rawContent.match(regexLines); const branches = rawContent.match(regexBranches); const functions = rawContent.match(regexFunctions); const statements = rawContent.match(regexStatements); return { lines: lines ? Number.parseInt(lines[1]) : void 0, branches: branches ? Number.parseInt(branches[1]) : void 0, functions: functions ? Number.parseInt(functions[1]) : void 0, statements: statements ? Number.parseInt(statements[1]) : void 0 }; } catch (err) { core5.warning( `Could not read vite config file for tresholds due to an error: ${err}` ); return {}; } }; // src/inputs/getVitestThresholds.ts var getVitestThresholds = async (projectCwd, viteConfigPath = "") => { const finalViteConfigPath = await getViteConfigPath( projectCwd, viteConfigPath ); const thresholds = finalViteConfigPath ? await parseCoverageThresholds(finalViteConfigPath) : {}; return thresholds; }; // src/report/generateFileCoverageHtml.ts process.cwd(); // src/report/generateHeadline.ts function generateHeadline(options) { const relativeDir = options.relativeDir; if (options.name && relativeDir) { return `Coverage Report for ${options.name} (${relativeDir})`; } if (options.name) { return `Coverage Report for ${options.name}`; } if (relativeDir) { return `Coverage Report for ${relativeDir}`; } return "Coverage Report"; } function generateSummaryTableHtml(jsonReport, thresholds = {}, jsonCompareReport = void 0) { return oneLine` <table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> ${generateTableRow({ reportNumbers: jsonReport.lines, category: "Lines", threshold: thresholds.lines, reportCompareNumbers: jsonCompareReport == null ? void 0 : jsonCompareReport.lines })} </tr> <tr> ${generateTableRow({ reportNumbers: jsonReport.statements, category: "Statements", threshold: thresholds.statements, reportCompareNumbers: jsonCompareReport == null ? void 0 : jsonCompareReport.statements })} </tr> <tr> ${generateTableRow({ reportNumbers: jsonReport.functions, category: "Functions", threshold: thresholds.functions, reportCompareNumbers: jsonCompareReport == null ? void 0 : jsonCompareReport.functions })} </tr> <tr> ${generateTableRow({ reportNumbers: jsonReport.branches, category: "Branches", threshold: thresholds.branches, reportCompareNumbers: jsonCompareReport == null ? void 0 : jsonCompareReport.branches })} </tr> </tbody> </table> `; } function generateTableRow({ reportNumbers, category, threshold, reportCompareNumbers }) { let status = icons.blue; let percent = `${reportNumbers.pct}%`; if (threshold) { percent = `${percent} (${icons.target} ${threshold}%)`; status = reportNumbers.pct >= threshold ? icons.green : icons.red; } if (reportCompareNumbers) { const percentDiff = reportNumbers.pct - reportCompareNumbers.pct; const compareString = getCompareString(percentDiff); percent = `${percent}<br/>${compareString}`; } return ` <td align="center">${status}</td> <td align="left">${category}</td> <td align="right">${percent}</td> <td align="right">${reportNumbers.covered} / ${reportNumbers.total}</td> `; } function getCompareString(percentDiff) { if (percentDiff === 0) { return `${icons.equal} <em>\xB10%</em>`; } if (percentDiff > 0) { return `${icons.increase} <em>+${percentDiff.toFixed(2)}%</em>`; } return `${icons.decrease} <em>${percentDiff.toFixed(2)}%</em>`; } // src/report/generateCoverageSummary.ts var generateCoverageSummary = async (options) => { const changedPackages = await getChangedPackages( options.repoCwd, options.includeAllProjects ); const summary2 = false ? core5.summary.addHeading( generateHeadline({ name: options.name, relativeDir: "" }), 2 ) : core5.summary; for (const packageItem of changedPackages) { const projectCwd = packageItem.dir; core5.info(`generating coverage summary from: ${projectCwd}`); const { jsonSummaryPath, jsonSummaryComparePath} = getVitestJsonPath(projectCwd); if (!existsSync(jsonSummaryPath)) { core5.warning(`No summary report json file found, skip ${projectCwd}`); continue; } const jsonSummary = await parseVitestJsonSummaryReport(jsonSummaryPath); const thresholds = await getVitestThresholds(projectCwd); let jsonSummaryCompare; if (jsonSummaryComparePath) { jsonSummaryCompare = await parseVitestJsonSummaryReport( jsonSummaryComparePath ); } if (packageItem.relativeDir) { summary2.addHeading( generateHeadline({ name: options.name, relativeDir: packageItem.relativeDir }), 2 ); } const tableData = generateSummaryTableHtml( jsonSummary.total, thresholds, jsonSummaryCompare == null ? void 0 : jsonSummaryCompare.total ); summary2.addRaw(tableData); } try { summary2.addRaw( `<em>Generated in workflow <a href=${getWorkflowSummaryURL()}>#${github3.context.runNumber}</a></em>` ); } catch { } return summary2; }; function getWorkflowSummaryURL() { const { owner, repo } = github3.context.repo; const { runId } = github3.context; return `${github3.context.serverUrl}/${owner}/${repo}/actions/runs/${runId}`; } function updateReadmeEntry(readme, readmeUpdateBody) { const ast = unified().use(remarkParse).parse(readme); const updatedast = unified().use(remarkParse).parse(readmeUpdateBody); const nodes = ast.children; let headingStartInfo; let endIndex; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.type === "html") { const stringified = toString(node); if (headingStartInfo === void 0 && stringified === COVERAGE_README_MARKER) { headingStartInfo = { index: i }; continue; } if (endIndex === void 0 && headingStartInfo !== void 0) { endIndex = i; break; } } } if (headingStartInfo && endIndex) { ast.children.splice( headingStartInfo.index, endIndex - headingStartInfo.index + 1, ...updatedast.children ); } else { ast.children.splice(2, 0, ...updatedast.children); } return { content: unified().use(remarkStringify).stringify(ast) }; } // src/utils/writeSummaryToReadMe.ts var writeSummaryToReadMe = async (cwd, summary2, headline) => { const readmeUpdateBody = `${COVERAGE_README_MARKER} ${headline} ${summary2.stringify()}`; const readmeFile = join(cwd, "README.md"); if (existsSync(readmeFile)) { const readmeContents = await readFile(readmeFile, "utf8"); const entry = updateReadmeEntry(readmeContents, readmeUpdateBody); await writeFile(readmeFile, entry.content); } else { core5.warning(`No README.md found in ${cwd}`); } }; // src/main.mts var main = async (args) => { const argv = minimist(args, { "--": true, alias: { p: "path" }, default: { p: "coverage/badges", type: ["badges"], projectCwd: process.cwd() } }); const cwd = argv.projectCwd || process.cwd(); const badgesSavedTo = resolve(cwd, argv.path); const { jsonSummaryPath } = getVitestJsonPath(cwd); const jsonSummary = await parseVitestJsonSummaryReport(jsonSummaryPath); const types = Array.isArray(argv.type) ? argv.type : [argv.type]; for (const type of types) { if (type === "badges") { await generateBadges({ badgesSavedTo, totalCoverageReport: jsonSummary.total }); } else if (type === "readme") { const summary2 = await generateCoverageSummary({ name: "", repoCwd: cwd, includeAllProjects: true }); await writeSummaryToReadMe(cwd, summary2, "## Coverage Report"); } } }; export { main }; //# sourceMappingURL=main.mjs.map //# sourceMappingURL=main.mjs.map