diff --git a/README.md b/README.md index 64e486a..a26e414 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Compare HAR files Make it easier to find regressions by comparing your [HAR](http://www.softwareishard.com/blog/har-12-spec/) files. Test it out https://compare.sitespeed.io. -![Logo](https://raw.githubusercontent.com/sitespeedio/compare/main/img/compare.png) +![Logo](https://raw.githubusercontent.com/sitespeedio/compare/main/public/img/compare.png) ## First: Shout out! We couldn't have built compare without the support or inspiration from the following people: @@ -19,9 +19,9 @@ As long as your HAR files follow the [HAR specification](http://www.softwareisha The result page is the same for every HAR — sections that don't apply silently stay hidden, so a plain Chrome HAR doesn't show a half-empty "filmstrip" or "CPU" block. ### What you get for every HAR -* A **stacked waterfall** rendered by [waterfall-tools](https://github.com/pmeenan/waterfall-tools). Both HARs share the same time axis so widths are comparable at a glance. A **blend slider** fades HAR2 over HAR1, and a per-result toggle switches between **side-by-side** and **overlay**. Hovering a request highlights its peer in the other waterfall. +* A **stacked waterfall** rendered by [waterfall-tools](https://github.com/pmeenan/waterfall-tools). Both HARs share the same time axis so widths are comparable at a glance. A **blend slider** fades HAR2 over HAR1, and a per-result toggle switches between **side-by-side** and **overlay**. Hovering a request shows its URL. * A **Page X-ray** table (powered by [PageXray](https://github.com/sitespeedio/pagexray)) grouped into sections — Content, Render blocking, Visual metrics, Core Web Vitals, CPU and First/Third party. A **Δ column** shows the HAR2−HAR1 delta with regressions in red and improvements in green. An **"Only differences"** toggle hides rows where the two HARs match. -* A **request/response diff** when both HARs are for the same URL — added requests, removed requests, and (per-request) size, status and timing changes. Tick **"Strip version parameters"** on the start page (or set `"stripVersion": true` in your config) to ignore cache-busting query strings. +* A **request/response diff** when both HARs are for the same URL — new requests, removed requests, and requests whose transfer size changed by more than 1 KB. Tick **"Strip version parameters"** on the start page (or set `"stripVersion": true` in your config) to ignore cache-busting query strings. * A **domains** breakdown. * Chrome HARs with priority hints also produce a **Render blocking** row (blocking / potentially blocking / in-body parser blocking). * `.har` and `.har.gz` are both accepted on drag/drop and via URL. @@ -43,7 +43,7 @@ PageXray auto-detects first-party hosts from the page URL. If you want to overri ![First Party vs Third Party!](https://raw.githubusercontent.com/sitespeedio/compare/main/docs/img/firstparty.png) ## How to use it -You can either upload two HAR files (drag/drop) or give the URL to two URLs hosted online. If your HAR got multiple pages/runs, you can use just one HAR file. +You can either upload two HAR files (drag/drop) or give the URL to two HAR files hosted online. If your HAR got multiple pages/runs, you can use just one HAR file. Or you can just copy/paste your HAR file into the start page of [compare.sitespeed.io](https://compare.sitespeed.io/). diff --git a/index.html b/index.html index 9419f79..10bcfb9 100644 --- a/index.html +++ b/index.html @@ -77,7 +77,7 @@

Compare

@@ -98,8 +98,9 @@

Compare

Or add the URL to your HAR files and press compare:

- - +
+ +

You can also use a configuration file @@ -113,10 +114,10 @@

Compare

Read the full documentation

- +
@@ -128,7 +129,7 @@

Compare

@@ -179,7 +180,7 @@

Waterfall

Compare

@@ -203,10 +204,10 @@

Compare

- Built using waterfall-tools and PageXray and - other great Open Source tools.
+ other great Open Source tools.
Code licensed - MIT. - Changelog. Help us + MIT. + Changelog. Help us make it better!

diff --git a/public/js/compare/filmstripNav.js b/public/js/compare/filmstripNav.js index 560c094..1431326 100644 --- a/public/js/compare/filmstripNav.js +++ b/public/js/compare/filmstripNav.js @@ -28,11 +28,9 @@ function initFilmstripNav() { // Ignore mousedown on the actual zoom button — let the lightbox // handle that click. Drag-scroll triggers on mousedown anywhere // else inside the rail (the padding, the column gap, etc.). - if (e.target.closest('.lightbox-trigger')) { - // Still want to allow drag if user moves before releasing; we - // arm a "candidate" state and promote to dragging only after - // crossing the threshold. - } + // Still want to allow drag if user moves before releasing; we + // arm a "candidate" state and promote to dragging only after + // crossing the threshold. if (e.button !== 0) return; const startX = e.pageX; diff --git a/public/js/compare/generate.js b/public/js/compare/generate.js index f876efa..7b5a201 100644 --- a/public/js/compare/generate.js +++ b/public/js/compare/generate.js @@ -1,4 +1,4 @@ -/* global getLastTiming, removeAndHide, createUpload, getAllDomains, hideUpload, objectPropertiesToArray, registerTemplateHelpers, parseTemplate, getTotalDiff, generateVisualProgress, formatDate, getUniqueRequests, getFilmstrip, compareWaterfall, renderShareControls, blendWaterfalls, applyWaterfallLayoutPreference */ +/* global getLastTiming, removeAndHide, createUpload, getAllDomains, hideUpload, objectPropertiesToArray, parseTemplate, getTotalDiff, generateVisualProgress, formatDate, getUniqueRequests, getFilmstrip, compareWaterfall, renderShareControls, blendWaterfalls, applyWaterfallLayoutPreference */ /* exported showUpload, formatDate, generate, toggleRow, regenerate, formatTime, showLoading*/ /** @@ -168,25 +168,24 @@ function generate(config) { }); } - // It's time to start to parse content - // make sure we have the helpers ready - registerTemplateHelpers(); + const filmstrip = getFilmstrip( + config.har1.har, + config.har1.run, + config.har2.har, + config.har2.run + ); + // It's time to start to parse content parseTemplate( 'resultHeaderTemplate', { p1: pageXray1, - p2: pageXray2 + p2: pageXray2, + filmstrip: filmstrip }, 'resultHeaderContent' ); - const filmstrip = getFilmstrip( - config.har1.har, - config.har1.run, - config.har2.har, - config.har2.run - ); if (filmstrip) { parseTemplate( 'filmstripTemplate', diff --git a/public/js/compare/start.js b/public/js/compare/start.js index d698fb7..8c34215 100644 --- a/public/js/compare/start.js +++ b/public/js/compare/start.js @@ -96,6 +96,7 @@ const actions = { }; const actionElements = { + submit: 'form[data-action]', click: 'button[data-action]', change: 'select[data-action]', input: 'input[data-action]' @@ -104,6 +105,8 @@ const actionElements = { for (const [type, selector] of Object.entries(actionElements)) { document.addEventListener(type, e => { const el = e.target instanceof Element && e.target.closest(selector); - if (el) actions[el.dataset.action](el); + if (!el) return; + if (type === 'submit') e.preventDefault(); + actions[el.dataset.action](el); }); } diff --git a/public/js/compare/templates.js b/public/js/compare/templates.js index 84e9c47..f6c1842 100644 --- a/public/js/compare/templates.js +++ b/public/js/compare/templates.js @@ -1,14 +1,12 @@ /* global formatBytes, formatTime, formatDate, formatURL */ -/* exported parseTemplate, registerTemplateHelpers */ +/* exported parseTemplate */ // // Templates. Replaces the Template7 setup that used to live here — // each template is now a plain JS function that returns an HTML -// string built with template literals. +// string. // -// parseTemplate(templateId, data, divId) is the public entry point; -// callers in generate.js stay unchanged. registerTemplateHelpers is -// kept as a no-op so the existing call site doesn't need touching. +// parseTemplate(templateId, data, divId) is the public entry point. // // HTML-escape user-supplied or pulled-from-HAR strings. @@ -34,15 +32,6 @@ function each(collection, fn) { .join(''); } -// Safe property lookup for nested keys that might be missing on -// either of the two HARs (e.g. p2.firstParty.contentTypes['css'] may -// be undefined if HAR2 has no CSS at all). -// eslint-disable-next-line no-unused-vars -- not used now -function getValue(obj, key, name) { - if (!obj || !obj[key]) return 0; - return obj[key][name] != null ? obj[key][name] : 0; -} - // // visualProgressTemplate // @@ -111,6 +100,13 @@ function pageXrayTemplate(d) { // (URLs, dates, captures). const emptyDiff = ''; + function metricRow(label, a, b, formatter) { + return '' + h(label) + '' + + '' + (formatter ? formatter(a) : a) + '' + + '' + (formatter ? formatter(b) : b) + '' + + diffCell(a, b, formatter) + ''; + } + // "Only show differences" toggle — read the user's last choice from // localStorage so the preference survives reload. The button itself // is rendered below; this just controls the initial class on the @@ -232,22 +228,16 @@ function pageXrayTemplate(d) { const rs1 = p1.renderBlocking.recalculateStyle; const rs2 = p2.renderBlocking.recalculateStyle; if (rs1 && rs2) { - function recalcRow(label, a, b, formatter) { - return '' + h(label) + '' + - '' + (formatter ? formatter(a) : a) + '' + - '' + (formatter ? formatter(b) : b) + '' + - diffCell(a, b, formatter) + ''; - } if (rs1.beforeFCP && rs2.beforeFCP) { - html += recalcRow('Style recalc elements (FCP)', + html += metricRow('Style recalc elements (FCP)', rs1.beforeFCP.elements, rs2.beforeFCP.elements); - html += recalcRow('Style recalc time (FCP)', + html += metricRow('Style recalc time (FCP)', rs1.beforeFCP.durationInMillis, rs2.beforeFCP.durationInMillis, formatTime); } if (rs1.beforeLCP && rs2.beforeLCP) { - html += recalcRow('Style recalc elements (LCP)', + html += metricRow('Style recalc elements (LCP)', rs1.beforeLCP.elements, rs2.beforeLCP.elements); - html += recalcRow('Style recalc time (LCP)', + html += metricRow('Style recalc time (LCP)', rs1.beforeLCP.durationInMillis, rs2.beforeLCP.durationInMillis, formatTime); } } @@ -256,18 +246,12 @@ function pageXrayTemplate(d) { // resources fall into each render-blocking class. The "requests" // suffix makes that explicit so the numbers aren't mistaken for // milliseconds or sizes. - html += 'Render blocking requests' + - '' + p1.renderBlocking.blocking + '' + - '' + p2.renderBlocking.blocking + '' + - diffCell(p1.renderBlocking.blocking, p2.renderBlocking.blocking) + ''; - html += 'Potentially blocking requests' + - '' + p1.renderBlocking.potentiallyBlocking + '' + - '' + p2.renderBlocking.potentiallyBlocking + '' + - diffCell(p1.renderBlocking.potentiallyBlocking, p2.renderBlocking.potentiallyBlocking) + ''; - html += 'In body parser blocking requests' + - '' + p1.renderBlocking.in_body_parser_blocking + '' + - '' + p2.renderBlocking.in_body_parser_blocking + '' + - diffCell(p1.renderBlocking.in_body_parser_blocking, p2.renderBlocking.in_body_parser_blocking) + ''; + const rb1 = p1.renderBlocking, rb2 = p2.renderBlocking; + html += metricRow('Render blocking requests', rb1.blocking, rb2.blocking); + html += metricRow('Potentially blocking requests', + rb1.potentiallyBlocking, rb2.potentiallyBlocking); + html += metricRow('In body parser blocking requests', + rb1.in_body_parser_blocking, rb2.in_body_parser_blocking); } if (p1.visualMetrics) { @@ -285,10 +269,7 @@ function pageXrayTemplate(d) { vmRows.forEach(function (row) { const label = row[0], key = row[1]; if (p1.visualMetrics[key] && p2.visualMetrics && p2.visualMetrics[key]) { - vmHtml += '' + label + '' + - '' + formatTime(p1.visualMetrics[key]) + '' + - '' + formatTime(p2.visualMetrics[key]) + '' + - diffCell(p1.visualMetrics[key], p2.visualMetrics[key], formatTime) + ''; + vmHtml += metricRow(label, p1.visualMetrics[key], p2.visualMetrics[key], formatTime); } }); if (vmHtml) html += section('Visual metrics', 'visual') + vmHtml; @@ -296,22 +277,15 @@ function pageXrayTemplate(d) { if (p1.googleWebVitals && p2.googleWebVitals) { html += section('Core Web Vitals', 'cwv'); - html += 'First Contentful Paint' + - '' + formatTime(p1.googleWebVitals.firstContentfulPaint) + '' + - '' + formatTime(p2.googleWebVitals.firstContentfulPaint) + '' + - diffCell(p1.googleWebVitals.firstContentfulPaint, p2.googleWebVitals.firstContentfulPaint, formatTime) + ''; - html += 'Largest Contentful Paint' + - '' + formatTime(p1.googleWebVitals.largestContentfulPaint) + '' + - '' + formatTime(p2.googleWebVitals.largestContentfulPaint) + '' + - diffCell(p1.googleWebVitals.largestContentfulPaint, p2.googleWebVitals.largestContentfulPaint, formatTime) + ''; - html += 'Total Blocking Time' + - '' + formatTime(p1.googleWebVitals.totalBlockingTime) + '' + - '' + formatTime(p2.googleWebVitals.totalBlockingTime) + '' + - diffCell(p1.googleWebVitals.totalBlockingTime, p2.googleWebVitals.totalBlockingTime, formatTime) + ''; - html += 'Cumulative Layout Shift' + - '' + fmtCLS(p1.googleWebVitals.cumulativeLayoutShift) + '' + - '' + fmtCLS(p2.googleWebVitals.cumulativeLayoutShift) + '' + - diffCell(p1.googleWebVitals.cumulativeLayoutShift, p2.googleWebVitals.cumulativeLayoutShift, function (n) { return n.toFixed(3); }) + ''; + const gw1 = p1.googleWebVitals, gw2 = p2.googleWebVitals; + html += metricRow('First Contentful Paint', + gw1.firstContentfulPaint, gw2.firstContentfulPaint, formatTime); + html += metricRow('Largest Contentful Paint', + gw1.largestContentfulPaint, gw2.largestContentfulPaint, formatTime); + html += metricRow('Total Blocking Time', + gw1.totalBlockingTime, gw2.totalBlockingTime, formatTime); + html += metricRow('Cumulative Layout Shift', + gw1.cumulativeLayoutShift, gw2.cumulativeLayoutShift, fmtCLS); } // Long-task block — sitespeed.io HARs that ran Lighthouse get @@ -325,23 +299,17 @@ function pageXrayTemplate(d) { } } if (p1.cpu && p2.cpu && p1.cpu.longTasks && p2.cpu.longTasks) { - if (p1.cpu.longTasks.totalBlockingTime && p2.cpu.longTasks.totalBlockingTime) { - cpuHtml += 'Total Blocking Time' + - '' + formatTime(p1.cpu.longTasks.totalBlockingTime) + '' + - '' + formatTime(p2.cpu.longTasks.totalBlockingTime) + '' + - diffCell(p1.cpu.longTasks.totalBlockingTime, p2.cpu.longTasks.totalBlockingTime, formatTime) + ''; + const lt1 = p1.cpu.longTasks, lt2 = p2.cpu.longTasks; + if (lt1.totalBlockingTime && lt2.totalBlockingTime) { + cpuHtml += metricRow('Total Blocking Time', + lt1.totalBlockingTime, lt2.totalBlockingTime, formatTime); } - if (p1.cpu.longTasks.maxPotentialFid && p2.cpu.longTasks.maxPotentialFid) { - cpuHtml += 'Max Potential FID' + - '' + formatTime(p1.cpu.longTasks.maxPotentialFid) + '' + - '' + formatTime(p2.cpu.longTasks.maxPotentialFid) + '' + - diffCell(p1.cpu.longTasks.maxPotentialFid, p2.cpu.longTasks.maxPotentialFid, formatTime) + ''; + if (lt1.maxPotentialFid && lt2.maxPotentialFid) { + cpuHtml += metricRow('Max Potential FID', + lt1.maxPotentialFid, lt2.maxPotentialFid, formatTime); } - if (p1.cpu.longTasks.tasks && p2.cpu.longTasks.tasks) { - cpuHtml += 'Total CPU Long Tasks' + - '' + p1.cpu.longTasks.tasks + '' + - '' + p2.cpu.longTasks.tasks + '' + - diffCell(p1.cpu.longTasks.tasks, p2.cpu.longTasks.tasks) + ''; + if (lt1.tasks && lt2.tasks) { + cpuHtml += metricRow('Total CPU Long Tasks', lt1.tasks, lt2.tasks); } if (cpuHtml) { ensureCpuSection(); @@ -615,6 +583,9 @@ function resultHeaderTemplate(d) { const p1 = d.p1, p2 = d.p2; let html = ''; @@ -679,7 +650,7 @@ function domainsTemplate(d) { // function requestDiffTemplate(d) { if (!d.requestDiff) return ''; - let html = '

Request/response transfer size difference (larger than 1 KB)

'; + let html = '

Request/response transfer size difference (larger than 1 KB)

'; html += '
'; if (!d.requestDiff.length) { @@ -763,7 +734,3 @@ function parseTemplate(templateId, data, divId) { const target = document.getElementById(divId); if (target) target.innerHTML = renderer(data); } - -// No-op kept for backwards compatibility with generate.js calling it -// at the start of every render. -function registerTemplateHelpers() {} diff --git a/public/js/compare/upload.js b/public/js/compare/upload.js index 5767b51..cd4db86 100644 --- a/public/js/compare/upload.js +++ b/public/js/compare/upload.js @@ -143,8 +143,8 @@ function createUpload(id) { const har2 = changeHar1 ? prev.har2.har : har; const run1 = changeHar1 ? 0 : run; const run2 = changeHar1 ? run : 0; - const label1 = changeHar1 ? 'HAR1' : prev.har1.label; - const label2 = changeHar1 ? prev.har2.label : 'HAR2'; + const label1 = changeHar1 ? shortLabel(files[0].name) : prev.har1.label; + const label2 = changeHar1 ? prev.har2.label : shortLabel(files[0].name); // The freshly-uploaded HAR has no source URL; preserve the // other HAR's URL so the share UI keeps "Copy share link" // available if both are still URL-backed (else it correctly diff --git a/public/js/compare/ux.js b/public/js/compare/ux.js index d57aa5e..0c76ff4 100644 --- a/public/js/compare/ux.js +++ b/public/js/compare/ux.js @@ -197,23 +197,15 @@ function toggleDiffOnly(btn) { } function toggleRow(element, className, toggler) { - const rows = element.parentNode.parentNode.parentNode.getElementsByClassName( - className - ); + const rows = element.closest('table').getElementsByClassName(className); for (let i = 0; i < rows.length; i++) { - const status = rows[i].currentStyle - ? rows[i].currentStyle.display - : getComputedStyle(rows[i], null).display; - - if (status === 'none') { + if (getComputedStyle(rows[i]).display === 'none') { rows[i].style.display = 'table-row'; toggler.style.transform = 'rotate(45deg)'; - toggler.style['-webkit-transform'] = 'rotate(45deg)'; } else { rows[i].style.display = 'none'; toggler.style.transform = 'rotate(-45deg)'; - toggler.style['-webkit-transform'] = 'rotate(-45deg)'; } } } diff --git a/public/manifest.json b/public/manifest.json index 8f5d021..4b0f4c7 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -3,19 +3,19 @@ "name": "Sitespeed.io Compare", "icons": [ { - "src": "/img/ico/compare-192.png", + "src": "img/ico/compare-192.png", "type": "image/png", "sizes": "192x192" }, { - "src": "/img/ico/compare-512.png", + "src": "img/ico/compare-512.png", "type": "image/png", "sizes": "512x512" } ], - "start_url": "/", + "start_url": "./", "background_color": "#e1f6fd", "display": "browser", - "scope": "/", - "theme_color": "#e1f6fd" + "scope": "./", + "theme_color": "#0095d2" } diff --git a/release.sh b/release.sh index be9b989..b700a4f 100755 --- a/release.sh +++ b/release.sh @@ -1,9 +1,8 @@ #!/bin/bash set -e -# You need np and gulp for this to work +# You need np for this to work # npm install --global np -# npm install --global gulp-cli np $1 npm run build diff --git a/test/render.test.js b/test/render.test.js index 3e0c7da..33df8c3 100644 --- a/test/render.test.js +++ b/test/render.test.js @@ -55,3 +55,43 @@ test('error messages are shown as text', () => { assert.equal(message.textContent, ''); assert.equal(message.innerHTML, undefined); }); + +function pageXray(overrides) { + return { + url: 'https://a.com/', + meta: { title: 'A', startedDateTime: '2026-01-01T00:00:00.000Z' }, + requests: 10, + transferSize: 1000, + contentSize: 2000, + contentTypes: {}, + ...overrides + }; +} + +test('the page x-ray table shows Core Web Vitals with a Δ', () => { + const context = loadScripts(['ux', 'templates']); + const runs = [{ id: 0, selected: 'selected', show: 1 }]; + const html = context.pageXrayTemplate({ + p1: pageXray({ googleWebVitals: { firstContentfulPaint: 800, largestContentfulPaint: 1500, totalBlockingTime: 40, cumulativeLayoutShift: 0.05641193152186011 } }), + p2: pageXray({ googleWebVitals: { firstContentfulPaint: 600, largestContentfulPaint: 2100, totalBlockingTime: 40, cumulativeLayoutShift: 0.1 } }), + config: { har1: { label: 'A' }, har2: { label: 'B' } }, + runs1: runs, + runs2: runs + }); + assert.match(html, /First Contentful Paint<\/td>800 ms<\/td>600 ms<\/td>−200 ms/); + assert.match(html, /Largest Contentful Paint<\/td>1\.500 s<\/td>2\.100 s<\/td>\+600 ms/); + assert.match(html, /Total Blocking Time<\/td>40 ms<\/td>40 ms<\/td>no change/); + assert.match(html, /Cumulative Layout Shift<\/td>0\.056<\/td>0\.100<\/td>\+0\.044/); +}); + +test('the result header links to sections that exist', () => { + const context = loadScripts(['ux', 'templates']); + const p = pageXray({}); + const withFilmstrip = context.resultHeaderTemplate({ p1: p, p2: p, filmstrip: {} }); + assert.match(withFilmstrip, /href="#filmstripHeader"/); + assert.doesNotMatch(context.resultHeaderTemplate({ p1: p, p2: p }), /filmstripHeader/); + + const requestDiff = context.requestDiffTemplate({ requestDiff: [], config: {} }); + const anchor = withFilmstrip.match(/href="#(requestDiff[^"]*)"/)[1]; + assert.match(requestDiff, new RegExp(`id="${anchor}"`)); +});