UNPKG

@padigital/pumpkin

Version:

Generate a test report directly from gherkin feature files. Cucumber and manual tests in blissful harmony.

307 lines (271 loc) 10.5 kB
#!/usr/bin/env node const args = require("yargs").argv; const fs = require("fs"); const path = require("path"); const glob = require("glob"); const gherkin = require("gherkin"); const escapeHtml = require("escape-html"); const colours = require("colors"); const jsdom = require("jsdom"); const shell = require("shelljs"); const { JSDOM } = jsdom; const FEATURE_FILE_PATH = args.features; const CUCUMBER_REPORT = args.cucumberjson; const HTML_REPORT = args.reporthtml; const OPEN_AFTER = args.open; const TITLE = args.title; const DATE = args.date; const NOTES = args.notes; const EXCLUDE_TAGS = args.exclude; const OUTPUT_DIRECTORY = path.resolve("pumpkin", "."); const OUTPUT_FILE = path.resolve(`${OUTPUT_DIRECTORY}/report.html`, "."); const STATUS_TYPES = ["Not run", "Descoped", "In Progress", "Passed", "Failed", "Blocked"]; function printOkMessage(message) { console.log(message.green); } function printMessage(message) { console.log(message); } function exitWithError(message) { console.log(message.red); process.exit(1); } function loadCucumberJson() { if (!CUCUMBER_REPORT) return; if (!fs.existsSync(CUCUMBER_REPORT)) { exitWithError(`${CUCUMBER_REPORT} could not be found`); } return JSON.parse(fs.readFileSync(CUCUMBER_REPORT)); } function featureScenarios(feature) { return feature.children.filter(c => c.type === "Scenario" || c.type === "ScenarioOutline"); } function loadHtmlReport() { if (!HTML_REPORT) return; if (!fs.existsSync(HTML_REPORT)) { exitWithError(`${HTML_REPORT} could not be found`); } return new JSDOM(fs.readFileSync(HTML_REPORT).toString()); } function loadFeatureFiles() { let files = glob.sync(`${FEATURE_FILE_PATH}/**/*.feature`); if (files.length === 0) { exitWithError(`No feature files found in ${FEATURE_FILE_PATH}`); } files = files.map((file, i) => { const featureFile = gherkinParser.parse(fs.readFileSync(file, "utf8")); const feature = featureFile.feature; let order = feature.tags.map(tag => tag.name).find(tag => tag.startsWith("@report-order-")); order = order ? parseInt(order.replace(/^@report-order-/, "")) : 999 + i; return { feature: feature, order: order }; }); return files .sort((a, b) => a.order - b.order) .map(f => { printMessage( `+ Found feature '${f.feature.name}' with ${featureScenarios(f.feature).length} scenarios` ); return f.feature; }); } function isExcludedByTag(tags) { if (!EXCLUDE_TAGS) return; return tags.map(tag => tag.name).filter(tag => EXCLUDE_TAGS.split(" ").includes(tag)).length; } function scenarioStatus(featureName, scenarioName) { let status = ""; // attempt to get the status from a Cucumber test run if (cucumberReport) { cucumberReport.forEach(feature => { if (feature.name.trim() !== featureName.trim()) return; feature.elements.forEach(scenario => { if (scenario.name.trim() !== scenarioName.trim()) return; scenario.steps.forEach(step => { // if one previous step has already failed, the whole scenario has failed if (status === "failed") return; status = step.result.status; }); }); }); } // attempt to get the status from an HTML report if (htmlReport) { if (status === "") { const selectedStatus = htmlReport.window.document.querySelector( `.scenario[data-scenario-name="${scenarioName.toLowerCase()}"] .scenario-status option:checked` ); if (selectedStatus) status = selectedStatus.value; } } return status.toLowerCase(); } function formatStatus(status) { return `<span class='scenario-status-print'></span>${scenarioStatusDropdown(status)}`; } function featureStatusDropdown() { return `<select class='feature-status custom-select' style='width:130px;'><option value=''>Change all</option>${STATUS_TYPES.map( s => `<option value="${s.toLowerCase()}">${s}</option>` ).join("")}</select>`; } function scenarioStatusDropdown(status) { return `<select class='scenario-status custom-select' style='width:130px;'>${STATUS_TYPES.map( s => `<option value="${s.toLowerCase()}" ${ s.toLowerCase() === status ? 'selected="selected"' : "" }>${s}</option>` ).join("")}</select>`; } function uppercaseFirst(string) { return string.charAt(0).toUpperCase() + string.slice(1); } function formatDataTable(table) { let html = '<table class="table table-sm text-muted">'; table.rows.forEach(row => { if (row.type === "TableRow") { html += "<tr>"; html += row.cells.map(c => `<td class="small">${c.value}</td>`).join(""); html += "</tr>"; } }); html += "</table>"; return html; } function formatExampleTable(table) { let html = '<div class="scenario-example">'; html += '<table class="table table-sm text-muted">'; if (table.description) { html += `<caption class="small text-muted">${table.description}</caption>`; } if (table.tableHeader) { html += "<tr>"; html += table.tableHeader.cells.map(c => `<th class="small">${c.value}</th>`).join(""); html += "</tr>"; } if (table.tableBody) { table.tableBody.forEach(row => { html += "<tr>"; html += row.cells.map(c => `<td class="small">${c.value}</td>`).join(""); html += "</tr>"; }); } html += "</table>"; html += "</div>"; return html; } function formatSteps(scenario) { let html = '<p class="scenario-steps small text-muted">'; scenario.steps.forEach(step => { html += `<span class="scenario-step">${step.keyword} ${escapeHtml(step.text)}</span>`; if (step.argument) { if (step.argument.type === "DocString") { html += `<span class="scenario-doc-string">${step.argument.content}</span>`; } else if (step.argument.type === "DataTable") { html += formatDataTable(step.argument); } } }); html += "</p>"; if (scenario.examples) { html += scenario.examples.map(example => formatExampleTable(example)); } return html; } function formatScenarios(featureName, items) { let html = ""; items.forEach((scenario, i) => { if (isExcludedByTag(scenario.tags)) { printMessage(`- Excluding scenario '${scenario.name}' based on tag`); return; } const status = scenarioStatus(featureName, scenario.name); html += `<tr class='scenario' data-scenario-name="${scenario.name.toLowerCase()}"> <td style='width:1px' class='text-muted index'></td> <td> <button class='btn btn-outline-secondary btn-sm float-right remove-scenario' tabindex='-1'>Remove scenario</button> <p class="scenario-title">${uppercaseFirst(scenario.name)}</p> ${formatTags(scenario.tags, "dark")} ${formatSteps(scenario)} </td> <td style='width:1px'>${formatStatus(status)}</td> </tr>`; }); return html; } function formatTags(tags, type = "primary") { if (!tags.length) return ""; return `<div class="tags">${tags .map(tag => `<span class='badge badge-${type}'>${tag.name}</span>`) .join("")}</div>`; } const gherkinParser = new gherkin.Parser(); const cucumberReport = loadCucumberJson(); const htmlReport = loadHtmlReport(); const featureFiles = loadFeatureFiles(); const now = `${new Date().getDate()}/${new Date().getMonth() + 1}/${new Date().getFullYear()} ${new Date().getHours()}:${new Date().getMinutes()}`; let report = ` <html> <head> <meta charset='utf-8'/> <style type='text/css'>${fs.readFileSync( path.join(__dirname, "assets/bootstrap.min.css") )}</style> <style type='text/css'>${fs.readFileSync(path.join(__dirname, "assets/application.css"))}</style> <script>window.STATUS_TYPES = ${JSON.stringify(STATUS_TYPES.map(s => s.toLowerCase()))};</script> <script>${fs.readFileSync(path.join(__dirname, "assets/jquery.js"))}</script> <script>${fs.readFileSync(path.join(__dirname, "assets/application.js"))}</script> </head> <body> <iframe id='iframe' style='display:none;'></iframe> <div class='container'> <span class='logo'></span> <h1>Test Report<br><span class='report-title-print text-muted'></span><span class='report-date-print text-muted'></span></h1> <div class='form-group'><input type='text' placeholder='Project Name' class='form-control report-title' value='${ TITLE ? TITLE : "" }'/></div> <div class='form-group'><input type='text' placeholder='Date' class='form-control report-date' value='${ DATE ? DATE : now }'/></div> <div class='form-group'><textarea class='form-control report-description' placeholder='Notes'>${ NOTES ? NOTES.split("\\n").join("\n") : "" }</textarea></div> <div class='form-group'><div class='form-check'><input class='form-check-input' type='checkbox' value='yes' id='print-steps'><label class='form-check-label' for='print-steps'>Print scenario steps</label></div></div> <div class='form-group'><a target='iframe' download='pumpkin-report.html' href='#' onclick='saveHTML();' class='btn btn-primary save-report'>Save HTML</a></div> <h3>Summary</h3> <p class='report-description-print'></p> <table class='table'> <thead><tr><th>Status</th><th>Total <span class='text-muted'>(<span class='total'></span>)</span></th></tr></thead> <tbody id='results'></tbody> </table>`; featureFiles.forEach(featureFile => { if (isExcludedByTag(featureFile.tags)) { printMessage(`- Excluding feature '${featureFile.name}' based on tag`); return; } report += `<div class='feature'> <div class='feature-header'> <div class='float-left'> <h3>Feature: ${featureFile.name}</h3> ${formatTags(featureFile.tags)} </div> <div class='float-right feature-actions'> <button class='btn btn-outline-secondary btn-sm remove-feature'>Remove feature</button> <div class='float-right'>${featureStatusDropdown()}</div> </div> </div> <table class='table table-condensed'> <thead><th colspan='2'>Scenario</th><th style='width:1px'>Status</th></thead> <tbody>${formatScenarios(featureFile.name, featureScenarios(featureFile))}</tbody> </table> </div>`; }); report += `</div></body></html>`; // create the output directory if it doesn't exist fs.mkdirSync(OUTPUT_DIRECTORY, { recursive: true }); // write the report file fs.writeFileSync(OUTPUT_FILE, report, "utf8"); printOkMessage(`Report generated at ${OUTPUT_FILE}`); if (OPEN_AFTER) shell.exec(`open ${OUTPUT_FILE}`);