diff --git a/bin/pa11y-ci.js b/bin/pa11y-ci.js index 515b68bf..61c64429 100755 --- a/bin/pa11y-ci.js +++ b/bin/pa11y-ci.js @@ -17,7 +17,7 @@ const globby = require('globby'); const protocolify = require('protocolify'); const pkg = require('../package.json'); const commander = require('commander'); - +const csvReporter = require('../lib/reporters/csv'); // Here we're using Commander to specify the CLI options commander @@ -47,6 +47,10 @@ commander '-j, --json', 'Output results as JSON' ) + .option( + '--csv', + 'Output results as CSV' + ) .option( '-T, --threshold ', 'permit this number of errors, warnings, or notices, otherwise fail with exit code 2', @@ -96,6 +100,11 @@ Promise.resolve() return value; })); } + // Output CSV if asked for it + if (commander.csv) { + const reporter = csvReporter(); + reporter(report); + } // Decide on an exit code based on whether // errors are below threshold or everything passes if (report.errors >= parseInt(commander.threshold, 10) && report.passes < report.total) { diff --git a/lib/helpers/resolver.js b/lib/helpers/resolver.js index fff25ee3..63d44a28 100644 --- a/lib/helpers/resolver.js +++ b/lib/helpers/resolver.js @@ -4,7 +4,8 @@ const loadReporter = require('./loader'); const reporterShorthand = { cli: require.resolve('../reporters/cli.js'), - json: require.resolve('../reporters/json.js') + json: require.resolve('../reporters/json.js'), + csv: require.resolve('../reporters/csv.js') }; module.exports = function resolveReporters(config = {}) { diff --git a/lib/reporters/csv.js b/lib/reporters/csv.js new file mode 100644 index 00000000..716a28a0 --- /dev/null +++ b/lib/reporters/csv.js @@ -0,0 +1,74 @@ +'use strict'; + +const fs = require('fs'); +const {resolve, isAbsolute, dirname} = require('path'); + +function writeReport(fileName, data) { + try { + const dirPath = dirname(fileName); + if (!(fs.existsSync(dirPath) && fs.lstatSync(dirPath).isDirectory())) { + fs.mkdirSync(dirPath, {recursive: true}); + } + fs.writeFileSync(fileName, data); + } catch (error) { + console.error(`Unable to write ${fileName}`); + console.error(error); + } +} + +function resolveFile(fileName) { + if (typeof fileName !== 'string') { + return null; + } + return isAbsolute(fileName) ? fileName : resolve(process.cwd(), fileName); +} + +function stringify(value) { + if (typeof value === 'undefined' || value === null || value === '') { + return ''; + } + + const str = String(value); + return needsQuote(str) ? quoteField(str) : str; +} + +function quoteField(field) { + return `"${field.replace(/"/g, '""')}"`; +} + +function needsQuote(str) { + return str.includes(',') || str.includes('\r') || str.includes('\n') || str.includes('"'); +} + +module.exports = function csvReporter(options = {}) { + const fileName = resolveFile(options.fileName); + return { + afterAll(report) { + let csvString = 'url,code,type,typeCode,message,context,selector,runner\n'; + const results = report.results; + for (const url in results) { + if (Object.prototype.hasOwnProperty.call(results, url)) { + const urlErrors = report.results[url]; + for (const errorRow of urlErrors) { + const typeCode = errorRow.typeCode; + const message = stringify(errorRow.message); + const context = stringify(errorRow.context); + const selector = stringify(errorRow.selector); + csvString += `${url},${errorRow.code},${errorRow.type},${typeCode},` + + `${message},${context},` + + `${selector},${errorRow.runner}\n`; + } + } + } + + // If reporter options specify an output file, write to file. + // Otherwise, write to console for backwards compatibility with + // previous --csv CLI option. + if (fileName) { + writeReport(fileName, csvString); + } else { + console.log(csvString); + } + } + }; +};