From a3b01bc293f97da5ee138c14323da5dbdb1f8c64 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Wed, 21 Oct 2026 06:24:42 +0200 Subject: [PATCH] Clean up templates, markup and docs The page x-ray table repeated the same row markup for every metric, so a change to how one row looks had to be copied by hand to around twenty others. They now share one helper, and the rendered HTML is unchanged. Dead code from the Template7 and IE days is gone. Small fixes from the review: the request diff link in the header pointed at a misspelled anchor, the filmstrip had no header link, a HAR swapped in with Upload lost its file name, and Enter in the URL fields did nothing. The logos now have alt text, and the manifest works when the site runs under a sub-path. The README now describes the waterfall hover and the request diff as they really work, and its logo image is no longer broken. Assisted-by: Claude Opus 5.5 noreply@anthropic.com --- README.md | 8 +- index.html | 21 +++--- public/js/compare/filmstripNav.js | 8 +- public/js/compare/generate.js | 21 +++--- public/js/compare/start.js | 5 +- public/js/compare/templates.js | 121 +++++++++++------------------- public/js/compare/upload.js | 4 +- public/js/compare/ux.js | 12 +-- public/manifest.json | 10 +-- release.sh | 3 +- test/render.test.js | 40 ++++++++++ 11 files changed, 126 insertions(+), 127 deletions(-) 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}"`)); +});