From 8652478b4a8b20f2bde0bc6d8a286947bbb67427 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Thu, 4 May 2017 14:22:00 -0700 Subject: [PATCH] Report v2: Basic table formatter (#2019) --- lighthouse-core/audits/audit.js | 77 +++++++++++++++++ .../byte-efficiency/byte-efficiency-audit.js | 11 ++- .../byte-efficiency/offscreen-images.js | 19 +++-- .../byte-efficiency/total-byte-weight.js | 18 ++-- .../byte-efficiency/unused-css-rules.js | 18 ++-- .../byte-efficiency/uses-optimized-images.js | 21 ++--- .../uses-request-compression.js | 14 ++-- .../byte-efficiency/uses-responsive-images.js | 19 +++-- .../dobetterweb/link-blocking-first-paint.js | 18 ++-- .../report/v2/renderer/details-renderer.js | 83 +++++++++++++++++++ lighthouse-core/report/v2/renderer/dom.js | 15 ++++ lighthouse-core/report/v2/report-styles.css | 10 +++ .../byte-efficiency-audit-test.js | 16 ++-- .../uses-optimized-images-test.js | 6 +- 14 files changed, 278 insertions(+), 67 deletions(-) diff --git a/lighthouse-core/audits/audit.js b/lighthouse-core/audits/audit.js index fb6708086bf7..29ba3cf200c1 100644 --- a/lighthouse-core/audits/audit.js +++ b/lighthouse-core/audits/audit.js @@ -56,6 +56,65 @@ class Audit { }); } + /** + * @param {!Audit.Headings} headings + * @return {!Array} + */ + static makeV1TableHeadings(headings) { + const tableHeadings = {}; + headings.forEach(heading => tableHeadings[heading.key] = heading.text); + return tableHeadings; + } + + /** + * Table cells will use the type specified in headings[x].itemType. However a custom type + * can be provided: results[x].propName = {type: 'code', text: '...'} + * @param {!Audit.Headings} headings + * @param {!Array>} results + * @return {!Array} + */ + static makeV2TableRows(headings, results) { + const tableRows = results.map(item => { + return headings.map(heading => { + const value = item[heading.key]; + if (typeof value === 'object' && value.type) return value; + + return { + type: heading.itemType, + text: value + }; + }); + }); + return tableRows; + } + + /** + * @param {!Audit.Headings} headings + * @return {!Array} + */ + static makeV2TableHeaders(headings) { + return headings.map(heading => ({ + type: 'text', + text: heading.text + })); + } + + /** + * @param {!Audit.Headings} headings + * @param {!Array>} results + * @return {!DetailsRenderer.DetailsJSON} + */ + static makeV2TableDetails(headings, results) { + const v2TableHeaders = Audit.makeV2TableHeaders(headings); + const v2TableRows = Audit.makeV2TableRows(headings, results); + return { + type: 'table', + header: 'View Details', + itemHeaders: v2TableHeaders, + items: v2TableRows + }; + } + /** * @param {!Audit} audit * @param {!AuditResult} result @@ -97,3 +156,21 @@ class Audit { } module.exports = Audit; + +/** @typedef { + * !Array<{ + * key: string, + * itemType: string, + * text: string, + * }>} + */ +Audit.Headings; // eslint-disable-line no-unused-expressions + +/** @typedef {{ + * results: !Array>, + * headings: !Audit.Headings, + * passes: boolean, + * debugString: (string|undefined) + * }} + */ +Audit.HeadingsResult; // eslint-disable-line no-unused-expressions diff --git a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js index 33e4764c8c28..d7263dfbf722 100644 --- a/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js +++ b/lighthouse-core/audits/byte-efficiency/byte-efficiency-audit.js @@ -69,8 +69,7 @@ class UnusedBytes extends Audit { } /** - * @param {!{debugString: string=, passes: boolean=, tableHeadings: !Object, - * results: !Array}} result + * @param {!Audit.HeadingsResult} result * @param {number} networkThroughput * @return {!AuditResult} */ @@ -96,6 +95,9 @@ class UnusedBytes extends Audit { displayValue = `Potential savings of ${wastedKbDisplay} (~${wastedMsDisplay})`; } + const v1TableHeadings = Audit.makeV1TableHeadings(result.headings); + const v2TableDetails = Audit.makeV2TableDetails(result.headings, results); + return { debugString, displayValue, @@ -104,8 +106,9 @@ class UnusedBytes extends Audit { !!result.passes, extendedInfo: { formatter: Formatter.SUPPORTED_FORMATS.TABLE, - value: {results, tableHeadings: result.tableHeadings} - } + value: {results, tableHeadings: v1TableHeadings} + }, + details: v2TableDetails }; } diff --git a/lighthouse-core/audits/byte-efficiency/offscreen-images.js b/lighthouse-core/audits/byte-efficiency/offscreen-images.js index 9595384e8608..6f2941d7e9aa 100644 --- a/lighthouse-core/audits/byte-efficiency/offscreen-images.js +++ b/lighthouse-core/audits/byte-efficiency/offscreen-images.js @@ -83,6 +83,7 @@ class OffscreenImages extends ByteEfficiencyAudit { return { url, preview: { + type: 'thumbnail', url: image.networkRecord.url, mimeType: image.networkRecord.mimeType }, @@ -95,8 +96,7 @@ class OffscreenImages extends ByteEfficiencyAudit { /** * @param {!Artifacts} artifacts - * @return {{results: !Array, tableHeadings: Object, - * passes: boolean=, debugString: string=}} + * @return {!Audit.HeadingsResult} */ static audit_(artifacts) { const images = artifacts.ImageUsage; @@ -132,15 +132,18 @@ class OffscreenImages extends ByteEfficiencyAudit { const loadedEarly = item.requestStartTime < ttiTimestamp; return isWasteful && loadedEarly; }); + + const headings = [ + {key: 'preview', itemType: 'thumbnail', text: ''}, + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'totalKb', itemType: 'text', text: 'Original'}, + {key: 'potentialSavings', itemType: 'text', text: 'Potential Savings'}, + ]; + return { debugString, results, - tableHeadings: { - preview: '', - url: 'URL', - totalKb: 'Original', - potentialSavings: 'Potential Savings', - } + headings, }; }); } diff --git a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js index 51cdb3cdade4..b8413bc3c5a9 100644 --- a/lighthouse-core/audits/byte-efficiency/total-byte-weight.js +++ b/lighthouse-core/audits/byte-efficiency/total-byte-weight.js @@ -84,6 +84,15 @@ class TotalByteWeight extends ByteEfficiencyAudit { SCORING_MEDIAN, SCORING_POINT_OF_DIMINISHING_RETURNS); const score = 100 * distribution.computeComplementaryPercentile(totalBytes); + const headings = [ + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'totalKb', itemType: 'text', text: 'Total Size'}, + {key: 'totalMs', itemType: 'text', text: 'Transfer Time'}, + ]; + + const v1TableHeadings = ByteEfficiencyAudit.makeV1TableHeadings(headings); + const v2TableDetails = ByteEfficiencyAudit.makeV2TableDetails(headings, results); + return { rawValue: totalBytes, optimalValue: this.meta.optimalValue, @@ -93,13 +102,10 @@ class TotalByteWeight extends ByteEfficiencyAudit { formatter: Formatter.SUPPORTED_FORMATS.TABLE, value: { results, - tableHeadings: { - url: 'URL', - totalKb: 'Total Size', - totalMs: 'Transfer Time', - } + tableHeadings: v1TableHeadings } - } + }, + details: v2TableDetails }; }); } diff --git a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js index 6fa3aa5ed46f..61900da386d1 100644 --- a/lighthouse-core/audits/byte-efficiency/unused-css-rules.js +++ b/lighthouse-core/audits/byte-efficiency/unused-css-rules.js @@ -164,8 +164,7 @@ class UnusedCSSRules extends ByteEfficiencyAudit { /** * @param {!Artifacts} artifacts - * @return {{results: !Array, tableHeadings: Object, - * passes: boolean=, debugString: string=}} + * @return {{results: !Array, headings: !Audit.Headings}} */ static audit_(artifacts) { const styles = artifacts.Styles; @@ -179,14 +178,17 @@ class UnusedCSSRules extends ByteEfficiencyAudit { return UnusedCSSRules.mapSheetToResult(indexedSheets[sheetId], pageUrl); }).filter(sheet => sheet && sheet.wastedBytes > 1024); + + const headings = [ + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'numUnused', itemType: 'url', text: 'Unused Rules'}, + {key: 'totalKb', itemType: 'text', text: 'Original'}, + {key: 'potentialSavings', itemType: 'text', text: 'Potential Savings'}, + ]; + return { results, - tableHeadings: { - url: 'URL', - numUnused: 'Unused Rules', - totalKb: 'Original', - potentialSavings: 'Potential Savings', - } + headings }; } } diff --git a/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js b/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js index c17ce71cb8f5..ee45ce6db18c 100644 --- a/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js +++ b/lighthouse-core/audits/byte-efficiency/uses-optimized-images.js @@ -63,8 +63,7 @@ class UsesOptimizedImages extends ByteEfficiencyAudit { /** * @param {!Artifacts} artifacts - * @return {{results: !Array, tableHeadings: Object, - * passes: boolean=, debugString: string=}} + * @return {!Audit.HeadingsResult} */ static audit_(artifacts) { const images = artifacts.OptimizedImages; @@ -106,7 +105,7 @@ class UsesOptimizedImages extends ByteEfficiencyAudit { results.push({ url, isCrossOrigin: !image.isSameOrigin, - preview: {url: image.url, mimeType: image.mimeType}, + preview: {url: image.url, mimeType: image.mimeType, type: 'thumbnail'}, totalBytes: image.originalSize, wastedBytes: webpSavings.bytes, webpSavings: this.toSavingsString(webpSavings.bytes, webpSavings.percent), @@ -121,17 +120,19 @@ class UsesOptimizedImages extends ByteEfficiencyAudit { debugString = `Lighthouse was unable to decode some of your images: ${urls.join(', ')}`; } + const headings = [ + {key: 'preview', itemType: 'thumbnail', text: ''}, + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'totalKb', itemType: 'text', text: 'Original'}, + {key: 'webpSavings', itemType: 'text', text: 'Savings as WebP'}, + {key: 'jpegSavings', itemType: 'text', text: 'Savings as JPEG'}, + ]; + return { passes: hasAllEfficientImages && totalWastedBytes < TOTAL_WASTED_BYTES_THRESHOLD, debugString, results, - tableHeadings: { - preview: '', - url: 'URL', - totalKb: 'Original', - webpSavings: 'WebP Savings', - jpegSavings: 'JPEG Savings', - } + headings }; } } diff --git a/lighthouse-core/audits/byte-efficiency/uses-request-compression.js b/lighthouse-core/audits/byte-efficiency/uses-request-compression.js index 8ec7df151161..e9fa764602ef 100644 --- a/lighthouse-core/audits/byte-efficiency/uses-request-compression.js +++ b/lighthouse-core/audits/byte-efficiency/uses-request-compression.js @@ -47,7 +47,7 @@ class ResponsesAreCompressed extends ByteEfficiencyAudit { /** * @param {!Artifacts} artifacts * @param {number} networkThroughput - * @return {!AuditResult} + * @return {!Audit.HeadingsResult} */ static audit_(artifacts) { const uncompressedResponses = artifacts.ResponseCompression; @@ -90,15 +90,17 @@ class ResponsesAreCompressed extends ByteEfficiencyAudit { }); let debugString; + const headings = [ + {key: 'url', itemType: 'url', text: 'Uncompressed resource URL'}, + {key: 'totalKb', itemType: 'text', text: 'Original'}, + {key: 'potentialSavings', itemType: 'text', text: 'GZIP Savings'}, + ]; + return { passes: totalWastedBytes < TOTAL_WASTED_BYTES_THRESHOLD, debugString, results, - tableHeadings: { - url: 'Uncompressed resource URL', - totalKb: 'Original', - potentialSavings: 'GZIP Savings', - } + headings, }; } } diff --git a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js index e82b4f54ad35..b53e56b3b530 100644 --- a/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js +++ b/lighthouse-core/audits/byte-efficiency/uses-responsive-images.js @@ -68,6 +68,7 @@ class UsesResponsiveImages extends ByteEfficiencyAudit { return { url, preview: { + type: 'thumbnail', url: image.networkRecord.url, mimeType: image.networkRecord.mimeType }, @@ -80,8 +81,7 @@ class UsesResponsiveImages extends ByteEfficiencyAudit { /** * @param {!Artifacts} artifacts - * @return {{results: !Array, tableHeadings: Object, - * passes: boolean=, debugString: string=}} + * @return {!Audit.HeadingsResult} */ static audit_(artifacts) { const images = artifacts.ImageUsage; @@ -111,16 +111,19 @@ class UsesResponsiveImages extends ByteEfficiencyAudit { const results = Array.from(resultsMap.values()) .filter(item => item.wastedBytes > IGNORE_THRESHOLD_IN_BYTES); + + const headings = [ + {key: 'preview', itemType: 'thumbnail', text: ''}, + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'totalKb', itemType: 'text', text: 'Original'}, + {key: 'potentialSavings', itemType: 'text', text: 'Potential Savings'}, + ]; + return { debugString, passes: !results.find(item => item.isWasteful), results, - tableHeadings: { - preview: '', - url: 'URL', - totalKb: 'Original', - potentialSavings: 'Potential Savings', - } + headings, }; } } diff --git a/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js b/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js index d1b0ff65e4d6..6557212057cd 100644 --- a/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js +++ b/lighthouse-core/audits/dobetterweb/link-blocking-first-paint.js @@ -85,6 +85,15 @@ class LinkBlockingFirstPaintAudit extends Audit { displayValue = `${results.length} resource delayed first paint by ${delayTime}ms`; } + const headings = [ + {key: 'url', itemType: 'url', text: 'URL'}, + {key: 'totalKb', itemType: 'text', text: 'Size (KB)'}, + {key: 'totalMs', itemType: 'text', text: 'Delayed Paint By (ms)'}, + ]; + + const v1TableHeadings = Audit.makeV1TableHeadings(headings); + const v2TableDetails = Audit.makeV2TableDetails(headings, results); + return { displayValue, rawValue: results.length === 0, @@ -92,13 +101,10 @@ class LinkBlockingFirstPaintAudit extends Audit { formatter: Formatter.SUPPORTED_FORMATS.TABLE, value: { results, - tableHeadings: { - url: 'URL', - totalKb: 'Size (KB)', - totalMs: 'Delayed Paint By (ms)' - } + tableHeadings: v1TableHeadings } - } + }, + details: v2TableDetails }; } diff --git a/lighthouse-core/report/v2/renderer/details-renderer.js b/lighthouse-core/report/v2/renderer/details-renderer.js index 98c9375d17d5..d11c48f7838f 100644 --- a/lighthouse-core/report/v2/renderer/details-renderer.js +++ b/lighthouse-core/report/v2/renderer/details-renderer.js @@ -34,8 +34,14 @@ class DetailsRenderer { switch (details.type) { case 'text': return this._renderText(details); + case 'url': + return this._renderURL(details); + case 'thumbnail': + return this._renderThumbnail(details); case 'cards': return this._renderCards(/** @type {!DetailsRenderer.CardsDetailsJSON} */ (details)); + case 'table': + return this._renderTable(/** @type {!DetailsRenderer.TableDetailsJSON} */ (details)); case 'list': return this._renderList(/** @type {!DetailsRenderer.ListDetailsJSON} */ (details)); default: @@ -53,6 +59,34 @@ class DetailsRenderer { return element; } + /** + * @param {!DetailsRenderer.DetailsJSON} text + * @return {!Element} + */ + _renderURL(text) { + const element = this._renderText(text); + element.classList.add('lh-text__url'); + return element; + } + + /** + * Create small thumbnail with scaled down image asset. + * If the supplied details doesn't have an image/* mimeType, then an empty span is returned. + * @param {!DetailsRenderer.ThumbnailDetails} value + * @return {!Element} + */ + _renderThumbnail(value) { + if (/^image/.test(value.mimeType) === false) { + return this._dom.createElement('span'); + } + + const element = this._dom.createElement('img', 'lh-thumbnail'); + element.src = value.url; + element.alt = ''; + element.title = value.url; + return element; + } + /** * @param {!DetailsRenderer.ListDetailsJSON} list * @return {!Element} @@ -73,6 +107,37 @@ class DetailsRenderer { return element; } + + /** + * @param {!DetailsRenderer.TableDetailsJSON} details + * @return {!Element} + */ + _renderTable(details) { + if (!details.items.length) return this._dom.createElement('span'); + + const element = this._dom.createElement('details', 'lh-details'); + if (details.header) { + element.appendChild(this._dom.createElement('summary')).textContent = details.header; + } + + const tableElem = this._dom.createChildOf(element, 'table', 'lh-table'); + const theadElem = this._dom.createChildOf(tableElem, 'thead'); + const theadTrElem = this._dom.createChildOf(theadElem, 'tr'); + + for (const heading of details.itemHeaders) { + this._dom.createChildOf(theadTrElem, 'th').appendChild(this.render(heading)); + } + + const tbodyElem = this._dom.createChildOf(tableElem, 'tbody'); + for (const row of details.items) { + const rowElem = this._dom.createChildOf(tbodyElem, 'tr'); + for (const columnItem of row) { + this._dom.createChildOf(rowElem, 'td').appendChild(this.render(columnItem)); + } + } + return element; + } + /** * @param {!DetailsRenderer.CardsDetailsJSON} details * @return {!Element} @@ -134,3 +199,21 @@ DetailsRenderer.ListDetailsJSON; // eslint-disable-line no-unused-expressions * }} */ DetailsRenderer.CardsDetailsJSON; // eslint-disable-line no-unused-expressions + +/** @typedef {{ + * type: string, + * header: ({text: string}|undefined), + * items: !Array>, + * itemHeaders: !Array + * }} + */ +DetailsRenderer.TableDetailsJSON; // eslint-disable-line no-unused-expressions + + +/** @typedef {{ + * type: string, + * url: ({text: string}|undefined), + * mimeType: ({text: string}|undefined) + * }} + */ +DetailsRenderer.ThumbnailDetails; // eslint-disable-line no-unused-expressions diff --git a/lighthouse-core/report/v2/renderer/dom.js b/lighthouse-core/report/v2/renderer/dom.js index 412087a94d5e..3b96b4b28460 100644 --- a/lighthouse-core/report/v2/renderer/dom.js +++ b/lighthouse-core/report/v2/renderer/dom.js @@ -48,6 +48,21 @@ class DOM { return element; } + /** + * @param {!Element} parentElem + * @param {string} elementName + * @param {string=} className + * @param {!Object=} attrs Attribute key/val pairs. + * Note: if an attribute key has an undefined value, this method does not + * set the attribute on the node. + * @return {!Element} + */ + createChildOf(parentElem, elementName, className, attrs) { + const element = this.createElement(elementName, className, attrs); + parentElem.appendChild(element); + return element; + } + /** * @param {string} selector * @param {!Node} context diff --git a/lighthouse-core/report/v2/report-styles.css b/lighthouse-core/report/v2/report-styles.css index d0d7277db2da..a75854b03034 100644 --- a/lighthouse-core/report/v2/report-styles.css +++ b/lighthouse-core/report/v2/report-styles.css @@ -487,4 +487,14 @@ summary.lh-passed-audits-summary::-webkit-details-marker { } } +.lh-table { + --image-preview-size: 24px; +} + +.lh-thumbnail { + height: var(--image-preview-size); + width: var(--image-preview-size); + object-fit: contain; +} + /*# sourceURL=report.styles.css */ diff --git a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js index 53a34ba12d1a..360eed1a882f 100644 --- a/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js +++ b/lighthouse-core/test/audits/byte-efficiency/byte-efficiency-audit-test.js @@ -23,7 +23,7 @@ const assert = require('assert'); describe('Byte efficiency base audit', () => { it('should format as extendedInfo', () => { const result = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [], }); @@ -33,18 +33,18 @@ describe('Byte efficiency base audit', () => { it('should set the rawValue', () => { const goodResultInferred = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [{wastedBytes: 2345, totalBytes: 3000, wastedPercent: 65}], }); const badResultInferred = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [{wastedBytes: 45000, totalBytes: 45000, wastedPercent: 100}], }); const badResultExplicit = ByteEfficiencyAudit.createAuditResult({ passes: false, - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [{wastedBytes: 2345, totalBytes: 3000, wastedPercent: 65}], }); @@ -55,7 +55,7 @@ describe('Byte efficiency base audit', () => { it('should populate Kb', () => { const result = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [{wastedBytes: 2048, totalBytes: 4096, wastedPercent: 50}], }); @@ -65,7 +65,7 @@ describe('Byte efficiency base audit', () => { it('should populate Ms', () => { const result = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [{wastedBytes: 350, totalBytes: 700, wastedPercent: 50}], }, 1000); @@ -75,7 +75,7 @@ describe('Byte efficiency base audit', () => { it('should sort on wastedBytes', () => { const result = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [ {wastedBytes: 350, totalBytes: 700, wastedPercent: 50}, {wastedBytes: 450, totalBytes: 1000, wastedPercent: 50}, @@ -90,7 +90,7 @@ describe('Byte efficiency base audit', () => { it('should create a display value', () => { const result = ByteEfficiencyAudit.createAuditResult({ - tableHeadings: {value: 'Label'}, + headings: [{key: 'value', text: 'Label'}], results: [ {wastedBytes: 512, totalBytes: 700, wastedPercent: 50}, {wastedBytes: 512, totalBytes: 1000, wastedPercent: 50}, diff --git a/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js b/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js index c3959d29ed24..3d0907c292ec 100644 --- a/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js +++ b/lighthouse-core/test/audits/byte-efficiency/uses-optimized-images-test.js @@ -67,10 +67,10 @@ describe('Page uses optimized images', () => { ], }); - const headings = auditResult.tableHeadings; + const headings = auditResult.headings.map(heading => heading.text); assert.equal(auditResult.passes, false); - assert.deepEqual(Object.keys(headings).map(key => headings[key]), - ['', 'URL', 'Original', 'WebP Savings', 'JPEG Savings'], + assert.deepEqual(headings, + ['', 'URL', 'Original', 'Savings as WebP', 'Savings as JPEG'], 'table headings are correct and in order'); });