diff --git a/src/format.js b/src/format.js new file mode 100644 index 00000000..36ec7b8f --- /dev/null +++ b/src/format.js @@ -0,0 +1,91 @@ +const chalk = require('chalk'); +const { minify } = require('html-minifier'); +const { makeReplacements } = require('./replacements'); + +const belowThreshold = (id, expected, categories) => { + const category = categories.find((c) => c.id === id); + if (!category) { + console.warn(`Could not find category ${chalk.yellow(id)}`); + } + const actual = category ? category.score : Number.MAX_SAFE_INTEGER; + return actual < expected; +}; + +const getError = (id, expected, categories, audits) => { + const category = categories.find((c) => c.id === id); + + const categoryError = `Expected category ${chalk.cyan( + category.title, + )} to be greater or equal to ${chalk.green(expected)} but got ${chalk.red( + category.score !== null ? category.score : 'unknown', + )}`; + + const categoryAudits = category.auditRefs + .filter(({ weight, id }) => weight > 0 && audits[id].score < 1) + .map((ref) => { + const audit = audits[ref.id]; + return ` '${chalk.cyan( + audit.title, + )}' received a score of ${chalk.yellow(audit.score)}`; + }) + .join('\n'); + + return { message: categoryError, details: categoryAudits }; +}; + +const formatShortSummary = (categories) => { + return categories + .map(({ title, score }) => `${title}: ${Math.round(score * 100)}`) + .join(', '); +}; + +const formatResults = ({ results, thresholds }) => { + const runtimeError = results.lhr.runtimeError; + + const categories = Object.values(results.lhr.categories).map( + ({ title, score, id, auditRefs }) => ({ title, score, id, auditRefs }), + ); + + const categoriesBelowThreshold = Object.entries(thresholds).filter( + ([id, expected]) => belowThreshold(id, expected, categories), + ); + + const errors = categoriesBelowThreshold.map(([id, expected]) => + getError(id, expected, categories, results.lhr.audits), + ); + + const summary = categories.map(({ title, score, id }) => ({ + title, + score, + id, + ...(thresholds[id] ? { threshold: thresholds[id] } : {}), + })); + + const shortSummary = formatShortSummary(categories); + + const formattedReport = makeReplacements(results.report); + + // Pull some additional details to pass to App + const { formFactor, locale } = results.lhr.configSettings; + const installable = results.lhr.audits['installable-manifest']?.score === 1; + const details = { installable, formFactor, locale }; + + const report = minify(formattedReport, { + removeAttributeQuotes: true, + collapseWhitespace: true, + removeRedundantAttributes: true, + removeOptionalTags: true, + removeEmptyElements: true, + minifyCSS: true, + minifyJS: true, + }); + + return { summary, shortSummary, details, report, errors, runtimeError }; +}; + +module.exports = { + belowThreshold, + getError, + formatShortSummary, + formatResults, +}; diff --git a/src/format.test.js b/src/format.test.js new file mode 100644 index 00000000..59afb1fe --- /dev/null +++ b/src/format.test.js @@ -0,0 +1,219 @@ +const { + belowThreshold, + getError, + formatShortSummary, + formatResults, +} = require('./format'); + +// Strip ANSI color codes from strings, as they make CI sad. +const stripAnsiCodes = (str) => + str.replace( + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, + '', + ); + +describe('format', () => { + const getCategories = ({ score }) => [ + { + title: 'Performance', + score, + id: 'performance', + auditRefs: [ + { weight: 1, id: 'is-crawlable' }, + { weight: 1, id: 'robots-txt' }, + { weight: 1, id: 'tap-targets' }, + ], + }, + ]; + const audits = { + 'is-crawlable': { + id: 'is-crawlable', + title: 'Page isn’t blocked from indexing', + description: + "Search engines are unable to include your pages in search results if they don't have permission to crawl them. [Learn more](https://web.dev/is-crawable/).", + score: 1, + }, + 'robots-txt': { + id: 'robots-txt', + title: 'robots.txt is valid', + description: + 'If your robots.txt file is malformed, crawlers may not be able to understand how you want your website to be crawled or indexed. [Learn more](https://web.dev/robots-txt/).', + score: 0, + }, + 'tap-targets': { + id: 'tap-targets', + title: 'Tap targets are sized appropriately', + description: + 'Interactive elements like buttons and links should be large enough (48x48px), and have enough space around them, to be easy enough to tap without overlapping onto other elements. [Learn more](https://web.dev/tap-targets/).', + score: 0.5, + }, + }; + + const formattedError = { + details: + " 'robots.txt is valid' received a score of 0\n" + + " 'Tap targets are sized appropriately' received a score of 0.5", + message: + 'Expected category Performance to be greater or equal to 1 but got 0.5', + }; + + describe('belowThreshold', () => { + const categories = [ + { title: 'Performance', score: 0.9, id: 'performance' }, + { title: 'Accessibility', score: 0.8, id: 'accessibility' }, + ]; + + it('returns false when the score is above the threshold', () => { + expect(belowThreshold('performance', 0.8, categories)).toBe(false); + }); + + it('returns false when the category is not found', () => { + console.warn = jest.fn(); + const result = belowThreshold('seo', 0.8, categories); + expect(console.warn).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('returns true when the score is below the threshold', () => { + expect(belowThreshold('performance', 1, categories)).toBe(true); + }); + }); + + describe('getError', () => { + it('returns an expected error message and list of details with valid score', () => { + const errorMessage = getError( + 'performance', + 1, + getCategories({ score: 0.5 }), + audits, + ); + expect(stripAnsiCodes(errorMessage.details)).toEqual( + formattedError.details, + ); + expect(stripAnsiCodes(errorMessage.message)).toEqual( + formattedError.message, + ); + }); + + it('returns an expected error message and list of details without valid score', () => { + const errorMessage = getError( + 'performance', + 1, + getCategories({ score: null }), + audits, + ); + expect(stripAnsiCodes(errorMessage.message)).toContain( + 'to be greater or equal to 1 but got unknown', + ); + }); + }); + + describe('formatShortSummary', () => { + const categories = [ + { title: 'Performance', score: 1, id: 'performance' }, + { title: 'Accessibility', score: 0.9, id: 'accessibility' }, + { title: 'Best Practices', score: 0.8, id: 'best-practices' }, + { title: 'SEO', score: 0.7, id: 'seo' }, + { title: 'PWA', score: 0.6, id: 'pwa' }, + ]; + + it('should return a shortSummary containing scores if available', () => { + const shortSummary = formatShortSummary(categories); + expect(shortSummary).toEqual( + 'Performance: 100, Accessibility: 90, Best Practices: 80, SEO: 70, PWA: 60', + ); + }); + }); + + describe('formatResults', () => { + const getResults = () => ({ + lhr: { + lighthouseVersion: '9.6.3', + requestedUrl: 'http://localhost:5100/404.html', + finalUrl: 'http://localhost:5100/404.html', + audits, + configSettings: {}, + categories: getCategories({ score: 0.5 }), + }, + artifacts: {}, + report: '\n' + 'Hi\n', + }); + + it('should return formatted results', () => { + expect(formatResults({ results: getResults(), thresholds: {} })).toEqual({ + details: { + formFactor: undefined, + installable: false, + locale: undefined, + }, + errors: [], + report: 'Hi', + shortSummary: 'Performance: 50', + summary: [{ id: 'performance', score: 0.5, title: 'Performance' }], + }); + }); + + it('should return formatted results with passing thresholds', () => { + const thresholds = { + performance: 0.1, + }; + const formattedResults = formatResults({ + results: getResults(), + thresholds, + }); + expect(formattedResults.errors).toEqual([]); + expect(formattedResults.summary).toEqual([ + { + id: 'performance', + score: 0.5, + title: 'Performance', + threshold: 0.1, + }, + ]); + }); + + it('should return formatted results with failing thresholds', () => { + const thresholds = { + performance: 1, + }; + const formattedResults = formatResults({ + results: getResults(), + thresholds, + }); + expect(stripAnsiCodes(formattedResults.errors[0].message)).toEqual( + formattedError.message, + ); + expect(stripAnsiCodes(formattedResults.errors[0].details)).toEqual( + formattedError.details, + ); + expect(formattedResults.summary).toEqual([ + { + id: 'performance', + score: 0.5, + title: 'Performance', + threshold: 1, + }, + ]); + }); + + it('should use supplied config settings and data to populate `details`', () => { + const results = getResults(); + results.lhr.configSettings = { + locale: 'es', + formFactor: 'desktop', + }; + results.lhr.audits['installable-manifest'] = { + id: 'installable-manifest', + score: 1, + }; + + const formattedResults = formatResults({ results, thresholds: {} }); + expect(formattedResults.details).toEqual({ + formFactor: 'desktop', + installable: true, + locale: 'es', + }); + }); + }); +}); diff --git a/src/index.js b/src/index.js index dcf14451..2d6b7989 100644 --- a/src/index.js +++ b/src/index.js @@ -4,11 +4,10 @@ const express = require('express'); const compression = require('compression'); const chalk = require('chalk'); const fs = require('fs').promises; -const minify = require('html-minifier').minify; const { getConfiguration } = require('./config'); const { getSettings } = require('./settings'); const { getBrowserPath, runLighthouse } = require('./lighthouse'); -const { makeReplacements } = require('./replacements'); +const { formatResults } = require('./format'); const getServer = ({ serveDir, auditUrl }) => { if (auditUrl) { @@ -47,87 +46,18 @@ const getServer = ({ serveDir, auditUrl }) => { return { server }; }; -const belowThreshold = (id, expected, categories) => { - const category = categories.find((c) => c.id === id); - if (!category) { - console.warn(`Could not find category ${chalk.yellow(id)}`); - } - const actual = category ? category.score : Number.MAX_SAFE_INTEGER; - return actual < expected; -}; - -const getError = (id, expected, categories, audits) => { - const category = categories.find((c) => c.id === id); - - const categoryError = `Expected category ${chalk.cyan( - category.title, - )} to be greater or equal to ${chalk.green(expected)} but got ${chalk.red( - category.score !== null ? category.score : 'unknown', - )}`; - - const categoryAudits = category.auditRefs - .filter(({ weight, id }) => weight > 0 && audits[id].score < 1) - .map((ref) => { - const audit = audits[ref.id]; - return ` '${chalk.cyan( - audit.title, - )}' received a score of ${chalk.yellow(audit.score)}`; - }) - .join('\n'); - - return { message: categoryError, details: categoryAudits }; -}; - -const formatResults = ({ results, thresholds }) => { - const categories = Object.values(results.lhr.categories).map( - ({ title, score, id, auditRefs }) => ({ title, score, id, auditRefs }), - ); - - const categoriesBelowThreshold = Object.entries(thresholds).filter( - ([id, expected]) => belowThreshold(id, expected, categories), - ); - - const errors = categoriesBelowThreshold.map(([id, expected]) => - getError(id, expected, categories, results.lhr.audits), - ); - - const summary = categories.map(({ title, score, id }) => ({ - title, - score, - id, - ...(thresholds[id] ? { threshold: thresholds[id] } : {}), - })); - - const shortSummary = categories - .map(({ title, score }) => `${title}: ${Math.round(score * 100)}`) - .join(', '); - - const formattedReport = makeReplacements(results.report); - - // Pull some additional details to pass to App - const { formFactor, locale } = results.lhr.configSettings; - const installable = results.lhr.audits['installable-manifest'].score === 1; - const details = { installable, formFactor, locale }; - - const report = minify(formattedReport, { - removeAttributeQuotes: true, - collapseWhitespace: true, - removeRedundantAttributes: true, - removeOptionalTags: true, - removeEmptyElements: true, - minifyCSS: true, - minifyJS: true, - }); - - return { summary, shortSummary, details, report, errors }; -}; - const persistResults = async ({ report, path }) => { await fs.mkdir(dirname(path), { recursive: true }); await fs.writeFile(path, report); }; const getUtils = ({ utils }) => { + // This function checks to see if we're running within the Netlify Build system, + // and if so, we use the util functions. If not, we're likely running locally + // so fall back using console.log to emulate the output. + + // If available, fails the Netlify build with the supplied message + // https://docs.netlify.com/integrations/build-plugins/create-plugins/#error-reporting const failBuild = (utils && utils.build && utils.build.failBuild) || ((message, { error } = {}) => { @@ -135,6 +65,8 @@ const getUtils = ({ utils }) => { process.exitCode = 1; }); + // If available, displays the summary in the Netlify UI Deploy Summary section + // https://docs.netlify.com/integrations/build-plugins/create-plugins/#logging const show = (utils && utils.status && utils.status.show) || (({ summary }) => console.log(summary)); @@ -170,10 +102,11 @@ const runAudit = async ({ if (error) { return { error }; } else { - const { summary, shortSummary, details, report, errors } = formatResults({ - results, - thresholds, - }); + const { summary, shortSummary, details, report, errors, runtimeError } = + formatResults({ + results, + thresholds, + }); if (output_path) { await persistResults({ report, path: join(serveDir, output_path) }); @@ -185,6 +118,7 @@ const runAudit = async ({ details, report, errors, + runtimeError, }; } } catch (error) { @@ -237,28 +171,45 @@ const processResults = ({ data, errors }) => { return { error: err, summary: data - .map(({ path, url, summary, shortSummary, details, report }) => { - const obj = { report, details }; - - if (summary) { - obj.summary = summary.reduce((acc, item) => { - acc[item.id] = Math.round(item.score * 100); - return acc; - }, {}); - } - - if (path) { - obj.path = path; - reports.push(obj); - return `Summary for path '${chalk.magenta(path)}': ${shortSummary}`; - } - if (url) { - obj.url = url; - reports.push(obj); - return `Summary for url '${chalk.magenta(url)}': ${shortSummary}`; - } - return `${shortSummary}`; - }) + .map( + ({ + path, + url, + summary, + shortSummary, + details, + report, + runtimeError, + }) => { + const obj = { report, details }; + + if (!runtimeError && summary) { + obj.summary = summary.reduce((acc, item) => { + acc[item.id] = Math.round(item.score * 100); + return acc; + }, {}); + } + + if (runtimeError) { + reports.push(obj); + return `Error testing '${chalk.magenta(path || url)}': ${ + runtimeError.message + }`; + } + + if (path) { + obj.path = path; + reports.push(obj); + return `Summary for path '${chalk.magenta(path)}': ${shortSummary}`; + } + if (url) { + obj.url = url; + reports.push(obj); + return `Summary for url '${chalk.magenta(url)}': ${shortSummary}`; + } + return `${shortSummary}`; + }, + ) .join('\n'), extraData: reports, }; @@ -280,7 +231,7 @@ module.exports = { const allErrors = []; const data = []; for (const { serveDir, path, url, thresholds, output_path } of audits) { - const { errors, summary, shortSummary, details, report } = + const { errors, summary, shortSummary, details, report, runtimeError } = await runAudit({ serveDir, path, @@ -289,9 +240,13 @@ module.exports = { output_path, settings, }); - if (summary) { + + if (summary && !runtimeError) { console.log({ results: summary }); } + if (runtimeError) { + console.log({ runtimeError }); + } const fullPath = [serveDir, path].join('/'); if (report) { @@ -299,7 +254,9 @@ module.exports = { console.log( `Report collected: audited_uri: '${chalk.magenta( url || fullPath, - )}', html_report_size: ${chalk.magenta(size / 1024)} KiB`, + )}', html_report_size: ${chalk.magenta( + +(size / 1024).toFixed(2), + )} KiB`, ); } @@ -313,6 +270,7 @@ module.exports = { shortSummary, details, report, + runtimeError, }); } diff --git a/src/lighthouse.js b/src/lighthouse.js index 56bb14ef..e009fd81 100644 --- a/src/lighthouse.js +++ b/src/lighthouse.js @@ -54,9 +54,6 @@ const runLighthouse = async (browserPath, url, settings) => { }, settings, ); - if (results.lhr.runtimeError) { - throw new Error(results.lhr.runtimeError.message); - } return results; } finally { if (chrome) {