From 495d439374ba76dc5548625a3e95f0aead14747c Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Tue, 12 May 2026 12:59:35 +0200 Subject: [PATCH 1/2] Use transfer size for the request diff and add a Share action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request diff used to report HAR bodySize, which is the decoded body length per the spec and is often -1 (unknown) — under-reporting network cost and producing phantom changes. It now prefers _transferSize (real bytes over the wire when Chrome / sitespeed.io / WPT recorded it), falls back to bodySize > 0, then content.size > 0, otherwise zero. The "no meaningful change" presence check is updated to use in so unknown sizes (now stored as 0) aren't mis-classified as added or removed. Sharing a comparison used to mean re-uploading the HARs on the other end. The result header now offers a Share action: when both HARs came from URLs it copies a share link the recipient can open; otherwise it downloads a single JSON bundle with both HARs embedded, which the start page accepts via drop or paste. Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com --- index.html | 28 ++++--- public/js/compare/generate.js | 30 ++++++-- public/js/compare/har.js | 47 +++++++++--- public/js/compare/load.js | 50 ++++++++++++- public/js/compare/share.js | 132 +++++++++++++++++++++++++++++++++ public/js/compare/templates.js | 2 +- public/js/compare/upload.js | 33 +++++++-- src/css/result-header.css | 11 +++ 8 files changed, 294 insertions(+), 39 deletions(-) create mode 100644 public/js/compare/share.js diff --git a/index.html b/index.html index cad0299..81c8bc6 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,7 @@ + @@ -91,11 +92,16 @@ } getAndSet("har1", "harurl"); getAndSet("har2", "harurl2"); + // Carry stripVersion through share links — `?stripVersion=1` + // mirrors the start-page checkbox so a shared URL renders the + // same comparison the sender saw. + const stripVersionFromUrl = getParam("stripVersion") === "1"; if (getParam("har1") != undefined && getParam("har2") != undefined) { showLoading(); loadHARsFromConfig({ har1: { url: getParam("har1") }, - har2: { url: getParam("har2") } + har2: { url: getParam("har2") }, + stripVersion: stripVersionFromUrl }); } else if ( (getParam("har1") != undefined || getParam("har2") != undefined) && @@ -104,7 +110,8 @@ showLoading(); loadHARsFromConfig({ har1: { url: getParam("har1") || getParam("har2") }, - har2: {} + har2: {}, + stripVersion: stripVersionFromUrl }); } else if (getParam("gist") !== undefined) { showLoading(); @@ -116,25 +123,27 @@ document.addEventListener("paste", function (e) { const paste = e.clipboardData.getData("Text"); - // Is it a HAR or a gist? + // Is it a HAR, a bundle, or a gist? try { - const har = JSON.parse(paste); + const parsed = JSON.parse(paste); showLoading(); - if (har.log) { + if (isBundle(parsed)) { + loadFromBundle(parsed); + } else if (parsed.log) { generate({ har1: { - har: har, + har: parsed, run: 0, label: "HAR1" }, har2: { - har: har, - run: har.log.pages.length > 1 ? 1 : 0, + har: parsed, + run: parsed.log.pages.length > 1 ? 1 : 0, label: "HAR2" } }); } else { - readConfig(har); + readConfig(parsed); } } catch (e) { if (paste.startsWith("https://gist.github.com/")) { @@ -229,6 +238,7 @@

Compare

+
diff --git a/public/js/compare/generate.js b/public/js/compare/generate.js index f1ddd46..5be3b19 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 */ +/* global getLastTiming, removeAndHide, createUpload, getAllDomains, hideUpload, objectPropertiesToArray, registerTemplateHelpers, parseTemplate, getTotalDiff, generateVisualProgress, formatDate, getUniqueRequests, getFilmstrip, compareWaterfall, renderShareControls */ /* exported showUpload, formatDate, generate, toggleRow, regenerate, formatTime, showLoading*/ /** @@ -11,17 +11,28 @@ function regenerate(switchHar) { const e2 = document.getElementById('run2Option'); const runIndex = e ? e.options[e.selectedIndex].value : 0; const runIndex2 = e2 ? e2.options[e2.selectedIndex].value : 0; + const prev = window.har || {}; + // Carry per-HAR metadata (url) and top-level config (title, + // firstParty, stripVersion, comments) across a Switch / run change. + // Without this, the share UI would flip from "Copy share link" to + // "Download bundle" the moment the user toggled anything. generate({ har1: { - har: switchHar ? window.har.har2.har : window.har.har1.har, + har: switchHar ? prev.har2.har : prev.har1.har, run: switchHar ? runIndex2 : runIndex, - label: switchHar ? window.har.har2.label : window.har.har1.label + label: switchHar ? prev.har2.label : prev.har1.label, + url: switchHar ? prev.har2.url : prev.har1.url }, har2: { - har: switchHar ? window.har.har1.har : window.har.har2.har, + har: switchHar ? prev.har1.har : prev.har2.har, run: switchHar ? runIndex : runIndex2, - label: switchHar ? window.har.har1.label : window.har.har2.label - } + label: switchHar ? prev.har1.label : prev.har2.label, + url: switchHar ? prev.har1.url : prev.har2.url + }, + title: prev.title, + firstParty: prev.firstParty, + stripVersion: prev.stripVersion, + comments: prev.comments }); } @@ -293,4 +304,11 @@ function generate(config) { createUpload('har1upload'); createUpload('har2upload'); + + // Render/refresh the share affordance now that window.har is set. + // Guarded so a missing share.js (e.g. embedded usage) is a no-op + // rather than a hard error. + if (typeof renderShareControls === 'function') { + renderShareControls(); + } } diff --git a/public/js/compare/har.js b/public/js/compare/har.js index b44d175..493172c 100644 --- a/public/js/compare/har.js +++ b/public/js/compare/har.js @@ -107,21 +107,21 @@ function getUniqueRequests(har1, run1, har2, run2, options) { const urls2 = getURLs(har2, run2, options.stripVersion); const all = []; const minDiffInBytes = 1000; + // Use `in` for presence checks because a URL whose transfer size is + // unknown is stored as 0 — truthy checks would mis-classify those as + // removed/added. for (let url of Object.keys(urls1)) { - if ( - (urls2[url] && urls2[url] === urls1[url]) || - (urls2[url] && - urls2[url] - urls1[url] < minDiffInBytes && - urls2[url] - urls1[url] > -minDiffInBytes) - ) { - // TODO no diff, do nada - } else if (urls2[url]) { - // There's a diff in size + if (url in urls2) { + const delta = urls2[url] - urls1[url]; + if (delta < minDiffInBytes && delta > -minDiffInBytes) { + // no meaningful diff, skip + continue; + } all.push({ url: url, har1: urls1[url], har2: urls2[url], - diff: urls2[url] - urls1[url] + diff: delta }); } else { all.push({ @@ -132,7 +132,7 @@ function getUniqueRequests(har1, run1, har2, run2, options) { } } for (let url of Object.keys(urls2)) { - if (urls2[url] && !urls1[url]) { + if (!(url in urls1)) { all.push({ url: url, diff: urls2[url], @@ -162,7 +162,30 @@ function getURLs(har, run, stripVersion) { if (stripVersion) { url = url.replace(/version=[A-Za-z0-9]+/i, ''); } - urls[url] = entry.response.bodySize; + urls[url] = bestTransferSize(entry.response); } return urls; } + +// Best-effort "bytes over the wire" for a HAR response. The request +// diff cares about network cost, not decoded payload size, so prefer +// _transferSize (added by Chrome devtools / sitespeed.io / WPT) which +// is exactly that. HAR-spec bodySize is the encoded body length when +// known and -1 when not — usable as a fallback. content.size is the +// decoded body and a last resort. Anything missing or <= 0 (spec uses +// -1 for "unknown") becomes 0 so totals don't go negative. +function bestTransferSize(response) { + if (!response) return 0; + if (typeof response._transferSize === 'number' && response._transferSize > 0) { + return response._transferSize; + } + if (typeof response.bodySize === 'number' && response.bodySize > 0) { + return response.bodySize; + } + if (response.content && + typeof response.content.size === 'number' && + response.content.size > 0) { + return response.content.size; + } + return 0; +} diff --git a/public/js/compare/load.js b/public/js/compare/load.js index e29c1a4..ef85a83 100644 --- a/public/js/compare/load.js +++ b/public/js/compare/load.js @@ -1,5 +1,5 @@ /* global isFileGzipped, isFileZipped, gzipArrayBufferToJSON, readGZipFile, errorMessage, generate, showUpload */ -/* exported readHar, fetchHar, getHarURL, loadFilesFromURL, loadFilesFromGist, loadFilesFromConfig*/ +/* exported readHar, fetchHar, getHarURL, loadFilesFromURL, loadFilesFromGist, loadFilesFromConfig, loadFromBundle, isBundle*/ /** * Help functions to read HAR/JSON files from file @@ -166,6 +166,43 @@ function loadFilesFromGist(id) { }); } +// Compare bundle: a single JSON file with both HARs embedded, produced +// by the "Download bundle" share action. We detect it by the explicit +// `compareBundle` flag so a future format bump doesn't get mistaken +// for the current shape. +function isBundle(obj) { + return !!(obj && obj.compareBundle === true && obj.har1 && obj.har2); +} + +function loadFromBundle(bundle) { + if (!bundle.har1 || !bundle.har1.har || !bundle.har1.har.log) { + errorMessage('Bundle is missing har1.'); + showUpload(); + return; + } + if (!bundle.har2 || !bundle.har2.har || !bundle.har2.har.log) { + errorMessage('Bundle is missing har2.'); + showUpload(); + return; + } + generate({ + har1: { + har: bundle.har1.har, + run: bundle.har1.run || 0, + label: bundle.har1.label || 'HAR1' + }, + har2: { + har: bundle.har2.har, + run: bundle.har2.run || 0, + label: bundle.har2.label || 'HAR2' + }, + title: bundle.title || 'Compare HAR files', + firstParty: bundle.firstParty || undefined, + stripVersion: !!bundle.stripVersion, + comments: bundle.comments || undefined + }); +} + function loadHARsFromConfig(config) { // The runs/pages are zero based since it's an array but // in configuration we wanna use 1 based since it makes more sense @@ -201,16 +238,23 @@ function loadHARsFromConfig(config) { const har2Run = sameHar ? (har1.log.pages.length > 1 ? 1 : 0) : (reworkedConfig2.run || config.har2.run || 0); + // Preserve the *user-supplied* URLs (not the rewritten sitespeed + // .har.gz paths) so a share link sends the recipient to the same + // landing context the original viewer used. The single-HAR case + // (`?compare=1&har1=…`) intentionally leaves har2.url unset — the + // share UI then offers a bundle download instead. return generate({ har1: { har: har1, run: har1Run, - label: config.har1.label || 'HAR1' + label: config.har1.label || 'HAR1', + url: config.har1.url }, har2: { har: har2, run: har2Run, - label: config.har2.label || 'HAR2' + label: config.har2.label || 'HAR2', + url: sameHar ? undefined : config.har2.url }, comments: config.comments || undefined, title: config.title || 'Compare HAR files', diff --git a/public/js/compare/share.js b/public/js/compare/share.js new file mode 100644 index 0000000..743c810 --- /dev/null +++ b/public/js/compare/share.js @@ -0,0 +1,132 @@ +/* exported renderShareControls */ + +// The share affordance for the result page. Two modes: +// +// * Both HARs were fetched from URLs → "Copy share link". The +// recipient opens the URL and lands on the same comparison. +// +// * One or both HARs are local (drop / paste / single-HAR upload) → +// "Download bundle". The recipient can't reach a local file, so we +// ship a single JSON with both HARs embedded; drop / paste of that +// bundle on the start page re-renders the same comparison. +// +// renderShareControls() reads window.har (populated by generate.js) so +// it always reflects the current comparison after a swap or run change. + +function renderShareControls() { + const container = document.getElementById('shareControls'); + if (!container) return; + container.innerHTML = ''; + const cfg = window.har; + if (!cfg || !cfg.har1 || !cfg.har2) return; + + const u1 = cfg.har1.url; + const u2 = cfg.har2.url; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'submit submit-smaller share-button'; + + if (u1 && u2) { + btn.textContent = 'Copy share link'; + btn.title = 'Copy a link that opens this comparison'; + btn.addEventListener('click', function () { + copyShareLink(btn, u1, u2, cfg.stripVersion); + }); + } else { + btn.textContent = 'Download bundle'; + btn.title = 'Save both HARs into a single file you can re-open or share'; + btn.addEventListener('click', function () { + downloadBundle(btn, cfg); + }); + } + container.appendChild(btn); +} + +function copyShareLink(btn, u1, u2, stripVersion) { + const url = new URL(window.location.origin + window.location.pathname); + url.searchParams.set('har1', u1); + url.searchParams.set('har2', u2); + if (stripVersion) url.searchParams.set('stripVersion', '1'); + const link = url.toString(); + copyToClipboard(link).then(function (ok) { + flashShareFeedback(btn, ok ? 'Link copied' : 'Copy failed'); + }); +} + +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text) + .then(function () { return true; }) + .catch(function () { return fallbackCopy(text); }); + } + return Promise.resolve(fallbackCopy(text)); +} + +// Old execCommand path — kept for browsers / contexts where the async +// Clipboard API is unavailable (insecure origins, some embeds). +function fallbackCopy(text) { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'absolute'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch (e) { + return false; + } +} + +function flashShareFeedback(btn, text) { + const previous = btn.textContent; + btn.textContent = text; + btn.disabled = true; + setTimeout(function () { + btn.textContent = previous; + btn.disabled = false; + }, 1500); +} + +function downloadBundle(btn, cfg) { + const bundle = { + compareBundle: true, + version: 1, + title: cfg.title || undefined, + firstParty: cfg.firstParty || undefined, + stripVersion: !!cfg.stripVersion, + comments: cfg.comments || undefined, + har1: { + har: cfg.har1.har, + run: cfg.har1.run, + label: cfg.har1.label + }, + har2: { + har: cfg.har2.har, + run: cfg.har2.run, + label: cfg.har2.label + } + }; + const blob = new Blob([JSON.stringify(bundle)], { type: 'application/json' }); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = bundleFilename(cfg); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 0); + flashShareFeedback(btn, 'Downloaded'); +} + +function bundleFilename(cfg) { + function safe(s) { + return (s || '').toString().replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 40); + } + const a = safe(cfg.har1 && cfg.har1.label) || 'har1'; + const b = safe(cfg.har2 && cfg.har2.label) || 'har2'; + return 'compare-' + a + '-vs-' + b + '.json'; +} diff --git a/public/js/compare/templates.js b/public/js/compare/templates.js index fe92c80..7cbf715 100644 --- a/public/js/compare/templates.js +++ b/public/js/compare/templates.js @@ -644,7 +644,7 @@ function domainsTemplate(d) { // function requestDiffTemplate(d) { if (!d.requestDiff) return ''; - let html = '

Request/response difference (larger than 1 kb)

'; + let html = '

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

'; html += '
'; if (!d.requestDiff.length) { diff --git a/public/js/compare/upload.js b/public/js/compare/upload.js index f975e06..5767b51 100644 --- a/public/js/compare/upload.js +++ b/public/js/compare/upload.js @@ -1,4 +1,4 @@ -/* global readHar, errorMessage, generate, showUpload, showLoading, removeAndHide */ +/* global readHar, errorMessage, generate, showUpload, showLoading, removeAndHide, isBundle, loadFromBundle */ /* exported createMainDropZone, createUpload */ // Native drag-and-drop + click-to-pick replacement for the old @@ -58,6 +58,13 @@ function createMainDropZone(id) { readHar(files[0]) .then(har => { removeAndHide(); + // Bundles round-trip through the same file picker as HARs, + // so detect by the explicit flag and route to the bundle + // loader (which fills in both HARs from the embedded data). + if (isBundle(har)) { + loadFromBundle(har); + return; + } generate({ har1: { har: har, run: 0, label: 'HAR1' }, har2: { @@ -131,17 +138,27 @@ function createUpload(id) { const optionName = changeHar1 ? 'run2Option' : 'run1Option'; const e2 = document.getElementById(optionName); const run = e2 ? e2.options[e2.selectedIndex].value : 0; - const har1 = changeHar1 ? har : window.har.har1.har; - const har2 = changeHar1 ? window.har.har2.har : har; + const prev = window.har || {}; + const har1 = changeHar1 ? har : prev.har1.har; + const har2 = changeHar1 ? prev.har2.har : har; const run1 = changeHar1 ? 0 : run; const run2 = changeHar1 ? run : 0; - const label1 = changeHar1 ? 'HAR1' : window.har.har1.label; - const label2 = changeHar1 ? window.har.har2.label : 'HAR2'; + const label1 = changeHar1 ? 'HAR1' : prev.har1.label; + const label2 = changeHar1 ? prev.har2.label : 'HAR2'; + // 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 + // falls through to "Download bundle"). + const url1 = changeHar1 ? undefined : prev.har1 && prev.har1.url; + const url2 = changeHar1 ? (prev.har2 && prev.har2.url) : undefined; generate({ - har1: { har: har1, run: run1, label: label1 }, - har2: { har: har2, run: run2, label: label2 }, - stripVersion: stripVersion + har1: { har: har1, run: run1, label: label1, url: url1 }, + har2: { har: har2, run: run2, label: label2, url: url2 }, + stripVersion: stripVersion, + title: prev.title, + firstParty: prev.firstParty, + comments: prev.comments }); }) .catch(e => { diff --git a/src/css/result-header.css b/src/css/result-header.css index a6915a7..0796dd0 100644 --- a/src/css/result-header.css +++ b/src/css/result-header.css @@ -4,6 +4,17 @@ * Section-jump nav that sits inside the sticky `.header-result`. */ +.share-controls { + display: flex; + justify-content: center; + margin: 8px 0 0 0; + min-height: 1.5em; +} + +.share-controls .share-button { + font-size: 0.875rem; +} + .header-links { display: flex; flex-wrap: wrap; From 9c66a857899b25541bd996c9bc370d4d2d222558 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Tue, 12 May 2026 13:18:23 +0200 Subject: [PATCH 2/2] hepp --- .github/workflows/lint.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ecf4416..6e54193 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,9 +14,9 @@ jobs: - name: Use Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: - node-version: '10.x' - - name: Install dependencies + node-version: '20.x' + - name: Install dependencies run: npm ci - - name: Verify lint - run: npm run lint + - name: Verify build + run: npm run build \ No newline at end of file